fix(session): restore presence when sessions return
This commit is contained in:
1 parent
eb8efe84e4
commit
ca081d5658
7 files changed
+59
-9
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: 1fdf707322fb6996
|
||||
zh: 0204f7e9d4aa3cce
|
||||
/session-controller/use-this-package:
|
||||
en: f9e4042fae88f801
|
||||
zh: f379f78966d57c2e
|
||||
en: ad1074f2e0991d16
|
||||
zh: cbef260e5c6d4ac3
|
||||
/session-controller/client-references:
|
||||
en: 41bdda1ac32df558
|
||||
zh: aa5d0e325924066b
|
||||
|
||||
@@ -43,7 +43,7 @@ Client list rows and resident Sessions use the current `sessionListMetadata` pro
|
||||
|
||||
`session.create` returns a Session for a new conversation, and its response names the Session actually used. A named `sessionId` is reused while it is blank (no turn has started), belongs to the requested directory, is an unarchived member of the requested Workspace, and is not written by another owner; a live Session is reused in place, and a persisted one is resumed while retaining its writer lock. The reused Session runs the requested `agentPreset`, or the configured default when the request names none: its recorded preset is not resolved, and a change is logged as `agent-preset/selected` before the Agent is published. A named Session that has started, fails the Workspace placement, or is written by another owner stays unchanged, and a new Session is created instead. A `sessionId` with no stored Session becomes the new Session's identity. A named Session in another directory fails with `session/conflict`, and one owned by subagent routing with `session/agent-busy`; both checks precede preset resolution. Creations naming one identity run in arrival order, including their preset resolution, and each response reports the preset its own request left the Session running. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies.
|
||||
|
||||
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children.
|
||||
Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. A current Host addition or reconciled list restores a retained Session after plugin reload, preserving its draft and clearing the removed state; a removal received during a list request takes precedence over that response.
|
||||
|
||||
Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only.
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠
|
||||
|
||||
`session.create` 返回一个用于新对话的 Session,响应指明实际使用的 Session。请求指明的 `sessionId` 在满足以下条件时被复用:仍为空白(尚未开始任何轮次)、属于请求的目录、是请求的工作区中未归档的成员,且未被其他写者占用;活动 Session 原地复用,持久化 Session 被恢复并持续持有其写锁。被复用的 Session 运行请求的 `agentPreset`,请求未指明时运行配置的默认 preset:不解析它记录的 preset,变更在 Agent 发布前记录为 `agent-preset/selected`。指明的 Session 若已开始、不满足工作区归属,或被其他写者占用,则保持不变,改为新建 Session。没有对应已存储 Session 的 `sessionId` 成为新 Session 的标识。指明其他目录中的 Session 返回 `session/conflict`,指明由 subagent 路由拥有的 Session 返回 `session/agent-busy`;这两项检查先于 preset 解析。指明同一标识的创建请求按到达顺序执行,preset 解析也包含在内,每个响应报告其自身请求完成时该 Session 运行的 preset。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。
|
||||
|
||||
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。
|
||||
Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 当前 Host 新增事件或对账后的列表会恢复插件重载前保留的 Session,清除移除状态并保留草稿;列表请求期间收到的移除事件优先于该次响应。
|
||||
|
||||
后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。
|
||||
|
||||
|
||||
@@ -446,6 +446,7 @@ export class SessionManager {
|
||||
for (const s of this.summaries) {
|
||||
const session = this.sessions.get(s.sessionId)
|
||||
if (session === undefined) continue
|
||||
session.handlePresence(true)
|
||||
session.handleBlank(this.effectiveBlank(s))
|
||||
session.handleRunning(s.running)
|
||||
}
|
||||
@@ -667,6 +668,7 @@ export class SessionManager {
|
||||
handleSessionAdded(summary: SessionSummary): void {
|
||||
this.mergeSummary(summary)
|
||||
if (!this.disposed && summary.running) this.engagedSessions.add(summary.sessionId)
|
||||
this.sessions.get(summary.sessionId)?.handlePresence(true)
|
||||
this.sessions.get(summary.sessionId)?.handleBlank(this.effectiveBlank(summary))
|
||||
if (summary.projections !== undefined) this.applyListBlock(summary.sessionId, summary.projections)
|
||||
}
|
||||
@@ -706,7 +708,7 @@ export class SessionManager {
|
||||
? { kind: 'status', sessionId, running: false, agentAvailable: false }
|
||||
: { kind: 'remove', sessionId })
|
||||
if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false)
|
||||
else this.sessions.get(sessionId)?.handleRemoved()
|
||||
else this.sessions.get(sessionId)?.handlePresence(false)
|
||||
const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog
|
||||
if (!durableSubagent && (catalog === undefined || catalog.length === 0)) {
|
||||
this.projectionStores.delete(sessionId)
|
||||
|
||||
@@ -577,9 +577,13 @@ export class Session implements SessionFace {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */
|
||||
handleRemoved(): void {
|
||||
this.removed = true
|
||||
/**
|
||||
* Update Host membership while retaining the resident instance and its draft.
|
||||
* @param present - Whether a current Host event or reconciled list contains this Session.
|
||||
*/
|
||||
handlePresence(present: boolean): void {
|
||||
if (present === !this.removed) return
|
||||
this.removed = !present
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
|
||||
@@ -840,10 +840,17 @@ describe('remaining branches', () => {
|
||||
it('deduplicates repeated running flips and records removal', async ({ mock, start }) => {
|
||||
const session = await sessionBench(mock, start, SID)
|
||||
const before = session.getSnapshot()
|
||||
session.handlePresence(true)
|
||||
expect(session.getSnapshot()).toBe(before)
|
||||
session.handleRunning(false) // already false: dedup branch
|
||||
expect(session.getSnapshot()).toBe(before)
|
||||
session.handleRemoved()
|
||||
session.handlePresence(false)
|
||||
expect(session.getSnapshot().removed).toBe(true)
|
||||
const removed = session.getSnapshot()
|
||||
session.handlePresence(false)
|
||||
expect(session.getSnapshot()).toBe(removed)
|
||||
session.handlePresence(true)
|
||||
expect(session.getSnapshot().removed).toBe(false)
|
||||
})
|
||||
|
||||
it('drops live events while cold/error (no window upkeep)', async ({ mock, start }) => {
|
||||
|
||||
@@ -1072,6 +1072,43 @@ describe('catalog arrival', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Host membership recovery', () => {
|
||||
it.for(['event', 'list'] as const)('restores a retained Session through a current %s after plugin reload', async (source, { bench }) => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
|
||||
await reference.ready
|
||||
const session = reference.binding.session
|
||||
b.svc.handleSessionRemoved(sid('s1'))
|
||||
expect(session.getSnapshot().removed).toBe(true)
|
||||
if (source === 'event') {
|
||||
b.svc.handleSessionAdded({ agentAvailable: true, sessionId: sid('s1'), updatedAt: 2, running: false, blank: false })
|
||||
} else {
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
}
|
||||
expect(b.svc.binding(sid('s1'))).toBe(reference.binding)
|
||||
expect(session.getSnapshot().removed).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a removal newer than an in-flight list response', async ({ bench }) => {
|
||||
const b = bench()
|
||||
await feedList(b, [{ id: 's1' }])
|
||||
using reference = b.svc.retain(sid('s1'), { source: 'controllerOperation' })
|
||||
await reference.ready
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
b.unblock.push(() => { gate.resolve() })
|
||||
b.mock.remote.session.list.mockImplementation(async () => {
|
||||
await gate.promise
|
||||
return ok({ items: [{ agentAvailable: true, sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }] })
|
||||
})
|
||||
const refresh = b.svc.refresh()
|
||||
b.svc.handleSessionRemoved(sid('s1'))
|
||||
gate.resolve()
|
||||
await refresh
|
||||
expect(reference.binding.session.getSnapshot().removed).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('blank mirror', () => {
|
||||
it('flips blank=false from the running:true status frame (cross-client conversion)', async ({ bench }) => {
|
||||
const b = bench()
|
||||
|
||||
Reference in new issue
Block a user