@zhushanwen/pi-subagent-workflow 0.2.0 → 0.3.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.
Files changed (64) hide show
  1. package/README.md +56 -0
  2. package/agents/{scout.md → explorer.md} +1 -1
  3. package/agents/orchestrator.md +48 -0
  4. package/package.json +1 -1
  5. package/src/execution/__tests__/agent-registry.test.ts +3 -3
  6. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +484 -0
  7. package/src/execution/__tests__/channel-registry-handshake.test.ts +233 -0
  8. package/src/execution/__tests__/crash-recovery.test.ts +5 -1
  9. package/src/execution/__tests__/dialog-queue.test.ts +299 -0
  10. package/src/execution/__tests__/execute-nesting.test.ts +1 -1
  11. package/src/execution/__tests__/execute-options-mapper.test.ts +1 -1
  12. package/src/execution/__tests__/finalize-record.test.ts +173 -0
  13. package/src/execution/__tests__/gui-mode-dispatch.test.ts +2 -3
  14. package/src/execution/__tests__/helpers/spawn-mock.ts +209 -0
  15. package/src/execution/__tests__/host-mode.test.ts +87 -0
  16. package/src/execution/__tests__/index-session-start.test.ts +342 -0
  17. package/src/execution/__tests__/list-component.test.ts +1 -1
  18. package/src/execution/__tests__/notifier-flush.test.ts +78 -0
  19. package/src/execution/__tests__/path-encoding.test.ts +30 -1
  20. package/src/execution/__tests__/record-store.test.ts +86 -2
  21. package/src/execution/__tests__/records-cwd-isolation.test.ts +91 -0
  22. package/src/execution/__tests__/rpc-mode.test.ts +89 -0
  23. package/src/execution/__tests__/run-spawn-edges.test.ts +157 -153
  24. package/src/execution/__tests__/run-spawn-integration.test.ts +85 -151
  25. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +193 -0
  26. package/src/execution/__tests__/session-file-gc.test.ts +46 -0
  27. package/src/execution/__tests__/session-start-reaper.test.ts +7 -1
  28. package/src/execution/__tests__/spawn-args.test.ts +14 -19
  29. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +189 -0
  30. package/src/execution/__tests__/stdin-writer.test.ts +353 -0
  31. package/src/execution/__tests__/subagent-service.test.ts +73 -3
  32. package/src/execution/__tests__/tool-action.test.ts +1 -1
  33. package/src/execution/__tests__/ui-channels.test.ts +187 -0
  34. package/src/execution/__tests__/ui-interaction-model.test.ts +67 -0
  35. package/src/execution/__tests__/ui-request-handler-factory.test.ts +166 -0
  36. package/src/execution/__tests__/ui-request-handler.test.ts +204 -0
  37. package/src/execution/__tests__/ui-request-observability.test.ts +101 -0
  38. package/src/execution/__tests__/ui-request-queue.test.ts +133 -0
  39. package/src/execution/__tests__/worktree-manager.test.ts +1 -1
  40. package/src/execution/agent-registry.ts +1 -1
  41. package/src/execution/channel-registry-access.ts +138 -0
  42. package/src/execution/dialog-queue.ts +329 -0
  43. package/src/execution/finalize-record.ts +160 -0
  44. package/src/execution/get-state-handshake.ts +104 -0
  45. package/src/execution/host-mode.ts +52 -0
  46. package/src/execution/manifest-store.ts +206 -0
  47. package/src/execution/notifier.ts +5 -1
  48. package/src/execution/path-encoding.ts +18 -0
  49. package/src/execution/pi-invocation.ts +1 -1
  50. package/src/execution/record-store.ts +108 -2
  51. package/src/execution/session-file-gc.ts +25 -3
  52. package/src/execution/session-runner.ts +216 -32
  53. package/src/execution/spawn-event-adapter.ts +219 -6
  54. package/src/execution/stdin-writer.ts +106 -0
  55. package/src/execution/subagent-service.ts +167 -197
  56. package/src/execution/ui-channels.ts +216 -0
  57. package/src/execution/ui-interaction-model.ts +48 -0
  58. package/src/execution/ui-request-handler-factory.ts +175 -0
  59. package/src/execution/ui-request-observability.ts +77 -0
  60. package/src/execution/ui-request-queue.ts +168 -0
  61. package/src/index.ts +90 -6
  62. package/src/interface/format.ts +2 -0
  63. package/src/interface/subagent-actions.ts +9 -2
  64. package/src/interface/subagent-tool.ts +9 -8
@@ -0,0 +1,206 @@
1
+ import * as fs from "node:fs";
2
+ import * as fsPromises from "node:fs/promises";
3
+ import * as path from "node:path";
4
+
5
+ import { bestEffort } from "./best-effort.ts";
6
+
7
+ export interface ManifestRecord {
8
+ id: string;
9
+ rootSessionId: string;
10
+ agentName: string;
11
+ /**
12
+ * 终态枚举:finalizeRecord 写 running/completed/failed/cancelled 四态;cancelled 不再
13
+ * 归并 failed。crashed 不进 manifest——crashed 是重启重建时靠 sidecar 四分支推断的派生态
14
+ * (见 record-store.ts reconstructAll),持久化会与 sidecar source of truth 形成双源;
15
+ * manifest 职责保持纯粹,只记录 finalize 明确产出的终态。
16
+ * 历史 "error" 值已移除——读侧 isValidManifest 守卫拒绝,mapManifestStatus 越界返回 null。
17
+ */
18
+ status: "running" | "completed" | "failed" | "cancelled";
19
+ createdAt: number;
20
+ completedAt?: number;
21
+ sessionFile?: string;
22
+ /** FR-7 补字段:manifest 写入时从 ExecutionRecord 抓取,供 manifestToSubagent 投影真实值。 */
23
+ task?: string;
24
+ slug?: string;
25
+ model?: string;
26
+ }
27
+
28
+ /** 合法 manifest status 集合(4 态;运行时守卫用,磁盘文件可能陈旧/损坏)。crashed 不在其中。 */
29
+ const VALID_MANIFEST_STATUSES: ReadonlySet<string> = new Set([
30
+ "running",
31
+ "completed",
32
+ "failed",
33
+ "cancelled",
34
+ ]);
35
+
36
+ /**
37
+ * 校验 JSON.parse 产物是否为合法 ManifestRecord。
38
+ * 关键字段类型检查——不合法返回 false,调用方据此过滤(防损坏/陈旧文件污染投影)。
39
+ */
40
+ function isValidManifest(value: unknown): value is ManifestRecord {
41
+ if (typeof value !== "object" || value === null) return false;
42
+ const v = value as Record<string, unknown>;
43
+ return (
44
+ typeof v.id === "string" &&
45
+ typeof v.rootSessionId === "string" &&
46
+ typeof v.agentName === "string" &&
47
+ typeof v.createdAt === "number" &&
48
+ typeof v.status === "string" &&
49
+ VALID_MANIFEST_STATUSES.has(v.status)
50
+ );
51
+ }
52
+
53
+ export class ManifestStore {
54
+ private readonly dir: string;
55
+
56
+ constructor(dir: string) {
57
+ this.dir = dir;
58
+ if (!fs.existsSync(dir)) {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * 原子写:tmp → fsync → rename → fsync dir。真异步(fs.promises,不阻塞 event loop)。
65
+ *
66
+ * rename 失败时 best-effort 清理残留 tmp(用 renamed 标志在 catch 中决定是否 unlink),
67
+ * 不掩盖原错误。失败向上抛——调用方(finalizeRecord)决定降级策略。
68
+ */
69
+ async writeManifest(record: ManifestRecord): Promise<void> {
70
+ const filePath = path.join(this.dir, `${record.id}.json`);
71
+ const tmpPath = `${filePath}.tmp.${process.pid}`;
72
+ const content = JSON.stringify(record, null, 2);
73
+
74
+ let renamed = false;
75
+ try {
76
+ // 1. 写 tmp → fsync 文件
77
+ const fh = await fsPromises.open(tmpPath, "w");
78
+ try {
79
+ await fh.writeFile(content, "utf-8");
80
+ await fh.sync();
81
+ } finally {
82
+ await fh.close();
83
+ }
84
+
85
+ // 2. rename tmp → final(放入 try:失败时 catch 清理 tmp)
86
+ await fsPromises.rename(tmpPath, filePath);
87
+ renamed = true;
88
+
89
+ // 3. fsync 目录(best-effort:POSIX 不要求,失败不否定已成功的 rename)
90
+ try {
91
+ const dirFh = await fsPromises.open(this.dir, "r");
92
+ try {
93
+ await dirFh.sync();
94
+ } finally {
95
+ await dirFh.close();
96
+ }
97
+ } catch (dirSyncErr) {
98
+ bestEffort(dirSyncErr, "fsync dir (writeManifest)");
99
+ }
100
+ } catch (err) {
101
+ // rename 未成功 → 清理残留 tmp(best-effort,不掩盖原错误)
102
+ if (!renamed) {
103
+ try {
104
+ await fsPromises.unlink(tmpPath);
105
+ } catch (cleanupErr) {
106
+ // best-effort:tmp 可能已被 rename 消费或从未创建。不影响主错误(下面 re-throw err)
107
+ bestEffort(cleanupErr, "unlink tmp (writeManifest)");
108
+ }
109
+ }
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * 按 id 读 manifest。文件不存在/JSON 损坏/schema 不合法均返回 null。
116
+ * 调用方需处理 null。
117
+ */
118
+ async readManifest(id: string): Promise<ManifestRecord | null> {
119
+ const filePath = path.join(this.dir, `${id}.json`);
120
+ try {
121
+ const content = await fsPromises.readFile(filePath, "utf-8");
122
+ const parsed: unknown = JSON.parse(content);
123
+ return isValidManifest(parsed) ? parsed : null;
124
+ } catch {
125
+ // 文件缺失(ENOENT)或 JSON 损坏(SyntaxError)均降级为 null
126
+ return null;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 同步读取所有 manifest 记录(best-effort,损坏/非法文件跳过)。
132
+ * 供 RecordStore.collectRecords 投影 orphan 记录使用——替代对私有 dir 的反射访问。
133
+ * 仅返回通过 isValidManifest 校验的记录。
134
+ */
135
+ listAllSync(): readonly ManifestRecord[] {
136
+ let files: string[];
137
+ try {
138
+ files = fs.readdirSync(this.dir);
139
+ } catch {
140
+ return [];
141
+ }
142
+ const results: ManifestRecord[] = [];
143
+ for (const file of files) {
144
+ if (!file.endsWith(".json") || file.includes(".tmp.")) continue;
145
+ try {
146
+ const content = fs.readFileSync(path.join(this.dir, file), "utf-8");
147
+ const parsed: unknown = JSON.parse(content);
148
+ if (isValidManifest(parsed)) {
149
+ results.push(parsed);
150
+ }
151
+ } catch (fileErr) {
152
+ // best-effort:损坏/非法文件跳过(debug 记录便于排查)
153
+ bestEffort(fileErr, `read manifest ${file} (listAllSync)`);
154
+ }
155
+ }
156
+ return results;
157
+ }
158
+
159
+ /**
160
+ * 启动时恢复 tmp 文件。
161
+ * 3 分支逻辑:
162
+ * 1. manifest 已存在 → 删 tmp(陈旧)
163
+ * 2. tmp 合法 + manifest 缺失 → rename tmp 为 manifest
164
+ * 3. tmp 非法 + manifest 缺失 → 删 tmp
165
+ */
166
+ async recoverTmpFiles(): Promise<{ deleted: number; recovered: number }> {
167
+ let deleted = 0;
168
+ let recovered = 0;
169
+
170
+ const files = fs.readdirSync(this.dir);
171
+ const tmpFiles = files.filter((f) => f.includes(".json.tmp."));
172
+
173
+ for (const tmpFile of tmpFiles) {
174
+ const tmpPath = path.join(this.dir, tmpFile);
175
+ const manifestId = tmpFile.split(".json.tmp.")[0];
176
+ const manifestPath = path.join(this.dir, `${manifestId}.json`);
177
+
178
+ if (fs.existsSync(manifestPath)) {
179
+ // 分支 1: manifest 已存在,删 tmp
180
+ fs.unlinkSync(tmpPath);
181
+ deleted++;
182
+ } else {
183
+ // 试解析 tmp
184
+ try {
185
+ const content = fs.readFileSync(tmpPath, "utf-8");
186
+ const parsed: unknown = JSON.parse(content);
187
+ if (isValidManifest(parsed)) {
188
+ // 分支 2: tmp 是合法 manifest,rename 为正式文件
189
+ fs.renameSync(tmpPath, manifestPath);
190
+ recovered++;
191
+ } else {
192
+ // 分支 3b: 合法 JSON 但非合法 manifest(缺必填字段),删
193
+ fs.unlinkSync(tmpPath);
194
+ deleted++;
195
+ }
196
+ } catch {
197
+ // 分支 3a: JSON.parse 失败,删
198
+ fs.unlinkSync(tmpPath);
199
+ deleted++;
200
+ }
201
+ }
202
+ }
203
+
204
+ return { deleted, recovered };
205
+ }
206
+ }
@@ -129,7 +129,11 @@ export class BgNotifier {
129
129
  content,
130
130
  display: true,
131
131
  details,
132
- }, { triggerTurn: true, deliverAs: "followUp" });
132
+ // [W2 修复] followUp steer:subagent 完成通知需立即抢占主 agent 下一个 turn,
133
+ // 即使主 agent 处于轮询 subagent_list 的 processing 状态(followUp 永远排不上)。
134
+ // 与 workflow helpers.ts:151 同语义对齐(commit d214d0d83 验证 steer 能避免
135
+ // 'Agent is already processing' 错误)。
136
+ }, { triggerTurn: true, deliverAs: "steer" });
133
137
  }
134
138
 
135
139
  private buildLlmContent(record: BgNotifyRecord): string {
@@ -32,3 +32,21 @@ export function getSubagentSessionDir(agentDir: string, mainCwd: string): string
32
32
  // 本分支未发布,回退到既有布局即无需迁移、无数据丢失。
33
33
  return path.join(agentDir, "subagents", encodeCwd(mainCwd), "sessions");
34
34
  }
35
+
36
+ /**
37
+ * 获取 subagent records(manifest)持久化目录路径。
38
+ *
39
+ * 与 getSubagentSessionDir 同用 encodeCwd(mainCwd),保证 records 与 sessions 在同一
40
+ * <enc> 段下物理相邻——worktree 场景三者恒等(init.cwd /
41
+ * buildSessionRunnerContext.mainCwd / record.worktreeHandle.mainCwd 指向同一主 cwd)。
42
+ *
43
+ * D-004 同源:用主 cwd 编码做物理隔离,使 session-file-gc 按 <enc>/records/ 子目录
44
+ * 匹配 manifest .json 时天然限定在当前 cwd 范围内,不会越界清理其他 cwd 的 manifest。
45
+ *
46
+ * @param agentDir agent 配置目录(如 ~/.pi/agent)
47
+ * @param mainCwd 主 agent 的工作目录(非 subagent 的 effectiveCwd)
48
+ * @returns records 持久化目录绝对路径
49
+ */
50
+ export function getSubagentRecordsDir(agentDir: string, mainCwd: string): string {
51
+ return path.join(agentDir, "subagents", encodeCwd(mainCwd), "records");
52
+ }
@@ -42,7 +42,7 @@ function isGenericRuntime(execPath: string): boolean {
42
42
  /**
43
43
  * 组装 pi 子进程的 spawn 调用。
44
44
  *
45
- * @param userArgs pi CLI 参数(如 ["--mode", "json", "-p", "Task: ..."])
45
+ * @param userArgs pi CLI 参数(如 ["--mode", "rpc", "--session-dir", "..."])
46
46
  * @returns spawn 描述符(command + 完整 args)
47
47
  *
48
48
  * 决策链(按优先级):
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
 
14
14
  import { getCurrentActivity, getDisplayItems, getEventLog, markReconstructedStatus, snapshot as toSnapshot } from "./execution-record.ts";
15
+ import type { ManifestRecord, ManifestStore } from "./manifest-store.ts";
15
16
  import { reconstructFromFile } from "./session-reconstructor.ts";
16
17
  import type {
17
18
  ExecutionRecord,
@@ -37,7 +38,25 @@ const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
37
38
  };
38
39
 
39
40
  /** .alive sidecar 的 24 小时软超时(超过此时间即使 pid 存活也判 crashed)。 */
40
- const ALIVE_SOFT_TIMEOUT_MS = 86_400_000; // 24h in ms
41
+ const ALIVE_SOFT_TIMEOUT_MS = 3_600_000; // 1h in ms (reduced from 24h to minimize PID reuse window)
42
+
43
+ /**
44
+ * manifest status → ExecutionStatus 运行时守卫映射。
45
+ *
46
+ * manifest 写 running/completed/failed/cancelled 四态(ManifestRecord.status union),但磁盘
47
+ * 文件可能陈旧(含历史 "error" 值、被外部篡改、或意外出现 crashed 值)。越界值返回 null——
48
+ * manifestToSubagent 据此返回 null,collectRecords 跳过损坏 record 并 console.warn,不因单个
49
+ * 坏文件崩溃,也不把损坏 record 错误降级为 failed(failed 会触发重试/告警,是误报)。
50
+ *
51
+ * 提取为纯函数:同时解决 PR#85 反射问题(三元 + `as ExecutionStatus` cast)。
52
+ */
53
+ function mapManifestStatus(s: string): ExecutionStatus | null {
54
+ if (s === "completed") return "done";
55
+ if (s === "failed") return "failed";
56
+ if (s === "running") return "running";
57
+ if (s === "cancelled") return "cancelled";
58
+ return null; // 越界=数据损坏(含历史 "error"、意外 crashed 值),返回 null 让调用方跳过
59
+ }
41
60
 
42
61
  /** store 变更监听器(返回取消订阅函数)。 */
43
62
  export type ChangeListener = () => void;
@@ -45,6 +64,12 @@ export type ChangeListener = () => void;
45
64
  /** status 过滤模式(collectRecords 的核心能力参数)。 */
46
65
  export type StatusFilter = "running" | "all";
47
66
 
67
+ /** Pi ExtensionAPI 的最小子集(仅 collectRecords 跳过损坏 manifest 时上报用)。
68
+ * 解构为局部类型,避免与 subagent-service 的 PiLike 循环依赖。 */
69
+ export type RecordStorePi = {
70
+ appendEntry?: (customType: string, data: unknown) => void;
71
+ } | null | undefined;
72
+
48
73
  // ============================================================
49
74
  // RecordStore
50
75
  // ============================================================
@@ -62,11 +87,31 @@ export class RecordStore {
62
87
  private readonly records = new Map<string, ExecutionRecord>();
63
88
  private readonly listeners = new Set<ChangeListener>();
64
89
  private _disposed = false;
90
+ /** Pi handle(用于 appendEntry 上报损坏 manifest)。构造时可空,setPi() 后续注入。
91
+ * 显式存为字段而非构造参数 readonly:setPi 需要写权限。 */
92
+ private pi: RecordStorePi = null;
65
93
 
66
94
  /** 重建缓存:sessionFile → SubagentRecord。notifyChange 时失效。 */
67
95
  private reconCache: Map<string, SubagentRecord> | undefined;
68
96
 
69
- constructor(private readonly sessionsDir: string) {}
97
+ constructor(
98
+ private readonly sessionsDir: string,
99
+ private readonly manifestStore?: ManifestStore,
100
+ /** Pi 入口(注入 appendEntry 用于上报损坏 manifest)。
101
+ * SubagentService 构造时 this.pi 尚未注入(session_start 之前),传 undefined 兜底;
102
+ * 后续通过 setPi() 注入(见下)。允许 null = 兼容 PiLike 字段类型。 */
103
+ pi?: RecordStorePi,
104
+ ) {
105
+ this.pi = pi ?? null;
106
+ }
107
+
108
+ /** session_start 后由 SubagentService.initSession 调,注入真实 Pi handle。
109
+ * 设计为独立方法而非要求构造时必传——RecordStore 在 SubagentService 构造时即建
110
+ * (与 sessionsDir/manifestStore 一同初始化),但 this.pi 此时尚未注入。
111
+ * 后续构造期外的 appendEntry 上报才有意义。 */
112
+ setPi(pi: RecordStorePi): void {
113
+ this.pi = pi ?? null;
114
+ }
70
115
 
71
116
  /** 注册新 record。触发 onChange。 */
72
117
  register(record: ExecutionRecord): void {
@@ -154,6 +199,32 @@ export class RecordStore {
154
199
  byId.set(rec.id, rec);
155
200
  }
156
201
 
202
+ // 1.5 FR-8: manifest 源补充 orphan 记录。
203
+ // 优先级:内存 > 磁盘重建 > manifest。manifest 仅补充 session.jsonl 重建失败的记录。
204
+ if (this.manifestStore) {
205
+ for (const manifest of this.readManifestsSync()) {
206
+ if (byId.has(manifest.id)) continue; // 已被磁盘/内存源覆盖
207
+ if (rootSessionFilter !== undefined && manifest.rootSessionId !== rootSessionFilter) continue;
208
+ const rec = RecordStore.manifestToSubagent(manifest);
209
+ if (!rec) {
210
+ // manifest status 越界=数据损坏(含历史 "error"、意外 crashed 值):跳过而非降级 failed,
211
+ // 避免损坏 record 被误显示为 failed(触发错误重试/告警)。
212
+ // 双通道上报:console.warn 给开发者(终端调试);pi.appendEntry 给用户(session 内可见,
213
+ // 即使退出后也能从 session.jsonl 复盘事故原因)。SubagentService 构造时 pi 未注入
214
+ // (session_start 之前),appendEntry 走可选链安全降级。
215
+ console.warn("[subagents] skip manifest with invalid status:", manifest.id, manifest.status);
216
+ this.pi?.appendEntry?.("subagent:manifest-invalid-status", {
217
+ id: manifest.id,
218
+ status: manifest.status,
219
+ rootSessionId: manifest.rootSessionId,
220
+ agentName: manifest.agentName,
221
+ });
222
+ continue;
223
+ }
224
+ byId.set(rec.id, rec);
225
+ }
226
+ }
227
+
157
228
  // 2. 内存源覆盖(running record 优先——它是活态,比磁盘重建更新鲜)。同样按 session 过滤。
158
229
  for (const r of this.records.values()) {
159
230
  if (rootSessionFilter !== undefined && r.rootSessionId !== rootSessionFilter) continue;
@@ -323,6 +394,41 @@ export class RecordStore {
323
394
  return b.startedAt - a.startedAt; // 新→旧
324
395
  }
325
396
 
397
+ /** FR-8: 同步读取所有 manifest 记录(封装 ManifestStore.listAllSync,消除反射访问)。 */
398
+ private readManifestsSync(): readonly ManifestRecord[] {
399
+ return this.manifestStore?.listAllSync() ?? [];
400
+ }
401
+
402
+ /** FR-8: ManifestRecord → SubagentRecord(manifest 源投影)。
403
+ * task/slug/model 从 manifest 真实值投影(配合 writeManifest 补字段),缺失兜底空串。
404
+ * status 越界(mapManifestStatus 返回 null)时返回 null,由 collectRecords 跳过。 */
405
+ private static manifestToSubagent(m: ManifestRecord): SubagentRecord | null {
406
+ const status = mapManifestStatus(m.status);
407
+ if (status === null) return null;
408
+ return {
409
+ id: m.id,
410
+ agent: m.agentName,
411
+ task: m.task ?? "",
412
+ slug: m.slug ?? "",
413
+ status,
414
+ mode: "background" as const,
415
+ startedAt: m.createdAt,
416
+ rootSessionId: m.rootSessionId || undefined,
417
+ parentRecordId: undefined,
418
+ depth: 0,
419
+ endedAt: m.completedAt,
420
+ turns: 0,
421
+ totalTokens: 0,
422
+ model: m.model ?? "",
423
+ thinkingLevel: undefined,
424
+ eventLog: [],
425
+ displayItems: [],
426
+ result: undefined,
427
+ error: status === "failed" ? "manifest record" : undefined,
428
+ sessionFile: m.sessionFile,
429
+ };
430
+ }
431
+
326
432
  /** ExecutionRecord → SubagentRecord(内存源投影)。 */
327
433
  private static recordToSubagent(r: ExecutionRecord): SubagentRecord {
328
434
  return {
@@ -44,8 +44,11 @@ export function maybeCleanupExpiredSessionFiles(agentDir: string, cwd: string):
44
44
  }
45
45
  }
46
46
 
47
- /** 递归扫描目录,unlink 超 TTL 的 .jsonl 文件及其 .cancelled sidecar。 */
48
- function walkAndClean(dir: string, now: number): void {
47
+ /** 递归扫描目录,unlink 超 TTL 的 .jsonl 文件及其 .cancelled sidecar。
48
+ * [F2] 进入名为 records 的子目录时,额外清理超 TTL 的 manifest .json(跳过 .tmp.——
49
+ * recoverTmpFiles 同步处理)。allowManifestJson 仅由父调用按目录名开启,其他位置
50
+ * (如 subagents/worktrees.json)不匹配 .json,避免误删 worktree reaper 状态文件。 */
51
+ function walkAndClean(dir: string, now: number, allowManifestJson = false): void {
49
52
  let entries: fs.Dirent[];
50
53
  try {
51
54
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -56,7 +59,26 @@ function walkAndClean(dir: string, now: number): void {
56
59
  for (const entry of entries) {
57
60
  const full = path.join(dir, entry.name);
58
61
  if (entry.isDirectory()) {
59
- walkAndClean(full, now);
62
+ // 只在进入名为 records 的子目录时打开 manifest .json 清理。
63
+ // records 在 <enc>/records/ 下递归自动覆盖;其他位置(如 subagents/worktrees.json)
64
+ // 不能匹配 .json——否则会误删 worktree reaper 依赖的状态文件。
65
+ walkAndClean(full, now, entry.name === "records");
66
+ } else if (
67
+ allowManifestJson &&
68
+ entry.name.endsWith(".json") &&
69
+ // 跳过 .tmp.:recoverTmpFiles(session_start)同步处理 tmp,GC 不重复。
70
+ // 不校验内容——30 天 mtime 已是强 orphan 信号,扩展名 + 文件名足够。
71
+ !entry.name.includes(".tmp.")
72
+ ) {
73
+ try {
74
+ const stat = fs.statSync(full);
75
+ if (now - stat.mtimeMs > TTL_MS) {
76
+ fs.unlinkSync(full);
77
+ }
78
+ } catch (_e) {
79
+ // 文件可能已被删除,忽略
80
+ void _e;
81
+ }
60
82
  } else if (entry.name.endsWith(".jsonl")) {
61
83
  try {
62
84
  const stat = fs.statSync(full);