@zhushanwen/pi-session-reader 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-session-reader",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "taiji": {
@@ -19,12 +19,12 @@
19
19
  "@zhushanwen/pi-extension-logger": "0.6.1",
20
20
  "@zhushanwen/pi-ext-guards": "0.4.2",
21
21
  "@zhushanwen/session-core": "0.11.0",
22
- "@zhushanwen/zcode-session-source": "0.2.2"
22
+ "@zhushanwen/zcode-session-source": "0.2.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@vitest/coverage-v8": "^4.1.9",
26
26
  "vitest": "^4.1.8",
27
- "@zhushanwen/subagent-core": "0.13.0"
27
+ "@zhushanwen/subagent-core": "1.0.0"
28
28
  },
29
29
  "files": [
30
30
  "index.ts",
@@ -9,6 +9,7 @@ import { handleSessionRead } from '../tool-handler.js'
9
9
  import {
10
10
  ModelConfigService,
11
11
  RecordStore,
12
+ SUBAGENT_RECORD_CUSTOM_TYPE,
12
13
  SubagentService,
13
14
  type SubagentRecord,
14
15
  } from '@zhushanwen/subagent-core'
@@ -135,7 +136,9 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
135
136
  fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
136
137
  })
137
138
 
138
- /** 真实 RecordStore(写文件 pi:种子经真实 toSubagentRecordEntry 序列化落主文件)。 */
139
+ /** 真实 RecordStore(写文件 pi:种子经真实 register 通路 + v2 条目构造器
140
+ * (toRegisteredEntryData,[v1 兼容层删除] v1 快照写点 toSubagentRecordEntry 已删)
141
+ * 落主文件)。 */
139
142
  function makeSeedStore(): RecordStore {
140
143
  return new RecordStore(
141
144
  getSubagentSessionDir(agentDir, agentDir),
@@ -199,12 +202,12 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
199
202
  data: { id: m.id, agent: '/agents/worker.md', mode: 'background', task: 'cross-package task', startedAt: 1000, rootSessionId: ROOT_SESSION, depth: 0 },
200
203
  }),
201
204
  JSON.stringify({
205
+ // 登记 §3.3:主/子文件条目面 = v2 注册条(身份域;运行态事实源在 record 事件文件)。
202
206
  type: 'custom', id: `cid-${m.id}-2`, parentId: `cid-${m.id}-1`, timestamp: ts,
203
207
  customType: 'subagent-record',
204
208
  data: {
205
- v: 1, id: m.id, agent: '/agents/worker.md', task: 'cross-package task', slug: 'cross',
206
- status: 'running', mode: 'background', startedAt: 1000, rootSessionId: ROOT_SESSION,
207
- depth: 0, turns: 1, totalTokens: 10, model: m.model, eventLog: [], displayItems: [],
209
+ v: 2, kind: 'registered', id: m.id, agent: '/agents/worker.md', task: 'cross-package task',
210
+ slug: 'cross', origin: 'tool', rootSessionId: ROOT_SESSION, depth: 0, startedAt: 1000,
208
211
  },
209
212
  }),
210
213
  )
@@ -216,11 +219,31 @@ describe('跨包集成:subagent-core 重建 manifest → session-reader result
216
219
  * 全文 + sessionFile)——[U4/D4] 翻边后成功成员崩溃时的真实末条形态(U5 前的
217
220
  * running+resumable 桥接形态已随字段退役消亡)。 */
218
221
  function seedRoundTerminalEntries(m: MemberSeed, childFile: string): void {
219
- const store = makeSeedStore()
220
- store.reportSubagentRecord(memberRecord({ id: m.id, sessionFile: childFile, model: m.model }))
221
- store.reportSubagentRecord(
222
- memberRecord({ id: m.id, sessionFile: childFile, model: m.model, status: 'idle', result: m.resultText }),
223
- )
222
+ // 登记 §3.3:v1 快照写点已删——经写入型 pi 落 v2 条目族(注册 + 终态)。
223
+ const pi = makeWritingPi(mainFile)
224
+ const seed = (record: SubagentRecord): void => {
225
+ for (const entry of v2Entries(record)) pi.appendEntry(SUBAGENT_RECORD_CUSTOM_TYPE, entry)
226
+ }
227
+ seed(memberRecord({ id: m.id, sessionFile: childFile, model: m.model }))
228
+ seed(memberRecord({ id: m.id, sessionFile: childFile, model: m.model, status: 'idle', result: m.resultText }))
229
+ }
230
+
231
+ /** record → v2 条目族(注册恒在场;非 running 再补终态条)。 */
232
+ function v2Entries(record: SubagentRecord): Array<Record<string, unknown>> {
233
+ const registered = {
234
+ v: 2, kind: 'registered', id: record.id, agent: record.agent, task: record.task, slug: record.slug,
235
+ origin: record.origin ?? 'tool', rootSessionId: record.rootSessionId, depth: record.depth,
236
+ startedAt: record.startedAt,
237
+ }
238
+ if (record.status === 'running') return [registered]
239
+ return [registered, {
240
+ v: 2, kind: 'settled', id: record.id, status: 'idle',
241
+ stopReason: record.stopReason ?? 'end_turn', endedAt: record.endedAt ?? record.startedAt,
242
+ turns: record.turns, totalTokens: record.totalTokens, model: record.model,
243
+ thinkingLevel: record.thinkingLevel,
244
+ ...(record.sessionFile !== undefined ? { sessionFile: record.sessionFile } : {}),
245
+ ...(record.result !== undefined ? { result: record.result } : {}),
246
+ }]
224
247
  }
225
248
 
226
249
  /** 全链路驱动:种子 → 恢复 service(orphan 覆写)→ rebuildIndexes(现行 boot
@@ -32,14 +32,26 @@ function recordLine(
32
32
  entryId: string,
33
33
  saId: string,
34
34
  sessionRef: Record<string, string>,
35
- v = 1,
35
+ v = 2,
36
36
  ): string {
37
+ // 登记 §3.3:v1 全量快照形态已删——锚源 = v2 终态条(settled 携带 engine/engineHandle)。
37
38
  return JSON.stringify({
38
39
  type: 'custom',
39
40
  customType: 'subagent-record',
40
41
  id: entryId,
41
42
  parentId: null,
42
- data: { v, id: saId, engine: 'zcode', engineHandle: { sessionRef, poolKey: 'shared' } },
43
+ data: {
44
+ v,
45
+ kind: 'settled',
46
+ id: saId,
47
+ status: 'idle',
48
+ stopReason: 'completed',
49
+ endedAt: 2,
50
+ turns: 1,
51
+ totalTokens: 1,
52
+ engine: 'zcode',
53
+ engineHandle: { sessionRef, poolKey: 'shared' },
54
+ },
43
55
  })
44
56
  }
45
57
 
@@ -208,7 +220,7 @@ describe('形状校验窄而严:缺键条目不算命中,继续找更早条
208
220
  expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
209
221
  })
210
222
 
211
- it('data.v 为认识的 v1/v2 之外的版本(future-v,如 3)不算命中,更早 v=1 条目仍可命中', async () => {
223
+ it('data.v 非当前版本(future-v,如 3)不算命中,更早 v=2 条目仍可命中', async () => {
212
224
  const file = writeMainSession('main.jsonl', [
213
225
  JSON.stringify({ type: 'session', id: 'main-1' }),
214
226
  recordLine('e1', 'sa-1', { sessionId: 'sess-A', dbPath: DB_PATH }),
@@ -251,22 +263,22 @@ describe('v2 终态条锚定:kind="settled" + engineHandle.sessionRef 双键',
251
263
  expect(await findZcodeEntryAnchor([file], 'sa-1')).toBeUndefined()
252
264
  })
253
265
 
254
- it('v1 快照条(早)+ v2 终态条(晚)→ 末条 v2 胜(末条锚定跨版本一致)', async () => {
266
+ it('同 id 两条 v2 终态条(早/晚)→ 末条胜(末条锚定语义)', async () => {
255
267
  const file = writeMainSession('main.jsonl', [
256
268
  JSON.stringify({ type: 'session', id: 'main-1' }),
257
- recordLine('e1', 'sa-1', { sessionId: 'sess-V1OLD', dbPath: DB_PATH }),
269
+ recordLine('e1', 'sa-1', { sessionId: 'sess-OLD', dbPath: DB_PATH }),
258
270
  v2SettledLine('e2', 'sa-1', {
259
271
  engine: 'zcode',
260
- sessionRef: { sessionId: 'sess-V2NEW', dbPath: DB_PATH },
272
+ sessionRef: { sessionId: 'sess-NEW', dbPath: DB_PATH },
261
273
  }),
262
274
  ])
263
275
 
264
276
  const anchor = await findZcodeEntryAnchor([file], 'sa-1')
265
- expect(anchor?.sessionId).toBe('sess-V2NEW')
266
- expect(anchor?.sessionId).not.toBe('sess-V1OLD')
277
+ expect(anchor?.sessionId).toBe('sess-NEW')
278
+ expect(anchor?.sessionId).not.toBe('sess-OLD')
267
279
  })
268
280
 
269
- it('v2 终态条锚残缺(缺 engineHandle)→ 不命中,更早 v1 完整条目仍可命中(缺键不遮蔽)', async () => {
281
+ it('v2 终态条锚残缺(缺 engineHandle)→ 不命中,更早完整条目仍可命中(缺键不遮蔽)', async () => {
270
282
  const file = writeMainSession('main.jsonl', [
271
283
  JSON.stringify({ type: 'session', id: 'main-1' }),
272
284
  recordLine('e1', 'sa-1', { sessionId: 'sess-EARLY', dbPath: DB_PATH }),
@@ -114,42 +114,45 @@ async function writeRecordManifest(
114
114
  await writeFile(join(recordsDir, `${id}.json`), JSON.stringify({ id, ...fields }))
115
115
  }
116
116
 
117
- /** 写 wf-state 文件(NEW 格式 v=wf-run-v1,calls[].sessionFile)。返回绝对路径。 */
118
- async function writeWfState(
117
+ /** [ADR-0095] v2 单档 fixture:写 record 流(agent-settled 帧提 calls)并向 session 文件
118
+ * 追加 v2 注册条目——替代已删除的 wf-state 快照 + workflow-state-link 组合。 */
119
+ async function writeWfV2Run(
119
120
  dir: string,
120
- slug: string,
121
+ cwdSlug: string,
122
+ sessionPath: string,
123
+ sessionId: string,
121
124
  runId: string,
122
125
  callSessionFiles: string[],
123
126
  ): Promise<string> {
124
- const wfDir = join(dir, 'sessions', slug, 'workflow-state')
127
+ const wfDir = join(dir, 'sessions', cwdSlug, 'workflow-state')
125
128
  await mkdir(wfDir, { recursive: true })
126
- const path = join(wfDir, `${runId}.jsonl`)
127
- const snap = JSON.stringify({
128
- v: 'wf-run-v1',
129
- runId,
130
- state: {
131
- status: 'done',
132
- calls: callSessionFiles.map((sf, i) => ({ id: i, status: 'done', sessionFile: sf })),
133
- },
134
- })
135
- await writeFile(path, snap + '\n')
136
- return path
137
- }
138
-
139
- /** 向 session 文件追加 workflow-state-link custom entry。 */
140
- async function writeWfLink(
141
- sessionPath: string,
142
- sessionId: string,
143
- link: { runId: string; path: string },
144
- ): Promise<void> {
129
+ const recordPath = join(wfDir, `${runId}.record.jsonl`)
130
+ const lines = [
131
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1758000000000, runId, workflowName: 'tree-flow', argsSummary: '{}' }),
132
+ ...callSessionFiles.map((sf, i) =>
133
+ JSON.stringify({ type: 'agent-settled', seq: 2 + i, ts: 1758000001000 + i, taskIndex: i, attempt: 1, outcome: 'done', durationMs: 100, result: { content: 'ok', sessionFile: sf } }),
134
+ ),
135
+ ]
136
+ await writeFile(recordPath, lines.join('\n') + '\n')
145
137
  const line = JSON.stringify({
146
138
  type: 'custom',
147
- id: `wf-link-${link.runId}`,
139
+ id: `wf-reg-${runId}`,
148
140
  parentId: sessionId,
149
- customType: 'workflow-state-link',
150
- data: { runId: link.runId, path: link.path, updatedAt: '2026-08-07T16:48:24.933Z' },
141
+ customType: 'workflow-record',
142
+ data: {
143
+ v: 2,
144
+ kind: 'registered',
145
+ runId,
146
+ workflowName: 'tree-flow',
147
+ scriptName: 'tree-flow',
148
+ slug: 'tree',
149
+ startedAt: 1758000000000,
150
+ recordPath,
151
+ },
152
+ timestamp: '2026-09-28T00:00:00Z',
151
153
  })
152
154
  await writeFile(sessionPath, line + '\n', { flag: 'a' })
155
+ return recordPath
153
156
  }
154
157
 
155
158
  /** 在节点树里按 type+sessionId 查找节点(DFS)。 */
@@ -207,7 +210,7 @@ describe('buildExecutionTree - fixture', () => {
207
210
  status: 'completed',
208
211
  parentRecordId: recAId,
209
212
  })
210
- // workflow-call B:A 的 sessionFile 的 workflow-state-link → wf-state → calls[].sessionFile = SB
213
+ // workflow-call B:A 的 sessionFile 的 v2 注册条目 → record 流 → calls[].sessionFile = SB
211
214
  const sbB = join(
212
215
  dir,
213
216
  'subagents',
@@ -218,8 +221,7 @@ describe('buildExecutionTree - fixture', () => {
218
221
  // B call session 文件需存在(resolveWorkflows 读 call sessionFile;这里只需路径,不读内容建树)
219
222
  await mkdir(join(dir, 'subagents', '--main-cwd--', 'sessions'), { recursive: true })
220
223
  await writeFile(sbB, JSON.stringify({ type: 'session', id: B_REAL, cwd: '/proj/wf' }) + '\n')
221
- const wfPath = await writeWfState(dir, '--main-cwd--', RUN_A, [sbB])
222
- await writeWfLink(saA, A_REAL, { runId: RUN_A, path: wfPath })
224
+ await writeWfV2Run(dir, '--main-cwd--', saA, A_REAL, RUN_A, [sbB])
223
225
 
224
226
  const tree = await buildExecutionTree(MAIN, dir)
225
227
 
@@ -311,10 +313,8 @@ describe('buildExecutionTree - fixture', () => {
311
313
  agentName: 'explorer',
312
314
  sessionFile: saB,
313
315
  })
314
- const wfA = await writeWfState(dir, '--main-cwd--', RUN_A, [saB])
315
- const wfB = await writeWfState(dir, '--main-cwd--', RUN_B, [saA])
316
- await writeWfLink(saA, A_REAL, { runId: RUN_A, path: wfA })
317
- await writeWfLink(saB, B_REAL, { runId: RUN_B, path: wfB })
316
+ await writeWfV2Run(dir, '--main-cwd--', saA, A_REAL, RUN_A, [saB])
317
+ await writeWfV2Run(dir, '--main-cwd--', saB, B_REAL, RUN_B, [saA])
318
318
 
319
319
  const tree = await buildExecutionTree(MAIN, dir)
320
320
 
@@ -459,9 +459,8 @@ describe('buildExecutionTree - fixture', () => {
459
459
  status: 'completed',
460
460
  parentRecordId: recBId,
461
461
  })
462
- // A 的 workflow-state-link → wf-state → calls[0].sessionFile = sbB
463
- const wfPath = await writeWfState(dir, '--main-cwd--', RUN_A, [sbB])
464
- await writeWfLink(saA, A_REAL, { runId: RUN_A, path: wfPath })
462
+ // A 的 v2 注册条目 → record 流 → calls[0].sessionFile = sbB
463
+ await writeWfV2Run(dir, '--main-cwd--', saA, A_REAL, RUN_A, [sbB])
465
464
 
466
465
  const tree = await buildExecutionTree(MAIN, dir)
467
466
 
@@ -564,7 +563,7 @@ describe('buildExecutionTree - fixture', () => {
564
563
 
565
564
  it('TC-m3b-mf1-main-root:main root 填 sessionFile 后 main 自身发起的 workflow run 入树(MF-1)', async () => {
566
565
  const mainPath = await writeMainSession(dir, '--main-cwd--', MAIN)
567
- // main 的 workflow-state-link → wf-state → call session B
566
+ // main 的 v2 注册条目 → record 流 → call session B
568
567
  const sbB = join(
569
568
  dir,
570
569
  'subagents',
@@ -574,12 +573,11 @@ describe('buildExecutionTree - fixture', () => {
574
573
  )
575
574
  await mkdir(join(dir, 'subagents', '--main-cwd--', 'sessions'), { recursive: true })
576
575
  await writeFile(sbB, JSON.stringify({ type: 'session', id: B_REAL, cwd: '/proj/wf' }) + '\n')
577
- const wfPath = await writeWfState(dir, '--main-cwd--', RUN_A, [sbB])
578
- await writeWfLink(mainPath, MAIN, { runId: RUN_A, path: wfPath })
576
+ await writeWfV2Run(dir, '--main-cwd--', mainPath, MAIN, RUN_A, [sbB])
579
577
 
580
578
  const tree = await buildExecutionTree(MAIN, dir, mainPath)
581
579
 
582
- // main root 携带 sessionFile,workflow-state-link 被读取 → wf-call 子节点入树
580
+ // main root 携带 sessionFile,v2 注册条目被读取 → wf-call 子节点入树
583
581
  expect(tree.root.type).toBe('main')
584
582
  expect(tree.root.sessionFile).toBe(mainPath)
585
583
  const nodeB = findNode(tree.root, 'workflow-call', B_REAL)
@@ -7,8 +7,10 @@
7
7
  // 2. 重建窗口前后,session-reader identity 投影的输入字段集(rootSessionId/slug/
8
8
  // task/agentName/model/status/sessionFile/parentRecordId)逐字段一致——双写过渡
9
9
  // 字段(executionStatus)在场且不被本包消费(旧 status 三态投影继续生效);
10
- // 3. 重建源 = `.state` 权威(closedReason 从终态位派生,旧三态 cancelled 形态
11
- // 保真——buildRecord 分支 1 → derivedManifestRecord 派生)。
10
+ // 3. 重建源 = 事件流折叠 + identity entry([③ .state 退场] 后终态位消失)——
11
+ // 身份/富字段逐字段保真;legacy status 不再保真 closed(重建单规则恒 idle +
12
+ // 无 closedReason → derivedManifestRecord 如实投影 legacy "running",终态语义
13
+ // 由 record-settled 事件帧承载)。
12
14
  //
13
15
  // 形态同 cross-package-subagent-core.test.ts:subagent-core 真实 tmpdir 产出磁盘
14
16
  // 状态,本包零 mock 读同一 tmpdir;不触碰真实数据目录(红线)。
@@ -143,13 +145,15 @@ describe('跨包集成:subagent-core rebuildIndexes 重建 manifest → sessio
143
145
  // ── session-reader 视角:重建后 identity 投影不降级 ──
144
146
  const after = (await listRecordManifests(agentDir)).find((m) => m.id === 'sa-dw')
145
147
  expect(after).toBeDefined()
146
- expect(identityView(after!)).toEqual(viewBefore)
148
+ // 字段集保真(身份/富字段逐字段一致);仅 legacy status 按 ③ 后重建语义如实
149
+ // 投影 running(终态权威 = 事件流 record-settled 帧,磁盘重建无 closedReason 载体)。
150
+ expect(identityView(after!)).toEqual({ ...viewBefore, status: 'running' })
147
151
 
148
- // 重建源 = `.state` 权威:closedReason 从终态位派生回 manifest(旧三态 closed)
149
152
  const rawAfter = JSON.parse(fs.readFileSync(path.join(recordsDir, 'sa-dw.json'), 'utf-8')) as Record<string, unknown>
150
- expect(rawAfter.status).toBe('closed')
153
+ expect(rawAfter.status).toBe('running')
151
154
  expect(rawAfter.executionStatus).toBe('idle')
152
- expect(rawAfter.closedReason).toBe('gc')
155
+ // closedReason 无重建载体(`.state` 退场)→ 如实缺席。
156
+ expect(rawAfter.closedReason).toBeUndefined()
153
157
  })
154
158
 
155
159
  it('惰性通道:查询面(collectRecords)补建的 manifest 同样可被本包读取投影', async () => {
@@ -103,34 +103,6 @@ async function writeAliveSubagentNoIdentity(
103
103
  return path
104
104
  }
105
105
 
106
- /** 写 wf-state 文件(每行一个快照;字符串按原样写入,可注入坏行)。返回绝对路径。 */
107
- async function writeWfState(dir: string, slug: string, lines: string[]): Promise<string> {
108
- const wfDir = join(dir, 'sessions', slug, 'workflow-state')
109
- await mkdir(wfDir, { recursive: true })
110
- const path = join(wfDir, 'wf-test.jsonl')
111
- await writeFile(path, lines.join('\n') + '\n')
112
- return path
113
- }
114
-
115
- /** 向主 session 文件追加 workflow-state-link custom entry(resolveWorkflows 的输入)。 */
116
- async function writeWfLink(
117
- dir: string,
118
- slug: string,
119
- id: string,
120
- link: { runId: string; path: string },
121
- ): Promise<void> {
122
- const sessionPath = join(dir, 'sessions', slug, `${id}.jsonl`)
123
- const line = JSON.stringify({
124
- type: 'custom',
125
- id: `wf-link-${link.runId}`,
126
- parentId: id,
127
- customType: 'workflow-state-link',
128
- data: { runId: link.runId, path: link.path, updatedAt: '2026-08-07T16:48:24.933Z' },
129
- timestamp: '2026-08-07T16:48:24.933Z',
130
- })
131
- await writeFile(sessionPath, line + '\n', { flag: 'a' })
132
- }
133
-
134
106
  /** 写 records manifest(孤儿源)。U4 扩展:fields 支持富字段 task/slug/model/status。 */
135
107
  async function writeRecordManifest(
136
108
  dir: string,
@@ -256,14 +228,14 @@ describe('buildFamilyFromFs - fixture', () => {
256
228
  await expect(buildFamilyFromFs('nonexistent-session-id', dir)).rejects.toThrow(/not found/)
257
229
  })
258
230
 
259
- it('workflows:NEW 格式(v=wf-run-v1)state.calls 解析;命中 pathToRef 取完整 ref,GC\'d 路径回退最小 ref', async () => {
231
+ it('workflows:v2 注册条目 record 流直读提 calls;命中 pathToRef 取完整 ref,GC\'d 路径回退最小 ref', async () => {
260
232
  await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
261
233
  // 真实存在的 subagent(步骤 2 扫到 → pathToRef 命中 → 完整 SessionRef)
262
234
  const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
263
235
  rootSessionId: ROOT,
264
236
  slug: 'wf-sub',
265
237
  })
266
- // GC\'d 路径(文件不存在 → pathToRef 未命中 → sessionRefFromPath 最小 ref)
238
+ // GC'd 路径(文件不存在 → pathToRef 未命中 → sessionRefFromPath 最小 ref)
267
239
  const gced = join(
268
240
  dir,
269
241
  'subagents',
@@ -271,27 +243,43 @@ describe('buildFamilyFromFs - fixture', () => {
271
243
  'sessions',
272
244
  '2026-08-07T16-49-48-393Z_019fdd21-8169-7a02-8f11-eef6c9ca11cc.jsonl',
273
245
  )
274
- const wfPath = await writeWfState(dir, '--root-cwd--', [
275
- JSON.stringify({
276
- v: 'wf-run-v1',
277
- runId: 'wf-1786121304924-r7vgov',
278
- state: {
279
- status: 'done',
280
- calls: [
281
- { id: 0, status: 'done', sessionFile: subPath, sessionId: 'sa-x' },
282
- { id: 1, status: 'done', result: { sessionFile: gced, durationMs: 1 } },
283
- ],
284
- },
285
- }),
286
- ])
287
- await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-1786121304924-r7vgov', path: wfPath })
246
+ // record 流:agent-settled 帧携带 result.sessionFile(两 call:命中 + GC 各一)
247
+ const wfDir = join(dir, 'sessions', '--root-cwd--', 'workflow-state')
248
+ await mkdir(wfDir, { recursive: true })
249
+ const recordPath = join(wfDir, 'wf-v2-rich.record.jsonl')
250
+ await writeFile(
251
+ recordPath,
252
+ [
253
+ JSON.stringify({ type: 'run-created', seq: 1, ts: 1000, runId: 'wf-v2-rich', workflowName: 'rich', argsSummary: '{}' }),
254
+ JSON.stringify({ type: 'agent-settled', seq: 2, ts: 1100, taskIndex: 0, attempt: 1, outcome: 'done', durationMs: 1, result: { content: 'ok', sessionFile: subPath } }),
255
+ JSON.stringify({ type: 'agent-settled', seq: 3, ts: 1200, taskIndex: 1, attempt: 1, outcome: 'done', durationMs: 1, result: { content: 'ok', sessionFile: gced } }),
256
+ ].join('\n') + '\n',
257
+ )
258
+ const line = JSON.stringify({
259
+ type: 'custom',
260
+ id: 'wf-record-wf-v2-rich',
261
+ parentId: ROOT,
262
+ customType: 'workflow-record',
263
+ data: {
264
+ v: 2,
265
+ kind: 'registered',
266
+ runId: 'wf-v2-rich',
267
+ workflowName: 'rich',
268
+ scriptName: 'rich',
269
+ slug: 'rich',
270
+ startedAt: 1,
271
+ recordPath,
272
+ },
273
+ timestamp: '2026-08-07T16:48:24.933Z',
274
+ })
275
+ await writeFile(join(dir, 'sessions', '--root-cwd--', `${ROOT}.jsonl`), line + '\n', { flag: 'a' })
288
276
 
289
277
  const family = await buildFamilyFromFs(ROOT, dir)
290
278
 
291
279
  expect(family.workflows).toHaveLength(1)
292
280
  const wf = family.workflows[0]
293
- expect(wf.runId).toBe('wf-1786121304924-r7vgov')
294
- expect(wf.stateFile).toBe(wfPath)
281
+ expect(wf.runId).toBe('wf-v2-rich')
282
+ expect(wf.stateFile).toBe(recordPath)
295
283
  expect(wf.calls).toHaveLength(2)
296
284
  // 命中 pathToRef:完整 ref(真实 id / mtime / size / cwd)
297
285
  expect(wf.calls[0].fileName).toBe(subPath)
@@ -299,7 +287,7 @@ describe('buildFamilyFromFs - fixture', () => {
299
287
  expect(wf.calls[0].mtime).toBeGreaterThan(0)
300
288
  expect(wf.calls[0].sizeBytes).toBeGreaterThan(0)
301
289
  expect(wf.calls[0].cwd).toBe('/proj/--root-cwd--')
302
- // GC\'d 未命中:fileName-only 最小 ref(sessionId 从文件名提取,mtime/size/cwd 占位)
290
+ // GC'd 未命中:fileName-only 最小 ref(sessionId 从文件名提取,mtime/size/cwd 占位)
303
291
  expect(wf.calls[1].fileName).toBe(gced)
304
292
  expect(wf.calls[1].sessionId).toBe('019fdd21-8169-7a02-8f11-eef6c9ca11cc')
305
293
  expect(wf.calls[1].mtime).toBe(0)
@@ -307,83 +295,6 @@ describe('buildFamilyFromFs - fixture', () => {
307
295
  expect(wf.calls[1].cwd).toBe('')
308
296
  })
309
297
 
310
- it('workflows:NEW 格式坏尾行回退上一快照;顶层 sessionFile 优先于 result.sessionFile', async () => {
311
- await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
312
- const topLevel = join(
313
- dir,
314
- 'subagents',
315
- '--root-cwd--',
316
- 'sessions',
317
- '2026-08-01T00-00-00-000Z_019fdd11-1111-1111-1111-111111111111.jsonl',
318
- )
319
- const inResult = join(
320
- dir,
321
- 'subagents',
322
- '--root-cwd--',
323
- 'sessions',
324
- '2026-08-02T00-00-00-000Z_019fdd22-2222-2222-2222-222222222222.jsonl',
325
- )
326
- const snap = JSON.stringify({
327
- v: 'wf-run-v1',
328
- runId: 'wf-x',
329
- state: { calls: [{ id: 0, sessionFile: topLevel, result: { sessionFile: inResult } }] },
330
- })
331
- // 尾行坏 JSON → readWorkflowCallSessionFiles 从尾向头回退到上一有效快照
332
- const wfPath = await writeWfState(dir, '--root-cwd--', [snap, '{broken json'])
333
- await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-x', path: wfPath })
334
-
335
- const family = await buildFamilyFromFs(ROOT, dir)
336
-
337
- expect(family.workflows).toHaveLength(1)
338
- expect(family.workflows[0].calls).toHaveLength(1)
339
- // 顶层 sessionFile 优先(result.sessionFile 不覆盖)
340
- expect(family.workflows[0].calls[0].fileName).toBe(topLevel)
341
- })
342
-
343
- it('workflows:OLD 格式(无 v)callCache [{key,value}] → value.sessionFile + value.result.sessionFile 回退;无 sessionFile 的 call 不产出', async () => {
344
- await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
345
- const viaValue = join(
346
- dir,
347
- 'subagents',
348
- '--root-cwd--',
349
- 'sessions',
350
- '2026-08-03T00-00-00-000Z_019fdd33-3333-3333-3333-333333333333.jsonl',
351
- )
352
- const viaResult = join(
353
- dir,
354
- 'subagents',
355
- '--root-cwd--',
356
- 'sessions',
357
- '2026-08-04T00-00-00-000Z_019fdd44-4444-4444-4444-444444444444.jsonl',
358
- )
359
- const wfPath = await writeWfState(dir, '--root-cwd--', [
360
- JSON.stringify({
361
- runId: 'wf-old-1',
362
- name: 'old-wf',
363
- status: 'done',
364
- callCache: [
365
- // 真实 OLD 数据形态:value 无 sessionFile(旧 pi 不持久化)→ 不产出
366
- { key: 1, value: { content: 'PASS', durationMs: 100 } },
367
- // value.sessionFile(源码注释 OLD 分支读取点)
368
- { key: 2, value: { sessionFile: viaValue, content: 'ok' } },
369
- // value.result.sessionFile 回退
370
- { key: 3, value: { result: { sessionFile: viaResult, durationMs: 1 } } },
371
- // value 非对象 → 整项兜底(无 sessionFile → 不产出)
372
- { key: 4, value: 'str' },
373
- ],
374
- }),
375
- ])
376
- await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-old-1', path: wfPath })
377
-
378
- const family = await buildFamilyFromFs(ROOT, dir)
379
-
380
- expect(family.workflows).toHaveLength(1)
381
- expect(family.workflows[0].calls.map((c) => c.fileName)).toEqual([viaValue, viaResult])
382
- // 未命中 pathToRef → 最小 ref:sessionId 从文件名提取,mtime 占位 0
383
- expect(family.workflows[0].calls[0].sessionId).toBe('019fdd33-3333-3333-3333-333333333333')
384
- expect(family.workflows[0].calls[0].mtime).toBe(0)
385
- })
386
-
387
298
  it('MF-3 回归:alive 但无 identity 的 subagent(运行中)不被收编为 cleanedUp——U4 后 manifest 主路径建族', async () => {
388
299
  await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
389
300
  // 运行中 subagent:有效 header、无 identity 尾行(identity 完成时才写入),manifest 已存在
@@ -416,66 +327,6 @@ describe('buildFamilyFromFs - fixture', () => {
416
327
  })
417
328
  })
418
329
 
419
- // ============================================================
420
- // family workflows 富 run(多 call)契约(fixture)
421
- // ============================================================
422
-
423
- describe('buildFamilyFromFs - workflow 富 run(多 call)', () => {
424
- let dir: string
425
-
426
- beforeEach(async () => {
427
- dir = await makeAgentDir()
428
- })
429
- afterEach(async () => {
430
- await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
431
- })
432
-
433
- it('workflows 非空,多 call(4 个)workflow 的 fileName/stateFile/runId 契约', async () => {
434
- await writeMainSession(dir, '--root-cwd--', ROOT, { cwd: '/proj/root' })
435
- // 1 个存活 subagent(pathToRef 命中 → 完整 ref)+ 3 个 GC 路径(未命中 → 最小 ref,
436
- // fileName 仍非空——calls 的 sessionFile 路径恒落入 fileName)
437
- const subPath = await writeSubagentSession(dir, '--root-cwd--', SUB_REAL, {
438
- rootSessionId: ROOT,
439
- slug: 'wf-rich',
440
- })
441
- const ghost = (n: number): string =>
442
- join(
443
- dir,
444
- 'subagents',
445
- '--root-cwd--',
446
- 'sessions',
447
- `2026-08-0${n}T00-00-00-000Z_019fdd55-1111-1111-1111-11111111111${n}.jsonl`,
448
- )
449
- const wfPath = await writeWfState(dir, '--root-cwd--', [
450
- JSON.stringify({
451
- v: 'wf-run-v1',
452
- runId: 'wf-rich-run-1',
453
- state: {
454
- status: 'done',
455
- calls: [
456
- { id: 0, status: 'done', sessionFile: subPath, sessionId: SUB_REAL },
457
- { id: 1, status: 'done', sessionFile: ghost(1) },
458
- { id: 2, status: 'done', sessionFile: ghost(2) },
459
- { id: 3, status: 'done', sessionFile: ghost(3) },
460
- ],
461
- },
462
- }),
463
- ])
464
- await writeWfLink(dir, '--root-cwd--', ROOT, { runId: 'wf-rich-run-1', path: wfPath })
465
-
466
- const family = await buildFamilyFromFs(ROOT, dir)
467
-
468
- expect(family.workflows.length).toBeGreaterThan(0)
469
- const rich = family.workflows.find((w) => w.calls.length >= 4)
470
- expect(rich).toBeDefined()
471
- // calls 的 sessionFile 路径已落入 fileName(sessionRefFromPath)
472
- expect(rich!.calls.every((c) => c.fileName.length > 0)).toBe(true)
473
- // stateFile 是 wf-state 文件绝对路径
474
- expect(rich!.stateFile.endsWith('.jsonl')).toBe(true)
475
- expect(rich!.runId.startsWith('wf-')).toBe(true)
476
- })
477
- })
478
-
479
330
  // ============================================================
480
331
  // w2 TC1:listRecordManifests + RecordManifest 导出(IF2/DM2,行为零变更验证)
481
332
  // ============================================================
@@ -662,7 +513,7 @@ describe('U4 buildFamilyFromFs 富化(manifest 主 / P-fallback)', () => {
662
513
  // ① 结构性忽略——subagents 树扫描只收 .jsonl,.events 天然不进候选集(D3「无后缀
663
514
  // 的结构性收益」:被忽略或被误读两类风险一次排空);
664
515
  // ② 首行头行读者兼容——.events 首行是自描述头行 {"type":"record-journal","id":...}
665
- // (写侧 record-events.ts toRecordJournalHeader 契约),session-reader 对未知文件
516
+ // (写侧 record-events.ts toRecordEventHeader 契约),session-reader 对未知文件
666
517
  // 读首行判 header 时命中非 session header 即忽略(检查点④:零成本兼容——不改
667
518
  // 扫描器即可与该文件族共存)。
668
519