@zhushanwen/pi-subagent-workflow 8.0.0 → 8.1.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 (55) hide show
  1. package/package.json +7 -4
  2. package/scripts/rfl.mjs +308 -0
  3. package/src/execution/__tests__/finalize-record.test.ts +33 -7
  4. package/src/execution/__tests__/helpers/spawn-mock.ts +12 -3
  5. package/src/execution/__tests__/record-store.test.ts +284 -2
  6. package/src/execution/__tests__/spawn-args.test.ts +5 -5
  7. package/src/execution/__tests__/subagent-service-message-close.test.ts +31 -0
  8. package/src/execution/__tests__/worktree-manager.test.ts +9 -2
  9. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -1
  10. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
  11. package/src/execution/__tests__/worktree-registry.test.ts +72 -34
  12. package/src/execution/agent-result-mapper.ts +4 -1
  13. package/src/execution/argv-mirror.ts +1 -1
  14. package/src/execution/channel-registry-access.ts +3 -1
  15. package/src/execution/finalize-record.ts +16 -7
  16. package/src/execution/idle-gc.ts +47 -0
  17. package/src/execution/lifecycle-manager.ts +9 -2
  18. package/src/execution/record-entry.ts +118 -0
  19. package/src/execution/record-store.ts +290 -1
  20. package/src/execution/session-pending.ts +5 -4
  21. package/src/execution/session-runner.ts +484 -289
  22. package/src/execution/subagent-service.ts +87 -37
  23. package/src/execution/temp-prompt.ts +8 -3
  24. package/src/execution/types.ts +20 -2
  25. package/src/execution/worktree-manager.ts +325 -14
  26. package/src/execution/worktree-registry.ts +90 -33
  27. package/src/index.ts +42 -2
  28. package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
  29. package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
  30. package/src/interface/format.ts +10 -4
  31. package/src/interface/tool-render.ts +10 -3
  32. package/src/interface/tool-workflow-script.ts +29 -33
  33. package/src/interface/tool-workflow.ts +47 -65
  34. package/src/interface/views/detail-content.ts +1 -1
  35. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +9 -1
  36. package/src/orchestration/__tests__/execute-agent-call.test.ts +49 -1
  37. package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
  38. package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +181 -19
  39. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
  40. package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
  41. package/src/orchestration/__tests__/test-mocks.ts +9 -3
  42. package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
  43. package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +12 -0
  44. package/src/orchestration/__tests__/workflows-e2e.test.ts +37 -39
  45. package/src/orchestration/error-recovery.ts +4 -1
  46. package/src/orchestration/execute-agent-call.ts +13 -3
  47. package/src/orchestration/jsonl-run-store.ts +139 -60
  48. package/src/orchestration/lifecycle.ts +10 -1
  49. package/src/orchestration/worker-script-builder.ts +9 -1
  50. package/src/shared/__tests__/resource-discovery.test.ts +24 -0
  51. package/src/shared/agent-ref.ts +6 -1
  52. package/src/shared/resource-discovery.ts +15 -1
  53. package/src/shared/schema-jsonify.ts +4 -1
  54. package/workflows/review-fix-loop-utils.cjs +542 -32
  55. package/workflows/review-fix-loop.js +462 -109
@@ -45,29 +45,29 @@ describe("WorktreeRegistry", () => {
45
45
  });
46
46
 
47
47
  describe("add + load", () => {
48
- it("add 后 load 能读到", () => {
48
+ it("add 后 load 能读到", async () => {
49
49
  const entry = makeEntry();
50
- registry.add(entry);
50
+ await registry.add(entry);
51
51
  const loaded = registry.load();
52
52
  expect(loaded).toHaveLength(1);
53
53
  expect(loaded[0]).toEqual(entry);
54
54
  });
55
55
 
56
- it("多条目跨 repo 共存", () => {
56
+ it("多条目跨 repo 共存", async () => {
57
57
  const entryA = makeEntry({ repo: REPO_A, branch: "pi-sub-bg-1" });
58
58
  const entryB = makeEntry({ repo: REPO_B, branch: "pi-sub-bg-2" });
59
- registry.add(entryA);
60
- registry.add(entryB);
59
+ await registry.add(entryA);
60
+ await registry.add(entryB);
61
61
  const loaded = registry.load();
62
62
  expect(loaded).toHaveLength(2);
63
63
  });
64
64
 
65
- it("同 branch add 覆盖(去重)", () => {
65
+ it("同 branch add 覆盖(去重)", async () => {
66
66
  const entry = makeEntry({ pid: 0 });
67
- registry.add(entry);
67
+ await registry.add(entry);
68
68
  // 同 branch 再次 add(覆盖)
69
69
  const updated = makeEntry({ pid: 999 });
70
- registry.add(updated);
70
+ await registry.add(updated);
71
71
  const loaded = registry.load();
72
72
  expect(loaded).toHaveLength(1);
73
73
  expect(loaded[0].pid).toBe(999);
@@ -75,25 +75,25 @@ describe("WorktreeRegistry", () => {
75
75
  });
76
76
 
77
77
  describe("updatePid", () => {
78
- it("补全 pid(create 占位 → first header 补全)", () => {
79
- registry.add(makeEntry({ branch: "pi-sub-bg-1", pid: 0 }));
80
- registry.updatePid("pi-sub-bg-1", 12345);
78
+ it("补全 pid(create 占位 → first header 补全)", async () => {
79
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1", pid: 0 }));
80
+ await registry.updatePid("pi-sub-bg-1", 12345);
81
81
  const loaded = registry.load();
82
82
  expect(loaded[0].pid).toBe(12345);
83
83
  });
84
84
 
85
- it("branch 不存在时忽略(幂等)", () => {
86
- registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
87
- registry.updatePid("pi-sub-nonexistent", 12345);
85
+ it("branch 不存在时忽略(幂等)", async () => {
86
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
87
+ await registry.updatePid("pi-sub-nonexistent", 12345);
88
88
  const loaded = registry.load();
89
89
  expect(loaded).toHaveLength(1);
90
90
  expect(loaded[0].pid).toBe(0); // 原条目未变
91
91
  });
92
92
 
93
- it("update 不改变其他字段", () => {
93
+ it("update 不改变其他字段", async () => {
94
94
  const entry = makeEntry({ branch: "pi-sub-bg-1", repo: REPO_A, checkout: "/tmp/x" });
95
- registry.add(entry);
96
- registry.updatePid("pi-sub-bg-1", 999);
95
+ await registry.add(entry);
96
+ await registry.updatePid("pi-sub-bg-1", 999);
97
97
  const loaded = registry.load();
98
98
  expect(loaded[0].repo).toBe(REPO_A);
99
99
  expect(loaded[0].checkout).toBe("/tmp/x");
@@ -102,60 +102,98 @@ describe("WorktreeRegistry", () => {
102
102
  });
103
103
 
104
104
  describe("remove", () => {
105
- it("移除指定 branch", () => {
106
- registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
107
- registry.add(makeEntry({ branch: "pi-sub-bg-2" }));
108
- registry.remove("pi-sub-bg-1");
105
+ it("移除指定 branch", async () => {
106
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
107
+ await registry.add(makeEntry({ branch: "pi-sub-bg-2" }));
108
+ await registry.remove("pi-sub-bg-1");
109
109
  const loaded = registry.load();
110
110
  expect(loaded).toHaveLength(1);
111
111
  expect(loaded[0].branch).toBe("pi-sub-bg-2");
112
112
  });
113
113
 
114
- it("branch 不存在时忽略(幂等)", () => {
115
- registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
116
- registry.remove("pi-sub-nonexistent");
114
+ it("branch 不存在时忽略(幂等)", async () => {
115
+ await registry.add(makeEntry({ branch: "pi-sub-bg-1" }));
116
+ await registry.remove("pi-sub-nonexistent");
117
117
  expect(registry.load()).toHaveLength(1);
118
118
  });
119
119
 
120
- it("空注册表 remove 不报错", () => {
121
- expect(() => registry.remove("pi-sub-anything")).not.toThrow();
120
+ it("空注册表 remove 不报错", async () => {
121
+ await expect(registry.remove("pi-sub-anything")).resolves.not.toThrow();
122
122
  });
123
123
  });
124
124
 
125
125
  describe("降级与健壮性", () => {
126
- it("文件不存在时 load 返回空数组", () => {
126
+ it("文件不存在时 load 返回空数组", async () => {
127
127
  expect(registry.load()).toEqual([]);
128
128
  });
129
129
 
130
- it("损坏 JSON 时 load 返回空数组", () => {
130
+ it("损坏 JSON 时 load 返回空数组", async () => {
131
131
  fs.mkdirSync(path.dirname(registryFile), { recursive: true });
132
132
  fs.writeFileSync(registryFile, "{ not valid json }}}", "utf-8");
133
133
  expect(registry.load()).toEqual([]);
134
134
  });
135
135
 
136
- it("entries 字段缺失时 load 返回空数组", () => {
136
+ it("entries 字段缺失时 load 返回空数组", async () => {
137
137
  fs.mkdirSync(path.dirname(registryFile), { recursive: true });
138
138
  fs.writeFileSync(registryFile, '{"other": 123}', "utf-8");
139
139
  expect(registry.load()).toEqual([]);
140
140
  });
141
141
 
142
- it("save 创建不存在的父目录", () => {
142
+ it("save 创建不存在的父目录", async () => {
143
143
  // registryFile 在 tmpAgentDir/subagents/ 下,subagents 目录不存在
144
144
  expect(fs.existsSync(path.dirname(registryFile))).toBe(false);
145
- registry.add(makeEntry());
145
+ await registry.add(makeEntry());
146
146
  expect(fs.existsSync(registryFile)).toBe(true);
147
147
  });
148
148
 
149
- it("save 后无残留 .tmp 文件(原子写)", () => {
150
- registry.add(makeEntry());
149
+ it("save 后无残留 .tmp 文件(原子写)", async () => {
150
+ await registry.add(makeEntry());
151
151
  const tmpFile = `${registryFile}.tmp`;
152
152
  expect(fs.existsSync(tmpFile)).toBe(false);
153
153
  });
154
154
  });
155
155
 
156
156
  describe("SPAWN_GRACE_MS 常量", () => {
157
- it("值为 60s", () => {
157
+ it("值为 60s", async () => {
158
158
  expect(SPAWN_GRACE_MS).toBe(60_000);
159
159
  });
160
160
  });
161
+
162
+ describe("D5a 锁互斥(两写方并发不丢条目)", () => {
163
+ // [HISTORICAL] 2026-08-20 PR #185:真实 proper-lockfile 文件锁 RMW 用例显式超时——
164
+ // 单跑 >1s,整包满并行 + 系统余载下 100/31 次持锁 load→mutate→save 超过 vitest
165
+ // 默认 5s testTimeout(隔离跑通过,纯环境饿死),对齐本包 record-store-index 口径。
166
+ it("两个 registry 实例(模拟两 pi 进程写方)并发各 add 50 条,零丢失", { timeout: 30_000 }, async () => {
167
+ // 失败模式 G 的复现形态:无锁时两实例 load→mutate→save 交错,后写者覆盖
168
+ // 先写者 → 条目丢失。锁内 RMW 应保证 100 条全部落盘。
169
+ const r1 = new WorktreeRegistry(tmpAgentDir);
170
+ const r2 = new WorktreeRegistry(tmpAgentDir);
171
+ const tasks: Promise<void>[] = [];
172
+ for (let i = 0; i < 50; i++) {
173
+ tasks.push(r1.add(makeEntry({ branch: `pi-sub-w1-${i}` })));
174
+ tasks.push(r2.add(makeEntry({ branch: `pi-sub-w2-${i}` })));
175
+ }
176
+ await Promise.all(tasks);
177
+ const loaded = r1.load();
178
+ expect(loaded).toHaveLength(100);
179
+ // 两写方的条目都在(非「后写者全量覆盖」形态)
180
+ expect(loaded.filter((e) => e.branch.startsWith("pi-sub-w1-"))).toHaveLength(50);
181
+ expect(loaded.filter((e) => e.branch.startsWith("pi-sub-w2-"))).toHaveLength(50);
182
+ });
183
+
184
+ it("并发 add + remove 交错:remove 只删自己的 branch", { timeout: 30_000 }, async () => {
185
+ const r1 = new WorktreeRegistry(tmpAgentDir);
186
+ const r2 = new WorktreeRegistry(tmpAgentDir);
187
+ await r1.add(makeEntry({ branch: "pi-sub-keep" }));
188
+ const tasks: Promise<void>[] = [];
189
+ for (let i = 0; i < 30; i++) {
190
+ tasks.push(r2.add(makeEntry({ branch: `pi-sub-n-${i}` })));
191
+ }
192
+ tasks.push(r1.remove("pi-sub-keep"));
193
+ await Promise.all(tasks);
194
+ const loaded = r1.load();
195
+ expect(loaded).toHaveLength(30);
196
+ expect(loaded.find((e) => e.branch === "pi-sub-keep")).toBeUndefined();
197
+ });
198
+ });
161
199
  });
@@ -8,6 +8,9 @@
8
8
  import type { AgentResult as WorkflowAgentResult, AgentUsage as WorkflowAgentUsage, ToolCallEntry } from "../orchestration/models/types.ts";
9
9
  import type { AgentResult as SubagentsAgentResult, AgentUsageTotal, ToolCall } from "./types.ts";
10
10
 
11
+ /** safeStringify 的 JSON 截断长度(字符),防大对象拼进 toolCalls 挤爆 trace。 */
12
+ const TOOL_ARGS_JSON_MAX_CHARS = 500;
13
+
11
14
  /**
12
15
  * D-A10: subagents AgentResult → workflow AgentResult 映射。
13
16
  *
@@ -80,7 +83,7 @@ function mapToolCalls(calls: ToolCall[]): ToolCallEntry[] {
80
83
  function safeStringify(value: unknown): string {
81
84
  try {
82
85
  const s = JSON.stringify(value);
83
- return s.length > 500 ? `${s.slice(0, 500)}...` : s;
86
+ return s.length > TOOL_ARGS_JSON_MAX_CHARS ? `${s.slice(0, TOOL_ARGS_JSON_MAX_CHARS)}...` : s;
84
87
  } catch {
85
88
  return String(value);
86
89
  }
@@ -41,7 +41,7 @@ const ARGV_FLAG_START = 2;
41
41
  * 解析规则:
42
42
  * - --no-extensions / -ne、--approve / -a:布尔 flag
43
43
  * - --no-context-files / -nc:布尔 flag(镜像理由:--extension 镜像会把
44
- * xyz-system-prompt-extension.js 带进每个 subagent,该扩展靠子进程自己的
44
+ * @zhushanwen/pi-system-prompt 带进每个 subagent,该扩展靠子进程自己的
45
45
  * argv 检测此 flag 决定是否注入全局 AGENTS.md——不镜像则用户 opt-out
46
46
  * 在 subagent 路径失效)
47
47
  * - --extension / -e:支持 `--extension <path>`(空格)与 `--extension=<path>`(等号)
@@ -76,7 +76,9 @@ function readHandshakeSlot(): ChannelRegistryHandshake | undefined {
76
76
  );
77
77
  return undefined;
78
78
  }
79
- const version = (slot as { version?: unknown }).version;
79
+ // 断言目标含必填 version 字段(object/null 已守卫;version 值本身在下一行校验),
80
+ // 对齐 format.ts extractAgentName 的必填字段断言先例,避免全可选断言零校验。
81
+ const version = (slot as { version: unknown }).version;
80
82
  if (version !== HANDSHAKE_VERSION) {
81
83
  logger.warn(
82
84
  `[pi-subagent-workflow] channel handshake version mismatch (got ${String(
@@ -191,12 +191,14 @@ export async function doFinalizeRoundToIdle(
191
191
  ): Promise<void> {
192
192
  // MF-2:设 record.result 供 notifier idle 回复正文(否则恒 "(empty)",G1/G2 不成立)。
193
193
  // MF-6 兜底:失败轮次(success=false)的 result.text 可能为空,用 error 让 notify 可读。
194
- // [增量 G2] 第三分支 chatMode 条件占位:本写点被 one-shot 成功路径共用(subagent-service
195
- // runAndFinalize 的 `!record.chatMode && !aborted && result.success` 分支 finalizeRoundToIdle),
196
- // chatMode 必须保持 `|| record.result` 现状——one-shot 空文本成功完成(collectResult
197
- // getFullText 返回 ""、success=true、真实可达)时 result 前值 undefined → notifier
198
- // buildLlmContent 的 `record.result ?? "(empty)"` 兜底确定性产出 "(empty)",通知逐字节
199
- // 不变(G4);无条件替换会把文案漂移为占位。chatMode 空增量轮 固定占位
194
+ // [R2-1] 轮终写点恒写非空:one-shot 空文本成功完成(collectResult getFullText 返回 ""、
195
+ // success=true、真实可达,本写点被 subagent-service runAndFinalize 的成功分支共用)首轮
196
+ // result 前值 undefined,兜底补 "(empty)" 占位——措辞与 notifier buildLlmContent 的
197
+ // `record.result ?? "(empty)"` 兜底同款,通知文案逐字节不变([增量 G2] G4 取舍保持)。
198
+ // 轮终信号优先:record.result undefined renderer hasRunning 排除「轮终
199
+ // running-resumable」的判据(shared SubagentRecord.result 契约),保持 undefined 会让
200
+ // 完成注入后末位 turn 永久「工作中」。续轮(前值存在)沿用前值:one-shot 无增量语义,
201
+ // record.result = 该 subagent 最终输出。chatMode 空增量轮 → 固定占位
200
202
  // "(no output this round)"(D5:增量语义下沿用旧 record.result = 上一轮增量,本轮通知
201
203
  // 正文 = 上一轮内容,父 agent 误读为原样重复回复)。
202
204
  let nextResult: string | undefined;
@@ -207,7 +209,7 @@ export async function doFinalizeRoundToIdle(
207
209
  } else if (record.chatMode) {
208
210
  nextResult = "(no output this round)";
209
211
  } else {
210
- nextResult = record.result;
212
+ nextResult = record.result ?? "(empty)";
211
213
  }
212
214
  record.result = nextResult;
213
215
 
@@ -236,6 +238,13 @@ export async function doFinalizeRoundToIdle(
236
238
  record.closedReason = undefined;
237
239
  record.round = (record.round ?? 0) + 1;
238
240
  record.idleSince = Date.now();
241
+ // 执行态信号(residual-fixes):轮终回 running-resumable = 无活进程驱动(idle timer
242
+ // 回收/保活等待续聊),GUI 侧据此判 waiting(非 streaming)。冷路径续轮(进程启动)清除。
243
+ record.resumable = true;
244
+
245
+ // W16 [D4]:轮终回 running-resumable 是类外状态写点(record 留内存不走 archive),
246
+ // 显式上报迁移——entry 携带新 round 与本轮 result,pi 文件的重建源不滞后。
247
+ deps.store.reportRecordTransition(record);
239
248
 
240
249
  // [review 修复] 已删除残留 pendingMessages 的 redeliverPending 补投段(MF-1 消费
241
250
  // 确认安全网):三段消费链随 deliverToRunning 一并移除,本段不可达。
@@ -0,0 +1,47 @@
1
+ /**
2
+ * SP-4 idle record GC:30 天 TTL 定时归档,防 idle record 永久驻留内存。
3
+ * 从 subagent-service.ts 抽出(文件超 max-lines;逻辑自包含:interval + TTL 扫描)。
4
+ * 启动幂等由调用方(service.startGcTimer)守卫;返回 stop 函数供 dispose 清理。
5
+ */
6
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
7
+ import { bestEffort } from "./best-effort.ts";
8
+ import { isResumable } from "./lifecycle-predicates.ts";
9
+ import type { RecordStore } from "./record-store.ts";
10
+
11
+ const logger = getLogger("subagents");
12
+
13
+ /** GC 扫描间隔:1 小时。 */
14
+ // eslint-disable-next-line no-magic-numbers -- 60*60*1000 = 1h 的毫秒换算常数
15
+ const GC_INTERVAL_MS = 60 * 60 * 1000;
16
+ /** idle record TTL:30 天(超龄归档)。 */
17
+ // eslint-disable-next-line no-magic-numbers -- 30*24*60*60*1000 = 30d 的毫秒换算常数
18
+ const IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
19
+ /** 毫秒/天(GC 日志的 d 换算)。 */
20
+ // eslint-disable-next-line no-magic-numbers -- 24*60*60*1000 = 1d 的毫秒换算常数
21
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
22
+
23
+ /**
24
+ * 启动 idle record GC 定时器,返回 stop 函数(清理 interval;幂等)。
25
+ * 每个扫描周期:对 store 内全部 active record 中 resumable 且带 idleSince 的,
26
+ * 超过 IDLE_TTL_MS 的归档(archive 单条失败不阻断其余,bestEffort 留痕)。
27
+ */
28
+ export function startIdleGc(store: RecordStore): () => void {
29
+ const timer = setInterval(() => {
30
+ const now = Date.now();
31
+ for (const record of store.listAllActive()) {
32
+ if (isResumable(record) && record.idleSince) {
33
+ const age = now - record.idleSince;
34
+ if (age > IDLE_TTL_MS) {
35
+ logger.warn(`[subagents] GC: archiving idle record ${record.id} (idle for ${Math.round(age / MS_PER_DAY)}d)`);
36
+ try {
37
+ store.archive(record);
38
+ } catch (err) {
39
+ bestEffort(err, `GC archive record ${record.id}`);
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }, GC_INTERVAL_MS);
45
+ timer.unref?.();
46
+ return () => clearInterval(timer);
47
+ }
@@ -28,13 +28,19 @@
28
28
  // 默认常量
29
29
  // ============================================================
30
30
 
31
+ /** 毫秒/秒(时间换算常数)。 */
32
+ const MS_PER_SECOND = 1000;
33
+ /** 秒/分钟(时间换算常数)。 */
34
+ const SECONDS_PER_MINUTE = 60;
35
+
31
36
  /**
32
37
  * 默认 idle 超时(per-record)。
33
38
  *
34
39
  * V2 §5.4 / 决策 4:初拟 ≤ prompt cache TTL(~5min)——超出 cacheTTL 的活进程白占
35
40
  * 内存(续聊仍 cache miss),小于则 kill 丢热 cache。实测定(P-timeout)。
36
41
  */
37
- export const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 分钟
42
+ const IDLE_TIMEOUT_MINUTES = 5;
43
+ export const DEFAULT_IDLE_TIMEOUT_MS = IDLE_TIMEOUT_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
38
44
 
39
45
  /**
40
46
  * 从环境变量 XYZ_SUBAGENT_IDLE_TIMEOUT_MS 读取全局默认超时。
@@ -376,7 +382,8 @@ const activateLockTails = new Map<string, Promise<void>>();
376
382
  * 的错误(调用方可用 message action 重试)。30s 远超正常冷路径 resume spawn 耗时(~ms 级),
377
383
  * 留足异常恢复余量而不误伤正常排队。
378
384
  */
379
- const ACTIVATE_LOCK_TIMEOUT_MS = 30 * 1000;
385
+ const ACTIVATE_LOCK_TIMEOUT_SECONDS = 30;
386
+ const ACTIVATE_LOCK_TIMEOUT_MS = ACTIVATE_LOCK_TIMEOUT_SECONDS * MS_PER_SECOND;
380
387
 
381
388
  export function acquireActivateLock(recordId: string): Promise<() => void> {
382
389
  const prev = activateLockTails.get(recordId) ?? Promise.resolve();
@@ -0,0 +1,118 @@
1
+ // src/execution/record-entry.ts
2
+ //
3
+ // W16 [D4]:subagent record 自描述持久化 entry 的形态权威。
4
+ //
5
+ // pi 文件(session JSONL)是扩展数据持久化权威:record 状态每次迁移都经
6
+ // pi.appendEntry 落一条自描述完整快照(字段即 SubagentRecord),读取方无需
7
+ // 逆向解析 toolCall/toolResult。内存 record-store 保持运行时权威,entry 是
8
+ // 重建源(两者不冲突)。
9
+ //
10
+ // customType 与既有 `subagent-identity`(session 文件首行身份 entry)同族命名
11
+ //(连字符风格);写点见 record-store.ts 的状态迁移点(register/archive/
12
+ // reportRecordTransition),custom entry 由 pi 写进 session JSONL,不进 LLM context。
13
+
14
+ import type {
15
+ AgentEventLogEntry,
16
+ ClosedReason,
17
+ DisplayItem,
18
+ ExecutionMode,
19
+ ExecutionStatus,
20
+ SubagentRecord,
21
+ } from "./types.ts";
22
+
23
+ /** 自描述 record entry 的 customType。写点字面量与本常量的等值由
24
+ * __tests__/record-store.test.ts 断言钉住(消费方引用本常量,勿用裸字符串)。 */
25
+ export const SUBAGENT_RECORD_CUSTOM_TYPE = "subagent-record";
26
+
27
+ /**
28
+ * `subagent-record` entry 的 data schema(v1)。
29
+ *
30
+ * = 完整 SubagentRecord 快照(GUI 侧列表/详情需要的全部持久化字段)+ 版本号。
31
+ * 显式排除三个非持久化字段(与 SubagentRecord 的差集):
32
+ * - currentActivity:running 时的瞬时流态,重开 session 无重建价值;
33
+ * - externalInstance:跨重启探活态(pid/startedAt),由 .alive sidecar 重建;
34
+ * - worktreeHandle:不可 JSON 序列化的运行时句柄(布尔投影 worktree 保留)。
35
+ *
36
+ * undefined 字段经 JSON.stringify 自然缺省(与 SubagentRecord 重建侧语义一致)。
37
+ */
38
+ export interface SubagentRecordEntryData {
39
+ /** schema 版本(W16 起 v1)。消费方按 v 判别解析,不认识的版本跳过而非猜测。 */
40
+ v: 1;
41
+ id: string;
42
+ agent: string;
43
+ /** 任务提示词(详情面板置顶展示)。 */
44
+ task: string;
45
+ /** 短标签(≤35 字符)。 */
46
+ slug: string;
47
+ status: ExecutionStatus;
48
+ /** L2 关闭原因(仅 status="closed" 时有意义)。 */
49
+ closedReason?: ClosedReason;
50
+ mode: ExecutionMode;
51
+ startedAt: number;
52
+ /** 根 Pi session ID(session 隔离过滤用)。 */
53
+ rootSessionId: string | undefined;
54
+ /** 直接父 subagent record ID(层级树构建用)。顶层为 undefined。 */
55
+ parentRecordId: string | undefined;
56
+ /** subagent 递归深度。顶层 = 0。 */
57
+ depth: number;
58
+ endedAt: number | undefined;
59
+ /** turn 计数。 */
60
+ turns: number;
61
+ totalTokens: number;
62
+ model: string;
63
+ thinkingLevel: string | undefined;
64
+ /** 详情事件日志(/subagents 详情面板)。 */
65
+ eventLog: AgentEventLogEntry[];
66
+ /** 从 turns[] 派生的展示项。 */
67
+ displayItems: DisplayItem[];
68
+ result?: string;
69
+ error?: string;
70
+ sessionFile?: string;
71
+ /** [MF#3] worktree 模式改动 patch 文件路径。 */
72
+ patchFile?: string;
73
+ /** 创建时是否启用 worktree 隔离。 */
74
+ worktree?: boolean;
75
+ /** 对话轮次计数(仅 chatMode 有意义;round+1 由轮终迁移写点携带)。 */
76
+ round?: number;
77
+ /**
78
+ * 对话模式标志(residual-fixes):chat 与否——GUI 侧 done/waiting 细分判据
79
+ * (one-shot 轮终 chatMode=false + result 有值 → 完成态)。register 起写入显式值
80
+ * (one-shot 为显式 false);v1 前存量 entry 缺省,消费端按保守方向处理。
81
+ */
82
+ chatMode?: boolean;
83
+ /** 执行态信号(residual-fixes):true = 无活进程驱动的 running(轮终/孤儿兜底)。 */
84
+ resumable?: boolean;
85
+ }
86
+
87
+ /** SubagentRecord → 自描述 entry data(快照投影,不 mutate 源)。 */
88
+ export function toSubagentRecordEntry(record: SubagentRecord): SubagentRecordEntryData {
89
+ return {
90
+ v: 1,
91
+ id: record.id,
92
+ agent: record.agent,
93
+ task: record.task,
94
+ slug: record.slug,
95
+ status: record.status,
96
+ closedReason: record.closedReason,
97
+ mode: record.mode,
98
+ startedAt: record.startedAt,
99
+ rootSessionId: record.rootSessionId,
100
+ parentRecordId: record.parentRecordId,
101
+ depth: record.depth,
102
+ endedAt: record.endedAt,
103
+ turns: record.turns,
104
+ totalTokens: record.totalTokens,
105
+ model: record.model,
106
+ thinkingLevel: record.thinkingLevel,
107
+ eventLog: record.eventLog,
108
+ displayItems: record.displayItems,
109
+ result: record.result,
110
+ error: record.error,
111
+ sessionFile: record.sessionFile,
112
+ patchFile: record.patchFile,
113
+ worktree: record.worktree,
114
+ round: record.round,
115
+ chatMode: record.chatMode,
116
+ resumable: record.resumable,
117
+ };
118
+ }