Browse Source

test(snapshot): bind sibling roles to catalog publication order

Dudu-0223 4 days ago
parent
commit
e2fa69f5e2

+ 2 - 2
.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

+ 2 - 0
.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

+ 2 - 0
.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 也可能来自顺序创建,因此相邻关系不能证明可交换性。
 
 ## 考虑过的替代方案

+ 33 - 6
snapshots/session/headless.snapshot.ts

@@ -176,8 +176,8 @@ async function persistedSessions(cwd: string): Promise<SessionLog[]> {
     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<SessionLog[]> {
     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 {