diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml index 1f34f2c1ea..0168528557 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md -2026-08-06-subagent-list-identity-projection.md: 535737f183c4e3a1f12e9e2c9c55c5a25c9e2006 -2026-08-06-subagent-list-identity-projection.zh.md: dccf0c781ddde882a5d1941fac470907f9422b44 +2026-08-06-subagent-list-identity-projection.md: ba023d04805ff3335c8f243510aa8ddc15fe13d6 +2026-08-06-subagent-list-identity-projection.zh.md: 368a70f5b3e5a27e4e1c648e8819476d40a57709 diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md index 535737f183..ba023d0480 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.md @@ -128,6 +128,7 @@ Known boundary deviations (deliberately accepted, recorded with this note): - A live/persisted header conflict: the old implementation made it per-child corrupt; enumeration now prefers live with no consistency check, the conflict goes unnoticed, and the live record forms the row. - A source-read failure on damaged storage (e.g. a bad surface rejected by the cold full read): the old implementation mapped it to per-child `corrupt`; it is now uniformly an `unavailable` row (the read side cannot tell the causes apart). - An unknown parent: the old implementation threw not-found through session-query ('parent session … was not found'); the subagent-owned merge now yields an empty subset for a nonexistent parent, enumeration returns an empty list, and later operations on the wire land as child-level subagent-not-found — a silent change of semantics and wording, recorded as explicitly accepted. +- Rung 2's later-event window: a cache row lands right after the first own descriptor, the log then appends a second own descriptor (or a malformed payload setting the null sentinel), and the process crashes before the next checkpoint — from then on a cold listing's rung 2, admitted by the seq≥seedLength gate, keeps serving the row's old identity (the first own descriptor's value), diverging from the authoritative refold (last-wins, the second), and a rung-2 hit triggers no refold, so nothing notices. Three boundaries: ① the precondition is a second own descriptor on the same child, violating the establishing provider's append-exactly-once contract — corruption-class data, same family and source as the multi-descriptor deviation; ② it takes both "corruption + a crash missing every checkpoint (the two mandatory points, turn/end and disposal, and the count/interval throttle points all unmet)" at once; ③ a healthy child (exactly one own descriptor) is unaffected — what the seq gate admits is precisely the only true identity. Self-healing: any live run of that child (the turn/end mandatory checkpoint) or any moment that triggers cache.write overwrites the whole row with a fresh fold (whole-record replace), and rung 2 serves correctly from then on; the authoritative paths (the rung-3 refold, the live snapshot, the resume fold) are correct from the start, and the divergence exists only in listing reads while the child stays cold and the row is never rewritten. The mechanical fixes were not taken: gate reconciliation would need the log-end seq, unavailable to a zero-read cold path; a cache row carrying the revision is an opaque token, incomparable and a cross-domain schema change — filed as accepted under the "the cache is never authoritative" doctrine. Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entirely as it was, zero changes** (the `list_agents` description and output schema are untouched; the plugin only narrows its load requirement — `sessionQuery` dropped from inject). The only behavioral changes are in apiproxy: on the route segment, the `hasSubagentDescriptor()` scan is deleted and `hasSubagentOwner` looks only at `header.origin` — pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and the pre-release stance accepts this; and `subagents.history` is aligned with `session.history`'s source — a live child served from in-memory events and the registry's watermark snapshot, a cold child from `inspectServable` reading persistence directly with a detached fold, no query service involved, the SESSION_QUERY_* error arms retired with it, and the wire shape unchanged (the `history` JSDoc wording becomes the live in-memory snapshot / cold persisted log dual arm). @@ -172,7 +173,7 @@ Consuming surfaces: diagnostic handling across wire, tool, and GUI **stays entir - The subagent list no longer requires a query backend: both pure-live and persistence-less deployments can list; `SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` is gone, and loading the `list_agents` plugin no longer requires `sessionQuery`. - Identity interpretation exists only in the single unit registered with the registry: the list's three-rung ladder and GUI history's cold read all use the registry's and the cache's existing reads (snapshot, cachedSnapshot, restore), and no bypass fold exists; if some future consuming surface bypasses the registry with a hand-written fold, values will drift across read faces — a discipline this design requires be maintained, not a mechanical guarantee. - Per-child isolation is back: a single child's cold-read failure loses only that row and healthy siblings are unaffected; a persistence listing failure still fails the whole enumeration. -- The diagnostic and enumeration semantics leaves five boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, and an unknown parent yielding an empty list instead of not-found); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data with resume authorization unaffected, and the unknown-parent one is a silent query-semantics change, explicitly accepted. +- The diagnostic and enumeration semantics leaves six boundary deviations (a stillborn fork surfacing under its ancestor's identity, multiple descriptors resolving to the last, header conflicts going unnoticed, damaged-source read failures shifting from `corrupt` to `unavailable`, an unknown parent yielding an empty list instead of not-found, and rung 2's later-event window); the full semantics is in the known-boundary-deviations list; the first four are display or classification deviations on debris-grade data, the unknown-parent one is a silent query-semantics change, and the rung-2 window is a self-healing cache-serving divergence under the double condition of corruption plus a crash; resume authorization is unaffected throughout, all explicitly accepted. - Pre-#1569 data without `origin` is no longer recognized as a subagent owner; it never entered the catalog anyway, and pre-release carries no compatibility promise. ## Related diff --git a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md index dccf0c781d..368a70f5b3 100644 --- a/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md @@ -128,6 +128,7 @@ export type SubagentListEntry = - live/persisted header 冲突,旧实现是 per-child corrupt;现枚举 live 优先、不做一致性校验,冲突不再被察觉,以 live 记录成行。 - 损坏存储的源读失败(如坏 surface 被冷读整读拒收),旧实现映射 per-child `corrupt`,现统一成 `unavailable` 行(读侧无从区分成因)。 - 未知 parent,旧实现经 session-query 抛 not-found('parent session … was not found');现自管合并对不存在的 parent 得到空子集,枚举返回空列表,wire 上后续操作落到 child 级 subagent-not-found——语义与文案的静默变化,显式接受。 +- rung 2 的更晚事件窗口:cache 行恰在首个自有描述符之后落盘,日志随后追加第二个自有描述符(或 malformed 载荷置 null 哨兵),且进程在下一次 checkpoint 前崩溃——此后冷列表的 rung 2 凭 seq≥seedLength 门持续供出行内旧身份(第一个自有描述符的值),与权威重折(last-wins 第二个)分歧,且 rung 2 命中期间不触发重折、无从察觉。边界三条:①前提是同一 child 出现第二个自有描述符,违反 establishing provider"恰追加一次"契约,属损坏类数据,与多描述符偏差同族同源;②需"损坏 + 崩溃错过 checkpoint(turn/end 与 disposal 两个 mandatory 点及 count/interval 节流点全部未及)"双条件同时成立;③健康 child(恰一自有描述符)不受影响——seq 门放行的正是唯一真身份。自愈条件:该 child 任一次 live 运行(turn/end mandatory checkpoint)或任何触发 cache.write 的时点,都会以新 fold 整行覆写(whole-record replace),rung 2 随即供正;权威路径(rung 3 重折、live snapshot、resume 折叠)自始正确,分歧只存在于持续冷、行未再更新期间的列表读。机制修法不采:gate 对账需知日志末端 seq,冷路径零读不可得;cache 行携 revision 是 opaque token,无法比较且跨域改 schema——按"cache 永不为权威"总纲归档为接受项。 消费面:wire、tool、GUI 的 diagnostic 处理**全部保持原状零改动**(`list_agents` 的 description 与 output schema 未动;该插件仅加载要求收窄——inject 去掉 `sessionQuery`)。行为上动的只有 apiproxy:路由段的 `hasSubagentDescriptor()` 扫描已删除,`hasSubagentOwner` 只看 `header.origin`——pre-#1569 的无 `origin` 存量不再被认作 subagent 属主,其本就不进目录,pre-release 立场接受;`subagents.history` 与 `session.history` 同源对齐——live child 用内存事件与注册表水位快照,cold child 用 `inspectServable` 直读持久化并 detached 折叠,不经查询服务,SESSION_QUERY_* 错误臂随之退役,wire 形状不变(`history` 的 JSDoc 措辞改为 live 内存快照/cold 持久日志双臂)。 @@ -172,7 +173,7 @@ export type SubagentListEntry = - subagent 列表不再要求 query backend:纯 live 与无 persistence 的部署都能列表;`SUBAGENT_CONTROL_SESSION_QUERY_UNAVAILABLE` 消失,`list_agents` 插件加载不再要求 `sessionQuery`。 - 身份解释只存在于 registry 注册的一份 unit:列表三级阶梯与 GUI history 冷读走的都是 registry 与 cache 的既有读法(snapshot、cachedSnapshot、restore),不存在旁路折叠;若未来某消费面绕开 registry 手写折叠,各读面的值将漂移——这是本设计要求维持的纪律,不是机制保证。 - per-child 隔离回归:单 child 冷读失败只损失该行,healthy sibling 不受影响;persistence 列表失败仍使整次枚举失败。 -- 诊断与枚举语义留下五处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,恢复鉴权不受影响,未知 parent 一处是查询语义的静默变化,显式接受。 +- 诊断与枚举语义留下六处边界偏差(stillborn fork 祖先身份误现、多描述符取末者、header 冲突不再被察觉、损坏源读失败由 `corrupt` 转 `unavailable`、未知 parent 由 not-found 改为空列表、rung 2 更晚事件窗口),完整语义见已知边界偏差清单;前四处为残骸级数据的展示或分类偏差,未知 parent 一处是查询语义的静默变化,rung 2 窗口一处是损坏加崩溃双条件下可自愈的缓存供值分歧;恢复鉴权均不受影响,显式接受。 - pre-#1569 的无 `origin` 存量不再被认作 subagent 属主;其本就不进目录,pre-release 无兼容承诺。 ## 相关 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d9ed8115f2..19fb0fe8a2 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -527,6 +527,28 @@ function detachedProjectionsFor( return registry.restore({}, events, 0).snapshot } +/** + * Best-effort projections for one subagent history page, fail-soft like + * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload + * never blocks transcript reading — the page is served without the block. + * @param ctx - context carrying the logger for the degradation warning. + * @param childSessionId - the child whose page is being decorated. + * @param compute - the arm-specific fold (live watermark or detached restore). + * @returns the projections block, or undefined when the fold failed. + */ +function subagentHistoryProjections( + ctx: Context, + childSessionId: SessionId, + compute: () => SessionProjectionsBlock | undefined, +): SessionProjectionsBlock | undefined { + try { + return compute() + } catch (error) { + ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`) + return undefined + } +} + /** Map continuation admission failures without exposing provider details. */ function subagentPromptError( request: RpcRequest<{ childSessionId: SessionId }>, @@ -1928,14 +1950,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (attached !== undefined) { header = attached.header events = [...attached.events] - projections = beforeSeq === undefined ? projectionsFor(ctx, attached) : undefined + projections = beforeSeq === undefined + ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached)) + : undefined } else { try { const inspected = await inspectServable(childSessionId) header = inspected.meta events = inspected.events projections = beforeSeq === undefined - ? detachedProjectionsFor(ctx, inspected.events) + ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events)) : undefined } catch (error: unknown) { if (signal?.aborted) { diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 691b97b7b4..c761484da5 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -24,6 +24,8 @@ function bench(options: { storedChild?: false /** Attach the child to the live session store instead of persistence only. */ liveChild?: true + /** Every registered projection unit throws on this child's payloads. */ + projectionsThrow?: true historyParent?: SessionId } = {}) { const parent = { id: PARENT } @@ -60,8 +62,14 @@ function bench(options: { const inspect = vi.fn(() => Promise.resolve({ meta: childHeader, events: childEvents })) const liveBlock = { values: {}, asOfSeq: 3 } const coldBlock = { values: {}, asOfSeq: 0 } - const snapshot = vi.fn(() => liveBlock) - const restore = vi.fn(() => ({ snapshot: coldBlock })) + const snapshot = vi.fn(() => { + if (options.projectionsThrow === true) throw new Error('hostile unit') + return liveBlock + }) + const restore = vi.fn(() => { + if (options.projectionsThrow === true) throw new Error('hostile unit') + return { snapshot: coldBlock } + }) const ctx = new Context() ctx.provide('agents', { get: getAgent }) ctx.provide('subagents', { listChildren, followup }) @@ -155,6 +163,29 @@ describe('subagent gateway', () => { expect(inspect).not.toHaveBeenCalled() }) + it('serves the page without projections when a hostile unit breaks the fold', async () => { + const cold = bench({ projectionsThrow: true }) + const coldResponse = await cold.api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + })) + expect(coldResponse.result).toMatchObject({ + ok: true, + value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, + }) + if (coldResponse.result.ok) expect('projections' in coldResponse.result.value).toBe(false) + + const live = bench({ projectionsThrow: true, liveChild: true }) + const liveResponse = await live.api.subagents.history(request({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + })) + expect(liveResponse.result).toMatchObject({ + ok: true, + value: { hasMore: false, events: [{ event: { type: 'user/message', seq: 0 } }] }, + }) + if (liveResponse.result.ok) expect('projections' in liveResponse.result.value).toBe(false) + expect(live.snapshot).toHaveBeenCalledTimes(1) + }) + it('reads one-shot history and rejects an address with the wrong mode', async () => { const oneShot = { kind: 'child', id: CHILD, mode: 'one-shot', label: 'batch', diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index f095be695f..fef098f90f 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -5,9 +5,10 @@ * durable `origin: 'subagent'` under the parent; each child's mode/label is * the registered `subagent` projection unit's value, resolved down a * three-rung ladder: the registry's watermark cache for a live child, a - * durable projection-cache row when the optional cache already serves the - * identity, and one persistence inspection folded through the registry - * otherwise. The projection fold is the single + * durable projection-cache row when it serves an own-suffix identity (the + * seq gate), and one persistence inspection folded through the registry + * otherwise, validated against the enumerated lifecycle. The projection + * fold is the single * classification authority — this module parses no descriptor itself. Absent * persistence, enumeration is live-only: a cold child is unreachable for * resume anyway, so its absence is capability absence, not an error. The @@ -92,7 +93,7 @@ export type SubagentListEntry = * live-preferred merge of `ctx.sessions` and optional session persistence, * serving each identity from the `subagent` projection unit: the registry's * watermark snapshot for a live child; for a cold one, a durable - * projection-cache row when the optional cache already serves the identity, + * projection-cache row when it serves an own-suffix identity (the seq gate), * else one bounded-concurrency persistence inspection folded through the * registry. * @see SubagentService.listChildren for the public cancellation and failure contract. diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index 118bba06f9..ef52883db2 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -461,7 +461,12 @@ describe('SubagentService.listChildren', () => { }) it.each([ + ['version', (meta: SessionHeader): SessionHeader => ({ ...meta, version: meta.version + 1 })], + ['id', (meta: SessionHeader): SessionHeader => ({ ...meta, id: SessionId('another-lifecycle') })], ['createdAt', (meta: SessionHeader): SessionHeader => ({ ...meta, createdAt: meta.createdAt + 1 })], + ['cwd', (meta: SessionHeader): SessionHeader => ({ ...meta, cwd: '/elsewhere' })], + ['parentSession', (meta: SessionHeader): SessionHeader => ({ ...meta, parentSession: SessionId('another-parent') })], + ['seedLength', (meta: SessionHeader): SessionHeader => ({ ...meta, seedLength: (meta.seedLength ?? 0) + 1 })], ['delegationDepth', (meta: SessionHeader): SessionHeader => ({ ...meta, delegationDepth: (meta.delegationDepth ?? 0) + 1 })], ] as const)('diagnoses an inspection returning another lifecycle (%s) as corrupt', async (_field, mutate) => { const { ctx, parent } = await setup([textResponse('done')])