@zhushanwen/pi-subagent-workflow 7.4.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.
- package/README.md +15 -0
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +197 -15
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +37 -10
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +442 -54
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +629 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +300 -90
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +99 -13
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +491 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-entry.ts +118 -0
- package/src/execution/record-store.ts +844 -108
- package/src/execution/session-pending.ts +121 -49
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +713 -316
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +917 -138
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +218 -19
- package/src/execution/worktree-manager.ts +449 -59
- package/src/execution/worktree-registry.ts +97 -29
- package/src/index.ts +318 -20
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +17 -4
- package/src/interface/__tests__/tool-render.test.ts +10 -8
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +53 -20
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +81 -16
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +21 -27
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +67 -100
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/detail-content.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +144 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
- package/src/orchestration/__tests__/test-mocks.ts +197 -0
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +38 -40
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +137 -30
- package/src/orchestration/execute-agent-call.ts +44 -10
- package/src/orchestration/jsonl-run-store.ts +397 -71
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +145 -133
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +38 -11
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +79 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +22 -1
- package/src/shared/resource-discovery.ts +162 -59
- package/src/shared/schema-jsonify.ts +56 -0
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -11,18 +11,23 @@
|
|
|
11
11
|
// 无人写终态 → 孤儿永久泄漏。→ 新判据:pid 死活一条判到底。
|
|
12
12
|
//
|
|
13
13
|
// 并发模型:
|
|
14
|
-
// -
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
14
|
+
// - 跨进程互斥(D5a):add/updatePid/remove 的 load→mutate→save 全程持
|
|
15
|
+
// proper-lockfile 异步锁(<worktrees.json>.lock,协议登记 data-source-registry.md §6)。
|
|
16
|
+
// 锁不可用(重试耗尽)时降级为无锁 RMW + warn——注册表是 best-effort 数据,
|
|
17
|
+
// 降级不比锁前更差,条目丢失由 reaper 对账兜底(worktree-manager scan 的
|
|
18
|
+
// 双向 diff,见 reconcileWithPhysical)。
|
|
19
|
+
// - 同步 IO(readFileSync/writeFileSync)持锁执行:锁内临界区毫秒级,
|
|
20
|
+
// async 锁 + sync IO 组合在单线程 event loop 内无 interleaving。
|
|
18
21
|
// - 原子写:写 .tmp → rename,防写一半崩溃产生损坏 JSON。
|
|
19
22
|
|
|
20
23
|
import * as fs from "node:fs";
|
|
21
24
|
import * as path from "node:path";
|
|
22
25
|
|
|
23
|
-
import {
|
|
26
|
+
import { withFileLock } from "@zhushanwen/pi-file-lock";
|
|
24
27
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
25
28
|
|
|
29
|
+
import { bestEffort } from "./best-effort.ts";
|
|
30
|
+
|
|
26
31
|
const logger = getLogger("subagents");
|
|
27
32
|
|
|
28
33
|
/** create→spawn 宽限期(ms):pid=0 条目超过此阈值判 create 后崩溃。 */
|
|
@@ -31,6 +36,11 @@ export const SPAWN_GRACE_MS = 60_000;
|
|
|
31
36
|
/** JSON 缩进空格数(可读性 + diff 友好)。 */
|
|
32
37
|
const JSON_INDENT = 2;
|
|
33
38
|
|
|
39
|
+
/** tmp 随机段:36 进制取 8 字符(跳过 "0." 前缀),与 pid 组合保证并发唯一。 */
|
|
40
|
+
const TMP_RANDOM_BASE = 36;
|
|
41
|
+
const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
|
|
42
|
+
const TMP_RANDOM_SLICE_END = 10;
|
|
43
|
+
|
|
34
44
|
/** 注册表 JSON 顶层结构的运行时类型守卫。 */
|
|
35
45
|
function isRegistryData(value: unknown): value is { entries: WorktreeEntry[] } {
|
|
36
46
|
return (
|
|
@@ -56,6 +66,12 @@ export interface WorktreeEntry {
|
|
|
56
66
|
readonly pid: number;
|
|
57
67
|
/** 创建时间戳(ms,SPAWN_GRACE 判据 + 调试用)。 */
|
|
58
68
|
readonly createdAt: number;
|
|
69
|
+
/**
|
|
70
|
+
* 对应 subagent session jsonl 文件全路径(诊断/兼容字段,reaper 据 pid 死活判孤儿)。
|
|
71
|
+
* session-runner first header 拿到 pid 时补全(create 时 record.sessionFile 尚未确定)。
|
|
72
|
+
* 可选:旧 worktrees.json / 非 worktree 模式无此字段,向后兼容(undefined 时 reaper 走原 pid 判据)。
|
|
73
|
+
*/
|
|
74
|
+
readonly sessionFile?: string;
|
|
59
75
|
}
|
|
60
76
|
|
|
61
77
|
/**
|
|
@@ -74,40 +90,91 @@ export class WorktreeRegistry {
|
|
|
74
90
|
/**
|
|
75
91
|
* 新增条目(create 成功后调,pid=0 占位)。
|
|
76
92
|
* 同 branch 已存在则覆盖(防残留覆盖)。
|
|
93
|
+
* 跨进程锁内 RMW(D5a);锁降级路径见 mutate。
|
|
77
94
|
*/
|
|
78
|
-
add(entry: WorktreeEntry): void {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
95
|
+
async add(entry: WorktreeEntry): Promise<void> {
|
|
96
|
+
await this.mutate((entries) => {
|
|
97
|
+
const idx = entries.findIndex((e) => e.branch === entry.branch);
|
|
98
|
+
if (idx >= 0) {
|
|
99
|
+
entries[idx] = entry;
|
|
100
|
+
} else {
|
|
101
|
+
entries.push(entry);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/**
|
|
90
107
|
* 更新 pid(runSpawn spawn() 返回后同步调)。
|
|
91
108
|
* branch 不存在则忽略(create 后崩溃 + reaper 已清的竞态)。
|
|
109
|
+
* sessionFile 可选补全:传入时填入 entry(reaper 据 pid 死活判孤儿,不读本字段)。
|
|
92
110
|
*/
|
|
93
|
-
updatePid(branch: string, pid: number): void {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
async updatePid(branch: string, pid: number, sessionFile?: string): Promise<void> {
|
|
112
|
+
await this.mutate(
|
|
113
|
+
(entries) => {
|
|
114
|
+
const idx = entries.findIndex((e) => e.branch === branch);
|
|
115
|
+
if (idx >= 0) {
|
|
116
|
+
entries[idx] = {
|
|
117
|
+
...entries[idx],
|
|
118
|
+
pid,
|
|
119
|
+
...(sessionFile !== undefined ? { sessionFile } : {}),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
{ branch, pid },
|
|
124
|
+
);
|
|
100
125
|
}
|
|
101
126
|
|
|
102
127
|
/**
|
|
103
128
|
* 移除条目(cleanup/reaper 清理后调)。
|
|
104
129
|
* branch 不存在则忽略(幂等)。
|
|
105
130
|
*/
|
|
106
|
-
remove(branch: string): void {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
131
|
+
async remove(branch: string): Promise<void> {
|
|
132
|
+
await this.mutate(
|
|
133
|
+
(entries) => {
|
|
134
|
+
const filtered = entries.filter((e) => e.branch !== branch);
|
|
135
|
+
if (filtered.length !== entries.length) {
|
|
136
|
+
entries.length = 0;
|
|
137
|
+
entries.push(...filtered);
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
{ branch },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 锁内 RMW 统一入口:withLock(load → mutate → save)。
|
|
146
|
+
*
|
|
147
|
+
* 降级语义(对齐本类既有 best-effort 约定——注册表写失败不阻断 create/cleanup
|
|
148
|
+
* 主流程):锁获取失败(重试耗尽 ELOCKED 等)→ warn + 无锁执行同一段 RMW
|
|
149
|
+
* (= D5a 之前的 last-write-wins 行为,条目丢失由 reaper 对账兜底),
|
|
150
|
+
* 不抛错、永不 reject(调用方含 session-runner 的 fire-and-forget 回调)。
|
|
151
|
+
*/
|
|
152
|
+
private async mutate(
|
|
153
|
+
mutate: (entries: WorktreeEntry[]) => void,
|
|
154
|
+
context?: { branch?: string; pid?: number },
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const run = (): void => {
|
|
157
|
+
const entries = this.load();
|
|
158
|
+
mutate(entries);
|
|
159
|
+
this.save(entries, context);
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
await withFileLock(this.filePath, () => {
|
|
163
|
+
run();
|
|
164
|
+
return Promise.resolve();
|
|
165
|
+
});
|
|
166
|
+
} catch (lockErr) {
|
|
167
|
+
// 锁不可用(ELOCKED 重试耗尽 / 锁目录损坏等):降级无锁 RMW。
|
|
168
|
+
// 竞争窗口内可能丢条目(旧缺陷形态),由 reaper 对账(scan 双向 diff)收敛。
|
|
169
|
+
logger.warn("[worktree] registry lock unavailable, degraded to lock-free RMW", {
|
|
170
|
+
...(context ?? {}),
|
|
171
|
+
err: lockErr instanceof Error ? lockErr.message : String(lockErr),
|
|
172
|
+
});
|
|
173
|
+
try {
|
|
174
|
+
run();
|
|
175
|
+
} catch (err) {
|
|
176
|
+
bestEffort(err, "worktree registry degraded RMW");
|
|
177
|
+
}
|
|
111
178
|
}
|
|
112
179
|
}
|
|
113
180
|
|
|
@@ -133,12 +200,13 @@ export class WorktreeRegistry {
|
|
|
133
200
|
* 原子写入全部条目。
|
|
134
201
|
* best-effort:写入失败不阻断主流程(create/cleanup 的 git 操作已执行,
|
|
135
202
|
* 注册表与 git 状态的短暂不一致靠下次 reaper 对账收敛)。
|
|
136
|
-
* 写盘失败时 warn
|
|
203
|
+
* 写盘失败时 warn 日志(带 branch/pid 上下文,补全失败可观测闭环)。
|
|
137
204
|
*/
|
|
138
|
-
private save(entries: WorktreeEntry[], context?: { branch
|
|
205
|
+
private save(entries: WorktreeEntry[], context?: { branch?: string; pid?: number }): void {
|
|
139
206
|
try {
|
|
140
207
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
141
|
-
|
|
208
|
+
// tmp 名带 pid + 随机段:锁降级(无锁并发 RMW)时多进程 tmp 互不覆盖
|
|
209
|
+
const tmp = `${this.filePath}.tmp_${process.pid}_${Math.random().toString(TMP_RANDOM_BASE).slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
|
|
142
210
|
fs.writeFileSync(tmp, JSON.stringify({ entries }, null, JSON_INDENT), "utf-8");
|
|
143
211
|
fs.renameSync(tmp, this.filePath);
|
|
144
212
|
} catch (err) {
|
package/src/index.ts
CHANGED
|
@@ -30,12 +30,15 @@ import {
|
|
|
30
30
|
ModelConfigService,
|
|
31
31
|
setModelConfigService,
|
|
32
32
|
} from "./execution/model-config-service.ts";
|
|
33
|
+
import { IDENTITY_CUSTOM_TYPE, type SubagentIdentityData } from "./execution/session-reconstructor.ts";
|
|
34
|
+
import type { ExecutionMode, SubagentRecord } from "./execution/types.ts";
|
|
33
35
|
import { maybeCleanupExpiredSessionFiles } from "./execution/session-file-gc.ts";
|
|
34
36
|
import {
|
|
35
37
|
getSubagentService,
|
|
36
38
|
setSubagentService,
|
|
37
39
|
SubagentService,
|
|
38
40
|
} from "./execution/subagent-service.ts";
|
|
41
|
+
import { killAllSpawnedChildren } from "./execution/session-runner.ts";
|
|
39
42
|
import { SubprocessAgentRunner } from "./execution/subprocess-agent-runner.ts";
|
|
40
43
|
import { WorktreeManager } from "./execution/worktree-manager.ts";
|
|
41
44
|
import { setupSubagentListInjector } from "./injectors/subagent-list-injector.ts";
|
|
@@ -43,17 +46,23 @@ import { setupWorkflowListInjector } from "./injectors/workflow-list-injector.ts
|
|
|
43
46
|
import { renderBgNotifyMessage } from "./interface/bg-notify-render.ts";
|
|
44
47
|
import { registerWorkflowsCommand } from "./interface/commands.ts";
|
|
45
48
|
import { toGuiCtx } from "./interface/gui-mappers.ts";
|
|
46
|
-
import { notifyDone } from "./interface/helpers.ts";
|
|
49
|
+
import { notifyDone, trackNotifiedRunId } from "./interface/helpers.ts";
|
|
47
50
|
import { registerSubagentTool } from "./interface/subagent-tool.ts";
|
|
48
51
|
// ═══ interface/ 层(tools/commands/tui 合并) ═══
|
|
49
52
|
import { registerSubagentsCommand } from "./interface/subagents.ts";
|
|
50
53
|
import { registerWorkflowTool } from "./interface/tool-workflow.ts";
|
|
51
54
|
import { registerWorkflowScriptTool } from "./interface/tool-workflow-script.ts";
|
|
52
55
|
import { JsonlRunStore } from "./orchestration/jsonl-run-store.ts";
|
|
56
|
+
import { clearSkillPathCache } from "./orchestration/skill-discovery.ts";
|
|
53
57
|
// ═══ orchestration/ 层(workflow engine + infra) ═══
|
|
54
58
|
import type { LauncherDeps } from "./orchestration/launcher.ts";
|
|
55
59
|
import { executeNestedWorkflow, runAndWait, type WorkflowRunResult } from "./orchestration/launcher.ts";
|
|
56
|
-
import {
|
|
60
|
+
import {
|
|
61
|
+
evictDoneRunsBeyondCap,
|
|
62
|
+
MAX_RETAINED_DONE_RUNS,
|
|
63
|
+
scheduleTimeBudget,
|
|
64
|
+
terminateRunningRuns,
|
|
65
|
+
} from "./orchestration/lifecycle.ts";
|
|
57
66
|
import type { WorkflowRun } from "./orchestration/models/workflow-run.ts";
|
|
58
67
|
import { WorkerHostImpl } from "./orchestration/worker-host.ts";
|
|
59
68
|
import { WorkflowScriptRegistryImpl } from "./orchestration/workflow-script-registry-impl.ts";
|
|
@@ -76,6 +85,75 @@ declare module "@earendil-works/pi-coding-agent" {
|
|
|
76
85
|
// 模块级 logger(setPiHandle 注入后自动走 appendEntry)
|
|
77
86
|
const logger = getLogger("subagents");
|
|
78
87
|
|
|
88
|
+
// ── subagent 状态快照格式化 ──
|
|
89
|
+
//
|
|
90
|
+
// [v4 A-6] before_agent_start 注入 hook 已删(活跃 subagent 清单改由 agent 按需调
|
|
91
|
+
// action:'list' 拉取,消除每 loop 注入的上下文税与盲点)。本函数保留为纯格式化工具:
|
|
92
|
+
// before-agent-start-injection / parent-child-matrix 测试覆盖其正确性,未来 list
|
|
93
|
+
// 视图或其他注入点可复用。
|
|
94
|
+
|
|
95
|
+
/** 活跃 subagent 数量上限(超过截断显示)。 */
|
|
96
|
+
const MAX_STATUS_INJECTION = 10;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 将活跃 subagent record 格式化为一行一条的快照文本。
|
|
100
|
+
*
|
|
101
|
+
* 格式:
|
|
102
|
+
* [subagent-status] N active subagents:
|
|
103
|
+
* - sa-xxx (slug): running, rounds 0
|
|
104
|
+
* - sa-yyy (slug): idle, rounds 3
|
|
105
|
+
* +2 more, use action:'list'
|
|
106
|
+
*
|
|
107
|
+
* @param records 已筛选的活跃 record(running + idle)
|
|
108
|
+
*/
|
|
109
|
+
export function formatSubagentStatusSnapshot(records: SubagentRecord[]): string {
|
|
110
|
+
const lines = [`[subagent-status] ${records.length} active subagent${records.length === 1 ? "" : "s"}:`];
|
|
111
|
+
const shown = records.slice(0, MAX_STATUS_INJECTION);
|
|
112
|
+
for (const r of shown) {
|
|
113
|
+
const slug = r.slug || r.agent;
|
|
114
|
+
const roundPart = r.round !== undefined && r.round > 0 ? `, rounds ${r.round}` : "";
|
|
115
|
+
lines.push(`- ${r.id} (${slug}): ${r.status}${roundPart}`);
|
|
116
|
+
}
|
|
117
|
+
const remaining = records.length - MAX_STATUS_INJECTION;
|
|
118
|
+
if (remaining > 0) {
|
|
119
|
+
lines.push(`+${remaining} more, use action:'list'`);
|
|
120
|
+
}
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ═══ [V2 决策 7 防线 i] process 级 shutdown hook ═══
|
|
125
|
+
//
|
|
126
|
+
// session_shutdown 是 pi 的 async hook,进程被 SIGTERM/SIGINT 强杀或崩溃时来不及
|
|
127
|
+
// 触发;sync 子进程(controller 为 undefined,abortRunningControllers 跳过它们)会
|
|
128
|
+
// 泄漏为孤儿。process.on 兜底显式 killAllSpawnedChildren 收割全部活子进程。
|
|
129
|
+
// guard 防多信号叠加(如 SIGINT 后又 beforeExit)重复 kill。
|
|
130
|
+
let processShutdownHookFired = false;
|
|
131
|
+
|
|
132
|
+
function reapSpawnedChildrenOnShutdown(): void {
|
|
133
|
+
if (processShutdownHookFired) return;
|
|
134
|
+
processShutdownHookFired = true;
|
|
135
|
+
try {
|
|
136
|
+
killAllSpawnedChildren("SIGTERM");
|
|
137
|
+
} catch (err) {
|
|
138
|
+
// best-effort:收割失败不阻断退出流程——debug 留痕(孤儿子进程排查线索),
|
|
139
|
+
// 不静默吞错,对齐「错误必须可操作」。
|
|
140
|
+
logger.debug(
|
|
141
|
+
"[subagents] process shutdown reap best-effort failed (killAllSpawnedChildren SIGTERM)",
|
|
142
|
+
{ reason: err instanceof Error ? err.message : String(err) },
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 测试钩子:重置 module 级 shutdown guard。
|
|
149
|
+
*
|
|
150
|
+
* 对齐 lifecycle-manager._resetLifecycleState 模式——processShutdownHookFired 是
|
|
151
|
+
* module 级单例状态,跨 test 持久,单测需显式重置以验证 idempotent 行为。
|
|
152
|
+
*/
|
|
153
|
+
export function _resetProcessShutdownGuardForTest(): void {
|
|
154
|
+
processShutdownHookFired = false;
|
|
155
|
+
}
|
|
156
|
+
|
|
79
157
|
export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
80
158
|
// 注入 pi handle 给全局 extension-logger,让深层代码(best-effort / error-recovery)
|
|
81
159
|
// 的 getLogger("subagents") 也能走 appendEntry。
|
|
@@ -105,6 +183,24 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
105
183
|
return cachedMainSessionFile;
|
|
106
184
|
}
|
|
107
185
|
|
|
186
|
+
/**
|
|
187
|
+
* 按 sessionId 解析主 session 文件路径(文件名约定 `<ISO 时间戳>_<sessionId>.jsonl`)。
|
|
188
|
+
* [E2E 实测] attach 场景下 ctx.sessionManager.getSessionFile() 会返回前一 session 的
|
|
189
|
+
* 文件(session_start(root=01a01bf5) 时仍返回刚新建 session 的路径)——恢复逻辑
|
|
190
|
+
* 读错文件会整段漏判。此处按 id 从 sessions 目录解析为准;新 session 文件未 flush
|
|
191
|
+
* 时(AGENTS.md 规则 6:首条 assistant 消息前可能不存在)返回 undefined,调用方
|
|
192
|
+
* (fork 解析 / 孤儿恢复)对该场景本就无 entry 可读。
|
|
193
|
+
*/
|
|
194
|
+
function resolveMainSessionFileById(sessionId: string): string | undefined {
|
|
195
|
+
const sessionsDir = path.join(getAgentDir(), "..", "sessions");
|
|
196
|
+
try {
|
|
197
|
+
const match = fs.readdirSync(sessionsDir).find((f) => f.endsWith(`_${sessionId}.jsonl`));
|
|
198
|
+
return match === undefined ? undefined : path.join(sessionsDir, match);
|
|
199
|
+
} catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
108
204
|
// resources_discover:不再注入额外 skill 目录(ADR-031 废弃 discovery.json)。
|
|
109
205
|
// pi 核心 auto-discovery 已覆盖 .agents/skills 等标准目录,子 session 的
|
|
110
206
|
// --skill 由 agent({skill}) 调用方显式传入,无需 extension 额外补充。
|
|
@@ -187,7 +283,23 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
187
283
|
runner: state.runner,
|
|
188
284
|
runs: state.runs,
|
|
189
285
|
registry,
|
|
190
|
-
onRunDone
|
|
286
|
+
// onRunDone 是全部 done 路径的单点汇聚(abortRun + error-recovery),顺序固化为
|
|
287
|
+
// notify → track → evict:notifyDone 先发完整聚合通知(淘汰后聚合根仍在闭包参数
|
|
288
|
+
// run 引用上不受影响),trackNotifiedRunId 有界化去重窗口,最后裁剪 done run 内存。
|
|
289
|
+
// 本轮 run 的 completedAt 在 transition("done") 时同步设为当前时刻=全局最新,
|
|
290
|
+
// 恒在保留端——结构性保证其不被自身触发的裁剪淘汰,无需 protectRunId。
|
|
291
|
+
onRunDone: (run: WorkflowRun) => {
|
|
292
|
+
notifyDone(pi, run.runId, run, notifiedRunIds, toGuiCtx(sessionCtx));
|
|
293
|
+
trackNotifiedRunId(notifiedRunIds, run.runId);
|
|
294
|
+
const evicted = evictDoneRunsBeyondCap(state.runs, MAX_RETAINED_DONE_RUNS);
|
|
295
|
+
if (evicted > 0) {
|
|
296
|
+
logger.debug("[subagent-workflow] evicted done runs beyond cap", {
|
|
297
|
+
evicted,
|
|
298
|
+
keep: MAX_RETAINED_DONE_RUNS,
|
|
299
|
+
sessionId: lsRef.lastSessionId,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
},
|
|
191
303
|
eventBus: pi.events,
|
|
192
304
|
scheduleTimeBudget: (runId: string, budgetTimeMs: number) =>
|
|
193
305
|
scheduleTimeBudget(runId, deps, budgetTimeMs),
|
|
@@ -217,6 +329,55 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
217
329
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
218
330
|
lsRef.lastSessionId = sessionId;
|
|
219
331
|
|
|
332
|
+
// skill 路径两级缓存 session 级失效:pi 同进程可能有多个 session(TUI /new、/fork),
|
|
333
|
+
// 运行中安装的 skill 需对新 session 可见(含曾 miss 缓存的 undefined 条目与 npm 新装
|
|
334
|
+
// 包的候选目录)。session 内复用收益不变(IF8/DM3 消重发生在同 session 的重复调用)。
|
|
335
|
+
clearSkillPathCache();
|
|
336
|
+
|
|
337
|
+
// ── [M4] identity 子进程写入(V2 决策 5)──
|
|
338
|
+
// 子进程经 env(PI_SUBAGENT_*)接收自己的 identity,在 session_start 用 pi.appendEntry
|
|
339
|
+
// 写 subagent-identity custom entry。pi 自动生成 id/parentId → message tree 连续。
|
|
340
|
+
// 旧实现父进程 fs.appendFileSync 补写的 custom entry 缺 id/parentId → 污染 _buildIndex
|
|
341
|
+
// leafId 指针 → message tree 断成两棵 → 多轮对话丢上下文(bug 根因)。
|
|
342
|
+
// 主/子进程判定:PI_SUBAGENT_SELF_RECORD_ID 仅 session-runner spawn 子进程时注入,
|
|
343
|
+
// 主进程无此 env → 跳过(identity 只在子进程写一次)。
|
|
344
|
+
const selfRecordId = process.env.PI_SUBAGENT_SELF_RECORD_ID;
|
|
345
|
+
if (selfRecordId) {
|
|
346
|
+
try {
|
|
347
|
+
const modeEnv = process.env.PI_SUBAGENT_MODE;
|
|
348
|
+
// ExecutionMode 联合窄化:父进程经 env 注入(record.mode 恒为 "background"),
|
|
349
|
+
// 运行时校验合法值,非法兜底 background(避免裸 cast,符合 taste/no-unsafe-cast)。
|
|
350
|
+
const mode: ExecutionMode = modeEnv === "background" ? modeEnv : "background";
|
|
351
|
+
const identity: SubagentIdentityData = {
|
|
352
|
+
id: selfRecordId,
|
|
353
|
+
agent: process.env.PI_SUBAGENT_AGENT ?? "",
|
|
354
|
+
mode,
|
|
355
|
+
task: process.env.PI_SUBAGENT_TASK ?? "",
|
|
356
|
+
slug: process.env.PI_SUBAGENT_SLUG,
|
|
357
|
+
startedAt: Number(process.env.PI_SUBAGENT_STARTED_AT ?? Date.now()),
|
|
358
|
+
rootSessionId: process.env.PI_SUBAGENT_ROOT_SESSION_ID,
|
|
359
|
+
parentRecordId: process.env.PI_SUBAGENT_PARENT_RECORD_ID,
|
|
360
|
+
depth:
|
|
361
|
+
process.env.PI_SUBAGENT_DEPTH !== undefined
|
|
362
|
+
? Number(process.env.PI_SUBAGENT_DEPTH)
|
|
363
|
+
: undefined,
|
|
364
|
+
forkDepth:
|
|
365
|
+
process.env.PI_SUBAGENT_FORK_DEPTH !== undefined
|
|
366
|
+
? Number(process.env.PI_SUBAGENT_FORK_DEPTH)
|
|
367
|
+
: undefined,
|
|
368
|
+
chatMode: process.env.PI_SUBAGENT_CHAT_MODE === "true",
|
|
369
|
+
// [review round2] worktree 隔离标志(session-runner 注入):跨重启重建路径据此
|
|
370
|
+
// 拒绝续聊(handle 不可序列化,reattach 不可行)。
|
|
371
|
+
worktree: process.env.PI_SUBAGENT_WORKTREE === "true",
|
|
372
|
+
};
|
|
373
|
+
pi.appendEntry(IDENTITY_CUSTOM_TYPE, identity);
|
|
374
|
+
} catch (err) {
|
|
375
|
+
logger.warn("[subagents] identity appendEntry failed in session_start", {
|
|
376
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
220
381
|
// ── subagents 域:双 Service 装配 ──
|
|
221
382
|
const existingService = getSubagentService();
|
|
222
383
|
const existingModelService = getModelConfigService();
|
|
@@ -238,9 +399,20 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
238
399
|
// SR-3: 无论 new 还是 existing(/resume /fork 复用),session_start 都必须注入 handler
|
|
239
400
|
service.setUiRequestHandler(uiRequestHandler);
|
|
240
401
|
|
|
402
|
+
// 主 session 文件:按 sessionId 解析(getSessionFile() 在 attach 场景会返回前一
|
|
403
|
+
// session 的文件,E2E 实测),未 flush 的新 session 回退 getSessionFile()。
|
|
404
|
+
// 值直传 initSession——jiti 多实例分裂下闭包缓存(cachedMainSessionFile)不跨
|
|
405
|
+
// 实例共享,恢复逻辑经缓存读会拿到滞后一个事件的值(E2E 实测 ENOENT 漏判);
|
|
406
|
+
// 缓存本身保留给既有 getter 消费者(fork source 解析)。
|
|
407
|
+
cachedMainSessionFile =
|
|
408
|
+
resolveMainSessionFileById(ctx.sessionManager.getSessionId()) ??
|
|
409
|
+
ctx.sessionManager.getSessionFile() ??
|
|
410
|
+
undefined;
|
|
411
|
+
|
|
241
412
|
service.initSession({
|
|
242
413
|
pi,
|
|
243
414
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
415
|
+
mainSessionFile: cachedMainSessionFile,
|
|
244
416
|
// 注入 ctx.ui.setWidget 作为 streaming sink(只绑方法,不持有整个 ctx)。
|
|
245
417
|
// background subagent 执行期间,text_delta 经 SubagentStream 合并后由此通道转发。
|
|
246
418
|
// [W1 修复] ctx.mode === 'rpc' 守卫:TUI/json/print 下 streamSink = undefined(无 widget 噪音),
|
|
@@ -267,7 +439,8 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
267
439
|
setSubagentService(service);
|
|
268
440
|
}
|
|
269
441
|
|
|
270
|
-
|
|
442
|
+
// S-2: 启动 idle record GC 定时器(30 天 TTL,每小时检查一次)
|
|
443
|
+
service.startGcTimer();
|
|
271
444
|
|
|
272
445
|
try {
|
|
273
446
|
maybeCleanupExpiredSessionFiles(agentDir, cwd);
|
|
@@ -292,7 +465,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
292
465
|
|
|
293
466
|
try {
|
|
294
467
|
const wtm = new WorktreeManager(agentDir);
|
|
295
|
-
wtm.scan();
|
|
468
|
+
await wtm.scan();
|
|
296
469
|
} catch (err) {
|
|
297
470
|
logger.warn("[subagents] worktree reaper scan failed", {
|
|
298
471
|
reason: err instanceof Error ? err.message : String(err),
|
|
@@ -325,9 +498,38 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
325
498
|
id: run.runId,
|
|
326
499
|
reason: "failed",
|
|
327
500
|
});
|
|
501
|
+
// 恢复终态必须落盘:save 走冷路径(done 绕过去抖)同步写 state 文件 +
|
|
502
|
+
// append 终态 workflow-record entry——entry_appended 事件驱动 runtime 派生
|
|
503
|
+
// 缓存失效重拉(无 triggerTurn 副作用)。不 save 则 entry/state 双双停留
|
|
504
|
+
// running,侧栏永久卡 running。失败仅记日志不阻断其余 run 的恢复(下次
|
|
505
|
+
// session_start 重开重试,恢复循环天然幂等)。
|
|
506
|
+
try {
|
|
507
|
+
await store.save(run);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
logger.error("[subagent-workflow] kill-9 recovery store.save failed", {
|
|
510
|
+
runId: run.runId,
|
|
511
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
328
514
|
}
|
|
329
515
|
runs.set(run.runId, run);
|
|
330
516
|
}
|
|
517
|
+
// done run 内存有界性:loadAll 全量重水合后立即裁剪到 K。kill-9 恢复(上方
|
|
518
|
+
// running → transition("done","failed"))的 run completedAt 为 transition 时刻
|
|
519
|
+
// (当前时间=全局最新)参与排序且必在保留端;多条恢复 run 同 ms completedAt →
|
|
520
|
+
// tie 稳定排序。淘汰只 delete runs Map 条目——磁盘 state 文件与
|
|
521
|
+
// workflow-state-link 指针条目均不动(历史审计保留);下次 session_start loadAll
|
|
522
|
+
// 从指针全量重水合后再次裁剪,该循环每次 session 启动重复且可接受:内存峰值只在
|
|
523
|
+
// 启动期,常驻 O(K + 活跃 run)。消除启动峰值需指针 compaction,属 append+replay
|
|
524
|
+
// 长期方案问题域,非本范围。
|
|
525
|
+
const evicted = evictDoneRunsBeyondCap(runs, MAX_RETAINED_DONE_RUNS);
|
|
526
|
+
if (evicted > 0) {
|
|
527
|
+
logger.debug("[subagent-workflow] evicted done runs beyond cap after loadAll", {
|
|
528
|
+
evicted,
|
|
529
|
+
keep: MAX_RETAINED_DONE_RUNS,
|
|
530
|
+
sessionId,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
331
533
|
} catch (err) {
|
|
332
534
|
// QMF-4 fix: store.loadAll 失败是关键路径错误,workflow 域将未初始化
|
|
333
535
|
logger.error("[subagent-workflow] store.loadAll failed, workflow domain uninitialized", {
|
|
@@ -371,7 +573,7 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
371
573
|
});
|
|
372
574
|
|
|
373
575
|
// ════════════════════════════════════════════════════════════
|
|
374
|
-
// session_tree
|
|
576
|
+
// session_tree:切分支前终止所有 running run(一次性生命周期——切走即作废)
|
|
375
577
|
// ════════════════════════════════════════════════════════════
|
|
376
578
|
pi.on("session_tree", async (_event: SessionTreeEvent, ctx: ExtensionContext) => {
|
|
377
579
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -379,33 +581,90 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
379
581
|
|
|
380
582
|
const state = sessionState.get(sessionId);
|
|
381
583
|
if (state) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
584
|
+
// 一次性生命周期(D-2):running run 转 done,failed 落盘(helper 内部自过滤
|
|
585
|
+
// running,单 run 失败不中断其余)。此处不再挂起待恢复。
|
|
586
|
+
try {
|
|
587
|
+
await terminateRunningRuns(makeDeps(state, ctx), "Session switched: run terminated");
|
|
588
|
+
} catch (err) {
|
|
589
|
+
bestEffort(err, "terminateRunningRuns (session_tree handler)");
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// ════════════════════════════════════════════════════════════
|
|
595
|
+
// SP-4: session_before_fork(/fork)/ session_before_switch(/new)级联关闭
|
|
596
|
+
// ════════════════════════════════════════════════════════════
|
|
597
|
+
// 主 session /fork 或 /new 时,清理旧 record(disposeAllRecords:CAS 转终态 +
|
|
598
|
+
// archive + worktree 清理)。before 事件在 session 替换前触发,确保旧 session 的
|
|
599
|
+
// subagent 在新 session 创建前被清理(随后的 session_shutdown → dispose 收割子进程)。
|
|
600
|
+
//
|
|
601
|
+
// [M2 修复] 旧实现把 /new 级联挂在 session_before_tree 上——SDK 中该事件只由
|
|
602
|
+
// AgentSession.navigateTree()(/tree 同 session 分支切换)触发,/new 走
|
|
603
|
+
// session_before_switch(reason:"new") + session_shutdown(reason:"new"),从不触发
|
|
604
|
+
// before_tree。后果双向:/new 级联是死代码;普通 /tree 分支导航反而误杀全部活跃
|
|
605
|
+
// subagent。现 /new 改挂 session_before_switch(reason==="new"),before_tree handler
|
|
606
|
+
// 移除(/tree 是同 session 内导航,record/子进程归属不变,无级联关闭诉求)。
|
|
607
|
+
pi.on("session_before_fork", (_event, _ctx) => {
|
|
608
|
+
const service = getSubagentService();
|
|
609
|
+
if (service) {
|
|
610
|
+
const count = service.onParentFork();
|
|
611
|
+
if (count > 0) {
|
|
612
|
+
logger.warn(`[subagents] /fork 级联关闭 ${count} 个 subagent`);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
pi.on("session_before_switch", (event, _ctx) => {
|
|
618
|
+
// /new(reason:"new")创建全新 session → 级联关闭旧 record。
|
|
619
|
+
// reason:"resume"(/resume /import 回到已有 session)不级联:record 按 rootSessionId
|
|
620
|
+
// 归属隔离,跨 session 读写由 store 过滤守卫,无需销毁。
|
|
621
|
+
if (event.reason !== "new") return;
|
|
622
|
+
const service = getSubagentService();
|
|
623
|
+
if (service) {
|
|
624
|
+
const count = service.onParentNew();
|
|
625
|
+
if (count > 0) {
|
|
626
|
+
logger.warn(`[subagents] /new 级联关闭 ${count} 个 subagent`);
|
|
390
627
|
}
|
|
391
628
|
}
|
|
392
629
|
});
|
|
393
630
|
|
|
394
631
|
// ════════════════════════════════════════════════════════════
|
|
395
|
-
// session_shutdown:dispose subagents +
|
|
632
|
+
// session_shutdown:dispose subagents + terminate workflows + store 收尾 + cleanup
|
|
633
|
+
//
|
|
634
|
+
// store 收尾:每 session 的 JsonlRunStore 在 terminateRunningRuns 之后 dispose(刷
|
|
635
|
+
// pending 去抖批 + await in-flight 链,见 W2C5)。R3 声明:SIGTERM/SIGINT 走下方
|
|
636
|
+
// process handler 不触发本路径,pending 去抖丢失等价崩溃链(重启后 kill-9 恢复
|
|
637
|
+
// 收编 running 残留——终态/创建均冷路径已落盘,丢的只有 ≤saveDebounceMs 的
|
|
638
|
+
// running 尾巴,ES1 已接受);不做 best-effort SIGTERM dispose(需同步 IO 改造,
|
|
639
|
+
// 超出 wave 边界)。
|
|
396
640
|
// ════════════════════════════════════════════════════════════
|
|
397
641
|
pi.on("session_shutdown", async (_event: SessionShutdownEvent, _ctx: ExtensionContext) => {
|
|
398
642
|
// ── subagents 域:dispose SubagentService ──
|
|
399
643
|
getSubagentService()?.dispose();
|
|
400
644
|
|
|
401
|
-
// ── workflow 域:
|
|
645
|
+
// ── workflow 域:terminate 所有 running run + store 收尾 + 清理 temp files ──
|
|
402
646
|
// H-5: 遍历所有 sessionState 条目清理(而不只 lastSessionId——
|
|
403
647
|
// 防御 session 切换但 session_tree 未先触发导致 lastSessionId 指向已删除 session 的情况)。
|
|
404
648
|
for (const [sessionId, state] of sessionState) {
|
|
405
|
-
|
|
406
|
-
await
|
|
407
|
-
|
|
408
|
-
|
|
649
|
+
// 编排顺序(W2C5):terminate(await,failed 落盘——重启后 kill-9 恢复不误判)
|
|
650
|
+
// → store.dispose(await,刷 pending 去抖批 + await in-flight 链,关「shutdown
|
|
651
|
+
// 时刻 pending 去抖写丢失」窗口)→ delete。terminate 的 running 过滤在 helper
|
|
652
|
+
// 内部(单 run 失败不中断其余);外层 try/catch 兜底防单 session 异常中断后续
|
|
653
|
+
// session 条目的 dispose + delete(对齐原 allSettled 的不中断语义)。
|
|
654
|
+
try {
|
|
655
|
+
await terminateRunningRuns(makeDeps(state, _ctx), "Session shutdown: run terminated");
|
|
656
|
+
} catch (err) {
|
|
657
|
+
bestEffort(err, "terminateRunningRuns (session_shutdown handler)");
|
|
658
|
+
}
|
|
659
|
+
// dispose 自身恒 resolve,catch 兜底防御——handler 内抛错会中断后续 session
|
|
660
|
+
// 条目清理。不留静默吞错(错误必须可操作):debug 留痕带 sessionId/sessionDir,
|
|
661
|
+
// 排查「shutdown 后 run 状态不落盘」类问题时有迹可循。
|
|
662
|
+
await state.store.dispose().catch((err: unknown) => {
|
|
663
|
+
logger.debug(
|
|
664
|
+
`[subagent-workflow] session_shutdown store.dispose failed (sessionId=${sessionId}, sessionDir=${state.sessionDir})`,
|
|
665
|
+
{ reason: err instanceof Error ? err.message : String(err) },
|
|
666
|
+
);
|
|
667
|
+
});
|
|
409
668
|
sessionState.delete(sessionId);
|
|
410
669
|
}
|
|
411
670
|
|
|
@@ -422,6 +681,45 @@ export default function subagentsWorkflowExtension(pi: ExtensionAPI): void {
|
|
|
422
681
|
dialogQueue.rejectAll();
|
|
423
682
|
});
|
|
424
683
|
|
|
684
|
+
// ════════════════════════════════════════════════════════════
|
|
685
|
+
// [V2 决策 7 防线 i] process 级 shutdown hook(显式收割三道防线之一)
|
|
686
|
+
//
|
|
687
|
+
// 上方 session_shutdown(pi async hook)在进程被 SIGTERM/SIGINT 强杀或崩溃时
|
|
688
|
+
// 不触发,此处 process.on 兜底确保 sync 子进程被收割(防线 i:shutdown 时显式
|
|
689
|
+
// SIGTERM 全部 activation)。
|
|
690
|
+
//
|
|
691
|
+
// - SIGTERM:pi 各 mode(rpc/interactive/print)自带 SIGTERM handler 负责退出编排,
|
|
692
|
+
// 本 extension 的 handler 只做收割 + 设 exitCode(不 re-raise、不抢 pi 的退出语义;
|
|
693
|
+
// xyz-agent 桌面 supervisor 用 SIGTERM 杀 pi 走这条路)。
|
|
694
|
+
// - SIGINT:pi 本体不注册常规 SIGINT handler(interactive/print/rpc 均 SIGTERM only),
|
|
695
|
+
// 依赖 Node 默认终止。本 extension 注册 listener 即取消默认终止——若只设 exitCode,
|
|
696
|
+
// 本地 pi CLI 的 Ctrl-C 杀不死进程(TUI/stdin/agent loop 仍在事件循环)。故收割
|
|
697
|
+
// 完成后 re-raise 恢复默认终止,见下方 sigintHandler。
|
|
698
|
+
// - beforeExit 是退出前最后事件,不 exit(自然退出)。
|
|
699
|
+
// - idempotent guard(reapSpawnedChildrenOnShutdown 内)防多信号叠加重复 kill。
|
|
700
|
+
//
|
|
701
|
+
// 防线 iii(activate 互斥)已接入:subagent-service.ts 冷路径 resume 调
|
|
702
|
+
// acquireActivateLock(含 30s 超时兜底,见 lifecycle-manager.ts ACTIVATE_LOCK_TIMEOUT_MS)。
|
|
703
|
+
// 防线 ii(启动 scanOrphanProcesses)骨架就位,启动时接入待实现。
|
|
704
|
+
// ════════════════════════════════════════════════════════════
|
|
705
|
+
process.on("SIGTERM", () => {
|
|
706
|
+
reapSpawnedChildrenOnShutdown();
|
|
707
|
+
// S-3: 改用 process.exitCode 而非 process.exit(0),让子进程 cleanup 完成后再自然退出。
|
|
708
|
+
// process.exit(0) 会立即终止,可能在 reapSpawnedChildrenOnShutdown 完成前截断。
|
|
709
|
+
// 退出编排归 pi 自身的 SIGTERM handler(rpc-mode 会主动退出)。
|
|
710
|
+
process.exitCode = 0;
|
|
711
|
+
});
|
|
712
|
+
// [review 修复] SIGINT re-raise:收割同步完成后,先移除自身 listener 再向自身重发
|
|
713
|
+
// SIGINT,恢复 Node 默认终止。不 removeListener 直接 kill(process.pid) 会再次进入
|
|
714
|
+
// 本 handler 递归;移除后无其他 SIGINT listener(pi 不注册)→ 默认行为终止进程。
|
|
715
|
+
const sigintHandler = (): void => {
|
|
716
|
+
reapSpawnedChildrenOnShutdown();
|
|
717
|
+
process.removeListener("SIGINT", sigintHandler);
|
|
718
|
+
process.kill(process.pid, "SIGINT");
|
|
719
|
+
};
|
|
720
|
+
process.on("SIGINT", sigintHandler);
|
|
721
|
+
process.on("beforeExit", reapSpawnedChildrenOnShutdown);
|
|
722
|
+
|
|
425
723
|
// ════════════════════════════════════════════════════════════
|
|
426
724
|
// pi.__workflowRun(D-8 签名)
|
|
427
725
|
// ════════════════════════════════════════════════════════════
|