fix(session): restore presence when sessions return

This commit is contained in:
creatixchu committed 2026-10-17 16:35:12 +08:00
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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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()