diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3abe900803..cf1badb15b 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: ae5b60a56f67dcf0 - zh: 04f64e8891cba464 + en: 0c8945a47f87b5f7 + zh: b099d66521dca175 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 471e2824f0..ae7cff7cb7 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -45,7 +45,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. 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. +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. Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 62a1dca4db..0bbbd6ddaf 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -45,7 +45,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。 当前 Host 新增事件或对账后的列表会恢复插件重载前保留的 Session,清除移除状态并保留草稿;列表请求期间收到的移除事件优先于该次响应。 +Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 6f52d2096c..105930d1ef 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -451,7 +451,6 @@ 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) } @@ -672,7 +671,6 @@ 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) } @@ -712,7 +710,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)?.handlePresence(false) + else this.sessions.get(sessionId)?.handleRemoved() const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { this.projectionStores.delete(sessionId) diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 7fd67057cc..09494e01ab 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -577,13 +577,9 @@ export class Session implements SessionFace { this.notifier.markDirty() } - /** - * 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 + /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */ + handleRemoved(): void { + this.removed = true this.notifier.markDirty() } diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 69846236d3..8d1838e40f 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -840,17 +840,10 @@ 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.handlePresence(false) + session.handleRemoved() 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 }) => { diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index bfa92bea95..f43e32e64e 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -1072,43 +1072,6 @@ 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 = 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()