From e2fa69f5e20d0350f72acf34caa3a58582df4976 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 8 Sep 2026 20:21:15 +0800 Subject: [PATCH] test(snapshot): bind sibling roles to catalog publication order --- ...01-parent-owned-subagent-catalog.i18n.yaml | 4 +- ...026-09-01-parent-owned-subagent-catalog.md | 2 + ...-09-01-parent-owned-subagent-catalog.zh.md | 2 + snapshots/session/headless.snapshot.ts | 39 ++++++++++++++++--- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml index f6a34bbc6b..2e12236e27 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.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-09-01-parent-owned-subagent-catalog.md -2026-09-01-parent-owned-subagent-catalog.md: b0eee83499f8f9f3b3e2be652037dc882e005c94 -2026-09-01-parent-owned-subagent-catalog.zh.md: 8985eb291c5dbfadb53eb3576dcd9306496aa983 +2026-09-01-parent-owned-subagent-catalog.md: 24036545d72070e0c46dd4d6ac4ac30728140afd +2026-09-01-parent-owned-subagent-catalog.zh.md: 416fba9fc75842b3b4a2a46c67568d232135cae2 diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md index b0eee83499..24036545d7 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.md @@ -24,6 +24,8 @@ The utility owns chunk layout and its shared capacity constant; the catalog owns Fork isolation uses the exact `Session.inheritedEventCount` supplied to projection initialization. The fold ignores `subagent/catalog` events below that offset. The state stores the inherited offset but not each event seq because acceptance is decided during folding. +Headless snapshot collection assigns sibling fixture roles by their parent catalog order, regardless of child creation timestamps: provider startup can publish an older Session after a newer one. The collection preserves each log verbatim. + Snapshot normalizers zero `childCreatedAt` because it originates from the process clock. Event order and source-event references remain intact: adjacent facts can come from sequential creation, so adjacency does not establish commutativity. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md index 8985eb291c..416fba9fc7 100644 --- a/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-01-parent-owned-subagent-catalog.zh.md @@ -24,6 +24,8 @@ child header 与 `subagent/descriptor` 继续拥有恢复与 composition 权威 fork 隔离使用 projection 初始化时提供的精确 `Session.inheritedEventCount`。fold 忽略该 offset 之前的 `subagent/catalog` 事件。state 保存 inherited offset,但不保存每条 event seq,因为接受判定已在 fold 时完成。 +Headless 快照采集按父目录顺序分配同父子级的 fixture 角色,不依赖子级创建时间戳:provider 启动可能在较新的 Session 之后发布较旧的 Session。采集过程原样保留每份日志。 + snapshot normalizer 会把 `childCreatedAt` 归零,因为它来自 process clock。事件顺序与来源事件引用保持不变:相邻 fact 也可能来自顺序创建,因此相邻关系不能证明可交换性。 ## 考虑过的替代方案 diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index fb5af64a62..552378d261 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -176,8 +176,8 @@ async function persistedSessions(cwd: string): Promise { expect(assertPersistedSessionVersion(basename(file), content), `${file}: current writer`).toBe(SESSION_FORMAT_VERSION) return { content, header: headerOf(content) } })) - // Same-millisecond siblings bind to fixture roles by their parent's recorded - // discovery order; random persistence filenames do not identify child roles. + // Siblings bind to fixture roles by catalog publication order; concurrent + // provider startup can publish an older Session after a newer one. const catalogOrders = new Map(logs.map(log => [log.header.id, new Map( records(log.content) .filter(event => event.type === 'subagent/catalog') @@ -187,10 +187,12 @@ async function persistedSessions(cwd: string): Promise { const leftChild = typeof left.header.parentSession === 'string' const rightChild = typeof right.header.parentSession === 'string' if (leftChild !== rightChild) return leftChild ? 1 : -1 - const timeOrder = Number(left.header.createdAt) - Number(right.header.createdAt) - if (timeOrder !== 0 || left.header.parentSession !== right.header.parentSession) return timeOrder - const catalogOrder = catalogOrders.get(left.header.parentSession) - return (catalogOrder?.get(left.header.id) ?? Infinity) - (catalogOrder?.get(right.header.id) ?? Infinity) + if (left.header.parentSession === right.header.parentSession) { + const catalogOrder = catalogOrders.get(left.header.parentSession) + const childOrder = (catalogOrder?.get(left.header.id) ?? Infinity) - (catalogOrder?.get(right.header.id) ?? Infinity) + if (childOrder) return childOrder + } + return Number(left.header.createdAt) - Number(right.header.createdAt) }) } @@ -782,6 +784,31 @@ describe('headless recorded-session snapshots', () => { expect(stderrFromSession(log)).toBe('dsh: reasoning:\nfirst thought\ndsh: reasoning:\nsecond\n') }) + it.each([10, 20])('assigns sibling roles by catalog order when the first child timestamp is %i', async (firstCreatedAt) => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-headless-catalog-order-')) + try { + const logs = [ + [ + { type: 'session', version: 2, id: 'parent', createdAt: 1 }, + { type: 'subagent/catalog', data: { childId: 'child-z' } }, + { type: 'subagent/catalog', data: { childId: 'child-a' } }, + ], + [{ type: 'session', version: 2, id: 'child-z', createdAt: firstCreatedAt, parentSession: 'parent' }], + [{ type: 'session', version: 2, id: 'child-a', createdAt: 10, parentSession: 'parent' }], + ].map(rows => rows.map(row => JSON.stringify(row)).join('\n') + '\n') + for (const content of logs) { + const directory = join(cwd, '.dsh', 'sessions', String(headerOf(content).id)) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'session.v2.jsonl'), content) + } + const actual = await persistedSessions(cwd) + expect(actual.map(log => log.header.id)).toEqual(['parent', 'child-z', 'child-a']) + expect(actual.map(log => log.content)).toEqual(logs) + } finally { + await rm(cwd, { recursive: true, force: true }) + } + }) + it('writes header sidecars without replacing a retained Session generation', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-headless-sidecars-')) try {