@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.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 +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- 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 +170 -14
- 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 +25 -7
- 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 +158 -52
- 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 +598 -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 +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -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-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- 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 +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- 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 +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- 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 +44 -17
- 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 +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -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 +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -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 +1 -1
- 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 +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- 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 +29 -10
- 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 +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -9,20 +9,26 @@ import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
|
9
9
|
import type { ExtensionMode } from "./host-mode.ts";
|
|
10
10
|
|
|
11
11
|
import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
|
|
12
|
+
import { displayAgentName } from "../shared/agent-ref.ts";
|
|
12
13
|
// D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
|
|
13
14
|
import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
|
|
14
15
|
import { removeAliveMarker } from "./alive-store.ts";
|
|
15
16
|
import { bestEffort } from "./best-effort.ts";
|
|
17
|
+
// [V2 决策 3] lifecycle-manager idle timer:chatMode 统一投递新 turn disarm(防误杀活进程)。
|
|
18
|
+
// [M3] hasIdleTimer:piAdapter.hasRunningBackground 排除等待续聊(timer armed)的 record。
|
|
19
|
+
import { acquireActivateLock, disarmIdleTimer, hasIdleTimer } from "./lifecycle-manager.ts";
|
|
16
20
|
import { type ConcurrencyPool,DefaultConcurrencyPool } from "./concurrency-pool.ts";
|
|
17
21
|
import type { DialogGlobalQueue, UiRequestHandler } from "./dialog-queue.ts";
|
|
18
22
|
import {
|
|
19
23
|
completeRecord,
|
|
20
24
|
createRecord,
|
|
25
|
+
getFullTextFrom,
|
|
26
|
+
nextRoundBaseTurnIndex,
|
|
21
27
|
project,
|
|
22
28
|
snapshot,
|
|
23
29
|
tryTransition,
|
|
24
30
|
} from "./execution-record.ts";
|
|
25
|
-
import { doFinalizeRecord } from "./finalize-record.ts";
|
|
31
|
+
import { doFinalizeRecord, doFinalizeRoundToIdle } from "./finalize-record.ts";
|
|
26
32
|
import { ManifestStore } from "./manifest-store.ts";
|
|
27
33
|
import type { ModelConfigService } from "./model-config-service.ts";
|
|
28
34
|
import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
|
|
@@ -32,7 +38,15 @@ import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts
|
|
|
32
38
|
import type { StatusFilter } from "./record-store.ts";
|
|
33
39
|
import { RecordStore } from "./record-store.ts";
|
|
34
40
|
import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
|
|
35
|
-
import { killAllSpawnedChildren, runSpawn, type SessionRunnerContext } from "./session-runner.ts";
|
|
41
|
+
import { getChildByRecord, killAllSpawnedChildren, runSpawn, spawnedChildren, type SessionRunnerContext, type SpawnResumeOpts } from "./session-runner.ts";
|
|
42
|
+
import { isIdle, isResumable, hasLiveProcessHandle } from "./lifecycle-predicates.ts";
|
|
43
|
+
import {
|
|
44
|
+
clearEpipeFailure,
|
|
45
|
+
EPIPE_FAILURE_THRESHOLD,
|
|
46
|
+
recordEpipeFailure,
|
|
47
|
+
resetAllEpipeFailures,
|
|
48
|
+
sendPromptCommand,
|
|
49
|
+
} from "./stdin-writer.ts";
|
|
36
50
|
import type { StreamSink } from "./stream-sink.ts";
|
|
37
51
|
import { SubagentStream } from "./stream-sink.ts";
|
|
38
52
|
import { writeCancelledTombstone } from "./tombstone-store.ts";
|
|
@@ -40,13 +54,13 @@ import type { WorktreeHandle } from "./types.ts";
|
|
|
40
54
|
import type {
|
|
41
55
|
AgentEvent,
|
|
42
56
|
AgentResult,
|
|
57
|
+
ClosedReason,
|
|
43
58
|
ExecuteOptions,
|
|
44
59
|
ExecutionHandle,
|
|
45
60
|
ExecutionMode,
|
|
46
61
|
ExecutionRecord,
|
|
47
62
|
RecordSnapshot,
|
|
48
63
|
SubagentRecord,
|
|
49
|
-
SubagentToolDetails,
|
|
50
64
|
} from "./types.ts";
|
|
51
65
|
import { ForkDepthExceededError } from "./types.ts";
|
|
52
66
|
import { DEFAULT_AGENT_NAME } from "./types.ts";
|
|
@@ -55,6 +69,10 @@ import { WorktreeManager } from "./worktree-manager.ts";
|
|
|
55
69
|
|
|
56
70
|
const logger = getLogger("subagents");
|
|
57
71
|
|
|
72
|
+
// [v4 A-1] EPIPE 连续失败计数器已迁移到 stdin-writer.ts(stdin 错误域,避免 session-runner
|
|
73
|
+
// 反向 import 本文件 helper 产生循环依赖)。同步路径(deliverMessage)与异步路径
|
|
74
|
+
//(session-runner child.stdin.on('error'))共用 stdin-writer 的同一计数器。
|
|
75
|
+
|
|
58
76
|
/** dispose 后注入的 stub UI 请求 handler。
|
|
59
77
|
*
|
|
60
78
|
* [背景] Pi 单进程 session 串行接管。session A shutdown 时 SIGTERM 子进程后、
|
|
@@ -89,12 +107,14 @@ interface PiLike {
|
|
|
89
107
|
* 通过 setWidget 通道转发到 RPC stdout(不经 sendMessage 的持久化路径)。 */
|
|
90
108
|
export type { StreamSink } from "./stream-sink.ts";
|
|
91
109
|
|
|
92
|
-
/** pending-notifications 注册/注销 helper(避免重复代码)。
|
|
110
|
+
/** pending-notifications 注册/注销 helper(避免重复代码)。
|
|
111
|
+
* name 是 GUI pending 通知的显示名——取 basename 短名(displayAgentName),
|
|
112
|
+
* 完整路径仍走 record.agent(env 注入 / 持久化)。 */
|
|
93
113
|
function emitPendingRegister(pi: PiLike | null, id: string, name?: string): void {
|
|
94
114
|
pi?.events.emit("pending:register", {
|
|
95
115
|
id,
|
|
96
116
|
type: "subagent",
|
|
97
|
-
name: name
|
|
117
|
+
name: name ? displayAgentName(name) : id,
|
|
98
118
|
});
|
|
99
119
|
}
|
|
100
120
|
|
|
@@ -158,24 +178,6 @@ const ENV_SELF_RECORD_ID = "PI_SUBAGENT_SELF_RECORD_ID";
|
|
|
158
178
|
const ENV_DEPTH = "PI_SUBAGENT_DEPTH";
|
|
159
179
|
const ENV_ROOT_CWD = "PI_SUBAGENT_ROOT_CWD";
|
|
160
180
|
|
|
161
|
-
/** 触发 onUpdate 的事件类型(streaming delta 不触发,避免每 token 刷新)。 */
|
|
162
|
-
const TRIGGERING_EVENT_TYPES = new Set<AgentEvent["type"]>([
|
|
163
|
-
"tool_start",
|
|
164
|
-
"tool_end",
|
|
165
|
-
"turn_end",
|
|
166
|
-
"message_end",
|
|
167
|
-
"error",
|
|
168
|
-
"compaction",
|
|
169
|
-
]);
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* onUpdate 最小发射间隔(ms)。leading + trailing 时间窗节流:窗口内首次事件立即发,
|
|
173
|
-
* 后续合并到窗口末尾补发一次。与 tool-render.ts SPINNER_INTERVAL_MS 对齐——视觉刷新
|
|
174
|
-
* 200ms 一帧,onUpdate 比这更快无感知增益,反而密集打 Pi tool_execution_update
|
|
175
|
-
* (嵌套场景内层一秒可产生 10+ 事件)触发 chatContainer 重绘残影。
|
|
176
|
-
*/
|
|
177
|
-
const ON_UPDATE_MIN_INTERVAL_MS = 200;
|
|
178
|
-
|
|
179
181
|
/** resolveIdentity 的产物——一次确定、写入 record 后不再变。 */
|
|
180
182
|
interface ResolvedIdentity {
|
|
181
183
|
agent: string;
|
|
@@ -256,6 +258,16 @@ export class SubagentService {
|
|
|
256
258
|
* 与 forkDepthAls 独立:后者只数 fork 链(fork=true 才递增),本 ALS 数所有 subagent 嵌套。 */
|
|
257
259
|
private readonly execCtxAls = new AsyncLocalStorage<{ recordId: string | undefined; depth: number }>();
|
|
258
260
|
|
|
261
|
+
/** [review MF1] record 级在途 resume 守卫。resumeRound 全部守卫通过后 add,
|
|
262
|
+
* runAndFinalize 结束(finally,覆盖轮次完成 / MF-6 失败回退 / abort / 终态化所有分支)时
|
|
263
|
+
* delete(幂等:execute() 新建 record 不在集合,no-op)。窗口 = resume 发起(含 pool.acquire
|
|
264
|
+
* 排队)→ 本轮 runAndFinalize 收尾。窗口内同 record 再次到达 resumeRound(冷路径重入 /
|
|
265
|
+
* EPIPE 兜底)直接 throw——防两个 pi 子进程以 --session 同一 JSONL 双写 + 前一个脱离
|
|
266
|
+
* kill 记账成孤儿(deliverMessage 冷路径的 acquireActivateLock 只覆盖 resumeRound 同步段,
|
|
267
|
+
* 锁释放在子进程注册(session-runner spawnedChildren.set)之前,锁空洞由此守卫兜住;
|
|
268
|
+
* EPIPE 兜底不持锁,同样被覆盖)。child 注册完成后 deliverMessage 走热路径,不经此守卫。 */
|
|
269
|
+
private readonly resumesInFlight = new Set<string>();
|
|
270
|
+
|
|
259
271
|
private readonly manifestStore: ManifestStore;
|
|
260
272
|
|
|
261
273
|
constructor(init: SubagentServiceInit) {
|
|
@@ -376,6 +388,100 @@ export class SubagentService {
|
|
|
376
388
|
}
|
|
377
389
|
}
|
|
378
390
|
|
|
391
|
+
/** SP-4: 关闭所有活跃 record。
|
|
392
|
+
*
|
|
393
|
+
* 遍历 store 中所有 running record,逐个 CAS 转终态 + completeRecord + archive。
|
|
394
|
+
* 对有 worktreeHandle 的 record 触发 worktreeManager.cleanup(T3: worktree 绑定清理)。
|
|
395
|
+
*
|
|
396
|
+
* [v4 A-6] 旧实现的 recentlyCascaded 收集(供已删除的 before_agent_start 注入告知)
|
|
397
|
+
* 与 drainCascaded 已一并移除——被关 record 的告知改由 list 的 closedReason 表达。
|
|
398
|
+
*
|
|
399
|
+
* @param reason 关闭原因(parent-fork / parent-new / parent-shutdown)
|
|
400
|
+
* @returns 被关闭的 record 数量
|
|
401
|
+
*/
|
|
402
|
+
disposeAllRecords(reason: ClosedReason): number {
|
|
403
|
+
const activeRecords = this.store.listAllActive();
|
|
404
|
+
let count = 0;
|
|
405
|
+
for (const record of activeRecords) {
|
|
406
|
+
// tryTransition 只对 running 生效;idle 需要直接 completeRecord(无 CAS 保护)。
|
|
407
|
+
// 与 closeChatIdle 对称:idle 无在途 AgentResult,构造合成 result。
|
|
408
|
+
if (record.status === "running") {
|
|
409
|
+
if (!tryTransition(record, "closed", reason)) continue;
|
|
410
|
+
}
|
|
411
|
+
const result: AgentResult = {
|
|
412
|
+
text: "",
|
|
413
|
+
turns: record.turnCount,
|
|
414
|
+
durationMs: Date.now() - record.startedAt,
|
|
415
|
+
success: false,
|
|
416
|
+
error: `closed due to ${reason}`,
|
|
417
|
+
sessionId: record.id,
|
|
418
|
+
toolCalls: [],
|
|
419
|
+
};
|
|
420
|
+
completeRecord(record, result, "closed", reason);
|
|
421
|
+
this.store.archive(record);
|
|
422
|
+
// worktree 绑定清理(T3)。cleanup 已 async 化——同步签名(返回计数)不变,
|
|
423
|
+
// 清理 fire-and-forget:失败经 bestEffort 留痕,不阻塞/不影响计数返回。
|
|
424
|
+
if (record.worktreeHandle) {
|
|
425
|
+
void this.worktreeManager.cleanup(record.worktreeHandle).catch((err: unknown) => {
|
|
426
|
+
bestEffort(err, `worktree cleanup (${reason})`);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
// pending-notifications 注销
|
|
430
|
+
emitPendingUnregister(this.pi, record.id, "closed");
|
|
431
|
+
count++;
|
|
432
|
+
}
|
|
433
|
+
return count;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** SP-4: /fork 新 session 时清理旧 record。
|
|
437
|
+
* 调用 disposeAllRecords("parent-fork")。由 index.ts 的 session_before_fork handler 触发。 */
|
|
438
|
+
onParentFork(): number {
|
|
439
|
+
return this.disposeAllRecords("parent-fork");
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** SP-4: /new 创建全新 session 时清理旧 record。
|
|
443
|
+
* 调用 disposeAllRecords("parent-new")。由 index.ts 的 session_before_switch
|
|
444
|
+
* (reason==="new")handler 触发。 */
|
|
445
|
+
onParentNew(): number {
|
|
446
|
+
return this.disposeAllRecords("parent-new");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** SP-4: idle record GC 定时器(30 天 TTL)。防止 idle record 永久驻留内存。
|
|
450
|
+
* session_start 时启动,session_shutdown 时清理。每小时检查一次。 */
|
|
451
|
+
private gcTimer: NodeJS.Timeout | undefined;
|
|
452
|
+
|
|
453
|
+
/** 启动 idle record GC 定时器(session_start 调用)。 */
|
|
454
|
+
startGcTimer(): void {
|
|
455
|
+
if (this.gcTimer) return;
|
|
456
|
+
const GC_INTERVAL_MS = 60 * 60 * 1000; // 1 小时
|
|
457
|
+
const IDLE_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天
|
|
458
|
+
this.gcTimer = setInterval(() => {
|
|
459
|
+
const now = Date.now();
|
|
460
|
+
for (const record of this.store.listAllActive()) {
|
|
461
|
+
if (isResumable(record) && record.idleSince) {
|
|
462
|
+
const age = now - record.idleSince;
|
|
463
|
+
if (age > IDLE_TTL_MS) {
|
|
464
|
+
logger.warn(`[subagents] GC: archiving idle record ${record.id} (idle for ${Math.round(age / 86400000)}d)`);
|
|
465
|
+
try {
|
|
466
|
+
this.store.archive(record);
|
|
467
|
+
} catch (err) {
|
|
468
|
+
bestEffort(err, `GC archive record ${record.id}`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}, GC_INTERVAL_MS);
|
|
474
|
+
this.gcTimer.unref?.();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** 停止 idle record GC 定时器(dispose 调用)。 */
|
|
478
|
+
private stopGcTimer(): void {
|
|
479
|
+
if (this.gcTimer) {
|
|
480
|
+
clearInterval(this.gcTimer);
|
|
481
|
+
this.gcTimer = undefined;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
379
485
|
/** session 结束清理(清定时器,丢弃 pending 通知)。幂等。
|
|
380
486
|
*
|
|
381
487
|
* [M-7] dispose 顺序假设:pending:unregister emit 依赖 pending-notifications 扩展的
|
|
@@ -385,28 +491,23 @@ export class SubagentService {
|
|
|
385
491
|
dispose(): void {
|
|
386
492
|
if (this._disposed) return;
|
|
387
493
|
this._disposed = true;
|
|
494
|
+
this.stopGcTimer();
|
|
388
495
|
// [dispose stub] 第一时间换 stub,防 trailing ui_request 调到 stale handler 闭包
|
|
389
496
|
// (仍持有 disposed session 的 ctx)产生误导性 console.error。stub 干净降级为 cancelled。
|
|
390
497
|
// 必须在 emit/abort 之前——这些步骤可能同步触发 trailing pump。
|
|
391
498
|
this.setUiRequestHandler(disposedUiRequestStub);
|
|
392
|
-
// [
|
|
393
|
-
//
|
|
394
|
-
// 必须在 abortRunningControllers 之前——此时 record 仍 running,listRunning 能取到。
|
|
395
|
-
for (const record of this.store.listRunning()) {
|
|
396
|
-
emitPendingUnregister(this.pi, record.id, "failed");
|
|
397
|
-
}
|
|
398
|
-
// [R0/C1 孤儿进程修复] 两层兜底 kill 所有 spawned 子进程(sync + background):
|
|
399
|
-
// 1. abortRunningControllers:background record 的 controller.abort → child.kill(CAS 收尾语义)。
|
|
400
|
-
// 2. killAllSpawnedChildren:遍历 session-runner spawnedChildren Set,对仍存活的发 SIGTERM
|
|
401
|
-
// (sync record 的 controller 是 undefined,abortRunningControllers 跳过它们,此处补齐)。
|
|
402
|
-
// 必须在 store.dispose 之前(先 kill 再清场)。dispose 同步返回后主进程可能立即 exit,
|
|
403
|
-
// runSpawn 的 finally 清理可能来不及跑——可接受退化(session.jsonl 已由子进程写入)。
|
|
499
|
+
// [R0/C1 孤儿进程修复] 先 abort running controllers + kill spawned children,再 dispose 资源。
|
|
500
|
+
// abortRunningControllers 需要在 disposeAllRecords archive 之前执行(archive 后 store 找不到 record)。
|
|
404
501
|
this.store.abortRunningControllers();
|
|
405
502
|
killAllSpawnedChildren();
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
503
|
+
// SP-4: 级联关闭所有活跃 record(parent-shutdown reason)
|
|
504
|
+
// 在 abort/kill 之后执行:先终止子进程,再清理 record 状态。
|
|
505
|
+
this.disposeAllRecords("parent-shutdown");
|
|
506
|
+
// [v4 A-1] EPIPE 连续失败计数器清零(计数器已迁移到 stdin-writer,防跨 session 泄漏)
|
|
507
|
+
resetAllEpipeFailures();
|
|
508
|
+
// [review MF1] 在途 resume 守卫清空(正常由 runAndFinalize finally 清除;此处兜底
|
|
509
|
+
// abort/kill 后仍挂着的条目,防跨 session 复活时残留)
|
|
510
|
+
this.resumesInFlight.clear();
|
|
410
511
|
// flush 待发通知后 dispose(防丢失)
|
|
411
512
|
this.notifier.flushPendingNotifications();
|
|
412
513
|
this.notifier.dispose();
|
|
@@ -416,12 +517,36 @@ export class SubagentService {
|
|
|
416
517
|
// ── 执行(subagent-tool 调)────────────────────────────
|
|
417
518
|
|
|
418
519
|
/** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。
|
|
419
|
-
*
|
|
520
|
+
* 正在执行(running + 活进程 + 非 timer-armed)静默跳过——notify 只对 closed(终态)、
|
|
521
|
+
* isIdle(chatMode 轮次完成)或 isResumable(SP-5 one-shot 成功完成 / MF-6 失败轮回退)有意义。
|
|
522
|
+
* SP-1: closed 统一终态(done/failed/crashed 合并),closedReason 携带 L2 原因。 */
|
|
420
523
|
private notifyComplete(record: ExecutionRecord): void {
|
|
421
524
|
const notify = this.toNotifyRecord(record);
|
|
422
525
|
if (notify) this.notifier.notify(notify);
|
|
423
526
|
}
|
|
424
527
|
|
|
528
|
+
/** [C-1] chatMode close 终态通知(设计 D2:正文空/本轮增量 + sessionFile 指针行)。
|
|
529
|
+
*
|
|
530
|
+
* 与 notifyComplete 的差异只在 dedup 身份与轮次统计:终态通知必须与最后一轮的轮次通知
|
|
531
|
+
* 区分(轮次通知 key=`id:round`),否则同 key 被 60s dedup 吞——close 后父 agent 永远
|
|
532
|
+
* 收不到带指针行的终态通知(审查 C-1)。故 round 置 undefined(key 回退为裸 id),
|
|
533
|
+
* 轮数改经 totalRounds 进文案 "completed after N rounds."(C-2)。
|
|
534
|
+
*
|
|
535
|
+
* 仅 chatMode close 语义调用(closeChatIdle / closeAfterRoundSettled 终态化成功后)。
|
|
536
|
+
* one-shot 显式拒绝(G4:one-shot close 路径现状无终态通知,字节不变);cancel 走
|
|
537
|
+
* cancelBackground 自己的 notifyComplete,不经本方法。幂等性:两条 close 路径均由
|
|
538
|
+
* closeSubagent 的 status 分流守卫(closed 后幂等 no-op)/ CAS 抢锁保证只执行一次,
|
|
539
|
+
* 本方法自身不重复发送;迟到的 kickOffBackground.then 通知与轮次通知同 key=`id:round`,
|
|
540
|
+
* 60s 窗内仍被吞,不构成第三条。 */
|
|
541
|
+
private notifyClosed(record: ExecutionRecord): void {
|
|
542
|
+
if (!record.chatMode) return;
|
|
543
|
+
const notify = this.toNotifyRecord(record);
|
|
544
|
+
if (!notify) return;
|
|
545
|
+
notify.round = undefined;
|
|
546
|
+
if (record.round != null) notify.totalRounds = record.round;
|
|
547
|
+
this.notifier.notify(notify);
|
|
548
|
+
}
|
|
549
|
+
|
|
425
550
|
/** notifier 的 NotifierHost 适配器(绑定到 pi.sendMessage + store 查询)。 */
|
|
426
551
|
private piAdapter(): NotifierHost {
|
|
427
552
|
return {
|
|
@@ -429,22 +554,44 @@ export class SubagentService {
|
|
|
429
554
|
this.pi?.sendMessage(message, options);
|
|
430
555
|
},
|
|
431
556
|
hasRunningBackground: () => {
|
|
432
|
-
|
|
557
|
+
// [M3] 「在跑的 background 工作」= 有活进程且非等待续聊(idle timer armed)。
|
|
558
|
+
// v4 B-1 把旧 idle 折入 running 且 record 留 store:轮次完成的 chatMode record
|
|
559
|
+
// (timer armed、无在跑轮)与 one-shot 完成后等待 message 升级的 record 都不再计入。
|
|
560
|
+
// 旧判定 `mode === "background"` 对这两类恒 true → 轮次完成通知恒挂 60s 合并窗口
|
|
561
|
+
// (notifier MERGE_WINDOW_MS),主 agent 的续聊回复固定延迟 60s 送达,持续对话(G1)失效。
|
|
562
|
+
return this.store.listRunning().some(
|
|
563
|
+
(r) => r.mode === "background" && hasLiveProcessHandle(r.id) && !hasIdleTimer(r.id),
|
|
564
|
+
);
|
|
433
565
|
},
|
|
434
566
|
isIdle: this.isIdleFn,
|
|
435
567
|
};
|
|
436
568
|
}
|
|
437
569
|
|
|
438
570
|
/** record → BgNotifyRecord(notifier.notify 入参映射,内部不外露)。
|
|
439
|
-
*
|
|
440
|
-
*
|
|
571
|
+
* v4 B-1:守卫放行 closed(终态,含 cancelled)、isIdle(对话模式轮次完成,notify 主 agent G1)
|
|
572
|
+
* 或 isResumable(running + 无活进程——SP-5 one-shot 成功完成 / MF-6 失败轮回退)。
|
|
573
|
+
* 正在执行(running + 活进程 + 非 timer-armed)返回 undefined(调用方 notifyComplete 跳过)。
|
|
574
|
+
* SP-1: closed 统一终态,closedReason 由 BgNotifyRecord 携带。 */
|
|
441
575
|
private toNotifyRecord(record: ExecutionRecord): BgNotifyRecord | undefined {
|
|
442
576
|
const snap = snapshot(record);
|
|
443
577
|
const s = snap.status;
|
|
444
|
-
|
|
578
|
+
// [N1] isResumable 放行:SP-5 one-shot 成功完成后 finalizeRoundToIdle 把 record 回退
|
|
579
|
+
// running-resumable——进程已死且永不 arm idle timer(armIdleTimer 只在 agent_settled 的
|
|
580
|
+
// chatMode 分支调用),旧守卫(closed / isIdle only)对其恒拒绝 → 完成通知静默丢失,
|
|
581
|
+
// 而 one-shot 失败走 finalizeRecord 保持 closed 反而通知——与 tool 契约「runs once,
|
|
582
|
+
// notifies on completion」完全倒置。isResumable = running + 无活进程,恰为该完成态;
|
|
583
|
+
// 在跑轮的 record 有活进程,不会被误放行。
|
|
584
|
+
if (s !== "closed" && !isIdle(record) && !isResumable(record)) return undefined;
|
|
585
|
+
// closed → BgNotifyRecord.closed(cancelled 区分靠 closedReason);chatMode 的 isIdle/
|
|
586
|
+
// isResumable(轮次完成或 MF-6 失败轮回退,对话可续)→ running(轮次完成)。
|
|
587
|
+
// isResumable 且非 chatMode(SP-5 one-shot 成功完成)→ closed:对主 agent 的语义是
|
|
588
|
+
// completed(非对话轮次),且只有 closed 分支文案携带 worktree patchFile 的 git apply
|
|
589
|
+
// 提示——one-shot worktree 模式的改动回收依赖该提示(running 分支文案不含 patchFile)。
|
|
590
|
+
const notifyStatus: BgNotifyRecord["status"] =
|
|
591
|
+
s === "closed" || !record.chatMode ? "closed" : "running";
|
|
445
592
|
return {
|
|
446
593
|
id: snap.id,
|
|
447
|
-
status:
|
|
594
|
+
status: notifyStatus,
|
|
448
595
|
agent: snap.agent,
|
|
449
596
|
model: snap.model,
|
|
450
597
|
result: snap.result,
|
|
@@ -452,6 +599,15 @@ export class SubagentService {
|
|
|
452
599
|
startedAt: snap.startedAt,
|
|
453
600
|
endedAt: snap.endedAt,
|
|
454
601
|
patchFile: record.patchFile,
|
|
602
|
+
// round 透传给 notifier 的 dedup key(对话模式按轮次去重,G1 决策 9)。
|
|
603
|
+
round: record.round,
|
|
604
|
+
// SP-1: closedReason 透传给 notifier(L2 原因,供通知文案按需展示)。
|
|
605
|
+
closedReason: record.closedReason,
|
|
606
|
+
// [wave2] chatMode 条件透传 sessionFile:通知末尾追加 Full transcript 指针行
|
|
607
|
+
//(增量语义的全文恢复通道,见 notifier.buildLlmContent)。one-shot(chatMode
|
|
608
|
+
// falsy)不透传——通知输出逐字节不变(G4),该条件由 message-close 测试的
|
|
609
|
+
// 必选用例锁死(漏加条件时 notifier 单测不红——notifier 层只见最终字段)。
|
|
610
|
+
sessionFile: record.chatMode ? record.sessionFile : undefined,
|
|
455
611
|
};
|
|
456
612
|
}
|
|
457
613
|
|
|
@@ -513,8 +669,17 @@ export class SubagentService {
|
|
|
513
669
|
} else if (opts.worktree === true) {
|
|
514
670
|
// worktree===true(显式要求)——创建新 worktree。与 fork 正交(worktree 文件隔离不依赖 fork 上下文继承)。
|
|
515
671
|
try {
|
|
516
|
-
worktreeHandle = this.worktreeManager.create(this.cwd, record.id);
|
|
672
|
+
worktreeHandle = await this.worktreeManager.create(this.cwd, record.id);
|
|
517
673
|
record.worktreeHandle = worktreeHandle;
|
|
674
|
+
// [create-await 竞态守卫] create 的 await 窗口内 cancel/dispose 可 CAS 把 record
|
|
675
|
+
// 转成 closed 终态——cancelBackground 当时读到的 worktreeHandle 可能仍是 undefined
|
|
676
|
+
// (cleanup 被跳过)。赋值后同同步段检查终态:closed 则主动 cleanup(幂等,抢先的
|
|
677
|
+
// fire-and-forget 清理无害)+ early-failed 返回,不进 kickOffBackground(避免子进程白跑)。
|
|
678
|
+
// 实现约束:赋值 → 终态检查 → kickOffBackground 必须在同一同步段,中间禁止插入 await。
|
|
679
|
+
if (record.status === "closed") {
|
|
680
|
+
await this.worktreeManager.cleanup(worktreeHandle);
|
|
681
|
+
return this.buildEarlyFailedHandle(record);
|
|
682
|
+
}
|
|
518
683
|
} catch (err) {
|
|
519
684
|
// create 失败→不进入 run,finalizeFailed 统一收尾(含 emitPendingUnregister failed)
|
|
520
685
|
const _result = await this.finalizeFailed(record, err);
|
|
@@ -527,11 +692,9 @@ export class SubagentService {
|
|
|
527
692
|
const priority = PRIORITY_BACKGROUND;
|
|
528
693
|
|
|
529
694
|
// ── 4-7. background 包 detached 立即返回 id ──
|
|
530
|
-
// background
|
|
531
|
-
// SubagentResultComponent spinner setInterval 堆叠)。detached 运行对 tool 层不可见,
|
|
532
|
-
// 完成由 notify 驱动新 turn。
|
|
695
|
+
// background detached 运行对 tool 层不可见,完成由 notify 驱动新 turn。
|
|
533
696
|
const bgDetails = project(record);
|
|
534
|
-
this.kickOffBackground(record, { ...opts,
|
|
697
|
+
this.kickOffBackground(record, { ...opts, worktree: worktreeHandle }, ctx, identity, signal, priority);
|
|
535
698
|
return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: bgDetails };
|
|
536
699
|
}
|
|
537
700
|
|
|
@@ -555,6 +718,431 @@ export class SubagentService {
|
|
|
555
718
|
return this.cancelBackground(record);
|
|
556
719
|
}
|
|
557
720
|
|
|
721
|
+
// ── 对话模式投递(M2-B3 message action 调用)──────────────
|
|
722
|
+
|
|
723
|
+
// [review 修复] 已删除 deliverToRunning(busy follow_up/steer 投递 + pendingMessages
|
|
724
|
+
// 消费确认制):SP-5 upgrade 后所有 running record 走 chatMode 分支 → deliverMessage
|
|
725
|
+
// 统一投递(热路径 prompt+streamingBehavior / 冷路径 resume),该方法无生产调用方,
|
|
726
|
+
// 其配套三段消费链(push / message_start shift / redeliverPending 补投)全部不可达,
|
|
727
|
+
// 一并移除(详见各文件同步删除)。
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* idle 投递:resume spawn 开启新一轮对话(设计决策 6 idle 分支)。
|
|
731
|
+
*
|
|
732
|
+
* record 必须 idle(轮次完成、进程已回收、record 留内存)。手动把 status 设回 "running"
|
|
733
|
+
* (M2-A 边界:idle→running 是恢复非终态,绕过 tryTransition——tryTransition 要求当前态
|
|
734
|
+
* running 才 CAS,idle record 直接进 runAndFinalize 会被 tryTransition 拒绝转态)。
|
|
735
|
+
*
|
|
736
|
+
* resume 参数从 record identity 读(防多轮对话模型漂移,探针 P-10):sessionFile、
|
|
737
|
+
* model、thinkingLevel 均为 record 身份字段(创建时确定、不可变)。maxTurns/schema 等
|
|
738
|
+
* 执行约束第一版不恢复(设计 §5 拆分 1 待验证检查点),agentConfig 用 undefined
|
|
739
|
+
* (pi --session 续写保留上下文,agent 行为由 session 内 messages 决定;M2-B3 messageHandler 可完善)。
|
|
740
|
+
*
|
|
741
|
+
* detached 编排(参照 kickOffBackground):不 await,runAndFinalize 在 background 跑。
|
|
742
|
+
* chatMode + done 时 runAndFinalize 的 M2-A 分流自动把 record 重新置 idle。并发槽在
|
|
743
|
+
* runAndFinalize 内重新 acquire(轮次间 idle 已 release);pool.acquire 是排队模型,
|
|
744
|
+
* 池满时排队等待槽位而非 throw(与 execute 一致)。
|
|
745
|
+
*
|
|
746
|
+
* @param record 目标 record(必须 idle)
|
|
747
|
+
* @param text 新一轮消息正文
|
|
748
|
+
* @throws Error record 非 idle / 无 sessionFile / 无 controller
|
|
749
|
+
*/
|
|
750
|
+
resumeRound(record: ExecutionRecord, text: string): void {
|
|
751
|
+
this.assertReady();
|
|
752
|
+
// [CL-b1-cas-coupling / v4 B-1] v4 把旧 idle 折入 running 后此守卫对 idle-resumable
|
|
753
|
+
// record 恒放行(idle 本来就是 running),`status = "running"`(下方)是幂等写——
|
|
754
|
+
// 旧「idle→running CAS」的单写者语义已消失。单写者守卫由 resumesInFlight 承担
|
|
755
|
+
//([review MF1],见字段注释);本守卫保留拦截终态(closed)record。
|
|
756
|
+
if (record.status !== "running") {
|
|
757
|
+
// MF-4:行动语言(spec §3.1),不暴露 resume/controller 等内部词汇。
|
|
758
|
+
throw new Error(
|
|
759
|
+
`subagent ${record.id} is not ready for a new message (current state: ${record.status}). ` +
|
|
760
|
+
`Recovery: use action:'list' to confirm state; wait for the current round to finish, or send the message again once it is idle.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
// [review MF1] 在途 resume 守卫:上一条消息发起的 resume 仍在途(spawn 尚未注册 /
|
|
764
|
+
// 本轮 runAndFinalize 未收尾)时,再次到达(冷路径重入 / EPIPE 兜底)直接拒绝。
|
|
765
|
+
// 触发链:pi 对同一 assistant message 的 tool calls 顺序执行(sequential),tool1 的
|
|
766
|
+
// deliverMessage 在冷路径 resumeRound 返回即 resolve(早于 spawn 注册完成),tool2
|
|
767
|
+
// 立即执行 → getChildByRecord 仍 undefined → 再次冷路径。无此守卫 → 两次 kickOff →
|
|
768
|
+
// runSpawn 2 次 → 两 pi 子进程双写同一 session JSONL + 第一个脱离 kill 记账成孤儿。
|
|
769
|
+
if (this.resumesInFlight.has(record.id)) {
|
|
770
|
+
// MF-4:行动语言。
|
|
771
|
+
throw new Error(
|
|
772
|
+
`subagent ${record.id} is already starting a new round (a previous message is still resuming). ` +
|
|
773
|
+
`Recovery: wait for the round to start (check with action:'list'), then send the message again; ` +
|
|
774
|
+
`or use action:'close' if this subagent is no longer needed.`,
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (!record.sessionFile) {
|
|
778
|
+
// MF-4:session 损坏 → canonical 文案(spec §3.1 失败表)。
|
|
779
|
+
throw new Error(
|
|
780
|
+
`session unavailable for subagent ${record.id} (session file missing or unreadable). ` +
|
|
781
|
+
`Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
if (!record.controller) {
|
|
785
|
+
// chatMode background record 创建时一定有 controller;兜底防御性检查。MF-4 行动语言。
|
|
786
|
+
throw new Error(
|
|
787
|
+
`subagent ${record.id} is not ready for a new message (internal state error). ` +
|
|
788
|
+
`Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
// [review round2] 跨重启 worktree 绑定丢失守卫:原 record 曾用 worktree 隔离
|
|
792
|
+
//(hadWorktree 由 getRecordForAction 磁盘重建时从 session entry 恢复),但 handle
|
|
793
|
+
// 不可序列化、跨重启后无法 reattach(reattach 也无 checkout 可用——reaper 在
|
|
794
|
+
// session_start 已按 pid 死活清理孤儿 worktree)。此时 resume 的 spawn cwd 会静默
|
|
795
|
+
// 回落主 repo,子 agent 直接编辑主仓库——正是 worktree 隔离要防的场景。拒绝续聊。
|
|
796
|
+
if (record.hadWorktree === true && !record.worktreeHandle) {
|
|
797
|
+
// MF-4:行动语言。
|
|
798
|
+
throw new Error(
|
|
799
|
+
`subagent ${record.id} was created with worktree isolation, but that binding was lost when the parent process restarted; ` +
|
|
800
|
+
`resuming it now would run in the main repository and bypass the isolation. ` +
|
|
801
|
+
`Recovery: use action:'close' to release this subagent, then action:'start' a new one with worktree isolation.`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
// 手动设回 running(M2-A 边界:绕过 tryTransition,idle→running 恢复非终态 CAS)。
|
|
806
|
+
record.status = "running";
|
|
807
|
+
|
|
808
|
+
// resume 参数从 record identity 读(防漂移,P-10)。
|
|
809
|
+
const resume: SpawnResumeOpts = {
|
|
810
|
+
sessionFile: record.sessionFile,
|
|
811
|
+
model: record.model,
|
|
812
|
+
thinkingLevel: record.thinkingLevel,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// 重建 resolved:runSpawn 内 resume.model/thinkingLevel 优先(覆盖 resolved),
|
|
816
|
+
// resolved.model.id 仅在 runSpawn 内被读(resume 短路时不报错)。从 record.model
|
|
817
|
+
// (createRecordForMode 写入的 "provider/id" 格式)解析 provider/id 构造最小 ModelInfo。
|
|
818
|
+
const slashIdx = record.model.indexOf("/");
|
|
819
|
+
const provider = slashIdx >= 0 ? record.model.slice(0, slashIdx) : "unknown";
|
|
820
|
+
const modelId = slashIdx >= 0 ? record.model.slice(slashIdx + 1) : record.model;
|
|
821
|
+
const identity: ResolvedIdentity = {
|
|
822
|
+
agent: record.agent,
|
|
823
|
+
agentConfig: undefined,
|
|
824
|
+
resolved: {
|
|
825
|
+
model: { id: modelId, name: record.model, provider, reasoning: false },
|
|
826
|
+
thinkingLevel: record.thinkingLevel,
|
|
827
|
+
},
|
|
828
|
+
};
|
|
829
|
+
|
|
830
|
+
const opts: ExecuteOptions = {
|
|
831
|
+
task: text,
|
|
832
|
+
slug: record.slug,
|
|
833
|
+
worktree: record.worktreeHandle,
|
|
834
|
+
};
|
|
835
|
+
const ctx = this.buildSessionRunnerContext();
|
|
836
|
+
|
|
837
|
+
// detached 编排:runAndFinalize 在 background 跑,pool 重新 acquire(轮次间 idle 已 release)。
|
|
838
|
+
// chatMode + done 时 M2-A 分流自动 finalizeRoundToIdle(record 回 idle、round+1)。
|
|
839
|
+
// [review MF1] 在途标记在 kickOff 前同步设置:resumeRound 返回即生效,后续重入
|
|
840
|
+
// (冷路径 / EPIPE 兜底)在守卫处被拒;runAndFinalize finally 统一清除。
|
|
841
|
+
this.resumesInFlight.add(record.id);
|
|
842
|
+
this.kickOffBackground(record, opts, ctx, identity, record.controller.signal, PRIORITY_BACKGROUND, resume);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* [V2 决策 3] chatMode 统一投递:按**进程死活**分流,不按 record.status。
|
|
847
|
+
*
|
|
848
|
+
* V2 进程长驻——chatMode record 首轮 agent_settled 后进轻量 idle(Step 4a:进程保活、
|
|
849
|
+
* idle timer armed),续聊时进程仍在内存,不该重开 session。故续聊投递不按 status
|
|
850
|
+
*(running/idle 都可能是热路径),而是判进程死活:
|
|
851
|
+
*
|
|
852
|
+
* 热路径(进程活):prompt + streamingBehavior——pi 权威裁决 busy/idle(F3/F4)。
|
|
853
|
+
* busy(isStreaming)时 followUp 入队/steer 抢占;idle 时 streamingBehavior 被忽略、
|
|
854
|
+
* 直接开新 turn。不用 steer/followUp 命令、不依赖 clearQueue(F8),结构上消除残留。
|
|
855
|
+
* 冷路径(进程死):复用 resumeRound 重开 session + prompt(仅崩溃/timeout kill/跨重启命中)。
|
|
856
|
+
*
|
|
857
|
+
* disarm idle timer:新 turn 开始必须 disarm(V2 决策 4),防 turn 期间 idle timer 误杀活进程。
|
|
858
|
+
*
|
|
859
|
+
* status 处理:判活分流后**各自**设 running——热路径手动设 running(新 turn 开始);
|
|
860
|
+
* 冷路径由 resumeRound 校验 idle 并自行设 running + spawn(故不在此预设 running,否则
|
|
861
|
+
* resumeRound 的 idle 检查会 throw)。resume spawn 后 session-runner 回填 record.pid,
|
|
862
|
+
* 热路径拿到 child 时也顺便刷新 pid(resume 重开进程后 pid 已变)。
|
|
863
|
+
*
|
|
864
|
+
* [review 修复] 曾对比的 deliverToRunning(非 chatMode busy 投递 + pendingMessages
|
|
865
|
+
* 消费确认制)已删除——SP-5 upgrade 后无生产调用方(V2 决策 3 已删消费确认制)。
|
|
866
|
+
*
|
|
867
|
+
* @param record 目标 record(chatMode,running 或 idle)
|
|
868
|
+
* @param text 消息正文
|
|
869
|
+
* @param interrupt true=steer(抢占)/ false=followUp(排队),仅热路径 prompt streamingBehavior 用
|
|
870
|
+
*/
|
|
871
|
+
async deliverMessage(record: ExecutionRecord, text: string, interrupt: boolean): Promise<void> {
|
|
872
|
+
this.assertReady();
|
|
873
|
+
// 新 turn,disarm idle timer(防 turn 期间误杀活进程,V2 决策 4)
|
|
874
|
+
disarmIdleTimer(record.id);
|
|
875
|
+
const child = getChildByRecord(record.id);
|
|
876
|
+
if (child && !child.killed) {
|
|
877
|
+
// 热路径:进程活,prompt + streamingBehavior(V2 决策 3,pi 权威裁决 busy/idle)
|
|
878
|
+
record.status = "running";
|
|
879
|
+
// 刷新 pid 内存记账(resume spawn 后 child.pid 已变,顺便更新)
|
|
880
|
+
if (child.pid !== undefined) record.pid = child.pid;
|
|
881
|
+
try {
|
|
882
|
+
sendPromptCommand(child, text, { streamingBehavior: interrupt ? "steer" : "followUp" });
|
|
883
|
+
// 热路径成功,清零 EPIPE 连续失败计数([v4 A-1] 计数器已迁移到 stdin-writer)
|
|
884
|
+
clearEpipeFailure(record.id);
|
|
885
|
+
} catch (err) {
|
|
886
|
+
// EPIPE 兜底:stdin 管道已断,进程实际已死但 close 事件尚未到达。
|
|
887
|
+
// 检测 EPIPE 关键词 → 进程按 dead 处理 → 自动转冷路径 resume + 消息重放。
|
|
888
|
+
// [review MF1] 本兜底不持 activateLock,但与冷路径共用 resumeRound 的在途守卫
|
|
889
|
+
//(resumesInFlight):resume 已在途时兜底的 resumeRound 调用被拒(throw 行动语言),
|
|
890
|
+
// 不会二次 spawn。
|
|
891
|
+
if (err instanceof Error && err.message.includes("EPIPE")) {
|
|
892
|
+
logger.warn(`[subagents] EPIPE on hot path for ${record.id}, falling back to cold path resume`, {
|
|
893
|
+
detail: err.message,
|
|
894
|
+
});
|
|
895
|
+
// 清理 spawnedChildren 中的死进程条目(让 resumeRound 能重新 spawn)。
|
|
896
|
+
// [M4] 按值守卫:仅当 Map 当前值仍是本次写 EPIPE 的 child 才删——若已被 resume
|
|
897
|
+
// spawn 覆盖为新 child(close 事件先于本 catch 到达的极端时序),不误删新注册
|
|
898
|
+
//(与 session-runner removeChildRegistration 同语义)。
|
|
899
|
+
if (spawnedChildren.get(record.id) === child) {
|
|
900
|
+
spawnedChildren.delete(record.id);
|
|
901
|
+
}
|
|
902
|
+
// 递增连续 EPIPE 计数([v4 A-1] helper 合并同步/异步路径计数)
|
|
903
|
+
const count = recordEpipeFailure(record.id);
|
|
904
|
+
if (count >= EPIPE_FAILURE_THRESHOLD) {
|
|
905
|
+
// 连续达阈值 EPIPE → 不再尝试 resume,throw 含恢复指引
|
|
906
|
+
clearEpipeFailure(record.id);
|
|
907
|
+
throw new Error(
|
|
908
|
+
`[subagents] EPIPE fallback exhausted for ${record.id}: ${count} consecutive EPIPE failures. ` +
|
|
909
|
+
`Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
// 冷路径 resume + 原消息重放(v4 B-1: status 已 running,resumeRound CAS 直接放行)
|
|
913
|
+
this.resumeRound(record, text);
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
// 非 EPIPE 错误——不应发生,重新抛出让调用方处理
|
|
917
|
+
throw err;
|
|
918
|
+
}
|
|
919
|
+
} else {
|
|
920
|
+
// 冷路径:进程死(idle timer reap / 崩溃 / 跨重启),record 应为 idle → resume spawn。
|
|
921
|
+
// D3:acquireActivateLock 双保险——注意锁只覆盖 resumeRound 同步段,释放在子进程注册
|
|
922
|
+
//(session-runner spawnedChildren.set)之前(中间隔 pool.acquire await + tempFile 等异步点)。
|
|
923
|
+
// 真正的单写者守卫是 resumeRound 的 resumesInFlight([review MF1]):锁释放后、child
|
|
924
|
+
// 注册前到达的第二次冷路径 message 在 resumeRound 处被拒,不会二次 spawn。
|
|
925
|
+
const releaseLock = await acquireActivateLock(record.id);
|
|
926
|
+
try {
|
|
927
|
+
this.resumeRound(record, text);
|
|
928
|
+
} finally {
|
|
929
|
+
releaseLock();
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// ── 对话模式 message/close action 支持(M2-B3)──────────────
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* 按 id 查 record 并做归属校验(message/close action 的统一入口)。
|
|
938
|
+
*
|
|
939
|
+
* 设计决策 3(归属守卫):校验 record.rootSessionId 必须等于当前 session 的根 id
|
|
940
|
+
*(this.sessionRootId)。不匹配 / 不存在统一抛「not found or not owned」——不区分
|
|
941
|
+
* 两种失败,防信息泄露(无法通过错误消息探测其他 session 的 subagent id)。
|
|
942
|
+
*
|
|
943
|
+
* 同进程内 running + idle record 都在内存(getMutable);终态 record 已 archive。
|
|
944
|
+
* 跨重启(SP-2)内存空时,从磁盘 collectRecords 重建 idle record 并 register 进内存。
|
|
945
|
+
* reconstructAll 已将跨重启 record(无 sidecar marker + pid 死)标记为 idle,
|
|
946
|
+
* collectRecords 返回的 SubagentRecord 可直接转为可变 ExecutionRecord 供续操作。
|
|
947
|
+
*
|
|
948
|
+
* @param id subagent record id
|
|
949
|
+
* @returns 可变 ExecutionRecord(message/close handler 直接操作)
|
|
950
|
+
* @throws Error record 不存在 / 非本 session 所有(含恢复指引)
|
|
951
|
+
*/
|
|
952
|
+
getRecordForAction(id: string): ExecutionRecord {
|
|
953
|
+
this.assertReady();
|
|
954
|
+
let record = this.store.getMutable(id);
|
|
955
|
+
// SP-2 跨重启恢复:内存未命中时,从磁盘 collectRecords 重建 idle record。
|
|
956
|
+
// reconstructAll 已将跨重启 record(无 sidecar + pid 死)标记为 idle(非 crashed),
|
|
957
|
+
// 直接转为可变 ExecutionRecord register 进内存,供 message/close action 续操作。
|
|
958
|
+
if (!record) {
|
|
959
|
+
// [perf] SP-2 冷路径:先走 idToFile 索引直查(单文件 stat 校验),未命中
|
|
960
|
+
//(进程重启后尚未扫描、索引未热)才全目录 collectRecords 兜底建索引。
|
|
961
|
+
// 跨重启后每条 message 从「readdir + N×4 stat 全扫」降为单文件校验。
|
|
962
|
+
const direct = this.store.findLightById(id);
|
|
963
|
+
const found =
|
|
964
|
+
(direct?.status === "running" ? direct : undefined) ??
|
|
965
|
+
this.store
|
|
966
|
+
.collectRecords(1000, "all", undefined)
|
|
967
|
+
.find((r) => r.id === id && r.status === "running");
|
|
968
|
+
if (found) {
|
|
969
|
+
record = createRecord(id, {
|
|
970
|
+
agent: found.agent,
|
|
971
|
+
model: found.model,
|
|
972
|
+
thinkingLevel: found.thinkingLevel,
|
|
973
|
+
mode: found.mode,
|
|
974
|
+
task: found.task,
|
|
975
|
+
slug: found.slug,
|
|
976
|
+
startedAt: found.startedAt,
|
|
977
|
+
rootSessionId: found.rootSessionId,
|
|
978
|
+
parentRecordId: found.parentRecordId,
|
|
979
|
+
depth: found.depth,
|
|
980
|
+
// [v4 A-3] 跨重启恢复入口——message 路径磁盘重建无条件置 chatMode=true(现状机制,
|
|
981
|
+
// V3 方案 A 方向兑现)。改动此处必须带 S3 回归场景(跨重启 message 续聊验证)。
|
|
982
|
+
// V3 SP-5 探针定案:机制已存在,本注释即定案,不再悬置。
|
|
983
|
+
chatMode: true,
|
|
984
|
+
controller: new AbortController(),
|
|
985
|
+
});
|
|
986
|
+
record.sessionFile = found.sessionFile;
|
|
987
|
+
record.round = found.round;
|
|
988
|
+
// [review round2] 跨重启 worktree 绑定丢失防护:原 record 创建时启用了 worktree 隔离
|
|
989
|
+
//(session entry 的 worktree 标志),但 WorktreeHandle 不可序列化、重建后恒缺失。
|
|
990
|
+
// 标记 hadWorktree,resumeRound 守卫据此拒绝续聊(防 spawn cwd 静默回落主 repo 破坏
|
|
991
|
+
// 隔离——正是 worktree 要防的并发写冲突场景)。close 不受影响(closeChatIdle 走
|
|
992
|
+
// doFinalizeRecord,泄漏的 worktree 由 reaper 兜底回收)。
|
|
993
|
+
record.hadWorktree = found.worktree === true;
|
|
994
|
+
this.store.register(record);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (!record || record.rootSessionId !== this.sessionRootId) {
|
|
998
|
+
throw new Error(
|
|
999
|
+
`subagent not found or not owned: ${id}. Recovery: use action:'list' to confirm the id; ` +
|
|
1000
|
+
`ended subagents cannot be messaged — start a new one; only subagents owned by the current session can be operated on.`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
// [v4 A-5 / P7] 直接父校验:rootSessionId 已确认 record 属于本 session 树,但递归场景下
|
|
1004
|
+
// 孙级 record(parentRecordId = 某子进程的 self recordId)的子进程句柄只存在于其直接父
|
|
1005
|
+
// 进程内存。主进程(execCtxBaseline=null)若仅凭 rootSessionId 通过就 message 孙级,会走
|
|
1006
|
+
// 冷路径重新 spawn → 双写同一 session 文件(P7 双写者窗口)。统一用 baseline recordId 校验:
|
|
1007
|
+
// - 主进程 baseline=undefined → 只能操作 parentRecordId=undefined 的根层 record
|
|
1008
|
+
// - 子进程 baseline="sa-X" → 只能操作 parentRecordId="sa-X" 的直接孩子
|
|
1009
|
+
// record.parentRecordId===undefined 视作根层,仅主进程可操作(身份缺省的旧/异常 record 归此)。
|
|
1010
|
+
const baselineRecordId = this.execCtxBaseline?.recordId ?? undefined;
|
|
1011
|
+
if (record.parentRecordId !== baselineRecordId) {
|
|
1012
|
+
throw new Error(
|
|
1013
|
+
`subagent ${id} is owned by its direct parent; message it through that parent ` +
|
|
1014
|
+
`(see /subagents list, parent=${record.parentRecordId ?? "(root layer)"}). [v4 A-5] cross-layer ` +
|
|
1015
|
+
`ownership guard: this process's baseline=${baselineRecordId ?? "(root)"} is not the direct parent of ${id}; ` +
|
|
1016
|
+
`operating here would race the owning child process's handle and double-write the session file.`,
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
return record;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* close action 的统一行为分流(running 子态 × force)。
|
|
1024
|
+
*
|
|
1025
|
+
* running + force:true → cancelBackground(显式 SIGTERM + closed+cancelled 终态)
|
|
1026
|
+
* running + force:false + 无在跑轮 → closeChatIdle(立即终态化 done + 回收保活进程 + disarm timer)
|
|
1027
|
+
* (isIdle timer armed 或 isResumable 无活进程)
|
|
1028
|
+
* running + force:false + 有活进程在跑轮 → 置 closeAfterRound=true(轮完成时终态化:
|
|
1029
|
+
* chatMode 消费点在 onRoundSettled,非 chatMode 在 runAndFinalize CAS 分支)
|
|
1030
|
+
* 其他终态 → 幂等 no-op(已结束)
|
|
1031
|
+
*
|
|
1032
|
+
* 与设计决策 5 一致:close = 正式终态(走 finalize),force 只影响 running 时机。
|
|
1033
|
+
*
|
|
1034
|
+
* @param record 目标 record(getRecordForAction 已校验归属)
|
|
1035
|
+
* @param force true=立即终止(running 时 SIGTERM)/ false=优雅关闭(running 时等轮完)
|
|
1036
|
+
*/
|
|
1037
|
+
async closeSubagent(record: ExecutionRecord, force: boolean): Promise<void> {
|
|
1038
|
+
this.assertReady();
|
|
1039
|
+
if (record.status === "running") {
|
|
1040
|
+
if (force) {
|
|
1041
|
+
// 立即终止:cancelBackground(controller.abort + tryTransition closed+cancelled + finalize)
|
|
1042
|
+
this.cancelBackground(record);
|
|
1043
|
+
} else if (isIdle(record) || isResumable(record)) {
|
|
1044
|
+
// [M5] 无在跑轮:Path A(isIdle timer armed、进程保活等待续聊)/ Path B(isResumable
|
|
1045
|
+
// 无活进程)→ 立即终态化 done(closeChatIdle 内回收保活进程 + disarm timer)。
|
|
1046
|
+
// 旧代码 Path A 走 else 置 closeAfterRound,但唯一消费点(runAndFinalize CAS 分支)
|
|
1047
|
+
// 对 chatMode 不可达——agent_settled 恒 arm idle timer → runAndFinalize 恒命中
|
|
1048
|
+
// early return(isIdle 恒 true),标志置了无人消费,tool 却返回 {closed:true}(谎报)。
|
|
1049
|
+
await this.closeChatIdle(record);
|
|
1050
|
+
} else {
|
|
1051
|
+
// 优雅关闭:正在执行(有活进程在跑轮),标记 closeAfterRound,轮完成时终态化
|
|
1052
|
+
//(chatMode 消费点在 onRoundSettled,非 chatMode 在 runAndFinalize CAS 分支)
|
|
1053
|
+
record.closeAfterRound = true;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// 其他终态(closed):幂等 no-op
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/**
|
|
1060
|
+
* 无在跑轮 record 的手动终态化为 done(close action 的 isIdle/isResumable 分支)。
|
|
1061
|
+
*
|
|
1062
|
+
* 无在途 AgentResult(轮次完成时 record 未冻结,turns[] 保留运行时状态),
|
|
1063
|
+
* 构造合成 done result(对齐 cancelBackground 的 cancelledResult 模式)。
|
|
1064
|
+
* 走 doFinalizeRecord 的完整终态化路径(completeRecord + archive + finalized + worktree
|
|
1065
|
+
* cleanup + alive marker + manifest)。
|
|
1066
|
+
*
|
|
1067
|
+
* [M5] 覆盖两路:Path B(无活进程,同旧行为)与 Path A(idle timer armed、进程保活等待
|
|
1068
|
+
* 续聊)。Path A 必须先显式回收进程 + disarm timer——否则 record 已终态化但保活进程
|
|
1069
|
+
* 继续驻留(终态后无人再杀它:closeSubagent 不再来、idle timer 已 disarm、runSpawn
|
|
1070
|
+
* promise 早已 resolve),直到宿主进程退出。
|
|
1071
|
+
*
|
|
1072
|
+
* 不走 tryTransition(v4 B-1 此态 status=running,但由 doFinalizeRecord 内部的
|
|
1073
|
+
* completeRecord 直接覆盖 status,与 cancelBackground 对 record 的处理同构)。
|
|
1074
|
+
*/
|
|
1075
|
+
private async closeChatIdle(record: ExecutionRecord): Promise<void> {
|
|
1076
|
+
// [M5] Path A:回收保活进程 + disarm idle timer(终态化后无其他 kill 路径)
|
|
1077
|
+
disarmIdleTimer(record.id);
|
|
1078
|
+
const child = getChildByRecord(record.id);
|
|
1079
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
1080
|
+
// 合成 closed result(无在途 AgentResult,对齐 cancelBackground cancelledResult)
|
|
1081
|
+
const doneResult: AgentResult = {
|
|
1082
|
+
text: "",
|
|
1083
|
+
turns: record.turnCount,
|
|
1084
|
+
durationMs: Date.now() - record.startedAt,
|
|
1085
|
+
success: true,
|
|
1086
|
+
sessionId: record.id,
|
|
1087
|
+
toolCalls: [],
|
|
1088
|
+
};
|
|
1089
|
+
await doFinalizeRecord(
|
|
1090
|
+
{
|
|
1091
|
+
manifestStore: this.manifestStore,
|
|
1092
|
+
worktreeManager: this.worktreeManager,
|
|
1093
|
+
store: this.store,
|
|
1094
|
+
modelService: this.modelService,
|
|
1095
|
+
pi: this.pi,
|
|
1096
|
+
emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
|
|
1097
|
+
},
|
|
1098
|
+
record,
|
|
1099
|
+
doneResult,
|
|
1100
|
+
"closed",
|
|
1101
|
+
"user-close", // close action 主动关闭
|
|
1102
|
+
);
|
|
1103
|
+
// [C-1] 终态通知(设计 D2 路径②):doneResult.text 恒空串 → 终态通知正文空 +
|
|
1104
|
+
// sessionFile 指针行(idle 下末轮增量已由该轮轮次通知送达,终态再发属重复)。
|
|
1105
|
+
// dedup 身份独立于轮次通知(notifyClosed 置 round=undefined),60s 窗内不被吞。
|
|
1106
|
+
// 防重入:closeSubagent 对 closed record 幂等 no-op,本路径不会被二次进入。
|
|
1107
|
+
this.notifyClosed(record);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/**
|
|
1111
|
+
* [M5] closeAfterRound 消费:chatMode 轮次完成时终态化 record(closed + user-close)。
|
|
1112
|
+
*
|
|
1113
|
+
* 由 onRoundSettled(agent_settled 回调)调用——chatMode 轮次完成的统一汇聚点(热路径轮
|
|
1114
|
+
* 不经 runAndFinalize CAS 分支,旧消费点对 chatMode 不可达)。合成 result 沿用 record.result
|
|
1115
|
+
*(= 本轮增量,设计 D2 路径①):本轮增量已由调用方前置的轮次通知送达,终态通知正文因此
|
|
1116
|
+
* 是同一段增量 + 轮次统计 + sessionFile 指针行(notifyClosed),不重发全历史。
|
|
1117
|
+
*
|
|
1118
|
+
* 时序:同步前缀(disarm + kill + CAS)在 session-runner 的 resolveRun(0) 之前执行完——
|
|
1119
|
+
* 冷路径轮的 runAndFinalize 续体因 timer 已 disarm 跳过 early return,但其 tryTransition
|
|
1120
|
+
* CAS 对已 closed 的 record 失败 → 跳过二次 finalize(无双收尾);热路径轮无 runAndFinalize
|
|
1121
|
+
* 续体,本方法是唯一收尾。冷路径续体 .then 的 notifyComplete 与轮次通知同 key=`id:round`,
|
|
1122
|
+
* 60s dedup 吞(不与下方终态通知叠加成第三条——后者 key 是裸 id)。
|
|
1123
|
+
*/
|
|
1124
|
+
private async closeAfterRoundSettled(record: ExecutionRecord): Promise<void> {
|
|
1125
|
+
// 回收保活进程(Path A:轮次完成后进程仍活)+ disarm idle timer(终态化后无其他 kill 路径)
|
|
1126
|
+
disarmIdleTimer(record.id);
|
|
1127
|
+
const child = getChildByRecord(record.id);
|
|
1128
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
1129
|
+
if (!tryTransition(record, "closed", "user-close")) {
|
|
1130
|
+
return; // 已被 cancel/finalize 抢先(CAS 失败),标志已消费即可——不发终态通知(幂等)
|
|
1131
|
+
}
|
|
1132
|
+
const doneResult: AgentResult = {
|
|
1133
|
+
text: record.result ?? "",
|
|
1134
|
+
turns: record.turnCount,
|
|
1135
|
+
durationMs: Date.now() - record.startedAt,
|
|
1136
|
+
success: true,
|
|
1137
|
+
sessionId: record.id,
|
|
1138
|
+
toolCalls: [],
|
|
1139
|
+
};
|
|
1140
|
+
await this.finalizeRecord(record, doneResult, "closed", "user-close");
|
|
1141
|
+
// [C-1] 终态通知(设计 D2 路径①):与前置轮次通知(key=`id:round`)dedup 身份区分,
|
|
1142
|
+
// 「最后一轮轮次通知 + 终态通知」两条都送达父 agent。
|
|
1143
|
+
this.notifyClosed(record);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
558
1146
|
// ── 编排层专用接口(workflow 消费)──────────────────────
|
|
559
1147
|
|
|
560
1148
|
/**
|
|
@@ -600,17 +1188,30 @@ export class SubagentService {
|
|
|
600
1188
|
// throw lets SAR.run() convert it to an AgentResult.error (not return-handle like execute()).
|
|
601
1189
|
let worktreeHandle: WorktreeHandle | undefined;
|
|
602
1190
|
if (opts.worktree === true) {
|
|
1191
|
+
// [create-await 竞态守卫] 与 execute 同款(见其 worktree 分支注释)——差异仅在
|
|
1192
|
+
// 失败语义:executeAndAwait 对齐「失败 throw」,SAR.run 的 catch 会转成 AgentResult.error
|
|
1193
|
+
// (cancelled 呈现对齐 cancel 抢先路径)。赋值→终态检查→runAndFinalize 同一同步段。
|
|
1194
|
+
// 检查结果经标志位带出 try(守卫 throw 不能落在 try 内——会被下方 catch 当作
|
|
1195
|
+
// create 失败再走 finalizeFailed,对已 closed 的 record 语义未定义)。
|
|
1196
|
+
let cancelledDuringCreate = false;
|
|
603
1197
|
try {
|
|
604
|
-
worktreeHandle = this.worktreeManager.create(this.cwd, record.id);
|
|
1198
|
+
worktreeHandle = await this.worktreeManager.create(this.cwd, record.id);
|
|
605
1199
|
record.worktreeHandle = worktreeHandle;
|
|
1200
|
+
if (record.status === "closed") {
|
|
1201
|
+
cancelledDuringCreate = true;
|
|
1202
|
+
}
|
|
606
1203
|
} catch (err) {
|
|
607
1204
|
// finalizeFailed: CAS→finalizeRecord→emitUnregister (record already registered above).
|
|
608
1205
|
// throw (not return-handle): executeAndAwait's caller SAR.run() catches and wraps into
|
|
609
|
-
// AgentResult.error. Diverges from execute()
|
|
1206
|
+
// AgentResult.error. Diverges from execute() which returns buildEarlyFailedHandle
|
|
610
1207
|
// because the two methods have different return types.
|
|
611
1208
|
await this.finalizeFailed(record, err);
|
|
612
1209
|
throw err;
|
|
613
1210
|
}
|
|
1211
|
+
if (cancelledDuringCreate) {
|
|
1212
|
+
await this.worktreeManager.cleanup(worktreeHandle);
|
|
1213
|
+
throw new Error(`subagent ${record.id} cancelled during worktree creation`);
|
|
1214
|
+
}
|
|
614
1215
|
}
|
|
615
1216
|
|
|
616
1217
|
// ── 步骤 3: SessionRunnerContext ──
|
|
@@ -619,10 +1220,10 @@ export class SubagentService {
|
|
|
619
1220
|
// ── 步骤 4: signal 决议 ──
|
|
620
1221
|
const effectiveSignal = signal ?? record.controller?.signal;
|
|
621
1222
|
|
|
622
|
-
// 步骤 5: runAndFinalize(await,不 detached)。
|
|
1223
|
+
// 步骤 5: runAndFinalize(await,不 detached)。onEvent 独立传,stream 透传。
|
|
623
1224
|
const result = await this.runAndFinalize(
|
|
624
1225
|
record,
|
|
625
|
-
{ ...opts,
|
|
1226
|
+
{ ...opts, worktree: worktreeHandle },
|
|
626
1227
|
ctx,
|
|
627
1228
|
identity,
|
|
628
1229
|
effectiveSignal,
|
|
@@ -671,11 +1272,20 @@ export class SubagentService {
|
|
|
671
1272
|
|
|
672
1273
|
/** 合并内存(running) + 磁盘(session.jsonl 重建) record(/subagents list + tool list 消费)。
|
|
673
1274
|
* 按 rootSessionId 过滤:根进程=本 session(sessionRootId===sessionId);
|
|
674
|
-
* 子进程=env 贯穿的真 ROOT(sessionRootId≠sessionId)→ 看到整棵 ROOT 树(决策 3)。
|
|
1275
|
+
* 子进程=env 贯穿的真 ROOT(sessionRootId≠sessionId)→ 看到整棵 ROOT 树(决策 3)。
|
|
1276
|
+
* [perf] 磁盘源为 light(头部 identity + 状态,无 eventLog/result/turns 等重数据)
|
|
1277
|
+
* ——列表/补全/hasRunning 够用;详情场景调 getFullRecord(id) 懒加载补齐。 */
|
|
675
1278
|
collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
|
|
676
1279
|
return this.store.collectRecords(limit, statusFilter, this.sessionRootId ?? this.sessionId ?? undefined);
|
|
677
1280
|
}
|
|
678
1281
|
|
|
1282
|
+
/** [perf] 单 record 详情懒加载(全量:eventLog/displayItems/result/turns/tokens)。
|
|
1283
|
+
* 内存 running record 直接投影;磁盘 record 全量重建(per-file 缓存,stat 戳校验)。
|
|
1284
|
+
* 返回 undefined:id 不存在于内存与磁盘。 */
|
|
1285
|
+
getFullRecord(id: string): SubagentRecord | undefined {
|
|
1286
|
+
return this.store.getFullRecord(id);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
679
1289
|
// ── 执行内部:身份解析 + record 创建 ──────────
|
|
680
1290
|
|
|
681
1291
|
/** 步骤 1:身份解析。agentConfig → resolveModel(三层:override → agentConfig → 主 agent model)。 */
|
|
@@ -727,6 +1337,8 @@ export class SubagentService {
|
|
|
727
1337
|
rootSessionId: this.sessionRootId ?? undefined,
|
|
728
1338
|
parentRecordId,
|
|
729
1339
|
depth,
|
|
1340
|
+
chatMode: opts.conversation === true,
|
|
1341
|
+
idleTimeoutMs: opts.idleTimeoutMs,
|
|
730
1342
|
controller,
|
|
731
1343
|
});
|
|
732
1344
|
|
|
@@ -753,6 +1365,8 @@ export class SubagentService {
|
|
|
753
1365
|
priority: number,
|
|
754
1366
|
rawOnEvent?: (event: AgentEvent) => void,
|
|
755
1367
|
stream?: SubagentStream,
|
|
1368
|
+
/** resume 选项(M2-B1):透传 runSpawn,重开已 idle 的 session 续聊。undefined = 新 session。 */
|
|
1369
|
+
resume?: SpawnResumeOpts,
|
|
756
1370
|
): Promise<AgentResult> {
|
|
757
1371
|
const pooled = record.mode === "background";
|
|
758
1372
|
let acquired = false;
|
|
@@ -767,11 +1381,11 @@ export class SubagentService {
|
|
|
767
1381
|
return this.finalizeFailed(record, new Error("aborted"));
|
|
768
1382
|
}
|
|
769
1383
|
}
|
|
770
|
-
// onEvent
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
1384
|
+
// onEvent 直通(原此处曾有 onUpdate(project(record)) 节流回流包装——生产死路径,
|
|
1385
|
+
// 三调用点恒 onUpdate: undefined、仅测试触达,已按 swf-perf-impl ledger #22 删除
|
|
1386
|
+
// (决策 TC4/IF14,详见 .cw/swf-perf-impl/cleanup-slice-design.json;git 历史可完整恢复)。
|
|
1387
|
+
// ExecuteOptions.onUpdate 字段一并删除,未来误用将编译期失败而非静默无效。
|
|
1388
|
+
const onEvent = rawOnEvent;
|
|
775
1389
|
|
|
776
1390
|
// 解析 worktree 参数:boolean → WorktreeHandle | undefined(true/undefined 由 run 内部处理)
|
|
777
1391
|
let worktreeHandle: WorktreeHandle | undefined;
|
|
@@ -804,7 +1418,7 @@ export class SubagentService {
|
|
|
804
1418
|
fork: opts.fork,
|
|
805
1419
|
worktree: worktreeHandle,
|
|
806
1420
|
parentForkDepth: parentDepth, // [MF#4] 父链深度,不从 opts 读
|
|
807
|
-
}, ctx),
|
|
1421
|
+
}, ctx, resume),
|
|
808
1422
|
),
|
|
809
1423
|
);
|
|
810
1424
|
} catch (err) {
|
|
@@ -812,22 +1426,90 @@ export class SubagentService {
|
|
|
812
1426
|
// 会逃逸出 run() —— 合成 failed result + 收尾。
|
|
813
1427
|
// swallow(不 re-throw):sync 调用方拿到合成 failed result,background 的
|
|
814
1428
|
// .then 正常跑 notify。避免异常逃逸到 tool 层 + record 卡 running。
|
|
1429
|
+
//
|
|
1430
|
+
// MF-6(决策 6 spec §3.1):chatMode(含 resume)spawn/创建失败不销毁对话——回退 idle
|
|
1431
|
+
//(可恢复),让 agent 可重试 message 或 close。与一次性模式(finalizeFailed 终态销毁)区分。
|
|
1432
|
+
if (record.chatMode) {
|
|
1433
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1434
|
+
const failedResult: AgentResult = {
|
|
1435
|
+
text: "",
|
|
1436
|
+
turns: record.turnCount,
|
|
1437
|
+
durationMs: Date.now() - record.startedAt,
|
|
1438
|
+
success: false,
|
|
1439
|
+
error: errMsg,
|
|
1440
|
+
sessionId: record.id,
|
|
1441
|
+
toolCalls: [],
|
|
1442
|
+
};
|
|
1443
|
+
if (tryTransition(record, "closed", "gc")) {
|
|
1444
|
+
// 回退 idle(record.result 由 finalizeRoundToIdle 设为 error 兜底文本,notify 可读)。
|
|
1445
|
+
await this.finalizeRoundToIdle(record, failedResult);
|
|
1446
|
+
}
|
|
1447
|
+
return failedResult;
|
|
1448
|
+
}
|
|
815
1449
|
result = await this.finalizeFailed(record, err);
|
|
816
1450
|
return result;
|
|
817
1451
|
} finally {
|
|
818
1452
|
if (pooled && acquired) this.pool.release();
|
|
819
1453
|
// 清除 streaming widget(subagent 终态,幂等)
|
|
820
1454
|
stream?.dispose();
|
|
1455
|
+
// [review MF1] 清除在途 resume 守卫(幂等):本轮收尾——无论轮次完成(early return)、
|
|
1456
|
+
// MF-6 失败回退 resumable、abort 还是终态化,record 都可再次接受冷路径 message。
|
|
1457
|
+
// execute() 新建 record 不在集合,delete 是 no-op。
|
|
1458
|
+
this.resumesInFlight.delete(record.id);
|
|
821
1459
|
}
|
|
822
1460
|
|
|
823
|
-
//
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1461
|
+
// [V2 决策 2/3] chatMode 首轮闭环:runSpawn 因 agent_settled 提前 resolve(onRoundSettled
|
|
1462
|
+
// 已设 record.status=idle + round+=1 + notify 主 agent)。进程仍保活(idle timer armed),
|
|
1463
|
+
// 不进下方 chatMode 分流(那是 close 后 done/failed/cancelled 终态化的,走 finalizeRoundToIdle
|
|
1464
|
+
// / finalizeRecord)。tryTransition(idle→done) 天然失败(要求 status==="running"),此处显式
|
|
1465
|
+
// early return 让语义清晰 + 防状态机未来改动。防 double-notify 由 notifier dedup 兜底
|
|
1466
|
+
//(同 id:round 60s 内吞,kickOffBackground.then 的 notify 是 no-op,见 notifier.ts L122)。
|
|
1467
|
+
if (record.chatMode && isIdle(record)) {
|
|
1468
|
+
return result;
|
|
1469
|
+
}
|
|
827
1470
|
|
|
828
|
-
//
|
|
829
|
-
|
|
830
|
-
|
|
1471
|
+
// v4 B-1: status 恒为 closed。cancelled 折入 closed(closedReason='cancelled')。
|
|
1472
|
+
const aborted = signal?.aborted === true;
|
|
1473
|
+
// closedReason 派生:aborted → cancelled;否则 success → user-close,!success → gc。
|
|
1474
|
+
const closedReason: ClosedReason = aborted ? "cancelled" : result.success ? "user-close" : "gc";
|
|
1475
|
+
|
|
1476
|
+
// CAS 抢锁:抢到则完整收尾;没抢到(cancel 已先设 closed+cancelled)则跳过
|
|
1477
|
+
if (tryTransition(record, "closed", closedReason)) {
|
|
1478
|
+
if (record.chatMode && !aborted && result.success) {
|
|
1479
|
+
if (record.closeAfterRound) {
|
|
1480
|
+
// close 优雅关闭(force:false):当前轮完成后终态化为 closed。
|
|
1481
|
+
record.closeAfterRound = undefined;
|
|
1482
|
+
await this.finalizeRecord(record, result, "closed", "user-close");
|
|
1483
|
+
} else {
|
|
1484
|
+
// 对话模式轮次成功完成 → 保持 running(旧 idle 折入 running,finalizeRoundToIdle 设回 running)。
|
|
1485
|
+
await this.finalizeRoundToIdle(record, result);
|
|
1486
|
+
}
|
|
1487
|
+
} else if (record.chatMode && (!result.success || aborted)) {
|
|
1488
|
+
// MF-6:chatMode 轮次失败/取消不销毁对话——回退 running-resumable(旧 idle,可恢复)。
|
|
1489
|
+
if (record.closeAfterRound) {
|
|
1490
|
+
// [M5] 优雅关闭挂起的失败/取消轮:轮已完成即兑现 close 意图终态化(含本轮 result),
|
|
1491
|
+
// 不再回退 resumable——否则标志残留到下一轮,record 已被 tool 谎报 closed。
|
|
1492
|
+
record.closeAfterRound = undefined;
|
|
1493
|
+
await this.finalizeRecord(record, result, "closed", closedReason);
|
|
1494
|
+
} else {
|
|
1495
|
+
await this.finalizeRoundToIdle(record, result);
|
|
1496
|
+
}
|
|
1497
|
+
} else if (!record.chatMode && !aborted && result.success) {
|
|
1498
|
+
if (record.closeAfterRound) {
|
|
1499
|
+
// [M5] 非 chatMode(one-shot)busy 时 close(force:false) 置的标志在本轮完成时消费
|
|
1500
|
+
// 终态化(对齐 close schema 文案 "release its resources")。旧代码走
|
|
1501
|
+
// finalizeRoundToIdle 不消费——tool 返回 {closed:true} 谎报,record 永久
|
|
1502
|
+
// running-resumable、5min idle timer 杀进程、期间还能继续收 message。
|
|
1503
|
+
record.closeAfterRound = undefined;
|
|
1504
|
+
await this.finalizeRecord(record, result, "closed", "user-close");
|
|
1505
|
+
} else {
|
|
1506
|
+
// [SP-5] one-shot 成功完成 → 保持 running(旧 idle),等待 message 触发 upgrade。
|
|
1507
|
+
await this.finalizeRoundToIdle(record, result);
|
|
1508
|
+
}
|
|
1509
|
+
} else {
|
|
1510
|
+
// 非 chatMode 失败/取消 或其他终态:一次性销毁(archive + worktree cleanup)。
|
|
1511
|
+
await this.finalizeRecord(record, result, "closed", closedReason);
|
|
1512
|
+
}
|
|
831
1513
|
}
|
|
832
1514
|
return result;
|
|
833
1515
|
}
|
|
@@ -840,6 +1522,8 @@ export class SubagentService {
|
|
|
840
1522
|
identity: ResolvedIdentity,
|
|
841
1523
|
signal: AbortSignal | undefined,
|
|
842
1524
|
priority: number,
|
|
1525
|
+
/** resume 选项(M2-B1):透传 runAndFinalize→runSpawn。undefined = 新 session。 */
|
|
1526
|
+
resume?: SpawnResumeOpts,
|
|
843
1527
|
): void {
|
|
844
1528
|
// 创建 streaming 生命周期对象——streamSink 为 null(session_start 未注入)时降级为 undefined。
|
|
845
1529
|
const stream = this.streamSink
|
|
@@ -848,12 +1532,12 @@ export class SubagentService {
|
|
|
848
1532
|
|
|
849
1533
|
void this.runAndFinalize(
|
|
850
1534
|
record, opts, ctx, identity, signal, priority,
|
|
851
|
-
undefined, stream,
|
|
1535
|
+
undefined, stream, resume,
|
|
852
1536
|
)
|
|
853
1537
|
.then(() => {
|
|
854
|
-
// background 回注:仅当本路径抢到 CAS(
|
|
855
|
-
// cancel 抢先时
|
|
856
|
-
if (record.
|
|
1538
|
+
// background 回注:仅当本路径抢到 CAS(closedReason 非 cancelled)才 notify。
|
|
1539
|
+
// cancel 抢先时 closedReason='cancelled',cancelBackground 自己 notify,此处跳过。
|
|
1540
|
+
if (record.closedReason !== "cancelled") {
|
|
857
1541
|
this.notifyComplete(record);
|
|
858
1542
|
}
|
|
859
1543
|
})
|
|
@@ -872,14 +1556,25 @@ export class SubagentService {
|
|
|
872
1556
|
/** 取消 background record。CAS 抢锁——抢到则 notify + 写 tombstone。 */
|
|
873
1557
|
private cancelBackground(record: ExecutionRecord): boolean {
|
|
874
1558
|
record.controller?.abort();
|
|
875
|
-
|
|
1559
|
+
// [M6] 显式 kill + disarm:chatMode 首轮 agent_settled 后 runSpawn 提前 resolveRun(0)
|
|
1560
|
+
// 返回,`opts.signal.removeEventListener("abort", onAbort)`(session-runner runSpawn 尾部)
|
|
1561
|
+
// 已移除 abort→kill listener;热路径续聊轮(deliverMessage 直接 sendPromptCommand)不再
|
|
1562
|
+
// 进 runSpawn。此后 cancel 只有 controller.abort() 无人响应——record 已终态化 cancelled
|
|
1563
|
+
// 但子进程继续跑完当前 turn(工具副作用继续发生),之后 agent_settled 还对已 archived
|
|
1564
|
+
// record 触发脏通知(round+1 → 新 dedup key → "finished a round"),最终靠 5min idle
|
|
1565
|
+
// timer 兜底 kill。故 cancel 必须显式 SIGTERM + disarm idle timer。非 chatMode 路径
|
|
1566
|
+
// listener 仍在(abort 已 kill 一次),此处对已 killed child 是 no-op,无副作用。
|
|
1567
|
+
const child = getChildByRecord(record.id);
|
|
1568
|
+
if (child && !child.killed) child.kill("SIGTERM");
|
|
1569
|
+
disarmIdleTimer(record.id);
|
|
1570
|
+
if (!tryTransition(record, "closed", "cancelled")) {
|
|
876
1571
|
return false; // detached 已 finalize,cancel 来晚了
|
|
877
1572
|
}
|
|
878
1573
|
// 抢到锁:completeRecord(用空 result 填 cancelled)+ archive(立即移出内存)+ notify。
|
|
879
1574
|
// 写 cancelled tombstone:session.jsonl 被 abort 截断,cancelled 状态靠 sidecar 标记,
|
|
880
1575
|
// collectRecords 重建时 override status=cancelled。durationMs 用真实耗时(startedAt → now)。
|
|
881
1576
|
const cancelledResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: "cancelled by user", sessionId: record.id, toolCalls: [] };
|
|
882
|
-
completeRecord(record, cancelledResult, "cancelled");
|
|
1577
|
+
completeRecord(record, cancelledResult, "closed", "cancelled");
|
|
883
1578
|
// 写 tombstone(best-effort,sessionFile 可能为 undefined——窗口期 cancel)。
|
|
884
1579
|
if (record.sessionFile) {
|
|
885
1580
|
writeCancelledTombstone(record.sessionFile, {
|
|
@@ -891,13 +1586,12 @@ export class SubagentService {
|
|
|
891
1586
|
});
|
|
892
1587
|
}
|
|
893
1588
|
this.store.archive(record);
|
|
894
|
-
// worktree cleanup + removeAliveMarker(cancel 不写 finalized,BC-4
|
|
1589
|
+
// worktree cleanup + removeAliveMarker(cancel 不写 finalized,BC-4 互斥)。
|
|
1590
|
+
// cleanup 已 async 化——boolean 同步返回语义不变,清理 fire-and-forget。
|
|
895
1591
|
if (record.worktreeHandle) {
|
|
896
|
-
|
|
897
|
-
this.worktreeManager.cleanup(record.worktreeHandle);
|
|
898
|
-
} catch (err) {
|
|
1592
|
+
void this.worktreeManager.cleanup(record.worktreeHandle).catch((err: unknown) => {
|
|
899
1593
|
bestEffort(err, "worktree cleanup (cancelBackground)");
|
|
900
|
-
}
|
|
1594
|
+
});
|
|
901
1595
|
}
|
|
902
1596
|
if (record.sessionFile) {
|
|
903
1597
|
try {
|
|
@@ -907,7 +1601,7 @@ export class SubagentService {
|
|
|
907
1601
|
}
|
|
908
1602
|
}
|
|
909
1603
|
// pending-notifications:cancel 注销(只记 registry 状态)
|
|
910
|
-
emitPendingUnregister(this.pi, record.id, "
|
|
1604
|
+
emitPendingUnregister(this.pi, record.id, "closed");
|
|
911
1605
|
// cancel 完成通知(与 kickOffBackground.then 对称——cancel 抢先时 .then 跳过 notify)
|
|
912
1606
|
this.notifyComplete(record);
|
|
913
1607
|
return true;
|
|
@@ -919,7 +1613,8 @@ export class SubagentService {
|
|
|
919
1613
|
private async finalizeRecord(
|
|
920
1614
|
record: ExecutionRecord,
|
|
921
1615
|
result: AgentResult,
|
|
922
|
-
status: "
|
|
1616
|
+
status: "closed",
|
|
1617
|
+
closedReason?: ClosedReason,
|
|
923
1618
|
): Promise<void> {
|
|
924
1619
|
await doFinalizeRecord(
|
|
925
1620
|
{
|
|
@@ -928,12 +1623,34 @@ export class SubagentService {
|
|
|
928
1623
|
store: this.store,
|
|
929
1624
|
modelService: this.modelService,
|
|
930
1625
|
pi: this.pi,
|
|
931
|
-
clearThrottle: (id) => this.clearThrottle(id),
|
|
932
1626
|
emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
|
|
933
1627
|
},
|
|
934
1628
|
record,
|
|
935
1629
|
result,
|
|
936
1630
|
status,
|
|
1631
|
+
closedReason,
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
/**
|
|
1636
|
+
* 对话模式轮次完成收尾:委托 doFinalizeRoundToIdle(record 进 idle,保留内存 + worktree)。
|
|
1637
|
+
* 与 finalizeRecord 对称的委托方法,deps 同源注入。chatMode + done/failed/cancelled 时由 runAndFinalize 调用
|
|
1638
|
+
*(MF-6:chatMode 失败/取消也回退 idle 而非终态销毁)。 */
|
|
1639
|
+
private async finalizeRoundToIdle(
|
|
1640
|
+
record: ExecutionRecord,
|
|
1641
|
+
result: AgentResult,
|
|
1642
|
+
): Promise<void> {
|
|
1643
|
+
await doFinalizeRoundToIdle(
|
|
1644
|
+
{
|
|
1645
|
+
manifestStore: this.manifestStore,
|
|
1646
|
+
worktreeManager: this.worktreeManager,
|
|
1647
|
+
store: this.store,
|
|
1648
|
+
modelService: this.modelService,
|
|
1649
|
+
pi: this.pi,
|
|
1650
|
+
emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
|
|
1651
|
+
},
|
|
1652
|
+
record,
|
|
1653
|
+
result,
|
|
937
1654
|
);
|
|
938
1655
|
}
|
|
939
1656
|
|
|
@@ -945,8 +1662,9 @@ export class SubagentService {
|
|
|
945
1662
|
// durationMs 用真实耗时(startedAt → now),避免失败统计恒为 0 失真。
|
|
946
1663
|
const failedResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: errMsg, sessionId: record.id, toolCalls: [] };
|
|
947
1664
|
// CAS 抢锁:抢到(status 仍 running)则完整收尾;没抢到(cancel 已先设 cancelled)跳过。
|
|
948
|
-
|
|
949
|
-
|
|
1665
|
+
// SP-1: failed → closed + gc(通用失败终态)。
|
|
1666
|
+
if (tryTransition(record, "closed", "gc")) {
|
|
1667
|
+
await this.finalizeRecord(record, failedResult, "closed", "gc");
|
|
950
1668
|
}
|
|
951
1669
|
return failedResult;
|
|
952
1670
|
}
|
|
@@ -954,63 +1672,12 @@ export class SubagentService {
|
|
|
954
1672
|
/** S1: 排队中被 abort 走 cancelled 终态(对齐已运行被 abort 的 cancelBackground)。 */
|
|
955
1673
|
private async finalizeAborted(record: ExecutionRecord): Promise<AgentResult> {
|
|
956
1674
|
const cancelledResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: "cancelled by user", sessionId: record.id, toolCalls: [] };
|
|
957
|
-
if (tryTransition(record, "cancelled")) {
|
|
958
|
-
await this.finalizeRecord(record, cancelledResult, "cancelled");
|
|
1675
|
+
if (tryTransition(record, "closed", "cancelled")) {
|
|
1676
|
+
await this.finalizeRecord(record, cancelledResult, "closed", "cancelled");
|
|
959
1677
|
}
|
|
960
1678
|
return cancelledResult;
|
|
961
1679
|
}
|
|
962
1680
|
|
|
963
|
-
// onUpdate 节流状态(per-record Map)。每条 record 独立节流,避免嵌套(fork 链:主→A→B)
|
|
964
|
-
// 多条 onUpdate 链争用同一份状态。旧实现用单实例字段——trailing timer 异步导致跨链争用
|
|
965
|
-
// → onUpdate 被吞/延迟 → 主 agent 对话流残影。per-record 化让 A/B 各自独立节流。
|
|
966
|
-
private readonly throttleState = new Map<string, { lastEmitAt: number; timer?: ReturnType<typeof setTimeout> }>();
|
|
967
|
-
|
|
968
|
-
/** AgentEvent 节流回流到 onUpdate(streaming delta 不触发 + 时间窗节流)。
|
|
969
|
-
* 名为 Throttled 必须真节流——否则嵌套场景一秒 10+ 事件密集回流 → Pi tool_execution_update
|
|
970
|
-
* 密集重绘 → 流式 tool 组件残影。leading + trailing:首次立即发(响应性),窗口内后续合并
|
|
971
|
-
* 到末尾补发一次(保证终态事件不丢)。节流状态 per-record,trailing timer 不会跨链污染。 */
|
|
972
|
-
private onEventThrottled(
|
|
973
|
-
record: ExecutionRecord,
|
|
974
|
-
event: AgentEvent,
|
|
975
|
-
onUpdate: (details: SubagentToolDetails) => void,
|
|
976
|
-
): void {
|
|
977
|
-
if (!TRIGGERING_EVENT_TYPES.has(event.type)) return;
|
|
978
|
-
const state = this.throttleState.get(record.id) ?? { lastEmitAt: 0 };
|
|
979
|
-
const now = Date.now();
|
|
980
|
-
if (now - state.lastEmitAt >= ON_UPDATE_MIN_INTERVAL_MS) {
|
|
981
|
-
// leading:窗口外立即发,清掉该 record 残留的 trailing timer(避免补发陈旧状态)
|
|
982
|
-
if (state.timer !== undefined) {
|
|
983
|
-
clearTimeout(state.timer);
|
|
984
|
-
state.timer = undefined;
|
|
985
|
-
}
|
|
986
|
-
state.lastEmitAt = now;
|
|
987
|
-
this.throttleState.set(record.id, state);
|
|
988
|
-
onUpdate(project(record));
|
|
989
|
-
// 终态清 entry(与 trailing 分支对称):防 CAS 后到 leading 误发陈旧状态 + Map 无限增长。
|
|
990
|
-
if (record.status !== "running") this.throttleState.delete(record.id);
|
|
991
|
-
return;
|
|
992
|
-
}
|
|
993
|
-
// trailing:窗口末尾补发最新(per-record timer,不与其他 record 的 trailing 争用)。
|
|
994
|
-
if (state.timer === undefined) {
|
|
995
|
-
const wait = ON_UPDATE_MIN_INTERVAL_MS - (now - state.lastEmitAt);
|
|
996
|
-
state.timer = setTimeout(() => {
|
|
997
|
-
state.timer = undefined;
|
|
998
|
-
state.lastEmitAt = Date.now();
|
|
999
|
-
onUpdate(project(record));
|
|
1000
|
-
// record 已终态且无 pending trailing → 清 entry 防 Map 无限增长
|
|
1001
|
-
if (record.status !== "running") this.throttleState.delete(record.id);
|
|
1002
|
-
}, wait);
|
|
1003
|
-
this.throttleState.set(record.id, state);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
/** 清指定 record 的节流状态(finalizeRecord 调,防终态后 trailing 误发陈旧状态)。 */
|
|
1008
|
-
private clearThrottle(recordId: string): void {
|
|
1009
|
-
const state = this.throttleState.get(recordId);
|
|
1010
|
-
if (state?.timer !== undefined) clearTimeout(state.timer);
|
|
1011
|
-
this.throttleState.delete(recordId);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
1681
|
// ── 内部 ────────────────────────────────────────────────
|
|
1015
1682
|
|
|
1016
1683
|
/**
|
|
@@ -1049,7 +1716,7 @@ export class SubagentService {
|
|
|
1049
1716
|
// mainSessionFile: fork source 解析用,从 session_start 缓存获取。
|
|
1050
1717
|
mainSessionFile: this.getMainSessionFile?.() ?? undefined,
|
|
1051
1718
|
// worktree pid 回调:session-runner first header 时补全注册表 pid。
|
|
1052
|
-
onWorktreePid: (branch: string, pid: number) => this.worktreeManager.registerPid(branch, pid),
|
|
1719
|
+
onWorktreePid: (branch: string, pid: number, sessionFile?: string) => this.worktreeManager.registerPid(branch, pid, sessionFile),
|
|
1053
1720
|
uiRequestHandler: this.uiRequestHandler,
|
|
1054
1721
|
// SR-4:L2 dialog 队列透传——child close 时 session-runner 据此调 rejectChildDialogs
|
|
1055
1722
|
// 清理 L2 pending dialog,防全局死锁。undefined 时 session-runner 跳过 L2 清理。
|
|
@@ -1065,6 +1732,68 @@ export class SubagentService {
|
|
|
1065
1732
|
// worktree 模式下 mainCwd = 本进程 checkout 路径,rootCwd 才是真 ROOT——session 文件
|
|
1066
1733
|
// 落盘统一用 rootCwd 编码,ROOT 磁盘重建才扫得到深层 record(与 sessionRootId 同构)。
|
|
1067
1734
|
rootCwd: this.rootCwd,
|
|
1735
|
+
// [V2 决策 2] chatMode 首轮闭环:agent_settled 时 session-runner 调本回调。
|
|
1736
|
+
// 轻量 idle 化(选项 1):设 record.status=idle + round+=1 让 notify 守卫放行 + notify
|
|
1737
|
+
// 主 agent,但**不调 doFinalizeRoundToIdle**(不 emitUnregister /
|
|
1738
|
+
// 不 redeliver——V2 要删的副作用都不做)。runAndFinalize 检测到 status=idle 后 early return,
|
|
1739
|
+
// 不进现有 chatMode 分流。Step 5 删 idle 状态机时统一清理这个过渡 idle。
|
|
1740
|
+
// 防箭头函数 this 丢失:用箭头函数捕获 SubagentService 实例 this。
|
|
1741
|
+
onRoundSettled: (record) => {
|
|
1742
|
+
// v4 B-1:status 保持 running(旧 idle 折入 running);session-runner 已 arm idle timer,
|
|
1743
|
+
// isIdle=true 让 notify 守卫放行(时序:armIdleTimer → onRoundSettled,见 session-runner.ts:670)。
|
|
1744
|
+
// round 可能初始 undefined(与 notifier.ts `record.round ?? 0` 兜底一致),
|
|
1745
|
+
// 首轮 0+1=1。round 是 notifier dedup key 的组成部分,递增后同 id 下一轮不被 60s dedup 吞。
|
|
1746
|
+
record.round = (record.round ?? 0) + 1;
|
|
1747
|
+
// [N2][增量] 轮次回复写点(增量语义):roundText 自 roundBaseTurnIndex 起派生本轮增量
|
|
1748
|
+
//(undefined 视为 0——首轮增量 = 全量,与改造前首轮逐字节一致)。成功轮次的 MF-2 原写点
|
|
1749
|
+
//(doFinalizeRoundToIdle)不可达——agent_settled 恒 arm idle timer → runAndFinalize 恒
|
|
1750
|
+
// early return。写入 record.result 后再 notify;本轮无非空增量且无 lastError(纯工具轮 /
|
|
1751
|
+
// interrupt 抢占轮 / 模型空回复)时固定占位 "(no output this round)"(D5:增量语义下沿用
|
|
1752
|
+
// 旧 record.result = 上一轮增量 → 本轮通知正文 = 上一轮内容,父 agent 误读为原样重复回复;
|
|
1753
|
+
// lastError 兜底保留让失败轮通知可读)。后续 closeAfterRoundSettled 的合成 result 读
|
|
1754
|
+
// record.result,同样携带本轮增量。
|
|
1755
|
+
const roundText = getFullTextFrom(record, record.roundBaseTurnIndex ?? 0);
|
|
1756
|
+
record.result = roundText ||
|
|
1757
|
+
(record.lastError ? `round did not complete: ${record.lastError}` : "(no output this round)");
|
|
1758
|
+
// 先送达本轮增量(notify),再推进 base / 消费 closeAfterRound——终态通知由
|
|
1759
|
+
// closeAfterRoundSettled / closeChatIdle 的 notifyClosed 显式发出(dedup 身份为裸 id,
|
|
1760
|
+
// 与本次 round notify 的 id:round key 区分),保证「本轮增量 + 终态通知」都送达;
|
|
1761
|
+
// kickOffBackground.then 的冷路径 notifyComplete 仍与本次 round notify 同 key 被 60s
|
|
1762
|
+
// dedup 吞(不构成第三条)。
|
|
1763
|
+
//
|
|
1764
|
+
// 幂等性(覆盖面如实限定):同步路径 at-least-once——notifyComplete(同步 void)抛错时
|
|
1765
|
+
// 推进/消费被跳过 → base 不推进 → 增量未消费,下轮 roundText 必含本轮文本(重发载体为
|
|
1766
|
+
// 后续轮次增量拼接)。kickOffBackground.then 的冷路径 notifyComplete 不构成重发通道
|
|
1767
|
+
// (notifier dedup.set 与 pending.splice 均先于 sendMessage,同 key `${id}:${round}`——
|
|
1768
|
+
// round 已递增——60s 窗内重入被吞)。异步 flush 窗口不保证:合并 timer armed(其他 busy
|
|
1769
|
+
// background 在场)或 isIdle 退避期间 notify 的『成功』只是入队,实际 sendMessage 发生在
|
|
1770
|
+
// base 推进之后的异步时机;该窗口进程崩溃或 sendMessage 失败 → 丢失不可重发(现状全量
|
|
1771
|
+
// 重发的次轮自愈在增量语义下消失),wave1 期恢复通道仅父 agent 经 /subagents 详情读取
|
|
1772
|
+
// record.result,wave2 指针行落地后补全。反序(先推进后 notify)在同步路径 notify 失败时
|
|
1773
|
+
// 静默丢增量且无任何重算机会,故 notify 后推进是定案。重复发送由 notifier dedup key
|
|
1774
|
+
// 60s 窗界定。
|
|
1775
|
+
this.notifyComplete(record);
|
|
1776
|
+
// R1 观测哨(不变式违反形态):推进前检查末 turn 未闭合且 text 非空——pi 现序下不可达
|
|
1777
|
+
//(带 usage 的 message_end 恒先于 turn_end,settle 时 turn 全闭合,见 types.ts
|
|
1778
|
+
// roundBaseTurnIndex 注释的行号锚定),ES1 单测自造事件序列锁不住 pi 层变化;pi 升级若
|
|
1779
|
+
// 改变 turn_end/agent_end 时序,此哨兵留痕(该形态下公式仍把文本计入本轮,不丢数据)。
|
|
1780
|
+
const lastTurn = record.turns[record.turns.length - 1];
|
|
1781
|
+
if (lastTurn !== undefined && !lastTurn.closed && lastTurn.text.length > 0) {
|
|
1782
|
+
logger.warn(
|
|
1783
|
+
`[subagents] round settle with unclosed non-empty turn (record=${record.id}, turnIndex=${record.turns.length - 1}) — pi turn_end/agent_end ordering may have changed`,
|
|
1784
|
+
);
|
|
1785
|
+
}
|
|
1786
|
+
// [增量] base 推进(notify 之后):下一轮增量从本轮边界起。滞后空 turn 不计入边界
|
|
1787
|
+
//(防御分支,留在下一轮增量内防丢文本——nextRoundBaseTurnIndex 注释)。
|
|
1788
|
+
record.roundBaseTurnIndex = nextRoundBaseTurnIndex(record);
|
|
1789
|
+
// [M5] closeAfterRound 消费点:chatMode 每轮完成的统一汇聚点(热路径轮不经
|
|
1790
|
+
// runAndFinalize CAS 分支——agent_settled 恒 arm idle timer → runAndFinalize 恒
|
|
1791
|
+
// early return,旧消费点对 chatMode 不可达,标志置了无人消费、tool 谎报 closed:true)。
|
|
1792
|
+
if (record.closeAfterRound) {
|
|
1793
|
+
record.closeAfterRound = undefined;
|
|
1794
|
+
void this.closeAfterRoundSettled(record);
|
|
1795
|
+
}
|
|
1796
|
+
},
|
|
1068
1797
|
};
|
|
1069
1798
|
}
|
|
1070
1799
|
}
|