@zhushanwen/pi-session-reader 0.1.0 → 0.2.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 +1 -1
- package/src/__tests__/execution-tree.test.ts +792 -0
- package/src/__tests__/family.test.ts +150 -0
- package/src/__tests__/find.test.ts +342 -1
- package/src/__tests__/index.test.ts +50 -0
- package/src/__tests__/subagents.test.ts +291 -14
- package/src/__tests__/tool-handler.test.ts +862 -3
- package/src/__tests__/tree.test.ts +20 -0
- package/src/__tests__/workflow.test.ts +282 -0
- package/src/core/execution-tree.ts +517 -0
- package/src/core/family.ts +40 -0
- package/src/core/tree.ts +14 -5
- package/src/core/workflow.ts +329 -0
- package/src/discovery/find.ts +167 -20
- package/src/discovery/subagents.ts +113 -152
- package/src/discovery/workflows.ts +162 -0
- package/src/index.ts +22 -4
- package/src/tool-handler.ts +334 -21
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [M3b U7] 嵌套执行树构建(design m3 slice DM1/DM2/DM4 + IF3/IF5 + ES1-5)。
|
|
3
|
+
*
|
|
4
|
+
* 与 resolveFamily(core/family.ts,flat 家族)互补:buildExecutionTree 构建任意深度
|
|
5
|
+
* subagent↔workflow-call 相互嵌套的执行树,精确父子链基于 parentRecordId(M3a 落盘镜像)。
|
|
6
|
+
*
|
|
7
|
+
* 核心思路:
|
|
8
|
+
* - **subagent 后代**:parentRecordId 精确链挂载(undefined→顶层 main,==父.id→挂父下)。
|
|
9
|
+
* 三级数据源优先级(DM4):① manifest.parentRecordId ② identity.data.parentRecordId
|
|
10
|
+
* ③ 都缺→undefined(顶层/flat 回退)。旧机制 record 全 undefined→全挂 main(flat-fallback)。
|
|
11
|
+
* - **workflow-call 后代**:指针递归——读 subagent node 的 sessionFile 的 workflow-state-link
|
|
12
|
+
* → calls[].sessionFile(每个 call 作子 node)→ 递归读 call session 的 workflow-state-link
|
|
13
|
+
* (嵌套 workflow)。复用 M2 resolveWorkflows/readRunSnapshot 容错(ES4)。
|
|
14
|
+
* - **鲁棒性**:visited Set 防环(ES1,family.ts:209 seen Set 同构)+ MAX_DEPTH 截断(ES2),
|
|
15
|
+
* 截断标 tree.truncated=true 不抛错,返回部分树。
|
|
16
|
+
*
|
|
17
|
+
* 分层(同 family/workflow):本文件是 core 层,IO 经 discovery 层(listRecordManifests/
|
|
18
|
+
* resolveWorkflows/readTailIdentity),core 层只做树构建与渲染逻辑。agentDir 作参数注入,
|
|
19
|
+
* 零 pi 依赖,可完全单测。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { basename } from 'node:path'
|
|
23
|
+
import {
|
|
24
|
+
listRecordManifests,
|
|
25
|
+
readTailIdentity,
|
|
26
|
+
extractSessionIdFromFilename,
|
|
27
|
+
type RecordManifest,
|
|
28
|
+
} from '../discovery/subagents.js'
|
|
29
|
+
import { resolveWorkflows } from '../discovery/workflows.js'
|
|
30
|
+
import type { SessionRef, WorkflowRef } from './family.js'
|
|
31
|
+
|
|
32
|
+
// ============================================================
|
|
33
|
+
// 类型(DM1/DM2)
|
|
34
|
+
// ============================================================
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 执行树节点(DM1)。
|
|
38
|
+
*
|
|
39
|
+
* children 可同时含 subagent 后代(parentRecordId 链)与 workflow-call 后代(指针递归),
|
|
40
|
+
* 任意深度相互嵌套。workflow-call 节点无 record manifest(call 复用 subagent session,P-wfreuse)。
|
|
41
|
+
*/
|
|
42
|
+
export interface ExecutionTreeNode {
|
|
43
|
+
type: 'main' | 'subagent' | 'workflow-call'
|
|
44
|
+
/** main/subagent 的 session id;workflow-call 的 call session id(文件名提取,GC 路径可能为空) */
|
|
45
|
+
sessionId: string
|
|
46
|
+
/** workflow-call 专属 runId(subagent/main 无) */
|
|
47
|
+
runId?: string
|
|
48
|
+
/** 节点 session.jsonl 绝对路径(深读入口;GC 后可能 undefined) */
|
|
49
|
+
sessionFile?: string
|
|
50
|
+
/** workflow-call 的 wf-state 文件绝对路径 */
|
|
51
|
+
stateFile?: string
|
|
52
|
+
/** 0=main,1=直接 subagent/直接 workflow call,2+=嵌套后代 */
|
|
53
|
+
depth: number
|
|
54
|
+
/** 全树共享的顶层 main session id(旧机制=直接父,版本探测区分) */
|
|
55
|
+
rootSessionId: string
|
|
56
|
+
/** 精确直接父 record id(depth=0 顶层 subagent / main 为 undefined) */
|
|
57
|
+
parentRecordId?: string
|
|
58
|
+
/** subagent 专属(manifest.agentName / identity.data.agent) */
|
|
59
|
+
agentName?: string
|
|
60
|
+
slug?: string
|
|
61
|
+
task?: string
|
|
62
|
+
model?: string
|
|
63
|
+
/** subagent 终态 completed/failed/running(manifest.status) */
|
|
64
|
+
status?: string
|
|
65
|
+
/** 递归子节点 */
|
|
66
|
+
children: ExecutionTreeNode[]
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 执行树根(DM2)。
|
|
71
|
+
*
|
|
72
|
+
* sourceMode 让 LLM 知晓精度:precise(任意节点子树可精确切)/ flat-fallback(顶层全树
|
|
73
|
+
* 可用,中间节点子树不精确)。truncated=true 时 LLM 应改用 recursive=false flat family 兜底。
|
|
74
|
+
*/
|
|
75
|
+
export interface ExecutionTree {
|
|
76
|
+
root: ExecutionTreeNode
|
|
77
|
+
totalNodes: number
|
|
78
|
+
maxDepth: number
|
|
79
|
+
truncated: boolean
|
|
80
|
+
sourceMode: 'precise' | 'flat-fallback'
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================
|
|
84
|
+
// 常量
|
|
85
|
+
// ============================================================
|
|
86
|
+
|
|
87
|
+
/** 递归深度兜底(ES2,参考 pi PI_SUBAGENT_FORK_DEPTH 保守上限)。 */
|
|
88
|
+
const MAX_DEPTH = 20
|
|
89
|
+
|
|
90
|
+
/** task 文本渲染截断长度(IF5,防爆长 task 撑乱树形)。 */
|
|
91
|
+
const TASK_RENDER_LIMIT = 50
|
|
92
|
+
|
|
93
|
+
/** runId 渲染截断长度。 */
|
|
94
|
+
const RUNID_RENDER_LIMIT = 16
|
|
95
|
+
/** sessionId 渲染截断长度(与 tool-handler.ts formatXxxText 一致,uuid 前 8 位)。 */
|
|
96
|
+
const SESSION_ID_SLICE = 8
|
|
97
|
+
|
|
98
|
+
// ============================================================
|
|
99
|
+
// parentRecordId 三级数据源(DM4)
|
|
100
|
+
// ============================================================
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 解析单个 record 的 parentRecordId(DM4 优先级)。
|
|
104
|
+
*
|
|
105
|
+
* ① manifest.parentRecordId(M3a 落盘后新 record,最高优先级)
|
|
106
|
+
* ② identity.data.parentRecordId(session jsonl 尾行,新版本 session-runner 才写)
|
|
107
|
+
* ③ 都缺→undefined(顶层 subagent / flat 回退)
|
|
108
|
+
*
|
|
109
|
+
* ② 需读 sessionFile 尾行(IO),仅在 ① 缺时触发;当前本机旧数据 ① 全缺、② 采样也全缺,
|
|
110
|
+
* 绝大多数走 ③(flat 回退),M3a 落地后新 record 走 ①。
|
|
111
|
+
*/
|
|
112
|
+
async function resolveParentRecordId(
|
|
113
|
+
manifest: RecordManifest,
|
|
114
|
+
): Promise<{ parentRecordId: string | undefined }> {
|
|
115
|
+
// ① manifest.parentRecordId
|
|
116
|
+
if (manifest.parentRecordId !== undefined) {
|
|
117
|
+
return { parentRecordId: manifest.parentRecordId }
|
|
118
|
+
}
|
|
119
|
+
// ② identity.data.parentRecordId(读 sessionFile 尾行;readTailIdentity 容错返 undefined)
|
|
120
|
+
if (manifest.sessionFile) {
|
|
121
|
+
const ident = await readTailIdentity(manifest.sessionFile)
|
|
122
|
+
if (ident !== undefined) {
|
|
123
|
+
return { parentRecordId: ident.parentRecordId }
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// ③ 都缺
|
|
127
|
+
return { parentRecordId: undefined }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ============================================================
|
|
131
|
+
// 版本探测 + 相关 record 收集(旧机制 rootSessionId 链)
|
|
132
|
+
// ============================================================
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 收集属于 rootSessionId 树的所有 record(ES3 旧机制兼容)。
|
|
136
|
+
*
|
|
137
|
+
* 新机制:所有同树 record 的 rootSessionId === 顶层 main(全树共享)。
|
|
138
|
+
* 旧机制:record 的 rootSessionId === 直接父 subagent 的 session id(非 main)。
|
|
139
|
+
*
|
|
140
|
+
* BFS 从 rootSessionId 出发:每轮收 rootSessionId===当前节点 的 record,并把该 record 的
|
|
141
|
+
* session id 入队(旧机制下,嵌套后代的 rootSessionId===父 session id)。新机制下 record 的
|
|
142
|
+
* session id 不会是其他 record 的 rootSessionId(全=main),BFS 不扩散——兼容两种机制。
|
|
143
|
+
*
|
|
144
|
+
* 不丢弃任何相关 record(ES3 准则:旧数据必须兼容回退)。
|
|
145
|
+
*/
|
|
146
|
+
function collectRelatedRecords(
|
|
147
|
+
rootSessionId: string,
|
|
148
|
+
manifests: RecordManifest[],
|
|
149
|
+
): RecordManifest[] {
|
|
150
|
+
const related: RecordManifest[] = []
|
|
151
|
+
const visitedSid = new Set<string>([rootSessionId])
|
|
152
|
+
const queue: string[] = [rootSessionId]
|
|
153
|
+
|
|
154
|
+
while (queue.length > 0) {
|
|
155
|
+
const cur = queue.shift() as string
|
|
156
|
+
for (const m of manifests) {
|
|
157
|
+
if (m.rootSessionId !== cur) continue
|
|
158
|
+
if (visitedSid.has(m.id)) continue
|
|
159
|
+
visitedSid.add(m.id)
|
|
160
|
+
related.push(m)
|
|
161
|
+
// 旧机制:m 的 session id 可能是其后代的 rootSessionId,入队继续 BFS
|
|
162
|
+
const mSid = extractSessionIdFromFilename(basename(m.sessionFile))
|
|
163
|
+
if (mSid !== '' && !visitedSid.has(mSid)) {
|
|
164
|
+
visitedSid.add(mSid)
|
|
165
|
+
queue.push(mSid)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return related
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============================================================
|
|
173
|
+
// buildExecutionTree(IF3 核心)
|
|
174
|
+
// ============================================================
|
|
175
|
+
|
|
176
|
+
/** 建树过程的共享可变状态(避免递归函数参数爆炸,封装为 context)。 */
|
|
177
|
+
/** 建树过程的共享可变状态(避免递归函数参数爆炸,封装为 context)。 */
|
|
178
|
+
interface BuildContext {
|
|
179
|
+
rootSessionId: string
|
|
180
|
+
related: RecordManifest[]
|
|
181
|
+
parentOf: Map<string, string | undefined>
|
|
182
|
+
visitedRecord: Set<string>
|
|
183
|
+
/**
|
|
184
|
+
* 已作为树节点(main/subagent/workflow-call)展开的 sessionFile 集合。
|
|
185
|
+
*
|
|
186
|
+
* 双重职责:①避免 workflow-call 重复创建(同 call session 多次引用);②环检测——call 循环里
|
|
187
|
+
* 若 call.sessionFile 已在此集合(某祖先 node 的 session),说明指针成环,标 truncated 跳过(ES1)。
|
|
188
|
+
* main/subagent node 在 attachWorkflowChildrenOfTree 入口 add;workflow-call node 在创建时 add。
|
|
189
|
+
*/
|
|
190
|
+
expandedSessions: Set<string>
|
|
191
|
+
truncated: boolean
|
|
192
|
+
totalNodes: number
|
|
193
|
+
maxDepth: number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 挂载 subagent 后代(parentRecordId 精确链 + flat 回退)。
|
|
198
|
+
*
|
|
199
|
+
* 挂载条件(任一):
|
|
200
|
+
* - parentRecordId === nodeRecordId(精确链:node 是父 subagent record)
|
|
201
|
+
* - node.type==='main' && parentRecordId===undefined(顶层 subagent:父是 main;含 flat 回退)
|
|
202
|
+
*
|
|
203
|
+
* 旧机制 record 全 parentRecordId===undefined → 全挂 main(flat-fallback 的体现)。
|
|
204
|
+
* 递归挂载嵌套后代(visitedRecord 防环 ES1,MAX_DEPTH 截断 ES2)。
|
|
205
|
+
*/
|
|
206
|
+
async function attachSubagentChildren(
|
|
207
|
+
ctx: BuildContext,
|
|
208
|
+
node: ExecutionTreeNode,
|
|
209
|
+
nodeRecordId: string | undefined,
|
|
210
|
+
): Promise<void> {
|
|
211
|
+
for (const m of ctx.related) {
|
|
212
|
+
const pid = ctx.parentOf.get(m.id)
|
|
213
|
+
// 挂载条件:精确链 或 顶层(main + pid undefined)
|
|
214
|
+
const isPreciseChild = pid !== undefined && pid === nodeRecordId
|
|
215
|
+
const isTopLevel = node.type === 'main' && pid === undefined
|
|
216
|
+
if (!isPreciseChild && !isTopLevel) continue
|
|
217
|
+
// 环检测(ES1):m 应挂此节点但已挂他处(坏数据多父/parentRecordId 环)→ 标 truncated 跳过
|
|
218
|
+
if (ctx.visitedRecord.has(m.id)) {
|
|
219
|
+
ctx.truncated = true
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (node.depth >= MAX_DEPTH) {
|
|
224
|
+
ctx.truncated = true
|
|
225
|
+
return // 该分支截断,不再挂子节点
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
ctx.visitedRecord.add(m.id)
|
|
229
|
+
const sid = extractSessionIdFromFilename(basename(m.sessionFile)) || m.id
|
|
230
|
+
const child: ExecutionTreeNode = {
|
|
231
|
+
type: 'subagent',
|
|
232
|
+
sessionId: sid,
|
|
233
|
+
sessionFile: m.sessionFile,
|
|
234
|
+
depth: node.depth + 1,
|
|
235
|
+
rootSessionId: ctx.rootSessionId,
|
|
236
|
+
parentRecordId: pid,
|
|
237
|
+
agentName: m.agentName,
|
|
238
|
+
slug: m.slug,
|
|
239
|
+
task: m.task,
|
|
240
|
+
model: m.model,
|
|
241
|
+
status: m.status,
|
|
242
|
+
children: [],
|
|
243
|
+
}
|
|
244
|
+
node.children.push(child)
|
|
245
|
+
ctx.totalNodes++
|
|
246
|
+
if (child.depth > ctx.maxDepth) ctx.maxDepth = child.depth
|
|
247
|
+
// 递归挂该 subagent 的后代(parentRecordId 链)
|
|
248
|
+
await attachSubagentChildren(ctx, child, m.id)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 挂载 workflow-call 后代(指针递归,IF3 TC2)。
|
|
254
|
+
*
|
|
255
|
+
* 读 node.sessionFile 的 workflow-state-link(resolveWorkflows)→ 每个 call 作 workflow-call
|
|
256
|
+
* 子 node → 递归读 call session 的 workflow-state-link(嵌套 workflow)。
|
|
257
|
+
*
|
|
258
|
+
* workflow-call 不挂 subagent 后代(parentRecordId 总指向 subagent 链,TC-m3b-nested-tree
|
|
259
|
+
* 确认 C 挂 A 不挂 B)。复用 resolveWorkflows/readRunSnapshot 容错(ES4,wf-state GC→calls=[])。
|
|
260
|
+
* expandedSessions 在 call 循环检测环(call.sessionFile 已是某祖先 node 的 session→指针成环,ES1)。
|
|
261
|
+
*/
|
|
262
|
+
async function attachWorkflowChildren(ctx: BuildContext, node: ExecutionTreeNode): Promise<void> {
|
|
263
|
+
if (!node.sessionFile) return
|
|
264
|
+
|
|
265
|
+
if (node.depth >= MAX_DEPTH) {
|
|
266
|
+
ctx.truncated = true
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// resolveWorkflows 需 sessionIdToPath(node.sessionId → sessionFile)
|
|
271
|
+
const sessionIdToPath = new Map<string, string>([[node.sessionId, node.sessionFile]])
|
|
272
|
+
const pathToRef = new Map<string, SessionRef>()
|
|
273
|
+
let workflows: WorkflowRef[]
|
|
274
|
+
try {
|
|
275
|
+
workflows = await resolveWorkflows(node.sessionId, sessionIdToPath, pathToRef)
|
|
276
|
+
} catch {
|
|
277
|
+
workflows = [] // resolveWorkflows 容错(ES4),异常时不中断树
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// MF-1 跨集合去重:收集本节点已有的 subagent 直接子节点 sessionFile。call session 在新机制下
|
|
281
|
+
// 同时被 parentRecordId 链(作 subagent 子节点)与 workflow 指针(作 workflow-call 子节点)命中,
|
|
282
|
+
// 两套去重集合(visitedRecord record id 维度 / expandedSessions sessionFile 维度)键空间不同会双重挂载。
|
|
283
|
+
// 仅对「同一父节点」去重——目标已是本节点 subagent 子节点时跳过 wf-call(非环,不标 truncated);
|
|
284
|
+
// 不同父节点的交叉引用(如环 A→B→A)不受影响,保留以触发环检测(设计 §4.1 场景 3「每个 call session 只出现一次」)。
|
|
285
|
+
const subagentChildFiles = new Set<string>()
|
|
286
|
+
for (const c of node.children) {
|
|
287
|
+
if (c.type === 'subagent' && c.sessionFile) subagentChildFiles.add(c.sessionFile)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for (const wf of workflows) {
|
|
291
|
+
for (const call of wf.calls) {
|
|
292
|
+
// MF-1 dedup:目标 session 已是本节点 subagent 直接子节点 → 跳过(去重,非环)
|
|
293
|
+
if (call.fileName && subagentChildFiles.has(call.fileName)) continue
|
|
294
|
+
const callKey = call.fileName || `wf:${wf.runId}:${call.sessionId}`
|
|
295
|
+
// 环检测(ES1):call session 已是某祖先 node 的 session(指针成环/重复引用)→ 标 truncated 跳过
|
|
296
|
+
if (ctx.expandedSessions.has(callKey)) {
|
|
297
|
+
ctx.truncated = true
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
ctx.expandedSessions.add(callKey)
|
|
301
|
+
|
|
302
|
+
if (node.depth + 1 > MAX_DEPTH) {
|
|
303
|
+
ctx.truncated = true
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const child: ExecutionTreeNode = {
|
|
308
|
+
type: 'workflow-call',
|
|
309
|
+
sessionId: call.sessionId,
|
|
310
|
+
runId: wf.runId,
|
|
311
|
+
stateFile: wf.stateFile,
|
|
312
|
+
sessionFile: call.fileName,
|
|
313
|
+
depth: node.depth + 1,
|
|
314
|
+
rootSessionId: ctx.rootSessionId,
|
|
315
|
+
children: [],
|
|
316
|
+
}
|
|
317
|
+
node.children.push(child)
|
|
318
|
+
ctx.totalNodes++
|
|
319
|
+
if (child.depth > ctx.maxDepth) ctx.maxDepth = child.depth
|
|
320
|
+
// 递归读 call session 的 workflow-state-link(嵌套 workflow)
|
|
321
|
+
await attachWorkflowChildren(ctx, child)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 遍历整树(DFS)对每个 main/subagent node 挂 workflow-call 后代。
|
|
328
|
+
*
|
|
329
|
+
* workflow-call node 的 workflow(嵌套 workflow)在 attachWorkflowChildren 内已递归,此处不再
|
|
330
|
+
* 对 workflow-call node 调用(避免重复)。复制 children 快照避免递归中数组变动影响迭代。
|
|
331
|
+
*/
|
|
332
|
+
async function attachWorkflowChildrenOfTree(
|
|
333
|
+
ctx: BuildContext,
|
|
334
|
+
node: ExecutionTreeNode,
|
|
335
|
+
): Promise<void> {
|
|
336
|
+
if (node.type === 'main' || node.type === 'subagent') {
|
|
337
|
+
if (node.sessionFile) ctx.expandedSessions.add(node.sessionFile)
|
|
338
|
+
await attachWorkflowChildren(ctx, node)
|
|
339
|
+
}
|
|
340
|
+
const snapshot = [...node.children]
|
|
341
|
+
for (const child of snapshot) {
|
|
342
|
+
await attachWorkflowChildrenOfTree(ctx, child)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 从任意节点(main 或 subagent)构建嵌套执行树(IF3 + 设计 §5.5「任意节点子树可切」)。
|
|
348
|
+
*
|
|
349
|
+
* 两种 root 形态:
|
|
350
|
+
* - **main root**(无 manifest 匹配 sessionFile):按 rootSessionId 链收全树(版本探测 +
|
|
351
|
+
* 旧机制兼容),root 节点 type='main',attachSubagentChildren 从 undefined 挂顶层 subagent。
|
|
352
|
+
* mainSessionFile 参数(MF-1):main session 自身发起的 workflow run 也要进树——main root
|
|
353
|
+
* 节点填 sessionFile 后 attachWorkflowChildren 才能读到其 workflow-state-link(否则
|
|
354
|
+
* workflow-call 分支对无 sessionFile 节点直接 return,main 的 workflow run 永不入树)。
|
|
355
|
+
* - **subagent root**(有 manifest 且 sessionFile 含 rootSessionId,MF-2):root 节点 type='subagent'
|
|
356
|
+
* 携带 manifest 元数据;复用其 rootSessionId(新机制=顶层 main)拉全树记录,再由
|
|
357
|
+
* attachSubagentChildren 按 parentRecordId 链只挂该 subagent 的后代(§5.5 决策五)。
|
|
358
|
+
*
|
|
359
|
+
* 流程:① listRecordManifests 扫所有 record;② 探测 root 形态 + collectRelatedRecords 收集;
|
|
360
|
+
* ③ resolveParentRecordId 解析每节点 parentRecordId(DM4 三级);④ subagent 后代按 parentRecordId
|
|
361
|
+
* 精确链挂载;⑤ workflow-call 后代指针递归(resolveWorkflows);⑥ visited Set 防环 + MAX_DEPTH 截断。
|
|
362
|
+
*
|
|
363
|
+
* 错误契约(ES1-5)全容错:环/深度超限截断标 truncated 不抛错;wf-state GC→children=[];
|
|
364
|
+
* 无后代→单节点树(totalNodes=1)。绝不丢弃 record。
|
|
365
|
+
*/
|
|
366
|
+
export async function buildExecutionTree(
|
|
367
|
+
rootSessionId: string,
|
|
368
|
+
agentDir: string,
|
|
369
|
+
/** main session .jsonl 绝对路径(MF-1:main root 填 sessionFile 使 main 的 workflow run 可读)。 */
|
|
370
|
+
mainSessionFile?: string,
|
|
371
|
+
): Promise<ExecutionTree> {
|
|
372
|
+
const manifests = await listRecordManifests(agentDir)
|
|
373
|
+
|
|
374
|
+
// MF-2:探测 root 是否为 subagent(有 manifest 且 sessionFile 文件名含 rootSessionId)。
|
|
375
|
+
// subagent root 时按 parentRecordId 链切子树;main root 时按 rootSessionId 链收全树。
|
|
376
|
+
const rootManifest = manifests.find(
|
|
377
|
+
(m) => extractSessionIdFromFilename(basename(m.sessionFile)) === rootSessionId,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
// ①② 收集相关 record:subagent root 复用其 rootSessionId(新机制=顶层 main)拉全树记录,
|
|
381
|
+
// 再由 attachSubagentChildren 按 parentRecordId 链只挂该 subagent 的后代(§5.5);
|
|
382
|
+
// main root 直接用 rootSessionId 收全树。旧机制 subagent root 无精确链 → 单节点(已知局限,§10)。
|
|
383
|
+
const collectRoot = rootManifest?.rootSessionId || rootSessionId
|
|
384
|
+
const related = collectRelatedRecords(collectRoot, manifests)
|
|
385
|
+
|
|
386
|
+
// ③ 解析每节点 parentRecordId(三级数据源)+ 探测 sourceMode
|
|
387
|
+
const parentOf = new Map<string, string | undefined>()
|
|
388
|
+
let hasPreciseLink = false
|
|
389
|
+
for (const m of related) {
|
|
390
|
+
const { parentRecordId } = await resolveParentRecordId(m)
|
|
391
|
+
parentOf.set(m.id, parentRecordId)
|
|
392
|
+
if (parentRecordId !== undefined) hasPreciseLink = true
|
|
393
|
+
}
|
|
394
|
+
// sourceMode(ES5):无 record → precise(确定无后代);有 record 但全无 parentRecordId →
|
|
395
|
+
// flat-fallback(旧机制回退);有任何精确链 → precise。
|
|
396
|
+
const sourceMode: 'precise' | 'flat-fallback' =
|
|
397
|
+
related.length > 0 && !hasPreciseLink ? 'flat-fallback' : 'precise'
|
|
398
|
+
|
|
399
|
+
// ctx.rootSessionId:subagent root 用其 manifest.rootSessionId(顶层 main),使后代节点的
|
|
400
|
+
// rootSessionId 字段仍指向全树共享的顶层 main(字段语义不变)。
|
|
401
|
+
const ctxRootSessionId = rootManifest?.rootSessionId || rootSessionId
|
|
402
|
+
const ctx: BuildContext = {
|
|
403
|
+
rootSessionId: ctxRootSessionId,
|
|
404
|
+
related,
|
|
405
|
+
parentOf,
|
|
406
|
+
visitedRecord: new Set<string>(),
|
|
407
|
+
expandedSessions: new Set<string>(),
|
|
408
|
+
truncated: false,
|
|
409
|
+
totalNodes: 1, // 含 root
|
|
410
|
+
maxDepth: 0,
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// root 节点:subagent root → 携带 manifest 元数据的 subagent 节点(MF-2);否则 main 节点
|
|
414
|
+
let root: ExecutionTreeNode
|
|
415
|
+
let rootRecordId: string | undefined
|
|
416
|
+
if (rootManifest) {
|
|
417
|
+
rootRecordId = rootManifest.id
|
|
418
|
+
// 防 root 自身被当后代重挂(环/坏数据多父)
|
|
419
|
+
ctx.visitedRecord.add(rootManifest.id)
|
|
420
|
+
root = {
|
|
421
|
+
type: 'subagent',
|
|
422
|
+
sessionId: rootSessionId,
|
|
423
|
+
sessionFile: rootManifest.sessionFile,
|
|
424
|
+
depth: 0,
|
|
425
|
+
rootSessionId: ctxRootSessionId,
|
|
426
|
+
parentRecordId: parentOf.get(rootManifest.id),
|
|
427
|
+
agentName: rootManifest.agentName,
|
|
428
|
+
slug: rootManifest.slug,
|
|
429
|
+
task: rootManifest.task,
|
|
430
|
+
model: rootManifest.model,
|
|
431
|
+
status: rootManifest.status,
|
|
432
|
+
children: [],
|
|
433
|
+
}
|
|
434
|
+
} else {
|
|
435
|
+
root = {
|
|
436
|
+
type: 'main',
|
|
437
|
+
sessionId: rootSessionId,
|
|
438
|
+
// MF-1:main root 携带 sessionFile(调用方传入),否则 attachWorkflowChildren 对无
|
|
439
|
+
// sessionFile 节点直接 return,main 自身发起的 workflow run 永远进不了执行树。
|
|
440
|
+
sessionFile: mainSessionFile,
|
|
441
|
+
depth: 0,
|
|
442
|
+
rootSessionId,
|
|
443
|
+
children: [],
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// 主流程:先挂 subagent 后代(parentRecordId 链,subagent root 从 rootRecordId 挂其后代),
|
|
448
|
+
// 再遍历整树挂 workflow 后代
|
|
449
|
+
await attachSubagentChildren(ctx, root, rootRecordId)
|
|
450
|
+
await attachWorkflowChildrenOfTree(ctx, root)
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
root,
|
|
454
|
+
totalNodes: ctx.totalNodes,
|
|
455
|
+
maxDepth: ctx.maxDepth,
|
|
456
|
+
truncated: ctx.truncated,
|
|
457
|
+
sourceMode,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ============================================================
|
|
462
|
+
// formatExecutionTreeText(IF5 渲染)
|
|
463
|
+
// ============================================================
|
|
464
|
+
|
|
465
|
+
/** 截断文本到 max 字符,超出加省略号。 */
|
|
466
|
+
function truncate(s: string, max: number): string {
|
|
467
|
+
return s.length <= max ? s : s.slice(0, max) + '…'
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 渲染执行树为缩进树形文本(IF5)。
|
|
472
|
+
*
|
|
473
|
+
* 头部摘要(totalNodes/maxDepth/sourceMode/truncated)+ 树形(按 depth 缩进,每行 type/sessionId
|
|
474
|
+
* 截断/status/task 摘要)+ 尾部 👉 节点 id 跳 outline/detail 深读。flat-fallback 时注明精度限制。
|
|
475
|
+
*/
|
|
476
|
+
export function formatExecutionTreeText(tree: ExecutionTree): string {
|
|
477
|
+
const lines: string[] = []
|
|
478
|
+
|
|
479
|
+
// 头部摘要
|
|
480
|
+
const modeLabel =
|
|
481
|
+
tree.sourceMode === 'precise'
|
|
482
|
+
? 'precise(parentRecordId 精确链)'
|
|
483
|
+
: 'flat-fallback(旧机制扁平回退)'
|
|
484
|
+
lines.push(
|
|
485
|
+
`execution tree: ${tree.totalNodes} node(s) · maxDepth ${tree.maxDepth} · ${modeLabel}${
|
|
486
|
+
tree.truncated ? ' · [truncated 环检测/深度截断]' : ''
|
|
487
|
+
}`,
|
|
488
|
+
)
|
|
489
|
+
if (tree.sourceMode === 'flat-fallback') {
|
|
490
|
+
lines.push('(旧机制数据:中间节点子树不可精确切,顶层全树可用)')
|
|
491
|
+
}
|
|
492
|
+
lines.push('')
|
|
493
|
+
|
|
494
|
+
// 树形渲染
|
|
495
|
+
const renderNode = (node: ExecutionTreeNode, indent: string): void => {
|
|
496
|
+
const sidLabel = node.sessionId !== '' ? node.sessionId.slice(0, SESSION_ID_SLICE) : '(unknown)'
|
|
497
|
+
const parts = [`${indent}${node.type} ${sidLabel}`]
|
|
498
|
+
if (node.type === 'workflow-call' && node.runId) {
|
|
499
|
+
parts.push(`run=${truncate(node.runId, RUNID_RENDER_LIMIT)}`)
|
|
500
|
+
}
|
|
501
|
+
if (node.status) parts.push(`[${node.status}]`)
|
|
502
|
+
if (node.slug) parts.push(`slug=${node.slug}`)
|
|
503
|
+
if (node.agentName) parts.push(`agent=${node.agentName}`)
|
|
504
|
+
if (node.task) parts.push(`· ${truncate(node.task, TASK_RENDER_LIMIT)}`)
|
|
505
|
+
lines.push(parts.join(' '))
|
|
506
|
+
const childIndent = indent + ' '
|
|
507
|
+
for (const c of node.children) renderNode(c, childIndent)
|
|
508
|
+
}
|
|
509
|
+
renderNode(tree.root, '')
|
|
510
|
+
|
|
511
|
+
// 尾部指引(M0 resolveSessionId 已打通任意节点 id 深读入口)
|
|
512
|
+
lines.push('')
|
|
513
|
+
lines.push(
|
|
514
|
+
`👉 树里任意节点 id 直接走 session_read {action:'outline'/'detail', session:'<nodeId>'} 深读`,
|
|
515
|
+
)
|
|
516
|
+
return lines.join('\n')
|
|
517
|
+
}
|
package/src/core/family.ts
CHANGED
|
@@ -32,6 +32,16 @@ export interface SubagentRef extends SessionRef {
|
|
|
32
32
|
slug: string
|
|
33
33
|
/** identity 在但文件已被 30 天 TTL GC(design §3.3 D-7 边界 / 失败路径 F3) */
|
|
34
34
|
cleanedUp?: boolean
|
|
35
|
+
/** subagent 任务文本(manifest.task,P-fallback 时 identity.data.task;两者都无则 undefined) */
|
|
36
|
+
task?: string
|
|
37
|
+
/** agent 类型名(manifest.agentName,P-fallback 时 identity.data.agent;同语义异名) */
|
|
38
|
+
agentName?: string
|
|
39
|
+
/** 模型 id(仅 manifest 有;P-fallback 时 undefined) */
|
|
40
|
+
model?: string
|
|
41
|
+
/** 终态 completed/failed/running(仅 manifest 有;P-fallback 时 undefined) */
|
|
42
|
+
status?: string
|
|
43
|
+
/** subagent session.jsonl 绝对路径(manifest.sessionFile 或 alive 文件 meta.path) */
|
|
44
|
+
sessionFile?: string
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export interface WorkflowRef {
|
|
@@ -68,14 +78,37 @@ export interface FamilyIndex {
|
|
|
68
78
|
|
|
69
79
|
// ---- 类型守卫:从 unknown 的 entry.data 提取 subagent identity 字段 ----
|
|
70
80
|
|
|
81
|
+
/**
|
|
82
|
+
* identity 尾行 data 结构(读取视图,DM-IdentityData)。
|
|
83
|
+
*
|
|
84
|
+
* 守卫只强制 rootSessionId+slug(m0 契约),其余富字段全 optional——由 buildFamilyFromFs
|
|
85
|
+
* 按 manifest 主/P-fallback 回退路径组装时决定是否填入(manifest 主填全,P-fallback
|
|
86
|
+
* 只填 task/agent/sessionFile,model/status 缺)。
|
|
87
|
+
*/
|
|
71
88
|
interface SubagentIdentityData {
|
|
72
89
|
rootSessionId: string
|
|
73
90
|
slug: string
|
|
91
|
+
/** 任务文本(manifest.task 或 identity.data.task) */
|
|
92
|
+
task?: string
|
|
93
|
+
/**
|
|
94
|
+
* agent 类型名(identity.data.agent)。
|
|
95
|
+
* 同语义异名:manifest.agentName ↔ identity.data.agent。buildFamilyFromFs 组装
|
|
96
|
+
* identity entry 时把 manifest.agentName 映射到 data.agent,此处统一读 data.agent
|
|
97
|
+
* 填 SubagentRef.agentName,消费方无需感知来源差异。
|
|
98
|
+
*/
|
|
99
|
+
agent?: string
|
|
100
|
+
/** 模型 id(仅 manifest 主路径写入;P-fallback 无) */
|
|
101
|
+
model?: string
|
|
102
|
+
/** 终态(仅 manifest 主路径写入;P-fallback 无) */
|
|
103
|
+
status?: string
|
|
104
|
+
/** subagent session.jsonl 绝对路径 */
|
|
105
|
+
sessionFile?: string
|
|
74
106
|
}
|
|
75
107
|
|
|
76
108
|
function isSubagentIdentityData(v: unknown): v is SubagentIdentityData {
|
|
77
109
|
if (typeof v !== 'object' || v === null) return false
|
|
78
110
|
const obj = v as Record<string, unknown>
|
|
111
|
+
// 守卫放宽:只校验 rootSessionId+slug 必填(m0 契约),task/agent/model/status/sessionFile optional
|
|
79
112
|
return typeof obj.rootSessionId === 'string' && typeof obj.slug === 'string'
|
|
80
113
|
}
|
|
81
114
|
|
|
@@ -163,6 +196,13 @@ export function buildFamilyIndex(
|
|
|
163
196
|
cwd: '', // identity entry 无 cwd;M2 从 subagent 文件 header 补
|
|
164
197
|
// M1: fileStats key=sessionId;M2 改用 subagent 真实文件路径(SubagentRef.fileName)查 fileStats
|
|
165
198
|
cleanedUp: !fileStats.has(ident.id),
|
|
199
|
+
// U4 富字段:从 identity data 读(manifest 主/P-fallback 由 buildFamilyFromFs 组装时决定)
|
|
200
|
+
task: ident.data.task,
|
|
201
|
+
// 异名映射:identity.data.agent ↔ manifest.agentName(buildFamilyFromFs 组装时统一到 data.agent)
|
|
202
|
+
agentName: ident.data.agent,
|
|
203
|
+
model: ident.data.model,
|
|
204
|
+
status: ident.data.status,
|
|
205
|
+
sessionFile: ident.data.sessionFile,
|
|
166
206
|
}
|
|
167
207
|
const list = subagentsByRoot.get(ident.data.rootSessionId) ?? []
|
|
168
208
|
list.push(ref)
|
package/src/core/tree.ts
CHANGED
|
@@ -36,9 +36,11 @@ function findForkPoint(
|
|
|
36
36
|
/**
|
|
37
37
|
* 按 design §3.5 算法 2 重建 leaf 路径视图。
|
|
38
38
|
*
|
|
39
|
-
* leafId =
|
|
40
|
-
*
|
|
41
|
-
*
|
|
39
|
+
* leafId = 末尾往前第一个 parentId !== null 的 entry(D-2 修正:pi 写的 subagent-identity
|
|
40
|
+
* 等元数据 custom entry 是 subagent session 文件尾行,但 parentId=null、非对话流节点;
|
|
41
|
+
* 若直接取文件尾 entry 会让 leafPath 在第一步断链,丢失全部对话 entry)。从 leafId 沿
|
|
42
|
+
* parentId 回溯到 root,遇 parentId 不在索引(断点/孤儿 root)即停。全空 session(仅
|
|
43
|
+
* header + 元数据,无 parentId!==null 的对话节点)→ leafId fallback entries[0]。
|
|
42
44
|
*
|
|
43
45
|
* orphans 只收集「不在 leafPath 上 + parentId 指向不存在」的 entry;leafPath 根自身
|
|
44
46
|
* 即使 parentId 断(断点处)也不重复计入——它已有归属(主链根),符合「孤儿=无归属」语义。
|
|
@@ -57,8 +59,15 @@ export function buildTreeView(entries: Entry[]): TreeView {
|
|
|
57
59
|
const index = new Map<string, Entry>()
|
|
58
60
|
for (const e of entries) index.set(e.id, e)
|
|
59
61
|
|
|
60
|
-
// 2. leafId =
|
|
61
|
-
|
|
62
|
+
// 2. leafId = 末尾往前第一个 parentId !== null 的 entry(跳过 subagent-identity 等
|
|
63
|
+
// 尾部元数据 custom;全空 session fallback entries[0]=root header)
|
|
64
|
+
let leafId: string = entries[0].id
|
|
65
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
66
|
+
if (entries[i].parentId !== null) {
|
|
67
|
+
leafId = entries[i].id
|
|
68
|
+
break
|
|
69
|
+
}
|
|
70
|
+
}
|
|
62
71
|
|
|
63
72
|
// 3. 从 leafId 沿 parentId 回溯到 root
|
|
64
73
|
let cur: string | null = leafId
|