@zhushanwen/subagent-core 0.6.0 → 0.7.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 +1 -1
- package/dist/chunk-KXVBIVOU.js +16 -0
- package/dist/chunk-T2SYBW3I.js +24 -0
- package/dist/chunk-VURUAGMM.js +2305 -0
- package/dist/{types-C3fE3R9x.d.cts → engine-discovery-scan-ocpM8FmI.d.cts} +770 -668
- package/dist/{types-C3fE3R9x.d.ts → engine-discovery-scan-ocpM8FmI.d.ts} +770 -668
- package/dist/execution/engine/engine-discovery-scan.cjs +2210 -0
- package/dist/execution/engine/engine-discovery-scan.d.cts +4 -0
- package/dist/execution/engine/engine-discovery-scan.d.ts +4 -0
- package/dist/execution/engine/engine-discovery-scan.js +20 -0
- package/dist/execution/engine/paths.cjs +6 -23
- package/dist/execution/engine/paths.d.cts +1 -8
- package/dist/execution/engine/paths.d.ts +1 -8
- package/dist/execution/engine/paths.js +1 -1
- package/dist/execution/relay-env.cjs +12 -28
- package/dist/execution/relay-env.d.cts +1 -33
- package/dist/execution/relay-env.d.ts +1 -33
- package/dist/execution/relay-env.js +5 -3
- package/dist/index.cjs +14045 -16059
- package/dist/index.d.cts +1035 -1579
- package/dist/index.d.ts +1035 -1579
- package/dist/index.js +9838 -13800
- package/dist.bundle/index.cjs +28875 -30323
- package/package.json +20 -26
- package/src/__tests__/record-store-last-line.test.ts +16 -7
- package/src/core/error-message.ts +7 -6
- package/src/core/host-services.ts +8 -2
- package/src/core/notify-ports.ts +17 -2
- package/src/execution/__tests__/chat-engine-routing.test.ts +62 -28
- package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +255 -0
- package/src/execution/__tests__/collect-coordinator-service.test.ts +36 -47
- package/src/execution/__tests__/collect-mixed-dispatch.test.ts +20 -53
- package/src/execution/__tests__/delivery-methods.test.ts +179 -237
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +0 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -13
- package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +20 -33
- package/src/execution/__tests__/gc-timer.test.ts +7 -22
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +20 -10
- package/src/execution/__tests__/helpers/fake-engine-port.ts +187 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +36 -9
- package/src/execution/__tests__/helpers/subagent-service-mocks.ts +1 -1
- package/src/execution/__tests__/idle-gc.test.ts +200 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +26 -21
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +5 -2
- package/src/execution/__tests__/reconcile-sweep.test.ts +200 -0
- package/src/execution/__tests__/record-store.test.ts +60 -6
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +20 -13
- package/src/execution/__tests__/round-supervisor.test.ts +293 -0
- package/src/execution/__tests__/session-pending.test.ts +119 -17
- package/src/execution/__tests__/stream-sink-retirement.test.ts +10 -10
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +1 -11
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +33 -30
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +2 -9
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +59 -129
- package/src/execution/__tests__/subagent-service.test.ts +2 -2
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +109 -7
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +108 -36
- package/src/execution/__tests__/sync-collect-recovery.test.ts +21 -36
- package/src/execution/__tests__/workflow-state-root.test.ts +232 -0
- package/src/execution/agent-registry.ts +6 -2
- package/src/execution/dialog-queue.ts +6 -73
- package/src/execution/engine/__tests__/common/capability-gate.test.ts +115 -15
- package/src/execution/engine/__tests__/common/errors.test.ts +11 -18
- package/src/execution/engine/__tests__/common/kill-chain.test.ts +7 -0
- package/src/execution/engine/__tests__/common/nesting-guard.test.ts +9 -3
- package/src/execution/engine/__tests__/common/session-view-service.test.ts +90 -3
- package/src/execution/engine/__tests__/conformance/H9-test-disposition.md +116 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +377 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/recorded.fixture.json +331 -0
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +213 -0
- package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +230 -0
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +69 -42
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +4 -81
- package/src/execution/engine/__tests__/conformance/contract.probe.test.ts +67 -44
- package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +81 -54
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +133 -70
- package/src/execution/engine/__tests__/conformance/engine-crash.test.ts +143 -0
- package/src/execution/engine/__tests__/conformance/fake-engine-capabilities.ts +19 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +7 -2
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +6 -5
- package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +398 -0
- package/src/execution/engine/__tests__/conformance/registry-fork-filter.test.ts +221 -0
- package/src/execution/engine/__tests__/conformance/round-liveness-supervisor.test.ts +302 -0
- package/src/execution/engine/__tests__/conformance/static-spawn-guard.test.ts +58 -0
- package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +558 -0
- package/src/execution/engine/__tests__/host-task-spec.test.ts +45 -0
- package/src/execution/engine/__tests__/model-ref-split.test.ts +74 -0
- package/src/execution/engine/__tests__/registry.test.ts +206 -1
- package/src/execution/engine/__tests__/routing.test.ts +97 -0
- package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +97 -0
- package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +294 -0
- package/src/execution/engine/client/__tests__/engine-client.test.ts +516 -0
- package/src/execution/engine/client/__tests__/mirror.test.ts +67 -0
- package/src/execution/engine/client/__tests__/pid-file.test.ts +257 -0
- package/src/execution/engine/client/__tests__/protocol-closure.test.ts +95 -0
- package/src/execution/engine/client/__tests__/remote-engine.test.ts +457 -0
- package/src/execution/engine/client/client-options.ts +103 -0
- package/src/execution/engine/client/engine-client.ts +687 -0
- package/src/execution/engine/client/mirror.ts +167 -0
- package/src/execution/engine/client/pid-file.ts +392 -0
- package/src/execution/engine/client/reaper.ts +95 -0
- package/src/execution/engine/client/remote-engine.ts +441 -0
- package/src/execution/engine/client/reverse-router.ts +255 -0
- package/src/execution/engine/common/capability-gate.ts +109 -22
- package/src/execution/engine/common/errors.ts +9 -25
- package/src/execution/engine/common/event-journal.ts +4 -50
- package/src/execution/engine/common/journal-replay.ts +3 -1
- package/src/execution/engine/common/kill-chain.ts +18 -251
- package/src/execution/engine/common/nesting-guard.ts +18 -114
- package/src/execution/engine/common/pool-manager.ts +4 -0
- package/src/execution/engine/common/session-view-service.ts +56 -84
- package/src/execution/engine/config.ts +151 -0
- package/src/execution/engine/d8-compat.ts +346 -0
- package/src/execution/engine/engine-discovery-roots.ts +107 -0
- package/src/execution/engine/engine-discovery-scan.ts +269 -0
- package/src/execution/engine/engine-discovery.ts +30 -7
- package/src/execution/engine/engine-inspect-package.ts +282 -0
- package/src/execution/engine/engine-manifest.ts +244 -0
- package/src/execution/engine/host/__tests__/host-askuser-endpoint.test.ts +123 -0
- package/src/execution/engine/host/__tests__/host-bridge.test.ts +135 -0
- package/src/execution/engine/host/__tests__/spawned-children.test.ts +73 -0
- package/src/execution/engine/host/host-bridge.ts +150 -0
- package/src/execution/engine/host/host-ui-endpoint.ts +47 -0
- package/src/execution/engine/host/pi-host-binding.ts +91 -0
- package/src/execution/engine/host/spawned-children.ts +139 -0
- package/src/execution/engine/host-task-spec.ts +29 -2
- package/src/execution/engine/model-validation.ts +55 -2
- package/src/execution/engine/paths.ts +16 -41
- package/src/execution/engine/port.ts +48 -2
- package/src/execution/engine/registry.ts +153 -34
- package/src/execution/engine/routing.ts +225 -52
- package/src/execution/engine/types.ts +25 -200
- package/src/execution/finalize-record.ts +25 -0
- package/src/execution/idle-gc.ts +89 -14
- package/src/execution/lifecycle-manager.ts +10 -7
- package/src/execution/lifecycle-predicates.ts +10 -8
- package/src/execution/record-store.ts +29 -2
- package/src/execution/relay-env.ts +19 -36
- package/src/execution/round-supervisor/domain.ts +89 -0
- package/src/execution/round-supervisor/index.ts +33 -0
- package/src/execution/round-supervisor/notify-accounting.ts +87 -0
- package/src/execution/round-supervisor/reconcile-sweep.ts +235 -0
- package/src/execution/round-supervisor/service-binding.test.ts +661 -0
- package/src/execution/round-supervisor/service-binding.ts +273 -0
- package/src/execution/round-supervisor/supervisor.ts +426 -0
- package/src/execution/session-pending.ts +68 -74
- package/src/execution/settled-watchdog.ts +39 -0
- package/src/execution/subagent-service.ts +746 -400
- package/src/execution/subprocess-agent-runner.ts +16 -8
- package/src/execution/types.ts +41 -117
- package/src/execution/ui-channels.ts +13 -214
- package/src/execution/ui-request-handler-factory.ts +7 -4
- package/src/execution/ui-request-observability.ts +2 -2
- package/src/execution/workflow-state-root.ts +67 -0
- package/src/execution/worktree-git-ops.ts +5 -0
- package/src/execution/worktree-manager.ts +11 -1
- package/src/index.ts +55 -26
- package/src/orchestration/__tests__/execute-agent-call.test.ts +1 -1
- package/src/orchestration/execute-agent-call.ts +1 -1
- package/src/orchestration/file-run-store.ts +144 -59
- package/src/orchestration/models/types.ts +43 -42
- package/src/shared/zcode-model-ref.ts +31 -0
- package/dist/chunk-43ONBFZX.js +0 -240
- package/dist/chunk-A4IVZWVX.js +0 -31
- package/dist/chunk-APZY4IME.js +0 -27
- package/dist/chunk-V3VHZ2VX.js +0 -59
- package/dist/execution/engine/engines/zcode/constants.cjs +0 -107
- package/dist/execution/engine/engines/zcode/constants.d.cts +0 -135
- package/dist/execution/engine/engines/zcode/constants.d.ts +0 -135
- package/dist/execution/engine/engines/zcode/constants.js +0 -54
- package/dist/execution/engine/engines/zcode/reader.cjs +0 -276
- package/dist/execution/engine/engines/zcode/reader.d.cts +0 -21
- package/dist/execution/engine/engines/zcode/reader.d.ts +0 -21
- package/dist/execution/engine/engines/zcode/reader.js +0 -9
- package/src/__tests__/append-system-prompt-assembly.test.ts +0 -243
- package/src/__tests__/fr4-get-state-handshake.test.ts +0 -125
- package/src/__tests__/session-runner.test.ts +0 -69
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +0 -490
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +0 -191
- package/src/execution/__tests__/descendant-sweep-guards.test.ts +0 -220
- package/src/execution/__tests__/descendant-sweep.test.ts +0 -269
- package/src/execution/__tests__/engine-model-validation.test.ts +0 -741
- package/src/execution/__tests__/epipe-fallback.test.ts +0 -248
- package/src/execution/__tests__/execution-runtime-face.test.ts +0 -272
- package/src/execution/__tests__/get-state-handshake.test.ts +0 -127
- package/src/execution/__tests__/helpers/session-runner-mocks.ts +0 -113
- package/src/execution/__tests__/keep-alive-no-progress.test.ts +0 -280
- package/src/execution/__tests__/kill-all-escalation.test.ts +0 -196
- package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +0 -113
- package/src/execution/__tests__/output-collector.test.ts +0 -469
- package/src/execution/__tests__/pi-invocation.test.ts +0 -166
- package/src/execution/__tests__/recursive-visibility-env.test.ts +0 -149
- package/src/execution/__tests__/rpc-mode.test.ts +0 -89
- package/src/execution/__tests__/run-and-finalize-anchoring.test.ts +0 -387
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +0 -277
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +0 -254
- package/src/execution/__tests__/run-spawn-edges.test.ts +0 -1079
- package/src/execution/__tests__/run-spawn-integration.test.ts +0 -933
- package/src/execution/__tests__/run-spawn-resume.test.ts +0 -322
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +0 -196
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +0 -207
- package/src/execution/__tests__/service-kill-escalation.test.ts +0 -91
- package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +0 -110
- package/src/execution/__tests__/session-runner-close-prune.test.ts +0 -181
- package/src/execution/__tests__/session-runner-dispatch.test.ts +0 -489
- package/src/execution/__tests__/session-runner-epipe.test.ts +0 -179
- package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +0 -227
- package/src/execution/__tests__/session-runner-lifecycle-helpers.test.ts +0 -290
- package/src/execution/__tests__/session-runner-schema-env.test.ts +0 -352
- package/src/execution/__tests__/settled-watchdog.test.ts +0 -524
- package/src/execution/__tests__/spawn-args.test.ts +0 -446
- package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +0 -192
- package/src/execution/__tests__/spawn-event-adapter.test.ts +0 -167
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +0 -208
- package/src/execution/__tests__/spawned-children.test.ts +0 -236
- package/src/execution/__tests__/start-sync-model-guard.test.ts +0 -150
- package/src/execution/__tests__/stdin-writer.test.ts +0 -464
- package/src/execution/__tests__/subagent-service-message-close.test.ts +0 -693
- package/src/execution/__tests__/temp-prompt.test.ts +0 -53
- package/src/execution/__tests__/timeout-integration.test.ts +0 -616
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +0 -194
- package/src/execution/__tests__/turn-limiter.test.ts +0 -65
- package/src/execution/__tests__/ui-request-handler.test.ts +0 -205
- package/src/execution/__tests__/ui-request-queue.test.ts +0 -298
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +0 -230
- package/src/execution/engine/__tests__/common/schema-emulation.test.ts +0 -128
- package/src/execution/engine/__tests__/common/session-view-service-zcode-dbpath.test.ts +0 -149
- package/src/execution/engine/__tests__/conformance/__fixtures__/pi-golden-events.json +0 -28
- package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +0 -128
- package/src/execution/engine/__tests__/paths.test.ts +0 -39
- package/src/execution/engine/common/schema-emulation.ts +0 -189
- package/src/execution/engine/common/session-view-projection.ts +0 -51
- package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +0 -615
- package/src/execution/engine/engines/pi/__tests__/reader.test.ts +0 -155
- package/src/execution/engine/engines/pi/__tests__/spawn-opts-direct.test.ts +0 -257
- package/src/execution/engine/engines/pi/argv-mirror.ts +0 -135
- package/src/execution/engine/engines/pi/get-state-handshake.ts +0 -179
- package/src/execution/engine/engines/pi/output-collector.ts +0 -317
- package/src/execution/engine/engines/pi/pi-engine.ts +0 -811
- package/src/execution/engine/engines/pi/pi-invocation.ts +0 -120
- package/src/execution/engine/engines/pi/reader.ts +0 -59
- package/src/execution/engine/engines/pi/registration.ts +0 -37
- package/src/execution/engine/engines/pi/session-runner.ts +0 -2840
- package/src/execution/engine/engines/pi/spawn-event-adapter.ts +0 -404
- package/src/execution/engine/engines/pi/stdin-writer.ts +0 -199
- package/src/execution/engine/engines/pi/temp-prompt.ts +0 -62
- package/src/execution/engine/engines/pi/turn-limiter.ts +0 -102
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +0 -301
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +0 -36
- package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +0 -321
- package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +0 -473
- package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +0 -43
- package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +0 -124
- package/src/execution/engine/engines/zcode/__tests__/reader.test.ts +0 -210
- package/src/execution/engine/engines/zcode/__tests__/registration.test.ts +0 -71
- package/src/execution/engine/engines/zcode/__tests__/session-channel-dispose-harvest.test.ts +0 -247
- package/src/execution/engine/engines/zcode/__tests__/session-channel-turn-timers.test.ts +0 -451
- package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +0 -792
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +0 -782
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +0 -168
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-dispose.test.ts +0 -210
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-retry.test.ts +0 -353
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-status.test.ts +0 -282
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-timeout.test.ts +0 -372
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +0 -145
- package/src/execution/engine/engines/zcode/appserver-launcher.ts +0 -167
- package/src/execution/engine/engines/zcode/connection.ts +0 -598
- package/src/execution/engine/engines/zcode/constants.ts +0 -176
- package/src/execution/engine/engines/zcode/golden-sample.ts +0 -47
- package/src/execution/engine/engines/zcode/parser.ts +0 -102
- package/src/execution/engine/engines/zcode/preparer.ts +0 -235
- package/src/execution/engine/engines/zcode/reader.ts +0 -381
- package/src/execution/engine/engines/zcode/registration.ts +0 -37
- package/src/execution/engine/engines/zcode/session-channel.ts +0 -968
- package/src/execution/engine/engines/zcode/zcode-engine.ts +0 -1431
- package/src/execution/round-settlement.ts +0 -93
- package/src/execution/ui-request-queue.ts +0 -215
- package/src/shared/schema-env.ts +0 -44
|
@@ -1,2840 +0,0 @@
|
|
|
1
|
-
// src/execution/engine/engines/pi/session-runner.ts
|
|
2
|
-
//
|
|
3
|
-
// spawn pi --mode rpc 子进程执行 session 的编排器。零 mode 感知。
|
|
4
|
-
//
|
|
5
|
-
// spawn 改造后:session 在独立子进程跑(进程隔离),事件经 stdout JSON 流回流。
|
|
6
|
-
// runSpawn 是唯一执行入口(sync/background 共用)。mode 分叉在 Runtime.execute 顶部。
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
type ChildProcess,
|
|
10
|
-
type ChildProcessWithoutNullStreams,
|
|
11
|
-
execFile,
|
|
12
|
-
spawn,
|
|
13
|
-
spawnSync,
|
|
14
|
-
} from "node:child_process";
|
|
15
|
-
import * as fs from "node:fs";
|
|
16
|
-
|
|
17
|
-
import { getLogger } from "../../../../core/logger.ts";
|
|
18
|
-
import { bestEffort } from "../../../best-effort.ts";
|
|
19
|
-
import { disposeEngines } from "../../registry.ts";
|
|
20
|
-
import { armIdleTimer, DEFAULT_IDLE_TIMEOUT_MS } from "../../../lifecycle-manager.ts";
|
|
21
|
-
import {
|
|
22
|
-
listActivePendingFromSessionFile,
|
|
23
|
-
prunePendingCursor,
|
|
24
|
-
readActivePendingFromSessionFile,
|
|
25
|
-
type ActivePendingResult,
|
|
26
|
-
} from "../../../session-pending.ts";
|
|
27
|
-
import { killChain } from "../../common/kill-chain.ts";
|
|
28
|
-
|
|
29
|
-
import type { ExtensionMode } from "../../../host-mode.ts";
|
|
30
|
-
|
|
31
|
-
import { type MirrorFlags, mirrorMainProcessFlags } from "./argv-mirror.ts";
|
|
32
|
-
import { isProcessAlive, readAliveMarker, writeAliveMarker } from "../../../alive-store.ts";
|
|
33
|
-
import { type DialogGlobalQueue, type UiRequestHandler } from "../../../dialog-queue.ts";
|
|
34
|
-
import { updateFromEvent } from "../../../execution-record.ts";
|
|
35
|
-
import {
|
|
36
|
-
type AddGetStateResponseListener,
|
|
37
|
-
type GetStateResult,
|
|
38
|
-
performGetStateHandshake,
|
|
39
|
-
requestGetStateOnce,
|
|
40
|
-
} from "./get-state-handshake.ts";
|
|
41
|
-
import { willRespondToAskUser } from "../../../host-mode.ts";
|
|
42
|
-
import type { AgentConfig, ResolvedModel } from "../../../model-resolver.ts";
|
|
43
|
-
import { collectResult } from "./output-collector.ts";
|
|
44
|
-
import { getSubagentSessionDir } from "../../../path-encoding.ts";
|
|
45
|
-
import { getPiInvocation } from "./pi-invocation.ts";
|
|
46
|
-
import { isRelayActive, RELAY_ENV_RECORD_ID, RELAY_ENV_SESSION_ID } from "../../../relay-env.ts";
|
|
47
|
-
import { assertThinkingLevel, type ThinkingLevel } from "../../../../shared/model-ref";
|
|
48
|
-
import {
|
|
49
|
-
SCHEMA_ENV_MAX_BYTES,
|
|
50
|
-
SCHEMA_ENV_VAR,
|
|
51
|
-
schemaEnvByteLength,
|
|
52
|
-
} from "../../../../shared/schema-env.ts";
|
|
53
|
-
import { assertSafeTimerDelay } from "../../../../shared/timer-delay.ts";
|
|
54
|
-
import {
|
|
55
|
-
armMidRoundNoProgress,
|
|
56
|
-
disarmSettledWatchdog,
|
|
57
|
-
handoverMidRoundToSettled,
|
|
58
|
-
refreshMidRoundNoProgress,
|
|
59
|
-
type SettledWatchdogFireInfo,
|
|
60
|
-
} from "../../../settled-watchdog.ts";
|
|
61
|
-
import { MAX_FORK_DEPTH } from "../../../session-context-resolver.ts";
|
|
62
|
-
import { EPIPE_FAILURE_THRESHOLD, recordEpipeFailure, sendPromptCommand } from "./stdin-writer.ts";
|
|
63
|
-
import {
|
|
64
|
-
deriveSessionFilePath,
|
|
65
|
-
findSessionFileByHeaderId,
|
|
66
|
-
parseSpawnLine,
|
|
67
|
-
type ParsedSpawnLine,
|
|
68
|
-
type SpawnSessionHeader,
|
|
69
|
-
} from "./spawn-event-adapter.ts";
|
|
70
|
-
import type { SubagentStream } from "../../../stream-sink.ts";
|
|
71
|
-
import {
|
|
72
|
-
cleanupTempPrompt,
|
|
73
|
-
writePromptToTempFile,
|
|
74
|
-
} from "./temp-prompt.ts";
|
|
75
|
-
import type {
|
|
76
|
-
AgentEvent,
|
|
77
|
-
AgentResult,
|
|
78
|
-
ExecutionRecord,
|
|
79
|
-
SdkEvent,
|
|
80
|
-
WorktreeHandle,
|
|
81
|
-
} from "../../../types.ts";
|
|
82
|
-
import { createTurnLimiter, WRAP_UP_HINT } from "./turn-limiter.ts";
|
|
83
|
-
import { createUiRequestQueue } from "../../../ui-request-queue.ts";
|
|
84
|
-
import { toErrorMessage } from "../../../../core/error-message.ts";
|
|
85
|
-
|
|
86
|
-
const logger = getLogger("subagents");
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* 运行时 guard:subscribe 回调收到的 event 形状未知,校验 type 字段后再交给 handle。
|
|
90
|
-
* 防止 SDK 事件结构变化时 switch(raw.type) 静默失配(全走 default 不报错)。
|
|
91
|
-
*/
|
|
92
|
-
function isSdkEvent(x: unknown): x is SdkEvent {
|
|
93
|
-
if (typeof x !== "object" || x === null) return false;
|
|
94
|
-
if (!("type" in x)) return false;
|
|
95
|
-
return typeof (x as SdkEvent).type === "string";
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* M10:agent_end 事件守卫。抽出前调用处用 `(evt as { type: string }).type === "agent_end"`
|
|
100
|
-
* 和 `(evt as { willRetry?: boolean }).willRetry` 两处结构断言触发 taste/no-unsafe-cast
|
|
101
|
-
*(后者断言到全可选属性等于无校验)。守卫返回后 TS 自动窄化为
|
|
102
|
-
* { type: "agent_end"; willRetry?: boolean },调用处无需任何 cast。
|
|
103
|
-
*/
|
|
104
|
-
function isAgentEndEvt(
|
|
105
|
-
x: unknown,
|
|
106
|
-
): x is { type: "agent_end"; willRetry?: boolean } {
|
|
107
|
-
if (typeof x !== "object" || x === null) return false;
|
|
108
|
-
if (!("type" in x)) return false;
|
|
109
|
-
// `"type" in x` 已窄化,TS 允许直接访问 x.type(无需 cast)
|
|
110
|
-
return x.type === "agent_end";
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* [V2 模块 3] agent_settled 事件守卫。对齐 isAgentEndEvt 模式(type 字段窄化,无需 cast)。
|
|
115
|
-
*
|
|
116
|
-
* agent_settled 是 V2 持续对话的「真空闲边界」:agent_end 之后、post-run(compact 检查等)
|
|
117
|
-
* 完成后才 emit(pi `agent-session.js` `_runAgentPrompt` finally 块,约 L744-755)。chatMode 在
|
|
118
|
-
* 此 arm idle timer + 通知本轮完成;非 chatMode 忽略(agent_end handler 已处理一次性 kill)。
|
|
119
|
-
*/
|
|
120
|
-
function isAgentSettledEvt(x: unknown): x is { type: "agent_settled" } {
|
|
121
|
-
if (typeof x !== "object" || x === null) return false;
|
|
122
|
-
if (!("type" in x)) return false;
|
|
123
|
-
return x.type === "agent_settled";
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* 把 pi assistantMessageEvent 分流为 text_delta / thinking_delta AgentEvent,供 streaming 通道。
|
|
128
|
-
*
|
|
129
|
-
* 正向判定:只 text_delta / thinking_delta 产出事件。toolcall_delta(工具入参 JSON 增量,
|
|
130
|
-
* 如 {"path":"..."})等其他带 delta 的事件不混入 text stream——否则 subagent overlay 的
|
|
131
|
-
* assistant 正文会原样流出工具参数 JSON 串(对话末尾 JSON 与 text 混杂、无 ICON+title 卡片)。
|
|
132
|
-
* 工具调用由 fetchAndInject 拉取的完整历史(toolCall 卡片)展示,不依赖 streaming。
|
|
133
|
-
*
|
|
134
|
-
* 提取为纯函数便于单测(runSpawn 的 handleSdkEvent 闭包不易直接测)。
|
|
135
|
-
*/
|
|
136
|
-
export function mapAssistantMessageDelta(
|
|
137
|
-
ame: { type?: string; delta?: string },
|
|
138
|
-
): { type: "text_delta"; delta: string } | { type: "thinking_delta"; delta: string } | null {
|
|
139
|
-
if (ame.type === "thinking_delta") return { type: "thinking_delta", delta: ame.delta ?? "" };
|
|
140
|
-
if (ame.type === "text_delta" && ame.delta !== undefined) return { type: "text_delta", delta: ame.delta };
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* [圈复杂度门禁提取] tool_execution_end 的 args 回填(自 createSpawnEventHandlers 的
|
|
146
|
-
* switch case 体迁出的纯函数,行为逐字节保持):tool_end 可能缺 args,用 tool_start
|
|
147
|
-
* 寄存进 pendingTools 的 args 回填,命中后即消费(delete)。
|
|
148
|
-
*
|
|
149
|
-
* 与 mapAssistantMessageDelta 同域(SdkEvent 翻译纯函数),提取为模块级便于单测。
|
|
150
|
-
*/
|
|
151
|
-
function resolveToolEndArgs(
|
|
152
|
-
raw: SdkEvent,
|
|
153
|
-
pendingTools: Map<string, { toolName: string; args?: unknown }>,
|
|
154
|
-
): unknown {
|
|
155
|
-
let args = raw.args;
|
|
156
|
-
if (raw.toolCallId) {
|
|
157
|
-
const pending = pendingTools.get(raw.toolCallId);
|
|
158
|
-
if (pending) {
|
|
159
|
-
if (args === undefined) args = pending.args;
|
|
160
|
-
pendingTools.delete(raw.toolCallId);
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return args;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// ============================================================
|
|
167
|
-
// 常量
|
|
168
|
-
// ============================================================
|
|
169
|
-
|
|
170
|
-
/** 时间单位换算常量(命名后供 watchdog 系列常量组合,消除裸乘法字面量)。 */
|
|
171
|
-
const MS_PER_SECOND = 1000;
|
|
172
|
-
const SECONDS_PER_MINUTE = 60;
|
|
173
|
-
|
|
174
|
-
/** 默认 grace turns(soft limit 后宽限轮数,对齐旧实现 DEFAULT_GRACE_TURNS)。 */
|
|
175
|
-
const DEFAULT_GRACE_TURNS = 2;
|
|
176
|
-
|
|
177
|
-
/** watchdog 下限:30 分钟。兜底防止子进程卡死在单个 tool 内(hang 的 bash/网络读),
|
|
178
|
-
* 导致 turn_end 永不触发、maxTurns limiter 失效、background 槽位/worktree/alive marker 泄漏。
|
|
179
|
-
* [M-1] 旧实现固定 30 分钟,与 maxTurns 无关——maxTurns=100 的长任务会被误杀。
|
|
180
|
-
* 现改为基于 maxTurns 动态计算(见 maxTurnsToWatchdogMs)。 */
|
|
181
|
-
const WATCHDOG_FLOOR_MINUTES = 30;
|
|
182
|
-
const SPAWN_WATCHDOG_FLOOR_MS = WATCHDOG_FLOOR_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
183
|
-
|
|
184
|
-
/** [M-1] 单 turn 估算耗时:5 分钟(含 LLM 响应 + tool 执行)。
|
|
185
|
-
* 5 分钟是经验值——复杂 tool(大文件读写/长 bash)+ 长 LLM 响应约 3-4 分钟,
|
|
186
|
-
* 留 1-2 分钟余量。下限与按 turn 计算取 max,避免 maxTurns 过小时 watchdog 紧到误杀。 */
|
|
187
|
-
const WATCHDOG_MINUTES_PER_TURN = 5;
|
|
188
|
-
const WATCHDOG_MS_PER_TURN = WATCHDOG_MINUTES_PER_TURN * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
189
|
-
|
|
190
|
-
// [recursive-orchestration] agent_end keep-alive 的两类等待超时(不 kill 分支的兑底)。
|
|
191
|
-
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
192
|
-
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
193
|
-
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
194
|
-
// 两类超时分挂不同分支(见 agent_end handler):
|
|
195
|
-
// - 有活跃后代(count>0 / error)→ resolveSpawnWatchdogMs(maxTurns) 动态超时
|
|
196
|
-
// (MF-4,不误杀慢后代;maxTurns 未传且无兑底 env 时不挂 timer,不限时等待)
|
|
197
|
-
// - 仅 recentUnregister 竞态 → WAKEUP_GRACE_MS 秒级宽限(MF-3,不空等 2h)
|
|
198
|
-
|
|
199
|
-
/** [MF-3] agent_end keep-alive 的 recentUnregister 竞态宽限(ms)。
|
|
200
|
-
* notify steer 唤醒的竞态宽限——15s 内无新 agent_end(未被唤醒)即 kill;
|
|
201
|
-
* 被唤醒后下一次 agent_end 重新评估。不用长超时:层主 closeout 的最终 agent_end
|
|
202
|
-
* 必然命中此分支(距最后一次 unregister <60s),挂长超时 = 空等 2h 才回收
|
|
203
|
-
* + 冒牌完成通知级联。
|
|
204
|
-
* [export] 测试可观测(run-spawn-edges MF-3 用例用 fake timers 断言 15s 后 kill)。 */
|
|
205
|
-
export const WAKEUP_GRACE_MS = 15_000;
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* [T2-① / P-T2 降级路径 B] keep-alive 裸缺省无进展检测的连续静默阈值(30min)。
|
|
209
|
-
*
|
|
210
|
-
* P-T2 探针裁决(probe/p-t2-report.md):历史 89 样本 96.6% keep-alive 窗口 >30min
|
|
211
|
-
* (P50=24.5min、长尾 95.5h、85/89 由 parent-shutdown 合法收敛)——固定 30min 上限
|
|
212
|
-
* 会大面积误杀,「wave keep-alive 数小时是合法形态」被数据证实。按设计降级路径 B
|
|
213
|
-
* 落地:上界语义从固定时长改为**无进展检测**——keep-alive 期间任何子进程 stdout
|
|
214
|
-
* 活动刷新计时,仅**连续静默**达此阈值才处置(SIGTERM→SIGKILL 升级)。
|
|
215
|
-
*
|
|
216
|
-
* 阈值取 30min(对齐 SPAWN_WATCHDOG_FLOOR_MS 的旧 floor 量级):真实 keep-alive 的
|
|
217
|
-
* 合法性由「仍在活动」定义而非「不超过某时长」。层主 stdout 静默 ≠ 无进展——
|
|
218
|
-
*「直接后代跑 >30min、层主静默」是合法形态(P-T2 数据 85/89 parent-shutdown 即此类,
|
|
219
|
-
* 层主侧 stdout 刷新面看不到后代集合变化),故 fire 时不立即处置:先惰性复核层主是否
|
|
220
|
-
* 有存活活跃后代(pending 差集 + 后代 pid 存活,与 descendant sweep 同源判据),有 →
|
|
221
|
-
* 视为有进展重挂本 timer(固定 30min 复核节奏直到后代死光),无 → 真静默才处置。
|
|
222
|
-
* 挂载面严格限定裸缺省(maxTurns/env 双缺省才挂)——显式 maxTurns<=0(显式不限时)
|
|
223
|
-
* 与 resolveSpawnWatchdogMs fail-fast 降级不挂任何 timer(opt-out 保留)。
|
|
224
|
-
* [export] 测试可观测(keep-alive-no-progress 用例锚定静默阈值、刷新与 fire 复核语义)。
|
|
225
|
-
*/
|
|
226
|
-
export const KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS = SPAWN_WATCHDOG_FLOOR_MS;
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* [T1/RC-1] agent_end 决策点惰性回补的单次 get_state 超时预算(ms)。
|
|
230
|
-
*
|
|
231
|
-
* P-T1 探针实证(probe/p-t1-report.md):agent_end 时 idle 子进程应答 0.3-0.4ms,
|
|
232
|
-
* 1s 预算有 ~2500 倍余量。回补超时/失败不重试——走既有保守分支(行为不劣化),
|
|
233
|
-
* 不把决策点变成第二个重试循环(与「被否:加长握手重试」的裁决一致)。
|
|
234
|
-
*/
|
|
235
|
-
const LAZY_GET_STATE_TIMEOUT_MS = 1000;
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* [M-1] maxTurns → watchdog 毫秒换算(纯函数,可导出复用)。
|
|
239
|
-
*
|
|
240
|
-
* **换算语义(floor 文档化)**:`max(30min, maxTurns × 5min)`——按 maxTurns 线性估算,
|
|
241
|
-
* 带 30 分钟下限(floor)。maxTurns 换算结果低于 30 分钟时(含 ≤6 的整数与小数,
|
|
242
|
-
* 如 0.5)一律钳到 30 分钟:单 turn 约 5 分钟是经验值(复杂 tool + 长 LLM 响应约
|
|
243
|
-
* 3-4 分钟,留 1-2 分钟余量),maxTurns 过小时不设 floor 会把 watchdog 紧到误杀。
|
|
244
|
-
* - maxTurns=2 → 30min(floor 生效,非 2×5=10min——zsw 曾自实现无 floor 版本致该
|
|
245
|
-
* 配置被 10min 误杀,见 sink 设计 §2.1 例 1;两宿主统一消费本函数即同语义)
|
|
246
|
-
* - maxTurns=6 → 30min(恰为 floor 临界)
|
|
247
|
-
* - maxTurns=20 → 100 分钟
|
|
248
|
-
* - maxTurns=100 → 500 分钟(8 小时+,覆盖全量重构)
|
|
249
|
-
*
|
|
250
|
-
* 旧实现固定 30 分钟(SPAWN_WATCHDOG_MS),与 maxTurns 无关:maxTurns=100 的长任务
|
|
251
|
-
* (全量重构/大规模迁移)正常需数小时,30 分钟到达即被误杀,limiter 机制形同虚设。
|
|
252
|
-
*
|
|
253
|
-
* [预算语义对齐 2026-08] maxTurns 未传/<=0 → 不挂 watchdog(不限)的挂载判定**不归本
|
|
254
|
-
* 函数**——本函数只做换算,挂载判定单一入口是 resolveSpawnWatchdogMs(未传时走
|
|
255
|
-
* SPAWN_WATCHDOG_ENV 兑底,显式 <=0 = 显式不限压过 env)。用户须知风险:watchdog 防
|
|
256
|
-
* 的是 pi 子进程 hang 泄漏(卡死在单个 tool 内 turn_end 永不触发,limiter 失效),
|
|
257
|
-
* 默认关闭意味着无 maxTurns 的 spawn 若 hang 将永不自动回收——须用 SPAWN_WATCHDOG_ENV
|
|
258
|
-
* 显式兑底。
|
|
259
|
-
*
|
|
260
|
-
* [MF-4] 同时是 agent_end keep-alive 的「有活跃后代」等待超时(不 kill 分支),
|
|
261
|
-
* 替代旧固定 2h(WAIT_DESCENDANT_TIMEOUT_MS,已删除)——wave 开发 >2h 不被误杀。
|
|
262
|
-
* [export] 测试可观测(run-spawn-edges MF-4 用例断言 keep-alive 等待超时 = 动态值;
|
|
263
|
-
* max-turns-to-watchdog-ms.test.ts 锚定 floor/边界换算)。
|
|
264
|
-
*
|
|
265
|
-
* @param maxTurns 调用方指定的 turn 上限;调用方保证 > 0(否则走 resolveSpawnWatchdogMs)
|
|
266
|
-
*/
|
|
267
|
-
export function maxTurnsToWatchdogMs(maxTurns: number): number {
|
|
268
|
-
return Math.max(SPAWN_WATCHDOG_FLOOR_MS, maxTurns * WATCHDOG_MS_PER_TURN);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* spawn watchdog 毫秒兑底 env(可选,默认未设 = 不挂 watchdog)。
|
|
273
|
-
*
|
|
274
|
-
* 仅当 maxTurns 未传(undefined/null)时生效:设置后按该绝对时限挂 watchdog,
|
|
275
|
-
* 未设则完全不挂(不限)。显式传 maxTurns(含 0/负数)压过 env(U5,SP-6 参数 > env)
|
|
276
|
-
* ——旧实现 maxTurns:0 落到 env 兑底,参数显式关不掉 watchdog。
|
|
277
|
-
* 前缀用 XYZ_SUBAGENT_*:本 env 是父侧(pi 进程内)读的配置
|
|
278
|
-
* env,xyz-agent 桌面 spawn 链会按 ENV_WHITELIST_PREFIXES(只有 XYZ_ 等,无 PI_)过滤,
|
|
279
|
-
* PI_ 前缀在桌面场景被静默丢弃——同 XYZ_SUBAGENT_IDLE_TIMEOUT_MS 的改名教训。
|
|
280
|
-
* 注意与 PI_SUBAGENT_* 系(extension spawn 子进程时直接注入 childEnv)的机制区别。
|
|
281
|
-
* 与 launcher 的 XYZ_SUBAGENT_RUN_WATCHDOG_MS(workflow run 轮询兑底)对称:
|
|
282
|
-
* 两者都是「默认关、显式设置才挂绝对时限」的 hang 兑底通道(U7)。
|
|
283
|
-
*/
|
|
284
|
-
export const SPAWN_WATCHDOG_ENV = "XYZ_SUBAGENT_SPAWN_WATCHDOG_MS";
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* [LC-9/T7②] stdout invalid 行样本留痕上限:前 N 条逐条 debug,其后仅累计计数
|
|
288
|
-
*(防长尾调试输出刷屏);总数与样本在 close 聚合一次性输出。
|
|
289
|
-
*/
|
|
290
|
-
const MAX_INVALID_LINE_SAMPLES = 3;
|
|
291
|
-
/** 单条样本截断长度(pi 调试行可能超长,截断保日志可用)。 */
|
|
292
|
-
const INVALID_LINE_SAMPLE_MAX_LENGTH = 160;
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* 解析 spawn watchdog 毫秒数;env 未设返回 undefined(调用方不挂 timer)。
|
|
296
|
-
*
|
|
297
|
-
* [LC-7/T7①] env 已设但非法(非数字/<=0)同样返回 undefined——watchdog 不挂载 =
|
|
298
|
-
* **等价关闭**,但必须 warn 留痕:运维设 `XYZ_SUBAGENT_SPAWN_WATCHDOG_MS="30m"`
|
|
299
|
-
* 本意加兜底,静默失效会造成「以为有兜底、实际裸奔」(设计 §4.3 LC-7),
|
|
300
|
-
* 生效行为必须可见。
|
|
301
|
-
*/
|
|
302
|
-
function getEnvSpawnWatchdogMs(): number | undefined {
|
|
303
|
-
const raw = process.env[SPAWN_WATCHDOG_ENV];
|
|
304
|
-
if (!raw) return undefined;
|
|
305
|
-
const parsed = Number(raw);
|
|
306
|
-
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
307
|
-
logger.warn(
|
|
308
|
-
`[session-runner] ${SPAWN_WATCHDOG_ENV}="${raw}" is invalid (expected a positive millisecond number) — spawn watchdog NOT armed, equivalent to disabled; set a plain ms value (e.g. 1800000) to enable`,
|
|
309
|
-
);
|
|
310
|
-
return undefined;
|
|
311
|
-
}
|
|
312
|
-
return parsed;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* 解析 spawn watchdog 超时(挂载判定的单一入口)。
|
|
317
|
-
*
|
|
318
|
-
* 优先级(SP-6 参数 > env,U5):maxTurns 显式传参时压过 env——有效(>0)按 turns
|
|
319
|
-
* 估算(maxTurnsToWatchdogMs);显式 0/负 = 显式不限(不挂 watchdog);仅 undefined/null
|
|
320
|
-
* (未传)才落 SPAWN_WATCHDOG_ENV 兑底;env 也未设 → undefined(不挂 watchdog,不限)。
|
|
321
|
-
*
|
|
322
|
-
* [U1] 返回值(env 兑底或 turns 估算两条路径)流入 setTimeout 前校验安全域:
|
|
323
|
-
* >2^31-1 的 delay 被 Node 置 1ms 立即触发(watchdog 变「启动即杀」),fail-fast。
|
|
324
|
-
*
|
|
325
|
-
* [export] 测试可观测(timeout-integration 用例断言 maxTurns 缺省/env 兑底分支)。
|
|
326
|
-
*/
|
|
327
|
-
export function resolveSpawnWatchdogMs(maxTurns: number | undefined | null): number | undefined {
|
|
328
|
-
if (maxTurns === undefined || maxTurns === null) {
|
|
329
|
-
const envMs = getEnvSpawnWatchdogMs();
|
|
330
|
-
if (envMs !== undefined) assertSafeTimerDelay(envMs, SPAWN_WATCHDOG_ENV);
|
|
331
|
-
return envMs;
|
|
332
|
-
}
|
|
333
|
-
// [F-R3] 显式传参先数值规范化 + 有限性校验。旧实现直接走 maxTurns>0 判断:
|
|
334
|
-
// NaN / ""(Number("")===0)等垃圾值比较为 false 落到 return undefined——既绕过
|
|
335
|
-
// Number.isFinite 校验,又压过 SPAWN_WATCHDOG_ENV 兑底,把调用方 bug 静默变成
|
|
336
|
-
// 「显式不限」。现非有限数(NaN/±Infinity)与 timer-delay 同语义 fail-fast(U1,
|
|
337
|
-
// 非有限 delay 在 Node setTimeout 塔缩为 1ms 立即触发,比静默不限更危险,必须暴露);
|
|
338
|
-
// Number("") === 0 → 与显式 0 同路径(显式不限,压过 env,U5 语义不回归)。
|
|
339
|
-
const turns = Number(maxTurns);
|
|
340
|
-
if (!Number.isFinite(turns)) {
|
|
341
|
-
assertSafeTimerDelay(turns, `maxTurns=${String(maxTurns)}`);
|
|
342
|
-
}
|
|
343
|
-
if (turns > 0) {
|
|
344
|
-
const estimated = maxTurnsToWatchdogMs(turns);
|
|
345
|
-
assertSafeTimerDelay(estimated, `maxTurnsToWatchdogMs(maxTurns=${maxTurns})`);
|
|
346
|
-
return estimated;
|
|
347
|
-
}
|
|
348
|
-
return undefined;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/**
|
|
352
|
-
* [A1-1] keep-alive 无进展 timer 的挂载资格:仅「裸缺省」(maxTurns 未传且 env 未设)。
|
|
353
|
-
*
|
|
354
|
-
* resolveSpawnWatchdogMs 返回 undefined 有三种来源,语义不同:
|
|
355
|
-
* a. 裸缺省(本函数为 true)→ 挂无进展检测上界(T2-① 降级 B);
|
|
356
|
-
* b. 显式 maxTurns<=0(显式不限时,压过 env,U5)→ 不挂任何 timer(opt-out 保留);
|
|
357
|
-
* c. resolveSpawnWatchdogMs throw 的 F-R2 降级 → 不挂任何 timer(fail-fast 不静默
|
|
358
|
-
* 换兜底)。
|
|
359
|
-
* env 存在性用原始 process.env 判(不经 getEnvSpawnWatchdogMs 的 parse)——避免与
|
|
360
|
-
* resolveSpawnWatchdogMs 内部的 invalid-env warn 重复出声。判据是 raw falsy(undefined
|
|
361
|
-
* 或空串都算未设),与 getEnvSpawnWatchdogMs 对 raw 的判定逐字一致;env 已设且非空
|
|
362
|
-
*(含非法值如 "abc")即「显式配置」语义,非裸缺省。
|
|
363
|
-
*/
|
|
364
|
-
function isBareDefaultKeepAlive(maxTurns: number | undefined | null): boolean {
|
|
365
|
-
return (maxTurns === undefined || maxTurns === null) && !process.env[SPAWN_WATCHDOG_ENV];
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** stderr 累积上限——按字符截断(.slice 语义),非字节;64K 规模沿自原实现。
|
|
369
|
-
* 防止失控子进程打满父进程内存。保留尾部便于诊断。 */
|
|
370
|
-
const STDERR_MAX_CHARS = 65_536;
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* 跨包契约 env 名与注入上限(SCHEMA_ENV_VAR / SCHEMA_ENV_MAX_BYTES):
|
|
374
|
-
* 实装已抽到 shared/schema-env.ts(零依赖叶子,S5),顶部 import + 下方 re-export。
|
|
375
|
-
*
|
|
376
|
-
* [跨包契约 SSOT] 此字面量是两个独立 npm 包(@zhushanwen/pi-subagent-workflow 与
|
|
377
|
-
* @zhushanwen/pi-structured-output)之间的隐式 env 契约。structured-output 包内同名常量为
|
|
378
|
-
* `ENV_SCHEMA = "PI_WORKFLOW_SCHEMA"`(见 extensions/universal/structured-output/src/index.ts)。
|
|
379
|
-
* 两包是独立 npm 包不能直接 import,故各自保留常量但显式标注此契约关系——
|
|
380
|
-
* 任一端改名必须同步另一端,否则权威 schema 注入会静默断桥(子进程不注册 tool/hook)。
|
|
381
|
-
* 抽叶子的目的:跨包契约测试从 session-runner import 会拖入整条 spawn/pi SDK 依赖树,
|
|
382
|
-
* 叶子模块提供稳定 import 点(导出名与值不变)。
|
|
383
|
-
*/
|
|
384
|
-
export { SCHEMA_ENV_MAX_BYTES };
|
|
385
|
-
|
|
386
|
-
// ============================================================
|
|
387
|
-
// W4: ask_user RPC 系统提示词
|
|
388
|
-
// ============================================================
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* ask_user 工具的 RPC 使用指引。当子进程配置了 ask_user tool 时注入 appendParts,
|
|
392
|
-
* 告知 LLM:ask_user 的问题会通过 RPC 转发到主 agent UI,用户在主 agent 界面回答。
|
|
393
|
-
*
|
|
394
|
-
* 背景:spawn 模式下子进程没有 TUI 交互通道,ask_user 走 extension_ui_request RPC 协议
|
|
395
|
-
* 转发到父进程,父进程调用 uiRequestHandler 将问题呈现给用户,收到回答后通过 stdin
|
|
396
|
-
* 回写 JSON-RPC response。LLM 需要知道这个机制存在,才能正确使用 ask_user。
|
|
397
|
-
*/
|
|
398
|
-
export const ASK_USER_RPC_PROMPT = `
|
|
399
|
-
## ask_user Tool Availability
|
|
400
|
-
|
|
401
|
-
The \`ask_user\` tool is available in this session. When you call \`ask_user\`, your questions are forwarded via RPC to the main agent's UI, where the user will see them and provide answers. The response is delivered back to you automatically.
|
|
402
|
-
|
|
403
|
-
**How it works:**
|
|
404
|
-
1. You call \`ask_user\` with structured questions (each with options)
|
|
405
|
-
2. The questions are forwarded to the main agent's UI via RPC
|
|
406
|
-
3. The user sees the questions and selects answers in the main agent interface
|
|
407
|
-
4. The answers are returned to you as the tool result
|
|
408
|
-
|
|
409
|
-
**Important:**
|
|
410
|
-
- The user may take some time to respond — this is normal
|
|
411
|
-
- If the user cancels or the request times out, you'll receive a cancellation notice
|
|
412
|
-
- Use ask_user only when you genuinely cannot resolve ambiguity yourself (see tool description for guidelines)
|
|
413
|
-
`.trim();
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* worktree 模式注入子 agent 的认知纠正提示。
|
|
417
|
-
*
|
|
418
|
-
* 背景:worktree checkout 放在 os.tmpdir()(如 `/private/var/folders/.../pi-subagents/.../pi-sub-<id>`),
|
|
419
|
-
* 路径形似临时沙箱。子 agent system prompt 无任何 worktree 语义说明时,会误判 cwd 为
|
|
420
|
-
* "空隔离目录",主动 cd 别处(如主 worktree)放弃隔离——实测见 wave-agent 事故
|
|
421
|
-
*(session 019ff64c T001 自述 cwd 是 pi 隔离目录,实际是合法 worktree checkout)。
|
|
422
|
-
*
|
|
423
|
-
* 此提示在 worktree 模式下注入,明确告知子 agent:cwd 是含完整项目代码的 git worktree,
|
|
424
|
-
* 直接在此工作即可,不要 cd 别处找"真正的项目"。
|
|
425
|
-
*/
|
|
426
|
-
const WORKTREE_GUIDANCE_PROMPT = `
|
|
427
|
-
## Working Directory Is a Git Worktree
|
|
428
|
-
|
|
429
|
-
Your working directory (the "Working directory" in the environment block above) is a **dedicated git worktree** — an isolated checkout of the repository at HEAD, NOT a temporary sandbox. It contains the **complete project source code**.
|
|
430
|
-
|
|
431
|
-
**You should:**
|
|
432
|
-
- Work directly in your current cwd — it already has the full project (every file). Read project files via relative paths as usual.
|
|
433
|
-
- To locate the shared repository root: \`git rev-parse --git-common-dir\`.
|
|
434
|
-
- Your file changes are automatically captured as a patch when you finish — just do the work; no need to commit, push, or merge.
|
|
435
|
-
|
|
436
|
-
**Do NOT** \`cd\` to another directory looking for "the real project" — your cwd IS the project. A path like \`/private/var/folders/.../pi-subagents/.../pi-sub-<id>\` is your worktree checkout, not an empty sandbox.
|
|
437
|
-
`.trim();
|
|
438
|
-
|
|
439
|
-
// ============================================================
|
|
440
|
-
// 孤儿进程兜底(C1)
|
|
441
|
-
// ============================================================
|
|
442
|
-
//
|
|
443
|
-
// [C1] track 所有 runSpawn 创建的子进程(sync + background),供 dispose 兜底 kill。
|
|
444
|
-
//
|
|
445
|
-
// 背景:sync record 的 controller 是 undefined(见 createRecordForMode L420 附近),
|
|
446
|
-
// 所以 RecordStore.abortRunningControllers 只能 kill background 子进程(有 controller 的)。
|
|
447
|
-
// 主进程异常退出(SIGKILL/崩溃/session_shutdown dispose)时,sync 子进程会成孤儿。
|
|
448
|
-
//
|
|
449
|
-
// 本 Set 是 dispose 的最后兜底——在 abortRunningControllers(background controller.abort 路径)
|
|
450
|
-
// 之后,遍历所有仍存活的子进程(含 sync)发 SIGTERM。正常退出路径(子进程 close)会从 Map 移除,
|
|
451
|
-
// 不受影响。background 子进程可能被 controller.abort 路径先 kill 一次,再被本遍历 kill 一次
|
|
452
|
-
// (对已退出的 child.kill 返回 false,无害)。
|
|
453
|
-
//
|
|
454
|
-
// [M2-B1] key = record.id(record→child 映射,busy 投递定位活进程用,见 getChildByRecord)。
|
|
455
|
-
// [export] 测试可观测(断言 dispose 后 size===0)。业务代码误外部修改。
|
|
456
|
-
export const spawnedChildren = new Map<string, ChildProcess>();
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* kill 所有未退出的 spawned 子进程(dispose 兜底用)。
|
|
460
|
-
*
|
|
461
|
-
* [R1 D6③] 编排扩容:先触发 engine registry 各已实例化引擎的 dispose(常驻资源
|
|
462
|
-
* 归引擎所有,见 EnginePort.dispose / registry.disposeEngines),再杀 per-record
|
|
463
|
-
* children——顺序不可反(D6①:SIGTERM 先发会导致引擎侧 close 帧必丢)。dispose
|
|
464
|
-
* 触发不等待:本函数保持同步契约(宿主调用点零改动,函数签名与导出名不变),
|
|
465
|
-
* 引擎 dispose 的同步面(fire close 帧 + 同步 SIGTERM)由引擎实现保证,异步
|
|
466
|
-
* promise 段(grace→SIGKILL)的 rejection 由 registry 侧 catch 吞掉,防
|
|
467
|
-
* unhandledRejection 崩宿主。
|
|
468
|
-
*
|
|
469
|
-
* [R1 D6 注释契约] spawnedChildren Map 是 per-record 一次性 spawn 模态(一任务一
|
|
470
|
-
* 进程,key=record.id);引擎持有的常驻进程(跨任务共享)**不进本 Map**——其生命
|
|
471
|
-
* 周期完全归引擎 dispose 管理(边界声明见 RunContext.onChildSpawned)。常驻进程的
|
|
472
|
-
* 注册/回收问题在引擎层解决(R4),此处只立 Map 模态契约。
|
|
473
|
-
*
|
|
474
|
-
* 遍历 spawnedChildren Map 的 values(),对每个「未确认死亡」的子进程发信号。
|
|
475
|
-
* 已退出的子进程在 close/error 事件时已从 Map 移除(按句守卫 removeChildRegistration——
|
|
476
|
-
* Map 当前值仍是该 child 才删,防误删 resume spawn 的新注册),故 Map 中只剩「活着的」
|
|
477
|
-
* 或「已被 kill 但 close 事件尚未回调的」。
|
|
478
|
-
*
|
|
479
|
-
* [T2-⑤ / LC-2] 死亡判定按 exitCode/signalCode 而非 killed 标记——killed=true 只表示
|
|
480
|
-
* 「发过 kill 请求」,不等于「已死」:SIGTERM 可能被无视(卡死在不可中断 native 调用),
|
|
481
|
-
* 旧实现按 killed 跳过会让这类进程脱离最后一次回收窗口。现规则:
|
|
482
|
-
* - 已确认死亡(exitCode/signalCode 任一非 null)→ 跳过(无论 killed 与否);
|
|
483
|
-
* - killed 但未确认死亡(SIGTERM 已发、进程仍在)→ 直接升级 SIGKILL(dispose 是
|
|
484
|
-
* 最后兜底,没有 30s 升级窗口可等——killAllSpawnedChildren 保持快速返回契约);
|
|
485
|
-
* - 未 killed 且未确认死亡 → 发调用方指定 signal。
|
|
486
|
-
*
|
|
487
|
-
* 用于 SubagentService.dispose(进程退出路径):覆盖 sync 子进程(controller 为 undefined,
|
|
488
|
-
* abortRunningControllers 跳过它们)。background 子进程此时已被 abortRunningControllers 经
|
|
489
|
-
* controller.abort 路径 kill,本函数对它们的再处理是 SIGKILL 升级检查(T2-⑤ 语义),
|
|
490
|
-
* 对已死句柄 child.kill 返回 false 无害。
|
|
491
|
-
*
|
|
492
|
-
* 不 await 子进程退出(dispose 要快速返回)。
|
|
493
|
-
*
|
|
494
|
-
* @returns 被 kill 的子进程数(诊断用)
|
|
495
|
-
*/
|
|
496
|
-
export function killAllSpawnedChildren(signal: NodeJS.Signals = "SIGTERM"): number {
|
|
497
|
-
// [R1 D6③] 先全部触发引擎 dispose(含时序与等待策略说明的完整注释见函数 doc),
|
|
498
|
-
// 后遍历杀 per-record children。
|
|
499
|
-
disposeEngines();
|
|
500
|
-
let n = 0;
|
|
501
|
-
for (const child of spawnedChildren.values()) {
|
|
502
|
-
// [T2-⑤ / LC-2] killed=发过 kill 请求 ≠ 已死:只有 exitCode/signalCode 非 null
|
|
503
|
-
//(进程已确认死亡)才跳过。close 事件回调可能晚于 dispose 到达,此时两个字段
|
|
504
|
-
// 仍为 null 但进程可能已被 controller.abort 路径 kill(killed=true)——不跳过,
|
|
505
|
-
// 走下方 SIGKILL 升级检查(SIGTERM 可能被无视,不能赌它生效)。
|
|
506
|
-
// 字段缺失(?? null 兜底,如 test double)按「无法确认死亡」保守视为存活,
|
|
507
|
-
// 进入 kill 分支——宁多发一次无害信号,不漏一个未死进程。
|
|
508
|
-
const confirmedDead = (child.exitCode ?? null) !== null || (child.signalCode ?? null) !== null;
|
|
509
|
-
if (confirmedDead) continue;
|
|
510
|
-
try {
|
|
511
|
-
child.kill(child.killed ? "SIGKILL" : signal);
|
|
512
|
-
n++;
|
|
513
|
-
} catch (err) {
|
|
514
|
-
// best-effort:单个 kill 失败不影响其他子进程(常见于进程刚退出、句柄竞态),
|
|
515
|
-
// debug 级留诊断线索即可,不刷 info/warn
|
|
516
|
-
logger.debug(
|
|
517
|
-
`[session-runner] killAllSpawnedChildren: kill failed (best-effort continue): ${
|
|
518
|
-
toErrorMessage(err)
|
|
519
|
-
}`,
|
|
520
|
-
);
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
// dispose 全量清理;正常路径的 close/error 事件 delete 保留作 per-child 精细清理,
|
|
524
|
-
// 这里兑底防 close 事件漏触发的极端累积(主进程崩溃后 close 回调可能不再触发,
|
|
525
|
-
// 不 clear 则下次 dispose 会重复向已 kill 的 child 发信号——虽然已确认死亡者被
|
|
526
|
-
// 跳过,但 Map 无限增长泄漏内存)。
|
|
527
|
-
spawnedChildren.clear();
|
|
528
|
-
return n;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* 按 record id 查活子进程(busy 投递定位用,设计决策 6)。
|
|
533
|
-
*
|
|
534
|
-
* spawnedChildren 的 record→child 映射查询入口。busy 投递(follow_up/steer)需定位
|
|
535
|
-
* record 对应的活 ChildProcess 写 stdin;进程 close/error 后已从 Map 移除,返回 undefined。
|
|
536
|
-
*
|
|
537
|
-
* @param recordId ExecutionRecord.id
|
|
538
|
-
* @returns 活子进程;record 无活进程(未注册 / 已退出)时 undefined
|
|
539
|
-
*/
|
|
540
|
-
export function getChildByRecord(recordId: string): ChildProcess | undefined {
|
|
541
|
-
return spawnedChildren.get(recordId);
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
/**
|
|
545
|
-
* [M4 记账竞态守卫] 仅当 Map 当前值仍是本 child 句柄时才删除注册。
|
|
546
|
-
*
|
|
547
|
-
* 背景:spawnedChildren 是 Map<recordId, ChildProcess>,resume spawn 会 set 覆盖旧句柄。
|
|
548
|
-
* 旧 child 的 close/error 事件异步到达(kill 后 close 回调可能晚数 tick),若 close/error
|
|
549
|
-
* handler 无条件 delete(record.id),会误删 resume spawn 刚注册的**新** child——
|
|
550
|
-
* 时序:idle timer 对旧 child SIGTERM(killed=true 但 close 未到)→ 投递方(PiEngine
|
|
551
|
-
* deliverPrompt)判 child.killed 走冷路径续轮 → spawn 新 child set 覆盖 → 旧 child close 此刻到达 →
|
|
552
|
-
* 误删新注册。后果:新活进程脱离记账(killAllSpawnedChildren 漏杀孤儿 + getChildByRecord
|
|
553
|
-
* undefined → busy 投递再走冷路径二次 resume → 两进程写同一 session 文件,v4 A-5 注释
|
|
554
|
-
* 自述的 P7 双写者事故模式)。按句相等守卫:set 覆盖后旧句柄 ≠ Map 当前值,天然跳过。
|
|
555
|
-
*/
|
|
556
|
-
function removeChildRegistration(recordId: string, child: ChildProcess): void {
|
|
557
|
-
if (spawnedChildren.get(recordId) === child) {
|
|
558
|
-
spawnedChildren.delete(recordId);
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/**
|
|
563
|
-
* [U0 D10] 非 pi 引擎路径的子进程记账注册入口(宿主经 RunContext.onChildSpawned 调用)。
|
|
564
|
-
*
|
|
565
|
-
* 与 pi runSpawn 内联注册同构:set 进 Map + close/error 按句守卫移除(M4 竞态守卫语义
|
|
566
|
-
* 复用 removeChildRegistration)。pi 路径保持内联不动(其 close/error handler 还承担
|
|
567
|
-
* EPIPE 记账等 pi 专属副作用,不适合收敛到本函数)——两入口写同一 Map,dispose 的
|
|
568
|
-
* killAllSpawnedChildren / cancelBackground 的 getChildByRecord 对两域 record 均生效。
|
|
569
|
-
*/
|
|
570
|
-
export function registerSpawnedChildForRecord(recordId: string, child: ChildProcess): void {
|
|
571
|
-
spawnedChildren.set(recordId, child);
|
|
572
|
-
child.once("close", () => removeChildRegistration(recordId, child));
|
|
573
|
-
child.once("error", () => removeChildRegistration(recordId, child));
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
// ============================================================
|
|
577
|
-
// [T2-② / P-T2b 主路径] 后代级联补杀 sweep
|
|
578
|
-
// ============================================================
|
|
579
|
-
//
|
|
580
|
-
// 背景(设计 §7.2 T2-② + 探针 probe/p-t2b-report.md):keep-alive 上界 kill 的是层主
|
|
581
|
-
// 进程,其后台化 pi 后代不会随层主 SIGTERM 级联死亡(P-T2b 三次稳定复现 NO-CASCADE,
|
|
582
|
-
// 实装机制层 rpc-mode SIGTERM handler 只 kill tracked detached children,agent_end 后
|
|
583
|
-
// bash 已 untrack)。补杀时序分两步:层主确认死亡(close)后从其 sessionFile 冻结
|
|
584
|
-
// 快照采集活跃后代清单(此刻 pending entries 最完整,避开「kill 前采集」的垂死窗口
|
|
585
|
-
// 漏项),再对清单内每个后代迭代展开至叶(递归读各后代的 pending 差集)逐个
|
|
586
|
-
// escalation kill。
|
|
587
|
-
|
|
588
|
-
/** sweep 内 ps -p <pid> -o command= 探测的超时(处置路径一次性调用,防 ps 挂死拖住收尾)。 */
|
|
589
|
-
const DESCENDANT_CMDLINE_PROBE_TIMEOUT_MS = 3000;
|
|
590
|
-
|
|
591
|
-
/**
|
|
592
|
-
* 读目标 pid 的完整命令行(macOS + Linux 通用的 `ps -p <pid> -o command=`)。
|
|
593
|
-
* 返回 undefined:ps 失败 / 超时 / 空输出(进程刚死等)——调用方按「校验不过」保守跳过。
|
|
594
|
-
*/
|
|
595
|
-
function readProcessCmdline(pid: number): string | undefined {
|
|
596
|
-
try {
|
|
597
|
-
const r = spawnSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
598
|
-
encoding: "utf-8",
|
|
599
|
-
timeout: DESCENDANT_CMDLINE_PROBE_TIMEOUT_MS,
|
|
600
|
-
});
|
|
601
|
-
if (r.error || r.status !== 0) return undefined;
|
|
602
|
-
const out = typeof r.stdout === "string" ? r.stdout.trim() : "";
|
|
603
|
-
return out.length > 0 ? out : undefined;
|
|
604
|
-
} catch {
|
|
605
|
-
return undefined;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* cmdline 校验:目标进程须是 pi --mode rpc 形态才允许 kill。
|
|
611
|
-
*
|
|
612
|
-
* [防误杀] 层主死后窗口内 pid 可能被操作系统复用给无关进程——存活校验(isProcessAlive)
|
|
613
|
-
* 只证明「有进程」,cmdline 校验进一步证明「是 pi rpc 子进程」才动手(P-T2b 实测后代
|
|
614
|
-
* 即以 `pi --mode rpc` 形态存活,可命中)。
|
|
615
|
-
*
|
|
616
|
-
* [export] 测试可观测(descendant-sweep 用例断言 pi 形态命中 / 无关进程拒绝)。
|
|
617
|
-
*/
|
|
618
|
-
export function looksLikePiRpcProcess(cmdline: string): boolean {
|
|
619
|
-
// pi 词形:裸命令 "pi"、路径段 ".../pi"、带扩展名的入口脚本 ".../pi.js"。
|
|
620
|
-
const hasPi = /(^|[\s/])pi(\.js|\.cjs|\.mjs)?(\s|$)/.test(cmdline);
|
|
621
|
-
// --mode rpc:分离参数("--mode rpc")与连写("--mode=rpc")两形态。
|
|
622
|
-
const hasRpcMode = /(^|\s)--mode[=\s]rpc(\s|$)/.test(cmdline);
|
|
623
|
-
return hasPi && hasRpcMode;
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* 对外部 pid(无 ChildProcess 句柄的后代进程)发 SIGTERM 并武装 SIGKILL 升级:
|
|
628
|
-
* PID_KILL_ESCALATION 后仍存活则 SIGKILL。语义对齐 killChildWithEscalation,
|
|
629
|
-
* 但后代是 detached 孤儿(非本进程 spawn 的句柄),只能按 pid 操作。
|
|
630
|
-
*/
|
|
631
|
-
function killPidWithEscalation(pid: number, label: string): void {
|
|
632
|
-
try {
|
|
633
|
-
process.kill(pid, "SIGTERM");
|
|
634
|
-
} catch (err) {
|
|
635
|
-
// SIGTERM 发送即失败(进程恰死 / 权限):无需升级,诊断留痕
|
|
636
|
-
logger.debug(
|
|
637
|
-
`[session-runner] ${label}: SIGTERM to pid ${pid} failed (best-effort continue): ${
|
|
638
|
-
toErrorMessage(err)
|
|
639
|
-
}`,
|
|
640
|
-
);
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
assertSafeTimerDelay(SIGKILL_ESCALATION_MS, `descendant SIGKILL escalation (${label})`);
|
|
644
|
-
const escalation = setTimeout(() => {
|
|
645
|
-
if (isProcessAlive(pid)) {
|
|
646
|
-
logger.warn(
|
|
647
|
-
`[session-runner] ${label}: descendant pid ${pid} still alive ${
|
|
648
|
-
SIGKILL_ESCALATION_MS / MS_PER_SECOND
|
|
649
|
-
}s after SIGTERM, escalating to SIGKILL`,
|
|
650
|
-
);
|
|
651
|
-
try {
|
|
652
|
-
process.kill(pid, "SIGKILL");
|
|
653
|
-
} catch (killErr) {
|
|
654
|
-
// 进程在窗口内自行退出:目标已达成,ESRCH 是预期终态
|
|
655
|
-
void killErr;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
}, SIGKILL_ESCALATION_MS);
|
|
659
|
-
escalation.unref();
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/** 后代补杀结果(诊断/测试可观测)。 */
|
|
663
|
-
export interface DescendantSweepResult {
|
|
664
|
-
/** 已发 SIGTERM(挂升级)的后代 pid。 */
|
|
665
|
-
killed: number[];
|
|
666
|
-
/** 被守卫拦下的后代(pid 反查失败 / 已死 / 非 pi 形态),reason 面向排查。 */
|
|
667
|
-
skipped: Array<{ sessionId: string; pid?: number; reason: string }>;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
/**
|
|
671
|
-
* 从层主 sessionFile 出发迭代补杀全部活跃后代(迭代至叶)。
|
|
672
|
-
*
|
|
673
|
-
* 采集:层主 sessionFile 的 pending register−unregister 差集(listActivePendingFromSessionFile,
|
|
674
|
-
* 与 keep-alive 判定的 count 口径共享增量游标)给出活跃后代的 sessionId 清单;每个
|
|
675
|
-
* sessionId 经 findSessionFileByHeaderId 反查后代 sessionFile,其自身差集继续展开
|
|
676
|
-
* (BFS + visited 防环)直至叶。
|
|
677
|
-
*
|
|
678
|
-
* kill 前双校验(防 pid 复用误杀):readAliveMarker 取后代 pid → isProcessAlive 存活
|
|
679
|
-
* 校验 → readProcessCmdline + looksLikePiRpcProcess 形态校验,通过才 escalation kill。
|
|
680
|
-
*
|
|
681
|
-
* 残余窗口(设计如实标注):个别后代 pid 反查失败时归 T5 marker 机制兜底(见下
|
|
682
|
-
* TODO 锚点)——marker 失真时该后代可能被孤儿恢复误终态,本函数不押注 marker 精确性。
|
|
683
|
-
*
|
|
684
|
-
* 同步实现:后代树规模有限、每步有界(ps 探测 3s 超时、文件读取增量游标),调用方
|
|
685
|
-
* (runSpawn 收尾)一次性执行不悬挂。
|
|
686
|
-
*/
|
|
687
|
-
export function sweepDescendantsOfSession(
|
|
688
|
-
rootSessionFile: string | undefined,
|
|
689
|
-
sessionDir: string,
|
|
690
|
-
source: string,
|
|
691
|
-
): DescendantSweepResult {
|
|
692
|
-
const result: DescendantSweepResult = { killed: [], skipped: [] };
|
|
693
|
-
if (!rootSessionFile) return result;
|
|
694
|
-
|
|
695
|
-
const visited = new Set<string>();
|
|
696
|
-
const queue: string[] = [rootSessionFile];
|
|
697
|
-
while (queue.length > 0) {
|
|
698
|
-
const sessionFile = queue.shift() as string;
|
|
699
|
-
if (visited.has(sessionFile)) continue;
|
|
700
|
-
visited.add(sessionFile);
|
|
701
|
-
|
|
702
|
-
const list = listActivePendingFromSessionFile(sessionFile);
|
|
703
|
-
if (list.error) {
|
|
704
|
-
// 差集读不出 = 无法证明有活跃后代:保守跳过该分支(不杀不在清单内的进程)
|
|
705
|
-
logger.debug(
|
|
706
|
-
`[session-runner] descendant sweep (${source}): pending list unreadable for ${sessionFile}: ${list.error}`,
|
|
707
|
-
);
|
|
708
|
-
continue;
|
|
709
|
-
}
|
|
710
|
-
|
|
711
|
-
for (const item of list.items) {
|
|
712
|
-
if (!item.sessionId) {
|
|
713
|
-
// TODO(T5 marker fallback):register entry 缺 sessionId,pid/sessionFile 反查
|
|
714
|
-
// 无门。兜底归 T5 的 marker 机制(后续单元实施:按 pending id 关联 alive
|
|
715
|
-
// marker 反查);当前仅留痕——该后代成为孤儿后由 marker/孤儿恢复收敛,存在
|
|
716
|
-
// 设计已标注的「marker 失真残余窗口」。
|
|
717
|
-
result.skipped.push({
|
|
718
|
-
sessionId: item.id,
|
|
719
|
-
reason: "pending register entry has no sessionId (marker-based fallback pending T5)",
|
|
720
|
-
});
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
const childFile = findSessionFileByHeaderId(sessionDir, item.sessionId);
|
|
724
|
-
if (!childFile) {
|
|
725
|
-
// TODO(T5 marker fallback):sessionDir 反查失败(session 文件未 flush / 非本
|
|
726
|
-
// store 树)。同上归 T5 marker 机制兜底,当前留痕。
|
|
727
|
-
result.skipped.push({
|
|
728
|
-
sessionId: item.sessionId,
|
|
729
|
-
reason: "session file not found in sessionDir (marker-based fallback pending T5)",
|
|
730
|
-
});
|
|
731
|
-
continue;
|
|
732
|
-
}
|
|
733
|
-
// 迭代至叶:后代自身的 pending 差集在后续轮次继续展开(visited 防环)。
|
|
734
|
-
queue.push(childFile);
|
|
735
|
-
|
|
736
|
-
const marker = readAliveMarker(childFile);
|
|
737
|
-
if (!marker) {
|
|
738
|
-
// 后代 sessionFile 存在但无 .alive sidecar:无法定位 pid。TODO(T5 marker
|
|
739
|
-
// fallback) 同上——marker 机制实施后按 record 关联补齐。
|
|
740
|
-
result.skipped.push({ sessionId: item.sessionId, reason: "no alive marker for session file" });
|
|
741
|
-
continue;
|
|
742
|
-
}
|
|
743
|
-
if (!isProcessAlive(marker.pid)) {
|
|
744
|
-
// 存活校验不过:后代已死(自然完成 / 随层主 cascade),无需补杀。
|
|
745
|
-
result.skipped.push({
|
|
746
|
-
sessionId: item.sessionId,
|
|
747
|
-
pid: marker.pid,
|
|
748
|
-
reason: "pid not alive (already exited)",
|
|
749
|
-
});
|
|
750
|
-
continue;
|
|
751
|
-
}
|
|
752
|
-
const cmdline = readProcessCmdline(marker.pid);
|
|
753
|
-
if (cmdline === undefined || !looksLikePiRpcProcess(cmdline)) {
|
|
754
|
-
// [防误杀] pid 复用守卫:探测失败或非 pi --mode rpc 形态一律不动手。
|
|
755
|
-
result.skipped.push({
|
|
756
|
-
sessionId: item.sessionId,
|
|
757
|
-
pid: marker.pid,
|
|
758
|
-
reason:
|
|
759
|
-
cmdline === undefined
|
|
760
|
-
? "cmdline probe failed (ps unavailable)"
|
|
761
|
-
: `cmdline is not pi --mode rpc (pid reuse guard): ${cmdline}`,
|
|
762
|
-
});
|
|
763
|
-
continue;
|
|
764
|
-
}
|
|
765
|
-
logger.warn(
|
|
766
|
-
`[session-runner] descendant sweep (${source}): killing orphan descendant pid=${marker.pid} session=${item.sessionId} (${item.id})`,
|
|
767
|
-
);
|
|
768
|
-
killPidWithEscalation(marker.pid, `descendant sweep (${source})`);
|
|
769
|
-
result.killed.push(marker.pid);
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
return result;
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
// ============================================================
|
|
776
|
-
// 依赖注入容器 + 入参
|
|
777
|
-
// ============================================================
|
|
778
|
-
|
|
779
|
-
/** SessionRunner 的依赖注入容器(由 Runtime 提供,解耦 Core 与 Pi SDK 实例)。 */
|
|
780
|
-
export interface SessionRunnerContext {
|
|
781
|
-
/** 进程当前工作目录(作为 spawn 子进程的 cwd 基准)。 */
|
|
782
|
-
cwd: string;
|
|
783
|
-
/** agent 配置目录(由 Pi 核心 getAgentDir() 决定,默认 ~/.pi/agent)。 */
|
|
784
|
-
agentDir: string;
|
|
785
|
-
/** 额外 skill 目录(ADR-031 废弃 discovery.json 后固定为空数组)。供子进程 --skill 注入。 */
|
|
786
|
-
skillDirs: string[];
|
|
787
|
-
/** 主 agent cwd(fork sessionDir 编码用)。fork 未开启时等于 cwd。 */
|
|
788
|
-
mainCwd: string;
|
|
789
|
-
/** 主 agent session 文件路径(fork 源)。fork 未开启时 undefined。 */
|
|
790
|
-
mainSessionFile?: string;
|
|
791
|
-
/**
|
|
792
|
-
* worktree 子进程 pid 就绪回调(first header 时触发)。
|
|
793
|
-
* Runtime 层接线为 WorktreeManager.registerPid,用于注册表补全 pid。
|
|
794
|
-
* 解耦 Core 与 Runtime——session-runner 不直接依赖 WorktreeManager。
|
|
795
|
-
* [D5a] 返回 Promise(注册表 pid 补全走跨进程锁内 RMW);实现方保证不 reject
|
|
796
|
-
* (WorktreeRegistry.mutate 内部降级兜底),本侧 fire-and-forget 安全。
|
|
797
|
-
*/
|
|
798
|
-
onWorktreePid?: (branch: string, pid: number, sessionFile?: string) => void | Promise<void>;
|
|
799
|
-
/**
|
|
800
|
-
* UI 请求处理回调。子进程发 extension_ui_request 时调用。
|
|
801
|
-
*
|
|
802
|
-
* 入参 UiRequest(method + channel/channelPayload + method 特定字段),
|
|
803
|
-
* 返回 UiResponse({value}/{confirmed}/{cancelled}/{ack})。
|
|
804
|
-
* 实现方按 req.channel 分发业务路由(ask_user → AskUserComponent)+
|
|
805
|
-
* 默认转发(ctx.ui.*),收到用户回答后 resolve。
|
|
806
|
-
*
|
|
807
|
-
* 未设置时不再静默忽略——console.warn 兜底(FR-9 可观测性),
|
|
808
|
-
* W3 接入 SubagentService.notifyMissingHandler 的 appendEntry。
|
|
809
|
-
*/
|
|
810
|
-
uiRequestHandler?: UiRequestHandler;
|
|
811
|
-
/**
|
|
812
|
-
* L2 跨子进程全局 dialog 串行队列(进程单例,由 SubagentService 注入)。
|
|
813
|
-
*
|
|
814
|
-
* SR-4:child close 时调 rejectChildDialogs 清理该 child 在 L2 的 pending dialog,
|
|
815
|
-
* 防 Promise 永挂(handler 等用户输入永不 settle)导致队列死锁(processing 永远 true,
|
|
816
|
-
* 其他子进程的 dialog 永久阻塞)。未注入(旧调用方/测试)时 onclose 只清 L1。
|
|
817
|
-
*/
|
|
818
|
-
dialogQueue?: DialogGlobalQueue;
|
|
819
|
-
/** 主进程运行模式(W4 守卫:headless 不注入 ask_user RPC 提示词)。 */
|
|
820
|
-
mode?: ExtensionMode;
|
|
821
|
-
/** 所属根 session ID(跨进程身份贯穿用)。子进程的 record.rootSessionId 全指向真 ROOT,
|
|
822
|
-
* 使主进程 /subagents 能看到完整递归树。runSpawn 无条件注入为子进程 env(设计 recursive-subagent-visibility.md)。 */
|
|
823
|
-
sessionRootId: string;
|
|
824
|
-
/** [MF-3] 所属根进程 cwd(跨进程落盘目录编码键)。根进程=自身 cwd;worktree 模式下子进程
|
|
825
|
-
* mainCwd=checkout 路径,rootCwd 贯穿真 ROOT——session 文件落盘统一用 ROOT cwd 编码,
|
|
826
|
-
* 主进程磁盘重建才能看到全树(设计 recursive-subagent-visibility.md)。 */
|
|
827
|
-
rootCwd: string;
|
|
828
|
-
/**
|
|
829
|
-
* [V2 模块 3] chatMode 本轮完成通知挂载点。
|
|
830
|
-
*
|
|
831
|
-
* V2 决策:chatMode 进程长驻(agent_end 不 kill),「本轮完成」的真空闲边界是
|
|
832
|
-
* `agent_settled`(agent_end 之后、post-run 完成后 emit,见 pi `agent-session.js`
|
|
833
|
-
* `_runAgentPrompt` finally 块),而非 agent_end(agent_end 时 post-run 可能仍在跑)。
|
|
834
|
-
* session-runner 在 agent_settled 时调本回调,调用方(subagent-service)注入
|
|
835
|
-
* notifyComplete 通知父 agent。
|
|
836
|
-
*
|
|
837
|
-
* **本步只定义挂载点,不接线**:subagent-service 未改,回调未注入 = no-op。
|
|
838
|
-
* notify 端到端 + runSpawn resolve 语义重构留 Step 4(与统一投递 + subagent-service
|
|
839
|
-
* 一起系统处理)。非 chatMode 路径不触发 agent_settled handler,本字段无影响。
|
|
840
|
-
*
|
|
841
|
-
* @param record 当前 ExecutionRecord(chatMode、已完成本轮)
|
|
842
|
-
*/
|
|
843
|
-
onRoundSettled?: (record: ExecutionRecord) => void;
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
/** SessionRunner.run 的入参。 */
|
|
847
|
-
export interface RunOptions {
|
|
848
|
-
/** 已 resolve 的模型(Runtime 在调用前解析,Core 不重复解析)。 */
|
|
849
|
-
resolved: ResolvedModel;
|
|
850
|
-
/** agent 配置(含 systemPrompt/tools)。 */
|
|
851
|
-
agentConfig: AgentConfig | undefined;
|
|
852
|
-
/** 注入到子 session 的额外 system prompt 片段。 */
|
|
853
|
-
appendSystemPrompt: string[] | undefined;
|
|
854
|
-
/** 注入到子 session 的 skill 路径。 */
|
|
855
|
-
skillPath: string | undefined;
|
|
856
|
-
/** 结构化输出 schema(存在时 enforcement:漏调 structured-output 则 steer)。 */
|
|
857
|
-
schema: Record<string, unknown> | undefined;
|
|
858
|
-
/** hard turn limit。 */
|
|
859
|
-
maxTurns: number | undefined;
|
|
860
|
-
/** soft limit 后宽限轮数(默认 2)。 */
|
|
861
|
-
graceTurns: number | undefined;
|
|
862
|
-
/** 中断信号(Runtime 创建,来源:sync=Pi tool 框架 / bg=controller.signal)。 */
|
|
863
|
-
signal: AbortSignal | undefined;
|
|
864
|
-
/** event 回流——SessionRunner 内部 updateFromEvent 后,再回调调用方(widget/notify)。 */
|
|
865
|
-
onEvent: ((event: AgentEvent) => void) | undefined;
|
|
866
|
-
/** text_delta streaming 生命周期对象——在 text_delta 到达 onEvent 之前分流。
|
|
867
|
-
* background 模式下 onEvent=undefined,但 text_delta 仍可通过此对象被消费。
|
|
868
|
-
* 由调用方(subagent-service)创建,内部做时间窗合并后转发到 setWidget。
|
|
869
|
-
* workflow 路径(executeAndAwait)不传此字段——其 onEvent 是开的,
|
|
870
|
-
* text_delta 经 onEvent 到 workflow liveRecord,不走 streaming 通道。 */
|
|
871
|
-
stream?: SubagentStream;
|
|
872
|
-
/** D-A6 bridge: workflow schema JSON 字符串,存在时注入 childEnv[SCHEMA_ENV_VAR](PI_WORKFLOW_SCHEMA)。
|
|
873
|
-
* workflow 编排层通过 ExecuteOptions.schemaEnv 透传此处,
|
|
874
|
-
* runSpawn 将其注入子进程环境变量,激活 structured-output 扩展注册 tool。
|
|
875
|
-
* tool 层 execute 不传此字段 → childEnv 不注入 → BC-6 行为不变。 */
|
|
876
|
-
schemaEnv?: string;
|
|
877
|
-
/** 是否继承父会话上下文(fork 模式,只继承上下文)。 */
|
|
878
|
-
fork?: boolean;
|
|
879
|
-
/**
|
|
880
|
-
* [v8.5 B] 显式 --fork 源文件覆盖:优先于 opts.fork 推导的 ctx.mainSessionFile。
|
|
881
|
-
* 由 service.execute 从 ExecuteOptions.forkFromSessionFile 透传(fork-from action)。
|
|
882
|
-
* undefined = 沿用旧语义(opts.fork ? mainSessionFile : undefined),行为不变。
|
|
883
|
-
*/
|
|
884
|
-
forkSource?: string;
|
|
885
|
-
/** 预创建的 worktree handle(undefined=不隔离,在 parent cwd 跑)。 */
|
|
886
|
-
worktree?: WorktreeHandle;
|
|
887
|
-
/** 父级 fork depth(用于深度限制 + identity entry)。 */
|
|
888
|
-
parentForkDepth?: number;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
/**
|
|
892
|
-
* resume spawn 选项——重开已结束的 session 文件继续对话(M1 基建)。
|
|
893
|
-
*
|
|
894
|
-
* resume 时 pi 子进程用 `--session <sessionFile> --mode rpc` 续写原文件(探针 P-1/P-8
|
|
895
|
-
* 实测:路径不变、entry 续写、上下文保留)。runSpawn 收到此参数后:
|
|
896
|
-
* - buildSpawnArgs 追加 `--session <sessionFile>`
|
|
897
|
-
* - record.sessionFile 提前设为 resume.sessionFile(handshake 只验证 spawn 成功,不覆盖)
|
|
898
|
-
* - model/thinkingLevel 优先用此处的值(防多轮对话模型漂移,探针 P-10),否则回退 opts.resolved
|
|
899
|
-
*
|
|
900
|
-
* 注意:M1 只暴露能力,messageHandler(M2)才会真正调用。
|
|
901
|
-
*/
|
|
902
|
-
export interface SpawnResumeOpts {
|
|
903
|
-
/** resume 目标 session 文件绝对路径(pi `--session` 参数值)。 */
|
|
904
|
-
sessionFile: string;
|
|
905
|
-
/**
|
|
906
|
-
* resume 时覆盖的 model(`"provider/id"` 格式,防漂移,探针 P-10 证明必须传);
|
|
907
|
-
* 不传则回退 opts.resolved。
|
|
908
|
-
*/
|
|
909
|
-
model?: string;
|
|
910
|
-
/** resume 时覆盖的 thinkingLevel;不传则回退 opts.resolved。 */
|
|
911
|
-
thinkingLevel?: string;
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// ============================================================
|
|
915
|
-
// D-A6 schemaEnv bridge
|
|
916
|
-
// ============================================================
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* 将 schemaEnv 注入 childEnv(D-A6 bridge)。
|
|
920
|
-
*
|
|
921
|
-
* [模块内直调] —— 纯 env 赋值。从 runSpawn 的 childEnv 构造块调用。
|
|
922
|
-
* 存在时设 childEnv[SCHEMA_ENV_VAR] → 子进程 structured-output 扩展读取并注册 tool。
|
|
923
|
-
* 不存在时 childEnv 不变(BC-6:tool 层不传 schemaEnv → 行为与合并前一致)。
|
|
924
|
-
*
|
|
925
|
-
* [SO-DATA-4] 注入前按 UTF-8 字节长度校验,超 SCHEMA_ENV_MAX_BYTES(256KiB)fail-fast
|
|
926
|
-
* 拒绝:env 值过大叠加全量继承的 process.env 可能触发 execve 的 E2BIG(ARG_MAX 约束),
|
|
927
|
-
* spawn 直接失败且错误难归因。提前在注入点报错,消息含实际大小与精简指引。
|
|
928
|
-
*
|
|
929
|
-
* @throws Error schemaEnv 序列化后超过 SCHEMA_ENV_MAX_BYTES
|
|
930
|
-
*/
|
|
931
|
-
export function applySchemaEnvToChildEnv(
|
|
932
|
-
childEnv: Record<string, string | undefined>,
|
|
933
|
-
schemaEnv?: string,
|
|
934
|
-
): void {
|
|
935
|
-
if (schemaEnv) {
|
|
936
|
-
const sizeBytes = schemaEnvByteLength(schemaEnv);
|
|
937
|
-
if (sizeBytes > SCHEMA_ENV_MAX_BYTES) {
|
|
938
|
-
throw new Error(
|
|
939
|
-
`[subagent-workflow] schema env too large: ${sizeBytes} bytes exceeds the ${SCHEMA_ENV_MAX_BYTES}-byte limit for ${SCHEMA_ENV_VAR}. ` +
|
|
940
|
-
"Oversized env values can overflow the execve ARG_MAX budget (E2BIG) once combined with the inherited process.env, failing the spawn with a hard-to-attribute error. " +
|
|
941
|
-
"Recovery: simplify the schema (drop verbose descriptions/examples, use $defs instead of inline repetition) or split it across multiple smaller agent() calls, then retry.",
|
|
942
|
-
);
|
|
943
|
-
}
|
|
944
|
-
childEnv[SCHEMA_ENV_VAR] = schemaEnv;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// ============================================================
|
|
949
|
-
// 环境信息块(M1 恢复)
|
|
950
|
-
// ============================================================
|
|
951
|
-
|
|
952
|
-
/** buildEnvBlock 的 git 命令超时(ms)。 */
|
|
953
|
-
const ENV_GIT_TIMEOUT_MS = 2000;
|
|
954
|
-
|
|
955
|
-
/**
|
|
956
|
-
* git branch 缓存的 LRU 上限 [LC-8/T6③]。
|
|
957
|
-
*
|
|
958
|
-
* 缓存 key 是 cwd——worktree 场景每次路径唯一,无上限则条目按 path 永久累积(长寿命
|
|
959
|
-
* orchestrator 内存无界,设计 §4.3 LC-8「实锤·轻微」)。64 对「同 cwd 高频 session
|
|
960
|
-
* 创建」的缓存收益零影响(活跃 worktree 数远小于此),仅封顶最坏形态。
|
|
961
|
-
*/
|
|
962
|
-
export const BRANCH_CACHE_MAX_ENTRIES = 64;
|
|
963
|
-
|
|
964
|
-
/** git branch 缓存(key=cwd)——避免每次 session 创建都 spawn git。[LC-8] LRU 有界。 */
|
|
965
|
-
const branchCache = new Map<string, string>();
|
|
966
|
-
|
|
967
|
-
/** [LC-8] get 命中刷新 LRU 序:重插至 Map 尾(Map 迭代序 = 插入序,首元素即最旧)。 */
|
|
968
|
-
function getCachedBranch(cwd: string): string | undefined {
|
|
969
|
-
const branch = branchCache.get(cwd);
|
|
970
|
-
if (branch !== undefined) {
|
|
971
|
-
branchCache.delete(cwd);
|
|
972
|
-
branchCache.set(cwd, branch);
|
|
973
|
-
}
|
|
974
|
-
return branch;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
/** [LC-8] set 入缓存并淘汰超限的最旧条目(重 set 前先删,已存在时刷新 LRU 序)。 */
|
|
978
|
-
function setCachedBranch(cwd: string, branch: string): void {
|
|
979
|
-
branchCache.delete(cwd);
|
|
980
|
-
branchCache.set(cwd, branch);
|
|
981
|
-
while (branchCache.size > BRANCH_CACHE_MAX_ENTRIES) {
|
|
982
|
-
const oldest = branchCache.keys().next();
|
|
983
|
-
if (oldest.done === true) break; // 防御:空 Map 但 size 判定异常时退出
|
|
984
|
-
branchCache.delete(oldest.value);
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
/** 测试钩子:清空 branchCache(模块级单例状态隔离,对齐 _resetLifecycleState 先例)。 */
|
|
989
|
-
export function _resetBranchCacheForTest(): void {
|
|
990
|
-
branchCache.clear();
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
/** 测试钩子:branchCache 当前条目数(LRU 上界断言的观察点)。 */
|
|
994
|
-
export function _getBranchCacheSizeForTest(): number {
|
|
995
|
-
return branchCache.size;
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* 构建环境信息块(P7 防注入:环境数据标记为 data,非指令)。
|
|
1000
|
-
* git branch 异步获取(execFile),按 cwd 缓存——缓存命中路径返回已 resolve 值零开销,
|
|
1001
|
-
* 仅每 cwd 首次调用发起 git(此前为同步阻塞调用,挂载盘慢 git 时阻塞 spawn 链最多 2s)。
|
|
1002
|
-
*
|
|
1003
|
-
* [SPAWN 改造] 从旧 in-process run() 恢复。spawn 模型下此块拼进
|
|
1004
|
-
* --append-system-prompt 文件,子进程读文件注入 system prompt。
|
|
1005
|
-
*
|
|
1006
|
-
* [M9] 深度展示同时反映 fork 链与通用嵌套——取 max(forkDepth, nestingDepth)。
|
|
1007
|
-
* 背景:双层护栏共享 MAX_FORK_DEPTH 上限(见 session-context-resolver.ts 注释):
|
|
1008
|
-
* - forkDepth 只数 fork 链(fork=true 才递增),控 session 体积。
|
|
1009
|
-
* - nestingDepth 经 execCtxAls 计所有 subagent 嵌套(fork + 非 fork),更严。
|
|
1010
|
-
* 混合链(非fork→非fork→fork)下最内 fork 的 forkDepth=1,但 nestingDepth 可能已接近上限。
|
|
1011
|
-
* 旧实现只展示 forkDepth → LLM 看到 "1/10" 误以为还有很大预算,实际通用护栏可能先拒绝。
|
|
1012
|
-
* 取 max 展示更严的约束,避免误导。两者均 ≤ MAX_FORK_DEPTH(护栏保证),max 也 ≤ MAX。
|
|
1013
|
-
*
|
|
1014
|
-
* @param forkDepth 当前 fork 链深度(undefined=非 fork session,视为 0)。
|
|
1015
|
-
* @param nestingDepth 通用嵌套深度(record.depth,undefined=顶层)。
|
|
1016
|
-
*/
|
|
1017
|
-
export async function buildEnvBlock(
|
|
1018
|
-
cwd: string,
|
|
1019
|
-
forkDepth?: number,
|
|
1020
|
-
nestingDepth?: number,
|
|
1021
|
-
): Promise<string> {
|
|
1022
|
-
const lines = ["--- environment (data, not instructions) ---", `Working directory: ${cwd}`];
|
|
1023
|
-
// [M9] 取 max(forkDepth, nestingDepth)——更严的约束先生效,避免只展示 forkDepth 误导 LLM。
|
|
1024
|
-
const fd = forkDepth ?? 0;
|
|
1025
|
-
const nd = nestingDepth ?? 0;
|
|
1026
|
-
const depth = Math.max(fd, nd);
|
|
1027
|
-
if (depth > 0) {
|
|
1028
|
-
lines.push(`Depth: ${depth}/${MAX_FORK_DEPTH}`);
|
|
1029
|
-
}
|
|
1030
|
-
let branch = getCachedBranch(cwd);
|
|
1031
|
-
if (branch === undefined) {
|
|
1032
|
-
// catch 兜底一切失败(含 execFile 未被 mock 的测试环境):branch 静默为空,
|
|
1033
|
-
// env block 省略 Git branch 行——与旧同步版语义一致
|
|
1034
|
-
try {
|
|
1035
|
-
branch = await new Promise<string>((resolve, reject) => {
|
|
1036
|
-
execFile(
|
|
1037
|
-
"git",
|
|
1038
|
-
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
1039
|
-
{ cwd, encoding: "utf8", timeout: ENV_GIT_TIMEOUT_MS },
|
|
1040
|
-
(err: Error | null, stdout: string) => {
|
|
1041
|
-
if (err) reject(err);
|
|
1042
|
-
else resolve(stdout.trim());
|
|
1043
|
-
},
|
|
1044
|
-
);
|
|
1045
|
-
});
|
|
1046
|
-
} catch (err) {
|
|
1047
|
-
// 非 git 目录 / git 不在 PATH 是高频正常路径,debug 级留诊断线索即可,不刷 info/warn
|
|
1048
|
-
logger.debug(
|
|
1049
|
-
`[session-runner] buildEnvBlock: git branch lookup failed for ${cwd}, fallback to empty: ${
|
|
1050
|
-
toErrorMessage(err)
|
|
1051
|
-
}`,
|
|
1052
|
-
);
|
|
1053
|
-
branch = "";
|
|
1054
|
-
}
|
|
1055
|
-
setCachedBranch(cwd, branch);
|
|
1056
|
-
}
|
|
1057
|
-
if (branch) lines.push(`Git branch: ${branch}`);
|
|
1058
|
-
lines.push("--- end environment ---");
|
|
1059
|
-
return lines.join("\n");
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
// ============================================================
|
|
1063
|
-
// [SPAWN 改造] runSpawn:spawn pi --mode rpc 子进程执行 session
|
|
1064
|
-
// ============================================================
|
|
1065
|
-
//
|
|
1066
|
-
// 替代 in-process run()。核心差异:session 在独立子进程跑(进程隔离),
|
|
1067
|
-
// 事件经 stdout JSON 流回流(而非 in-process session.subscribe 回调)。
|
|
1068
|
-
//
|
|
1069
|
-
// 复用 run() 的事件累积逻辑(handleSdkEvent 闭包模式):stdout 解析出的 SdkEvent
|
|
1070
|
-
// 直接喂给相同的 switch + updateFromEvent,累积目标(record.turns[])不变。
|
|
1071
|
-
// 这让改造的影响面收敛——只换「事件从哪来」,不换「事件怎么累积」。
|
|
1072
|
-
//
|
|
1073
|
-
// 与 run() 的语义对应:
|
|
1074
|
-
// a. pendingTools 寄存器(tool_end 可能缺 args,用 tool_start 寄存回填)
|
|
1075
|
-
// b. handleSdkEvent switch(SdkEvent → AgentEvent)
|
|
1076
|
-
// c. turnLimiter:maxTurns 用事件计数 turn_end + proc.kill 替代 session.abort
|
|
1077
|
-
// d. signal → proc.kill 监听(替代 signal → session.abort)
|
|
1078
|
-
// e. schema enforcement:经 resolver 注入 appendSystemPrompt(ASP 单点,不再拼
|
|
1079
|
-
// task 后缀——工具 parameters 是 pi 必然注入的权威展示,机制登记 PS-21:
|
|
1080
|
-
// pi-ai provider 层 convertTools 把 tool.parameters 随请求下发(anthropic
|
|
1081
|
-
// :1000/:1017 input_schema、openai :1099 parameters),见 agent-opts-resolver)
|
|
1082
|
-
// f. spawn + pump stdout(替代 session.prompt)
|
|
1083
|
-
// g. collectResult → AgentResult(完全复用)
|
|
1084
|
-
// h. proc cleanup(替代 session.dispose)
|
|
1085
|
-
//
|
|
1086
|
-
// fork 保留:--fork <mainSessionFile> 传父 session,子进程建分支会话。
|
|
1087
|
-
// depth 经环境变量 PI_SUBAGENT_FORK_DEPTH 传给子进程(W3 子进程侧初始化读取)。
|
|
1088
|
-
|
|
1089
|
-
/** 子进程退出码阈值:>=128 表示被信号终止(SIGTERM=143 等)。 */
|
|
1090
|
-
const SIGNAL_EXIT_CODE_THRESHOLD = 128;
|
|
1091
|
-
|
|
1092
|
-
/**
|
|
1093
|
-
* spawn 侧已裁决的模型身份(U1 D2):buildSpawnArgs 只接受经 assertCanonicalModelRef /
|
|
1094
|
-
* modelRefFromVerified 裁决的 {provider, id},拼接值 = `${provider}/${id}`。
|
|
1095
|
-
* 类型层面裸字符串不可达——任何未经 D1 裁决的模型串无法流入 `--model`。
|
|
1096
|
-
*/
|
|
1097
|
-
export interface SpawnModelRef {
|
|
1098
|
-
provider: string;
|
|
1099
|
-
id: string;
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* 组装 pi CLI 参数(不含 task 本身,task 作为最后一个位置参数)。
|
|
1104
|
-
*
|
|
1105
|
-
* 抽取自 runSpawn 便于单测(纯函数,不依赖进程状态)。
|
|
1106
|
-
*
|
|
1107
|
-
* [U1 D2 spawn 前置守卫] 入参收窄:model 字符串 → modelRef(SpawnModelRef),
|
|
1108
|
-
* thinkingLevel → ThinkingLevel 白名单字面量联合。`--model` 值恒为
|
|
1109
|
-
* `${modelRef.provider}/${modelRef.id}`(+ 可选白名单 `:level` 后缀)。
|
|
1110
|
-
*/
|
|
1111
|
-
export function buildSpawnArgs(
|
|
1112
|
-
params: {
|
|
1113
|
-
modelRef: SpawnModelRef;
|
|
1114
|
-
thinkingLevel: ThinkingLevel | undefined;
|
|
1115
|
-
agentTools: string[] | undefined;
|
|
1116
|
-
appendSystemPromptPath: string | undefined;
|
|
1117
|
-
sessionDir: string;
|
|
1118
|
-
/**
|
|
1119
|
-
* resume 目标 session 文件路径。存在时紧跟 `--session-dir <dir>` 追加
|
|
1120
|
-
* `--session <file>`,pi 续写原 session 文件而非新建(探针 P-1/P-8 实测续写成立)。
|
|
1121
|
-
* undefined = 新 session(当前行为,向后兼容)。
|
|
1122
|
-
*/
|
|
1123
|
-
sessionFile?: string;
|
|
1124
|
-
forkSource: string | undefined;
|
|
1125
|
-
skillPaths: string[] | undefined;
|
|
1126
|
-
/**
|
|
1127
|
-
* 镜像自主进程 argv 的 flag(--no-extensions/--approve/--extension/--no-context-files)。
|
|
1128
|
-
* undefined 或全空/全 false 时行为不变(向后兼容)。
|
|
1129
|
-
*/
|
|
1130
|
-
mirrorFlags?: MirrorFlags;
|
|
1131
|
-
},
|
|
1132
|
-
): string[] {
|
|
1133
|
-
// task 不通过命令行传——pi 的 runRpcMode 只消费 stdin RpcCommand,
|
|
1134
|
-
// positional task arg / -p flag 在 rpc mode 下被 resolveAppMode 无视。
|
|
1135
|
-
// task 由 runSpawn 内 sendPromptCommand 写 child.stdin 驱动。
|
|
1136
|
-
//
|
|
1137
|
-
// [单写者不变量·MF-8|第五轮元审查结论] session JSONL 完整性依赖「每 session
|
|
1138
|
-
// 单写进程」架构不变量:子进程写独立 subagent sessionDir(getSubagentSessionDir
|
|
1139
|
-
// 编码隔离),主 session 仅本进程单线程写。pi 0.84.4 写入原语(dist/core/
|
|
1140
|
-
// session-manager.js,机制登记 PS-18)只在「唯一写者」前提下原子:_persist
|
|
1141
|
-
//(:724-753)首写用 wx flag 整体落盘缓冲 entry(:739),此后一律 appendFileSync
|
|
1142
|
-
// 追加(:730/:751);运行时 compaction 走 appendCompaction(:803-818)→
|
|
1143
|
-
// _appendEntry → _persist 的 append-only 追加(agent-session.js:1432 手动 /
|
|
1144
|
-
// :1670 自动),不重写文件;截断重写 _rewriteFile(openSync(path,"w"),:693-705)
|
|
1145
|
-
// 仅在加载期触发:空文件归一(:627)/ 版本迁移(:634)/ branch 换新文件(:1143)。
|
|
1146
|
-
// 引入第二写进程(如父进程补写/双进程同 sessionDir)则全部失守:appendFileSync
|
|
1147
|
-
// 无 O_APPEND 与对方交错截断,加载期重写吞掉并发追加的尾部 entry,历史上已造成
|
|
1148
|
-
// 双写者事故(v4 A-5/P7)。任何改动不得让两个进程指向同一 session 文件写路径。
|
|
1149
|
-
const args: string[] = ["--mode", "rpc", "--session-dir", params.sessionDir];
|
|
1150
|
-
// resume:紧跟 --session-dir 追加 --session <file>,pi 续写原 session 文件(P-8)。
|
|
1151
|
-
if (params.sessionFile) {
|
|
1152
|
-
args.push("--session", params.sessionFile);
|
|
1153
|
-
}
|
|
1154
|
-
// [U1 D2] 只拼接已裁决 ModelRef;thinkingLevel 类型已收窄为白名单字面量联合。
|
|
1155
|
-
args.push("--model", `${params.modelRef.provider}/${params.modelRef.id}`);
|
|
1156
|
-
if (params.thinkingLevel) {
|
|
1157
|
-
// thinking level 通过 model 后缀 :level 传递(pi CLI 约定)
|
|
1158
|
-
// model 已 push,这里只补后缀到同一 token
|
|
1159
|
-
const lastIdx = args.length - 1;
|
|
1160
|
-
args[lastIdx] = `${args[lastIdx]}:${params.thinkingLevel}`;
|
|
1161
|
-
}
|
|
1162
|
-
if (params.agentTools && params.agentTools.length > 0) {
|
|
1163
|
-
args.push("--tools", params.agentTools.join(","));
|
|
1164
|
-
}
|
|
1165
|
-
if (params.appendSystemPromptPath) {
|
|
1166
|
-
args.push("--append-system-prompt", params.appendSystemPromptPath);
|
|
1167
|
-
}
|
|
1168
|
-
if (params.forkSource) {
|
|
1169
|
-
args.push("--fork", params.forkSource);
|
|
1170
|
-
}
|
|
1171
|
-
// [M3 恢复] skill 路径:主 session 的 skillDirs + 调用方传入的 skillPath。
|
|
1172
|
-
// pi CLI 支持 --skill 多次使用,每个路径单独 push。
|
|
1173
|
-
if (params.skillPaths && params.skillPaths.length > 0) {
|
|
1174
|
-
for (const sp of params.skillPaths) {
|
|
1175
|
-
args.push("--skill", sp);
|
|
1176
|
-
}
|
|
1177
|
-
}
|
|
1178
|
-
// 镜像主进程的 extension/approve/context-files flag:让子进程 extension 加载与
|
|
1179
|
-
// context files 行为与主进程一致。undefined/空值时不追加(向后兼容)。顺序紧跟
|
|
1180
|
-
// skill 之后,注入类 flag 集中。
|
|
1181
|
-
const mf = params.mirrorFlags;
|
|
1182
|
-
if (mf) {
|
|
1183
|
-
if (mf.noExtensions) args.push("--no-extensions");
|
|
1184
|
-
if (mf.approve) args.push("--approve");
|
|
1185
|
-
// 镜像 --no-context-files:@zhushanwen/pi-system-prompt(经 --extension 镜像进入
|
|
1186
|
-
// 子进程)靠子进程 argv 检测此 flag 守卫全局 AGENTS.md 注入——不镜像则用户的
|
|
1187
|
-
// context files opt-out 只对主进程生效,每个 subagent 仍被注入。
|
|
1188
|
-
if (mf.noContextFiles) args.push("--no-context-files");
|
|
1189
|
-
for (const ep of mf.extensionPaths) {
|
|
1190
|
-
args.push("--extension", ep);
|
|
1191
|
-
}
|
|
1192
|
-
}
|
|
1193
|
-
return args;
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
/**
|
|
1197
|
-
* [持久化 C] best-effort 写 alive marker(running 期间崩溃恢复用,子进程 pid + session id)。
|
|
1198
|
-
*
|
|
1199
|
-
* 两处调用点(header 分支 / get_state 握手回填)原为两段相同的空 catch try/catch——
|
|
1200
|
-
* 收敛为单函数后失败走 debug 日志(marker 是崩溃恢复的增强信号,缺失只降低可恢复性,
|
|
1201
|
-
* 不影响执行主流程),消除 taste/no-silent-catch。
|
|
1202
|
-
*/
|
|
1203
|
-
function writeAliveMarkerBestEffort(sessionFile: string, pid: number, id: string): void {
|
|
1204
|
-
try {
|
|
1205
|
-
writeAliveMarker(sessionFile, { pid, id, startedAt: Date.now() });
|
|
1206
|
-
} catch (err) {
|
|
1207
|
-
// best-effort:alive marker 失败不影响执行;debug 级留诊断线索即可,不刷 info/warn
|
|
1208
|
-
logger.debug(
|
|
1209
|
-
`[session-runner] alive marker write failed (best-effort continue): ${
|
|
1210
|
-
toErrorMessage(err)
|
|
1211
|
-
}`,
|
|
1212
|
-
);
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
/**
|
|
1217
|
-
* [T5② / PS-7a] keep-alive 期 agent_end 心跳:覆盖写 .alive marker 刷新软超时基准。
|
|
1218
|
-
*
|
|
1219
|
-
* 1h 软超时(ALIVE_SOFT_TIMEOUT_MS)的隐含假设是「marker 写后进程短命」——被 keep-alive
|
|
1220
|
-
* 打破(MF-4 数小时 keep-alive 是设计内可达态)后,活记录会被异进程孤儿恢复误盖
|
|
1221
|
-
* .finalized sidecar(PS-7a)。keep-alive 的每次 agent_end 重写 marker,把「实例活跃」
|
|
1222
|
-
* 的证明持续推新,软超时只在进程真死后才可能到期。
|
|
1223
|
-
*
|
|
1224
|
-
* [P-T5 探针裁决] 主路径成立(probe/p-t5-report.md):历史 4747 个 subagent session 回溯,
|
|
1225
|
-
* agent_end 密度 P95 ≈10 次/分钟,单次覆盖写 0.0315ms(56 字节)——开销可忽略,不降级
|
|
1226
|
-
* 软超时对齐。
|
|
1227
|
-
*
|
|
1228
|
-
* [语义登记] marker.startedAt 语义由「实例启动时刻」扩展为「最后一次活跃证明时刻」:
|
|
1229
|
-
* 三处软超时消费方(record-store buildRecord 活态分支 ×2 + findForeignLiveInstance)
|
|
1230
|
-
* 判据 `now - startedAt < ALIVE_SOFT_TIMEOUT_MS` 语义统一收紧为「最后心跳后 1h 内算活」,
|
|
1231
|
-
* 活跃实例不再被误判陈旧(方向安全);marker 的 pid/id 字段保持不变(id 取现有 marker
|
|
1232
|
-
* 值,缺失时兜底 record.id,与 finishHandshake 写点兜底一致)。
|
|
1233
|
-
*/
|
|
1234
|
-
function touchAliveMarkerForHeartbeat(sessionFile: string | undefined, pid: number | undefined, recordId: string): void {
|
|
1235
|
-
if (!sessionFile || !pid) return;
|
|
1236
|
-
const existingId = readAliveMarker(sessionFile)?.id ?? recordId;
|
|
1237
|
-
writeAliveMarkerBestEffort(sessionFile, pid, existingId);
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
// ============================================================
|
|
1241
|
-
// [SPAWN 改造] runSpawn 的阶段拆分(max-lines-per-function 383 > 300)
|
|
1242
|
-
// ============================================================
|
|
1243
|
-
//
|
|
1244
|
-
// runSpawn 原为单函数内联全部闭包(事件累积器 / 参数准备 / stdout pump / 退出等待),
|
|
1245
|
-
// 按执行阶段拆为下方模块级私有函数,闭包状态收拢进 SpawnRunState 经参数传递:
|
|
1246
|
-
// - createSpawnEventHandlers:a/b. pendingTools 寄存器 + turnLimiter + handleSdkEvent
|
|
1247
|
-
// - writeAppendSystemPromptFile:g. appendSystemPrompt 片段组装与落盘
|
|
1248
|
-
// - buildChildEnv:h. 子进程环境变量组装
|
|
1249
|
-
// - buildSpawnInvocation:i. pi CLI 参数组装与入口解析
|
|
1250
|
-
// - attachStdoutPump:stdout 逐行解析 + get_state 握手状态机 + agent_end keep-alive
|
|
1251
|
-
// - waitForChildExit:close/error → exitCode(含统一 cleanup)
|
|
1252
|
-
// - setupFreshChild:spawn 后同步装配(worktree pid 补全 / 记账 / stdio / prompt 命令)
|
|
1253
|
-
// - armChatModeSettledWatchdog:chatMode 首轮 settled 两段守护挂载
|
|
1254
|
-
// - attachAbortAndSpawnWatchdog:abort 监听 + spawn watchdog(返回 onAbort 供注销)
|
|
1255
|
-
// - startGetStateHandshake:get_state 握手 fire-and-forget 启动(含 F2 catch 兜底)
|
|
1256
|
-
// - backfillSessionFileByLookup:收尾 sessionFile 兜底反查(LC-4)
|
|
1257
|
-
// - sweepDescendantsOnChildClose:收尾后代级联补杀(T2-② 后半)
|
|
1258
|
-
// - resolveRunOutcome:成功/失败四分支判定
|
|
1259
|
-
// 拆分只移动代码不改行为——runSpawn 导出签名与事件语义不变。
|
|
1260
|
-
|
|
1261
|
-
/** runSpawn 各阶段共享的可变状态(原闭包变量收拢,经参数在阶段函数间传递)。 */
|
|
1262
|
-
interface SpawnRunState {
|
|
1263
|
-
record: ExecutionRecord;
|
|
1264
|
-
opts: RunOptions;
|
|
1265
|
-
ctx: SessionRunnerContext;
|
|
1266
|
-
/** 当前子进程句柄(limiter abort 经此 kill)。 */
|
|
1267
|
-
proc: ChildProcess | undefined;
|
|
1268
|
-
/** watchdog timer(stdout handler 的 agent_end keep-alive 分支重挂,收尾统一 clearTimeout)。 */
|
|
1269
|
-
watchdog: NodeJS.Timeout | undefined;
|
|
1270
|
-
/** stdout 首行 header(json mode 才有;RPC mode 恒 undefined)——收尾 sessionFile 兜底查找用。 */
|
|
1271
|
-
sessionHeader: SpawnSessionHeader | undefined;
|
|
1272
|
-
/** get_state 握手结果(RPC mode)——收尾 sessionFile 兜底查找用。 */
|
|
1273
|
-
handshakeResult: GetStateResult | undefined;
|
|
1274
|
-
/**
|
|
1275
|
-
* [V2 决策 2] chatMode 首轮 resolveRun:agent_settled 时提前 resolve runSpawn 的
|
|
1276
|
-
* exitCode promise(waitForChildExit 装配指向)。非 chatMode 不触发(agent_end
|
|
1277
|
-
* handler 一次性 kill → close resolve)。chatMode 后续轮次到达时 resolve(code)
|
|
1278
|
-
* 是 no-op(Promise 只 resolve 一次)。
|
|
1279
|
-
*/
|
|
1280
|
-
resolveRun: ((code: number) => void) | undefined;
|
|
1281
|
-
/**
|
|
1282
|
-
* [T2-① / P-T2 降级 B] keep-alive 裸缺省的无进展检测 timer(仅 isBareDefaultKeepAlive
|
|
1283
|
-
* 的 keep-alive 分支挂载):子进程 stdout 活动刷新计时,连续静默
|
|
1284
|
-
* KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS 且 fire 时惰性复核无存活后代才处置。undefined =
|
|
1285
|
-
* 未挂载(显式配置走既有固定时长 state.watchdog / 显式 opt-out 不挂任何 timer)。
|
|
1286
|
-
*/
|
|
1287
|
-
keepAliveNoProgressTimer: NodeJS.Timeout | undefined;
|
|
1288
|
-
/**
|
|
1289
|
-
* [T2-② / P-T2b 主路径] keep-alive 上界处置层主后置 true:runSpawn 收尾(层主
|
|
1290
|
-
* close 确认死亡 + sessionFile 冻结为最终快照)时对活跃后代做级联补杀 sweep。
|
|
1291
|
-
*/
|
|
1292
|
-
sweepDescendantsOnClose: boolean;
|
|
1293
|
-
/**
|
|
1294
|
-
* [T2-③ / D9 两段式] chatMode 首轮 settled 等待守护(timer 句柄记账在
|
|
1295
|
-
* settled-watchdog.ts 的 armedEntries Map,按 recordId arm/disarm/交棒;双挂载原语,
|
|
1296
|
-
* 热路径挂载在 subagent-service deliverMessage)。中段无进展检测 + 收尾段固定上界
|
|
1297
|
-
* 两段合用本标记。
|
|
1298
|
-
*/
|
|
1299
|
-
/**
|
|
1300
|
-
* [T2-③] settled watchdog 已触发及触发段——runSpawn 收尾据此转 failed + 恢复指引
|
|
1301
|
-
* (非正常完成)。undefined = 未触发;mid-round = 中段无进展(30min 静默);
|
|
1302
|
-
* settled = 收尾段固定上界(agent_end 后 600s)。
|
|
1303
|
-
*/
|
|
1304
|
-
settledWatchdogFired: SettledWatchdogFireInfo | undefined;
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
/**
|
|
1308
|
-
* 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
|
|
1309
|
-
*
|
|
1310
|
-
* pendingTools 寄存器 / turnLimiter / accumulateMessageEnd 均闭包在工厂内部,
|
|
1311
|
-
* 对外只暴露 handleSdkEvent(stdout 解析出的 SdkEvent 的唯一喂入口)。
|
|
1312
|
-
*/
|
|
1313
|
-
|
|
1314
|
-
/**
|
|
1315
|
-
* [D3-① 杀链合一] pi 侧杀链参数(现状值,[race-F4]):SIGTERM 优雅窗口 30s——
|
|
1316
|
-
* 超窗未见 exit 视为 SIGTERM 被无视,升级 SIGKILL。秒数单提命名常量再组合 ms:
|
|
1317
|
-
* no-magic-numbers 豁免 const init 单字面量但不豁免乘法表达式中的裸字面量
|
|
1318
|
-
* (对齐上方 watchdog 系列的常量组合写法)。
|
|
1319
|
-
*/
|
|
1320
|
-
const PI_KILL_GRACE_SECS = 30;
|
|
1321
|
-
const PI_KILL_GRACE_MS = PI_KILL_GRACE_SECS * MS_PER_SECOND;
|
|
1322
|
-
|
|
1323
|
-
/**
|
|
1324
|
-
* [D3-① 杀链合一 + race-F4] 杀 pi 子进程:发 SIGTERM,30s 优雅窗口后仍存活则 SIGKILL。
|
|
1325
|
-
*
|
|
1326
|
-
* 唯一实现 = common/kill-chain.killChain(本函数只是 pi 参数装配:grace 30s、timer
|
|
1327
|
-
* unref(不阻止主进程退出,dispose 路径 killAllSpawnedChildren 兜底)、SIGKILL 升级
|
|
1328
|
-
* warn 留痕含 record id 与来源)。fire-and-forget:调用方不 await 结果(子进程 close
|
|
1329
|
-
* 事件驱动后续收尾),killChain 内部 exit 事件自动清升级 timer。
|
|
1330
|
-
*
|
|
1331
|
-
* 背景(race-F4):watchdog/limiter/idle timer/abort 触发只发一次 SIGTERM——子进程若
|
|
1332
|
-
* 无视 SIGTERM(卡死在不可中断的 native 调用 / SIGTERM handler 挂死),close 永不触发
|
|
1333
|
-
* → runSpawn 悬挂、background 槽位/worktree/alive marker 泄漏。
|
|
1334
|
-
*
|
|
1335
|
-
* @param source 升级来源标识(warn 日志定位用,如 "spawn watchdog" / "turn limiter")
|
|
1336
|
-
*/
|
|
1337
|
-
function killPiChild(child: ChildProcess, recordId: string, source: string): void {
|
|
1338
|
-
void killChain(child, {
|
|
1339
|
-
graceMs: PI_KILL_GRACE_MS,
|
|
1340
|
-
unrefTimers: true,
|
|
1341
|
-
escalationNote: `child ${recordId} (source: ${source})`,
|
|
1342
|
-
});
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
/**
|
|
1346
|
-
* [race-F4] SIGTERM 后升级 SIGKILL 的等待窗口:30s 未见 exit 视为 SIGTERM 被无视,强杀。
|
|
1347
|
-
* (与上方 PI_KILL_GRACE 同值——runSpawn 内外两杀链入口的现状窗口一致。)
|
|
1348
|
-
*/
|
|
1349
|
-
const SIGKILL_ESCALATION_SECONDS = 30;
|
|
1350
|
-
const SIGKILL_ESCALATION_MS = SIGKILL_ESCALATION_SECONDS * MS_PER_SECOND;
|
|
1351
|
-
|
|
1352
|
-
/**
|
|
1353
|
-
* [race-F4] runSpawn 作用域内 kill 收敛入口(持有 SpawnRunState 的调用方):
|
|
1354
|
-
* 发 SIGTERM 并武装 30s SIGKILL 升级。
|
|
1355
|
-
*
|
|
1356
|
-
* 背景:watchdog/limiter/idle timer/abort 触发只发一次 SIGTERM——子进程若无视
|
|
1357
|
-
* SIGTERM(卡死在不可中断的 native 调用 / SIGTERM handler 挂死),close 永不触发
|
|
1358
|
-
* → runSpawn 悬挂、background 槽位/worktree/alive marker 泄漏,旧实现永不回收。
|
|
1359
|
-
*
|
|
1360
|
-
* [D3-① 合并注] 实现已合一到公共杀链(killPiChild → common/kill-chain.killChain,
|
|
1361
|
-
* grace 30s 与原私有链现状逐字节保持)——升级 timer 的记账与清理由 killChain 内部
|
|
1362
|
-
* 承担(exit 事件 settle 自动 clear),原 state.escalationTimer 兜底句柄随旧私有
|
|
1363
|
-
* 链删除(runSpawn 收尾不再 clearTimeout)。本函数保留 state 形态签名:state 参数
|
|
1364
|
-
* 供日志语境(record id),调用点(settled watchdog / keep-alive no-progress /
|
|
1365
|
-
* idle timer / agent_end 处置链)语义不变。
|
|
1366
|
-
*
|
|
1367
|
-
* @param source 升级来源标识(warn 日志定位用,如 "spawn watchdog" / "turn limiter")
|
|
1368
|
-
*/
|
|
1369
|
-
function killChildWithEscalation(state: SpawnRunState, child: ChildProcess, source: string): void {
|
|
1370
|
-
killPiChild(child, state.record.id, source);
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
/**
|
|
1374
|
-
* [T2-④ / LC-2] 服务侧 kill 收敛入口:无 SpawnRunState 的调用方(subagent-service 的
|
|
1375
|
-
* closeChatIdle / closeAfterRoundSettled / cancelBackground / disposeAllRecords)经本函数
|
|
1376
|
-
* 发 SIGTERM + 武装 30s SIGKILL 升级——替代四处裸 `child.kill("SIGTERM")`。
|
|
1377
|
-
*
|
|
1378
|
-
* 背景(LC-2):SIGTERM 可能被无视(子进程卡死在不可中断 native 调用 / SIGTERM handler
|
|
1379
|
-
* 挂死),裸 SIGTERM 后进程不退 → record 已终态归档 → 幽灵进程,且 dispose 兜底
|
|
1380
|
-
* killAllSpawnedChildren 的升级检查也可能不再触达(Map 条目已随 close 移除前的窗口)。
|
|
1381
|
-
* 服务侧 kill 时机都在「record 即将/已经终态化」——此后再无其他回收通道,必须有升级。
|
|
1382
|
-
*
|
|
1383
|
-
* 与 runSpawn 内 killChildWithEscalation 的差异:调用方没有 SpawnRunState(runSpawn 已
|
|
1384
|
-
* 返回 / 从未在本进程跑),升级 timer 记账在模块级 Map(recordId → timer),子进程 exit
|
|
1385
|
-
* 自动清除(对齐 state.escalationTimer 的 exit-clear 语义);同 record 重复调用先清旧
|
|
1386
|
-
* 升级 timer 防叠加。child 不在 spawnedChildren(已 close 移除 / 从未注册)或已发过
|
|
1387
|
-
* kill 请求(killed=true,升级窗口已由先前路径武装)时 no-op——与旧 `child && !child.killed`
|
|
1388
|
-
* 守卫语义逐字对齐,仅补升级。
|
|
1389
|
-
*/
|
|
1390
|
-
const serviceEscalationTimers = new Map<string, NodeJS.Timeout>();
|
|
1391
|
-
|
|
1392
|
-
export function killRecordChildWithEscalation(recordId: string, source: string): void {
|
|
1393
|
-
const child = spawnedChildren.get(recordId);
|
|
1394
|
-
if (!child || child.killed) return;
|
|
1395
|
-
child.kill("SIGTERM");
|
|
1396
|
-
assertSafeTimerDelay(SIGKILL_ESCALATION_MS, `SIGKILL escalation (${source})`);
|
|
1397
|
-
if (serviceEscalationTimers.has(recordId)) {
|
|
1398
|
-
clearTimeout(serviceEscalationTimers.get(recordId));
|
|
1399
|
-
}
|
|
1400
|
-
const escalation = setTimeout(
|
|
1401
|
-
() => {
|
|
1402
|
-
if (child.exitCode === null && child.signalCode === null) {
|
|
1403
|
-
logger.warn(
|
|
1404
|
-
`[session-runner] child ${recordId} still alive ${SIGKILL_ESCALATION_MS / MS_PER_SECOND}s after SIGTERM, escalating to SIGKILL (source: ${source})`,
|
|
1405
|
-
);
|
|
1406
|
-
child.kill("SIGKILL");
|
|
1407
|
-
}
|
|
1408
|
-
},
|
|
1409
|
-
SIGKILL_ESCALATION_MS,
|
|
1410
|
-
);
|
|
1411
|
-
escalation.unref();
|
|
1412
|
-
child.once("exit", () => {
|
|
1413
|
-
clearTimeout(escalation);
|
|
1414
|
-
if (serviceEscalationTimers.get(recordId) === escalation) {
|
|
1415
|
-
serviceEscalationTimers.delete(recordId);
|
|
1416
|
-
}
|
|
1417
|
-
});
|
|
1418
|
-
serviceEscalationTimers.set(recordId, escalation);
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
/** [T2-④] 测试隔离:清空服务侧升级 timer 记账(命名对齐 _resetSettledWatchdogsForTest)。 */
|
|
1422
|
-
export function _resetServiceKillStateForTest(): void {
|
|
1423
|
-
for (const timer of serviceEscalationTimers.values()) {
|
|
1424
|
-
clearTimeout(timer);
|
|
1425
|
-
}
|
|
1426
|
-
serviceEscalationTimers.clear();
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
/**
|
|
1430
|
-
* [A1-2] 层主是否有「存活且活跃」的直接后代(no-progress fire 时的惰性复核)。
|
|
1431
|
-
*
|
|
1432
|
-
* 与 descendant sweep(sweepDescendantsOfSession)同源判据:层主 sessionFile 的
|
|
1433
|
-
* pending register−unregister 差集(listActivePendingFromSessionFile)给出活跃后代
|
|
1434
|
-
* 清单,逐个经 sessionId 反查后代 sessionFile → readAliveMarker 取 pid → isProcessAlive
|
|
1435
|
-
* 探活;任一后代 pid 存活即视为有进展。刻意不做 sweep kill 前双校验的另一半
|
|
1436
|
-
*(cmdline pi 形态校验)——本函数只决定「不杀层主、再等一个复核周期」,pid 复用误报
|
|
1437
|
-
* 只延后节奏 30min(方向安全);pid 探不出的后代(register 缺 sessionId / 文件未
|
|
1438
|
-
* flush / 无 marker)不计入存活——与 sweep 同盲区,归 T5 marker 兜底,不以此永久豁免
|
|
1439
|
-
* 层主的无进展上界。
|
|
1440
|
-
*
|
|
1441
|
-
* 复核失败(层主 sessionFile 读不出)→ false(按无后代处置)+ warn 留痕。方向与
|
|
1442
|
-
* readActivePendingFromSessionFile 调用方的既有保守约定(error = 不杀)相反是刻意的:
|
|
1443
|
-
* 此处的「不确定」发生在已坐实的 30min 无进展之后,处置走 killChildWithEscalation
|
|
1444
|
-
* 升级链,外部 signal / dispose 兜底通道仍在,warn 保证行为可见。
|
|
1445
|
-
*/
|
|
1446
|
-
function hasLiveActiveDescendant(sessionFile: string | undefined, sessionDir: string): boolean {
|
|
1447
|
-
const list = listActivePendingFromSessionFile(sessionFile);
|
|
1448
|
-
if (list.error) {
|
|
1449
|
-
logger.warn(
|
|
1450
|
-
`[session-runner] keep-alive no-progress re-check failed (treating as no live descendants): ${list.error}`,
|
|
1451
|
-
);
|
|
1452
|
-
return false;
|
|
1453
|
-
}
|
|
1454
|
-
for (const item of list.items) {
|
|
1455
|
-
if (!item.sessionId) continue;
|
|
1456
|
-
const childFile = findSessionFileByHeaderId(sessionDir, item.sessionId);
|
|
1457
|
-
if (!childFile) continue;
|
|
1458
|
-
const marker = readAliveMarker(childFile);
|
|
1459
|
-
if (marker && isProcessAlive(marker.pid)) return true;
|
|
1460
|
-
}
|
|
1461
|
-
return false;
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/**
|
|
1465
|
-
* [T2-① / P-T2 降级 B] 挂载(或刷新)keep-alive 裸缺省无进展检测 timer。
|
|
1466
|
-
*
|
|
1467
|
-
* 仅在 keep-alive 分支的裸缺省形态(isBareDefaultKeepAlive:无 maxTurns 无 env)挂载。
|
|
1468
|
-
* 刷新机制:stdout pump 的每次 data 事件经 refreshKeepAliveNoProgressTimer 重挂(先清旧)
|
|
1469
|
-
* ——「连续静默」由「每次活动重置计时」实现;重复 arm 不叠加(旧 timer 先 clear)。
|
|
1470
|
-
*
|
|
1471
|
-
* [A1-2] 到期不立即处置:层主 stdout 静默 ≠ 无进展(fire 回调内先复核存活后代,
|
|
1472
|
-
* 见 hasLiveActiveDescendant)。无存活后代才真静默处置:SIGTERM→killChildWithEscalation
|
|
1473
|
-
* + 置 sweepDescendantsOnClose(T2-② 后代级联补杀的两步时序前半)。
|
|
1474
|
-
*
|
|
1475
|
-
* [A1-2 补修] 重挂分支随行心跳(touchAliveMarkerForHeartbeat):本 timer 合法化的
|
|
1476
|
-
* 目标形态「层主静默 + 后代长跑数小时」期间层主无 agent_end,原心跳写点(agent_end
|
|
1477
|
-
* 处置)不再触达——marker.startedAt 停在最后一次 agent_end,超 ALIVE_SOFT_TIMEOUT_MS
|
|
1478
|
-
* (1h)被判陈旧 → findForeignLiveInstance 放行透明重生(活层主被双写)+ record-store
|
|
1479
|
-
* 孤儿恢复误终态活 record(正是 T5②/PS-7a 心跳要防的失效)。复核发现存活后代本身就是
|
|
1480
|
-
* 「层主仍被需要」的活跃证明,每次重挂刷新软超时基准。sessionFile 为空由心跳函数
|
|
1481
|
-
* 自身守卫跳过(与既有写点同语义)。
|
|
1482
|
-
*/
|
|
1483
|
-
function armKeepAliveNoProgressTimer(
|
|
1484
|
-
state: SpawnRunState,
|
|
1485
|
-
child: ChildProcess,
|
|
1486
|
-
sessionDir: string,
|
|
1487
|
-
): void {
|
|
1488
|
-
if (state.keepAliveNoProgressTimer) clearTimeout(state.keepAliveNoProgressTimer);
|
|
1489
|
-
assertSafeTimerDelay(KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS, "keep-alive no-progress watchdog");
|
|
1490
|
-
state.keepAliveNoProgressTimer = setTimeout(() => {
|
|
1491
|
-
// [A1-2] fire 惰性复核:有存活活跃后代 = 有进展 → 重挂(固定 30min 再复核,
|
|
1492
|
-
// 直到后代死光才落处置分支)。
|
|
1493
|
-
if (hasLiveActiveDescendant(state.record.sessionFile, sessionDir)) {
|
|
1494
|
-
// [A1-2 补修] 重挂 = 层主仍被需要:随行心跳刷新 marker 软超时基准(详见函数 doc)。
|
|
1495
|
-
touchAliveMarkerForHeartbeat(state.record.sessionFile, child.pid, state.record.id);
|
|
1496
|
-
logger.debug(
|
|
1497
|
-
`[session-runner] keep-alive no-progress re-check: live descendant(s) present for ${state.record.id}, re-arm (cadence ${
|
|
1498
|
-
KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE
|
|
1499
|
-
} min)`,
|
|
1500
|
-
);
|
|
1501
|
-
armKeepAliveNoProgressTimer(state, child, sessionDir);
|
|
1502
|
-
return;
|
|
1503
|
-
}
|
|
1504
|
-
logger.warn(
|
|
1505
|
-
`[session-runner] keep-alive no-progress watchdog fired for ${state.record.id}: no child output for ${
|
|
1506
|
-
KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE
|
|
1507
|
-
} min and no live descendant (bare-default keep-alive without maxTurns/env), terminating`,
|
|
1508
|
-
);
|
|
1509
|
-
// [T2-②] 同 keep-alive watchdog 处置:层主 close 确认死亡后级联补杀活跃后代。
|
|
1510
|
-
state.sweepDescendantsOnClose = true;
|
|
1511
|
-
killChildWithEscalation(state, child, "keep-alive no-progress watchdog");
|
|
1512
|
-
}, KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS);
|
|
1513
|
-
state.keepAliveNoProgressTimer.unref();
|
|
1514
|
-
}
|
|
1515
|
-
|
|
1516
|
-
/**
|
|
1517
|
-
* [T2-①] 子进程有 stdout 活动 → 刷新静默计时。
|
|
1518
|
-
*
|
|
1519
|
-
* 未挂载(显式 maxTurns/env 的固定时长等待、显式 opt-out、非 keep-alive 阶段)时
|
|
1520
|
-
* no-op——刷新面严格限定在裸缺省上界,显式配置的行为不变(opt-out 语义保留)。
|
|
1521
|
-
*/
|
|
1522
|
-
function refreshKeepAliveNoProgressTimer(
|
|
1523
|
-
state: SpawnRunState,
|
|
1524
|
-
child: ChildProcess,
|
|
1525
|
-
sessionDir: string,
|
|
1526
|
-
): void {
|
|
1527
|
-
if (!state.keepAliveNoProgressTimer) return;
|
|
1528
|
-
armKeepAliveNoProgressTimer(state, child, sessionDir);
|
|
1529
|
-
}
|
|
1530
|
-
|
|
1531
|
-
/** [T2-①] 清除无进展检测 timer(keep-alive 重评估 / 收尾清理;未挂载时 no-op)。 */
|
|
1532
|
-
function disarmKeepAliveNoProgressTimer(state: SpawnRunState): void {
|
|
1533
|
-
if (state.keepAliveNoProgressTimer) {
|
|
1534
|
-
clearTimeout(state.keepAliveNoProgressTimer);
|
|
1535
|
-
state.keepAliveNoProgressTimer = undefined;
|
|
1536
|
-
}
|
|
1537
|
-
}
|
|
1538
|
-
|
|
1539
|
-
/**
|
|
1540
|
-
* 事件累积器工厂(原 runSpawn 内联的 a/b 两段 + handleSdkEvent/agentEvent 闭包)。
|
|
1541
|
-
*
|
|
1542
|
-
* pendingTools 寄存器 / turnLimiter / accumulateMessageEnd 均闭包在工厂内部,
|
|
1543
|
-
* 对外只暴露 handleSdkEvent(stdout 解析出的 SdkEvent 的唯一喂入口)。
|
|
1544
|
-
*/
|
|
1545
|
-
function createSpawnEventHandlers(state: SpawnRunState): (raw: SdkEvent) => void {
|
|
1546
|
-
const { record, opts, ctx } = state;
|
|
1547
|
-
|
|
1548
|
-
// a. transient 寄存器(同 run():tool_end 缺 args 时回填)
|
|
1549
|
-
const pendingTools = new Map<string, { toolName: string; args?: unknown }>();
|
|
1550
|
-
|
|
1551
|
-
// b. turnLimiter(spawn 版:abort = proc.kill;steer 是 no-op)
|
|
1552
|
-
// [M1] rpc mode 是长驻进程(agent_end 后不自动退出),maxTurns soft limit 依赖
|
|
1553
|
-
// graceTurns 后的 abort(proc.kill SIGTERM)兑现。agent 自然结束时由 stdout pump
|
|
1554
|
-
// 的 agent_end 拦截 kill(见 attachStdoutPump)。steer 通道当前未接通(见下方 steer no-op 注释)。
|
|
1555
|
-
const limiter = createTurnLimiter({
|
|
1556
|
-
maxTurns: opts.maxTurns ?? 0,
|
|
1557
|
-
graceTurns: opts.graceTurns ?? DEFAULT_GRACE_TURNS,
|
|
1558
|
-
steer: () => {
|
|
1559
|
-
// no-op:当前 runSpawn 未接通 rpc stdin steer 通道(rpc mode 支持 steer/followUp,
|
|
1560
|
-
// 但未实现写入逻辑)。补偿已在启动时注入 WRAP_UP_HINT 让 agent 主动收尾。
|
|
1561
|
-
},
|
|
1562
|
-
abort: () => {
|
|
1563
|
-
// [race-F4] 升级路径:SIGTERM 后 30s 未 exit 则 SIGKILL(挂住子进程永不回收防线)
|
|
1564
|
-
if (state.proc) killPiChild(state.proc, record.id, "turn limiter abort");
|
|
1565
|
-
},
|
|
1566
|
-
});
|
|
1567
|
-
|
|
1568
|
-
// agentEvent 统一出口:updateFromEvent + onTurnEnd(limiter)+ opts.onEvent
|
|
1569
|
-
const agentEvent = (event: AgentEvent): void => {
|
|
1570
|
-
updateFromEvent(record, event);
|
|
1571
|
-
if (event.type === "turn_end") limiter.onTurnEnd(record.turnCount);
|
|
1572
|
-
// text_delta 分流到 stream 通道(在 onEvent 之前)。
|
|
1573
|
-
// 双通道互斥设计:background 路径 stream 有值、onEvent=undefined;
|
|
1574
|
-
// workflow 路径 onEvent 有值、stream=undefined。详见 W3 注释。
|
|
1575
|
-
if (event.type === "text_delta") opts.stream?.onDelta(event.delta);
|
|
1576
|
-
opts.onEvent?.(event);
|
|
1577
|
-
};
|
|
1578
|
-
|
|
1579
|
-
// ── SDK 事件累积器(闭包模式与 run() 完全相同)──
|
|
1580
|
-
const accumulateMessageEnd = (raw: SdkEvent): void => {
|
|
1581
|
-
const msg = raw.message;
|
|
1582
|
-
if (msg?.usage) {
|
|
1583
|
-
const { cost: costObj, ...usageBase } = msg.usage;
|
|
1584
|
-
const usage = { ...usageBase, cost: costObj?.total };
|
|
1585
|
-
agentEvent({ type: "message_end", usage });
|
|
1586
|
-
}
|
|
1587
|
-
const stopReason = msg?.stopReason;
|
|
1588
|
-
if (stopReason === "error" || stopReason === "aborted") {
|
|
1589
|
-
const errMsg = msg?.errorMessage ?? raw.reason ?? stopReason;
|
|
1590
|
-
agentEvent({ type: "error", message: errMsg });
|
|
1591
|
-
}
|
|
1592
|
-
};
|
|
1593
|
-
|
|
1594
|
-
/**
|
|
1595
|
-
* [圈复杂度门禁提取] agent_settled × chatMode 分支整体自 handleSdkEvent 迁入
|
|
1596
|
-
* (只移动代码不改行为)。含 idle timer 挂载降级链 + 本轮完成通知 + 首轮提前 resolve。
|
|
1597
|
-
*/
|
|
1598
|
-
const handleAgentSettledForChatMode = (): void => {
|
|
1599
|
-
// [T2-③ / D9 两段式] settled 到达:本轮等待结束,两段守护(中段无进展 +
|
|
1600
|
-
// 收尾段上界)一并即清(resolveRun 在本分支同点调用,天然同清)。清除必须
|
|
1601
|
-
// 先于后续逻辑——idle timer / 回调 / resolve 抛错时 watchdog 已确保撤下,
|
|
1602
|
-
// 不会误杀下一个正常轮次。
|
|
1603
|
-
disarmSettledWatchdog(record.id);
|
|
1604
|
-
// [F-R2] 本闭包经 stdout data 回调同步调用(handleSdkEvent ← attachStdoutPump):
|
|
1605
|
-
// armIdleTimer → assertSafeTimerDelay fail-fast 的 throw 若逃出回调 = uncaughtException
|
|
1606
|
-
// 崩宿主。包 try/catch 降级,错误经 bestEffort("error") 可见但不升级为进程崩溃;
|
|
1607
|
-
// 后续 limiter.reset / onRoundSettled / resolveRun 照常执行(本轮完成通知不因 GC
|
|
1608
|
-
// timer 故障丢失)。
|
|
1609
|
-
// [T4② / PS-4] 降级语义修正:旧降级「不挂 idle timer」保住了「不崩进程」,却丢掉
|
|
1610
|
-
// timer 承载的两个下游不变量——isIdle 放行门(hasIdleTimer=false → 轮次完成通知被
|
|
1611
|
-
// lifecycle-predicates 吞)与进程回收(进程活着却无 timer 永久泄漏)。现降级改为
|
|
1612
|
-
// 「挂 DEFAULT_IDLE_TIMEOUT_MS + warn 留痕」:非配置替换(配置错误已在 spawn 入口
|
|
1613
|
-
// fail-fast,见 subagent-service),此处是防御性兜底,兜底必须可见且保住不变量。
|
|
1614
|
-
const armIdleTimerOnTimeout = (): void => {
|
|
1615
|
-
// onTimeout 复用现有 kill 路径:child.kill("SIGTERM") 触发 close → close handler
|
|
1616
|
-
// 统一 cleanup(spawnedChildren.delete / get_stateListeners.clear / resolve)。
|
|
1617
|
-
// 与 agent_end handler 现有 SIGTERM 分支一致,不新造 cleanup。
|
|
1618
|
-
// [race-F4] 升级:idle timer SIGTERM 后挂住 → 30s 后 SIGKILL。
|
|
1619
|
-
const child = getChildByRecord(record.id);
|
|
1620
|
-
if (child && !child.killed) killChildWithEscalation(state, child, "idle timer");
|
|
1621
|
-
};
|
|
1622
|
-
try {
|
|
1623
|
-
armIdleTimer(record.id, armIdleTimerOnTimeout, record.idleTimeoutMs);
|
|
1624
|
-
} catch (err) {
|
|
1625
|
-
bestEffort(err, "armIdleTimer (agent_settled chatMode)", "error");
|
|
1626
|
-
try {
|
|
1627
|
-
armIdleTimer(record.id, armIdleTimerOnTimeout, DEFAULT_IDLE_TIMEOUT_MS);
|
|
1628
|
-
logger.warn(
|
|
1629
|
-
`[session-runner] idleTimeoutMs invalid for ${record.id}, fell back to DEFAULT_IDLE_TIMEOUT_MS (${DEFAULT_IDLE_TIMEOUT_MS}ms) — idle GC and round notification gate stay active`,
|
|
1630
|
-
);
|
|
1631
|
-
} catch (fallbackErr) {
|
|
1632
|
-
// 双重失败(理论上不可达:DEFAULT 恒在安全域内)——退回旧「不挂」语义但留痕。
|
|
1633
|
-
bestEffort(fallbackErr, "armIdleTimer fallback (agent_settled chatMode)", "error");
|
|
1634
|
-
}
|
|
1635
|
-
}
|
|
1636
|
-
// [SP-9] chatMode 每轮 reset turn-limiter:新一轮开始(续聊)时,
|
|
1637
|
-
// maxTurns/graceTurns 不跨轮累计(续聊本质是无限轮,累计上限违背 G1)。
|
|
1638
|
-
// reset steered/aborted 标志 + turnCount 归零,下一轮独立计数。
|
|
1639
|
-
limiter.reset();
|
|
1640
|
-
record.turnCount = 0;
|
|
1641
|
-
ctx.onRoundSettled?.(record);
|
|
1642
|
-
// [V2 决策 2] chatMode 首轮:agent_settled = 本轮真空闲,提前 resolve runSpawn
|
|
1643
|
-
//(exit code 0,进程仍保活 idle timer armed)。runAndFinalize 拿到 result 后走
|
|
1644
|
-
// chatMode 首轮分支(不进 finalize 分流),onRoundSettled 已 notify 主 agent。
|
|
1645
|
-
state.resolveRun?.(0);
|
|
1646
|
-
};
|
|
1647
|
-
|
|
1648
|
-
/**
|
|
1649
|
-
* [圈复杂度门禁提取] SdkEvent.type switch 分派整体自 handleSdkEvent 迁入
|
|
1650
|
-
* (只移动代码不改行为)。tool_end 的 args 回填另有模块级 resolveToolEndArgs。
|
|
1651
|
-
*/
|
|
1652
|
-
const dispatchSdkEventByType = (raw: SdkEvent): void => {
|
|
1653
|
-
switch (raw.type) {
|
|
1654
|
-
case "tool_execution_start": {
|
|
1655
|
-
const toolName = raw.toolName ?? "";
|
|
1656
|
-
if (raw.toolCallId) {
|
|
1657
|
-
pendingTools.set(raw.toolCallId, { toolName, args: raw.args });
|
|
1658
|
-
}
|
|
1659
|
-
agentEvent({ type: "tool_start", toolName, args: raw.args });
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
case "tool_execution_end": {
|
|
1663
|
-
const toolName = raw.toolName ?? "";
|
|
1664
|
-
agentEvent({
|
|
1665
|
-
type: "tool_end",
|
|
1666
|
-
toolName,
|
|
1667
|
-
args: resolveToolEndArgs(raw, pendingTools),
|
|
1668
|
-
result: raw.result,
|
|
1669
|
-
isError: raw.isError,
|
|
1670
|
-
});
|
|
1671
|
-
return;
|
|
1672
|
-
}
|
|
1673
|
-
case "message_update": {
|
|
1674
|
-
const mapped = mapAssistantMessageDelta(raw.assistantMessageEvent ?? {});
|
|
1675
|
-
if (mapped) agentEvent(mapped);
|
|
1676
|
-
return;
|
|
1677
|
-
}
|
|
1678
|
-
case "turn_end": {
|
|
1679
|
-
agentEvent({ type: "turn_end" });
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
case "message_end": {
|
|
1683
|
-
accumulateMessageEnd(raw);
|
|
1684
|
-
return;
|
|
1685
|
-
}
|
|
1686
|
-
case "compaction_start": {
|
|
1687
|
-
agentEvent({ type: "compaction" });
|
|
1688
|
-
return;
|
|
1689
|
-
}
|
|
1690
|
-
// [review 修复] 已删除 turn_start / message_start 两 case 的 pendingMessages
|
|
1691
|
-
// 消费确认 shift(MF-5):三段消费链(deliverToRunning push / message_start(user)
|
|
1692
|
-
// shift / redeliverPending 补投)随 deliverToRunning 一并移除——SP-5 upgrade 后
|
|
1693
|
-
// 无生产调用方,整条链路不可达。两 case 均为 no-op,落 default 即可。
|
|
1694
|
-
default:
|
|
1695
|
-
return;
|
|
1696
|
-
}
|
|
1697
|
-
};
|
|
1698
|
-
|
|
1699
|
-
const handleSdkEvent = (raw: SdkEvent): void => {
|
|
1700
|
-
// [T2-③ / D9 两段式] 有效协议事件行刷新中段无进展计时(本函数是 stdout pump
|
|
1701
|
-
// 解析出的合法 SdkEvent 的唯一喂入口——LC-9 invalid 行不进入,天然不刷新)。
|
|
1702
|
-
// 未挂载(非 chatMode / env 关闭)或已交棒(收尾段不刷新)时幂等 no-op。
|
|
1703
|
-
refreshMidRoundNoProgress(record.id);
|
|
1704
|
-
// [V2 模块 3] agent_settled:真空闲边界(agent_end 之后、post-run 完成后才 emit)。
|
|
1705
|
-
// chatMode:arm idle timer(超时 SIGTERM 回收)+ 通知本轮完成(onRoundSettled)。
|
|
1706
|
-
// 非 chatMode:忽略(agent_end handler 的一次性 kill 已处理,进程不会活到 agent_settled)。
|
|
1707
|
-
if (isAgentSettledEvt(raw)) {
|
|
1708
|
-
if (record.chatMode) handleAgentSettledForChatMode();
|
|
1709
|
-
return;
|
|
1710
|
-
}
|
|
1711
|
-
dispatchSdkEventByType(raw);
|
|
1712
|
-
};
|
|
1713
|
-
|
|
1714
|
-
return handleSdkEvent;
|
|
1715
|
-
}
|
|
1716
|
-
|
|
1717
|
-
/**
|
|
1718
|
-
* g. appendSystemPrompt 落盘:env block + agent body + 调用方片段拼成
|
|
1719
|
-
* --append-system-prompt 文件。返回临时文件句柄(无片段时 undefined,
|
|
1720
|
-
* runSpawn finally 统一清理)。
|
|
1721
|
-
*/
|
|
1722
|
-
async function writeAppendSystemPromptFile(
|
|
1723
|
-
record: ExecutionRecord,
|
|
1724
|
-
opts: RunOptions,
|
|
1725
|
-
ctx: SessionRunnerContext,
|
|
1726
|
-
): Promise<{ dir: string; filePath: string } | undefined> {
|
|
1727
|
-
// [M1 恢复] 环境块(cwd / fork depth / git branch)拼在最前面,与旧 in-process
|
|
1728
|
-
// buildAppendSystemPrompt 顺序一致——parts[0] 是环境块,其后 agent systemPrompt、再后调用方片段。
|
|
1729
|
-
const ownForkDepth = opts.fork ? (opts.parentForkDepth ?? 0) + 1 : undefined;
|
|
1730
|
-
// [M9] buildEnvBlock 取 max(forkDepth, nestingDepth):record.depth === nestingDepth(都从
|
|
1731
|
-
// execCtxAls 派生,见 createRecordForMode L425-427 与 execute L257-258),传它让 env block
|
|
1732
|
-
// 展示更严的约束(混合嵌套链下通用护栏可能先于 fork 护栏拒绝)。
|
|
1733
|
-
const appendParts: string[] = [await buildEnvBlock(ctx.cwd, ownForkDepth, record.depth)];
|
|
1734
|
-
if (opts.agentConfig?.systemPrompt) appendParts.push(opts.agentConfig.systemPrompt);
|
|
1735
|
-
if (opts.appendSystemPrompt) appendParts.push(...opts.appendSystemPrompt);
|
|
1736
|
-
// [M1 补偿] rpc mode 的 steer 通道当前未接通,改为启动时预置 wrap-up 提示——
|
|
1737
|
-
// agent 感知接近上限时主动收尾。
|
|
1738
|
-
if (opts.maxTurns && opts.maxTurns > 0) appendParts.push(WRAP_UP_HINT);
|
|
1739
|
-
// W4: ask_user RPC 使用指引——当子进程配置了 ask_user tool 时,告知 LLM
|
|
1740
|
-
// ask_user 的问题会通过 RPC 转发到主 agent UI,用户在主 agent 界面回答。
|
|
1741
|
-
if (opts.agentConfig?.tools?.includes("ask_user") && willRespondToAskUser(ctx.mode)) {
|
|
1742
|
-
appendParts.push(ASK_USER_RPC_PROMPT);
|
|
1743
|
-
}
|
|
1744
|
-
// worktree 认知纠正:告知子 agent cwd 是 git worktree(非临时沙箱),含完整项目代码,
|
|
1745
|
-
// 直接在此工作。防 wave-agent 类误判 cwd 为空隔离目录后 cd 主 worktree 放弃隔离。
|
|
1746
|
-
if (opts.worktree) {
|
|
1747
|
-
appendParts.push(WORKTREE_GUIDANCE_PROMPT);
|
|
1748
|
-
}
|
|
1749
|
-
if (appendParts.length > 0) {
|
|
1750
|
-
return writePromptToTempFile(record.agent, appendParts.join("\n\n"));
|
|
1751
|
-
}
|
|
1752
|
-
return undefined;
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
/**
|
|
1756
|
-
* h. 子进程环境变量组装:继承 process.env + fork depth + 跨进程身份贯穿 4 元组 +
|
|
1757
|
-
* identity 专属字段 + worktree 隔离标志 + schemaEnv bridge。
|
|
1758
|
-
*/
|
|
1759
|
-
function buildChildEnv(
|
|
1760
|
-
record: ExecutionRecord,
|
|
1761
|
-
opts: RunOptions,
|
|
1762
|
-
ctx: SessionRunnerContext,
|
|
1763
|
-
): Record<string, string | undefined> {
|
|
1764
|
-
const childEnv: Record<string, string | undefined> = { ...process.env };
|
|
1765
|
-
if (opts.fork && opts.parentForkDepth !== undefined) {
|
|
1766
|
-
childEnv.PI_SUBAGENT_FORK_DEPTH = String(opts.parentForkDepth + 1);
|
|
1767
|
-
}
|
|
1768
|
-
// [递归可见性] 跨进程身份贯穿(设计 docs/design/recursive-subagent-visibility.md)。
|
|
1769
|
-
// 无条件注入每个 subagent(决策 2:身份贯穿是基础需求,不依赖 fork)。env 描述「子进程自己的身份」:
|
|
1770
|
-
// - ROOT_SESSION_ID:所属根 session(贯穿真 ROOT,子进程 sessionRootId 读它)
|
|
1771
|
-
// - SELF_RECORD_ID:子进程自己的 record id(子进程 execCtxAls 基线 = 孙的直接父)
|
|
1772
|
-
// - DEPTH:子进程的嵌套深度(子进程 execCtxAls 基线 depth)
|
|
1773
|
-
// - ROOT_CWD:真 ROOT 的 cwd([MF-3] 落盘目录编码键,worktree 下与自身 spawn cwd 不同)
|
|
1774
|
-
// 子进程 initSession 读这 4 个 env 建立基线 → createRecordForMode 读 execCtxAls 自动正确。
|
|
1775
|
-
childEnv.PI_SUBAGENT_ROOT_SESSION_ID = ctx.sessionRootId;
|
|
1776
|
-
childEnv.PI_SUBAGENT_SELF_RECORD_ID = record.id;
|
|
1777
|
-
childEnv.PI_SUBAGENT_DEPTH = String(record.depth);
|
|
1778
|
-
// [MF-3] 第 4 个贯穿 env:真 ROOT 的 cwd。worktree 模式下子进程 spawn cwd = checkout 路径,
|
|
1779
|
-
// 子进程的 store/runSpawn 落盘目录须统一编码在 enc(ROOT cwd) 段(与身份贯穿同构),
|
|
1780
|
-
// 否则 ROOT 磁盘重建扫不到深层 record(见 subagent-service ENV_ROOT_CWD 注释)。
|
|
1781
|
-
childEnv.PI_SUBAGENT_ROOT_CWD = ctx.rootCwd;
|
|
1782
|
-
// [M4 identity 子进程写] 子进程 session_start hook 读这些 env → pi.appendEntry 写
|
|
1783
|
-
// subagent-identity custom entry(V2 决策 5)。旧实现父进程 fs.appendFileSync 补写的
|
|
1784
|
-
// custom entry 缺 id/parentId → 污染 pi _buildIndex leafId → message tree 断成两棵
|
|
1785
|
-
// → 多轮对话丢上下文。改由子进程(session 文件所有者)用 appendEntry 写,pi 自动生成 id/parentId。
|
|
1786
|
-
// id/rootSessionId/depth/forkDepth 复用上方身份贯穿 env(SELF_RECORD_ID/ROOT_SESSION_ID/DEPTH/FORK_DEPTH),
|
|
1787
|
-
// 此处补 identity 专属字段:agent/mode/task/slug/startedAt/parentRecordId/chatMode。
|
|
1788
|
-
childEnv.PI_SUBAGENT_AGENT = record.agent;
|
|
1789
|
-
childEnv.PI_SUBAGENT_MODE = record.mode;
|
|
1790
|
-
childEnv.PI_SUBAGENT_TASK = record.task;
|
|
1791
|
-
childEnv.PI_SUBAGENT_SLUG = record.slug;
|
|
1792
|
-
childEnv.PI_SUBAGENT_STARTED_AT = String(record.startedAt);
|
|
1793
|
-
childEnv.PI_SUBAGENT_PARENT_RECORD_ID = record.parentRecordId;
|
|
1794
|
-
childEnv.PI_SUBAGENT_CHAT_MODE =
|
|
1795
|
-
record.chatMode !== undefined ? String(record.chatMode) : undefined;
|
|
1796
|
-
// [review round2] worktree 隔离标志贯穿:resume 轮 opts.worktree 来自 record.worktreeHandle
|
|
1797
|
-
//(同进程内保留),子进程 identity entry 据此记 worktree:true——跨重启重建时据此拒绝续聊
|
|
1798
|
-
//(WorktreeHandle 不可序列化,reattach 不可行,静默回落主 repo 会破坏隔离)。
|
|
1799
|
-
childEnv.PI_SUBAGENT_WORKTREE = opts.worktree !== undefined ? "true" : undefined;
|
|
1800
|
-
// [E 方案 §5.2-2] relay 帧归属 env:tee 帧路由键(→ 虚拟分区 subagent:<sid>:<rid>)。
|
|
1801
|
-
// 仅 relay 激活时写入实际值——未激活环境下子 pi 进程携带 record 值 env 是误导噪声
|
|
1802
|
-
//(归属 env 无消费者);{...process.env} 继承值照旧保持。同源性对齐上方 PI_SUBAGENT_*
|
|
1803
|
-
// 四元组:SESSION_ID = ctx.sessionRootId(嵌套 spawn 时孙进程仍归属真 ROOT 会话),
|
|
1804
|
-
// RECORD_ID = record.id。
|
|
1805
|
-
if (isRelayActive(process.env)) {
|
|
1806
|
-
childEnv[RELAY_ENV_SESSION_ID] = ctx.sessionRootId;
|
|
1807
|
-
childEnv[RELAY_ENV_RECORD_ID] = record.id;
|
|
1808
|
-
}
|
|
1809
|
-
// D-A6 bridge: schema 激活 structured-output 扩展注册 tool(workflow 编排层需要)
|
|
1810
|
-
applySchemaEnvToChildEnv(childEnv, opts.schemaEnv);
|
|
1811
|
-
return childEnv;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
/**
|
|
1815
|
-
* i. 组装 spawn args 并解析 pi 可执行入口。
|
|
1816
|
-
*/
|
|
1817
|
-
function buildSpawnInvocation(
|
|
1818
|
-
opts: RunOptions,
|
|
1819
|
-
ctx: SessionRunnerContext,
|
|
1820
|
-
resume: SpawnResumeOpts | undefined,
|
|
1821
|
-
tempPromptFile: { dir: string; filePath: string } | undefined,
|
|
1822
|
-
sessionDir: string,
|
|
1823
|
-
forkSource: string | undefined,
|
|
1824
|
-
): ReturnType<typeof getPiInvocation> {
|
|
1825
|
-
// [M3 恢复] skillPaths: 主 session 的 skillDirs + 调用方传入的 skillPath。
|
|
1826
|
-
// ADR-031 后 skillDirs 固定为空,仅 opts.skillPath 生效(agent({skill}) 解析)。
|
|
1827
|
-
const skillPaths = [...ctx.skillDirs, opts.skillPath].filter(
|
|
1828
|
-
(p): p is string => typeof p === "string" && p.length > 0,
|
|
1829
|
-
);
|
|
1830
|
-
// [U1 D2] modelRef 来源:
|
|
1831
|
-
// - 非 resume:opts.resolved.model(resolveModel 裁决放行的 registry 全等条目)。
|
|
1832
|
-
// - resume:resume.model 是 SpawnResumeOpts 回显(record.model,"provider/id" 系统自产
|
|
1833
|
-
// 已裁决形态,P-10 防漂移),按第一个 / 拆分(与 subagent-service record 回读同构),
|
|
1834
|
-
// 不再经 assertCanonicalModelRef(registry 快照可能已刷新,拒绝回显会破坏续聊)。
|
|
1835
|
-
const modelRef: SpawnModelRef = resume?.model
|
|
1836
|
-
? splitRecordModelRef(resume.model)
|
|
1837
|
-
: { provider: opts.resolved.model.provider, id: opts.resolved.model.id };
|
|
1838
|
-
// [M1 resume] resume 时 model/thinkingLevel 优先用 resume 值(防多轮对话模型漂移,P-10),
|
|
1839
|
-
// 否则回退 opts.resolved。thinkingLevel 经白名单断言收窄(非法值同步拒,不静默降级)。
|
|
1840
|
-
const effectiveThinkingLevel = assertThinkingLevel(
|
|
1841
|
-
resume?.thinkingLevel ?? opts.resolved.thinkingLevel,
|
|
1842
|
-
);
|
|
1843
|
-
const spawnArgs = buildSpawnArgs(
|
|
1844
|
-
{
|
|
1845
|
-
modelRef,
|
|
1846
|
-
thinkingLevel: effectiveThinkingLevel,
|
|
1847
|
-
agentTools: opts.agentConfig?.tools,
|
|
1848
|
-
appendSystemPromptPath: tempPromptFile?.filePath,
|
|
1849
|
-
sessionDir,
|
|
1850
|
-
sessionFile: resume?.sessionFile,
|
|
1851
|
-
forkSource,
|
|
1852
|
-
skillPaths: skillPaths.length > 0 ? skillPaths : undefined,
|
|
1853
|
-
// 镜像主进程 argv 的 extension/approve flag,让子进程加载行为对齐主进程
|
|
1854
|
-
mirrorFlags: mirrorMainProcessFlags(process.argv),
|
|
1855
|
-
},
|
|
1856
|
-
);
|
|
1857
|
-
return getPiInvocation(spawnArgs);
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
/**
|
|
1861
|
-
* 拆分 record 回显的 "provider/id" 模型串(resume 路径专用)。
|
|
1862
|
-
*
|
|
1863
|
-
* [U1 D2 豁免依据] 输入是 SubagentService 从 record.model 读出的系统自产回显
|
|
1864
|
-
* (createRecordForMode 写入 `${provider}/${id}`,源头已裁决),非用户自由字符串——
|
|
1865
|
-
* 不经 assertCanonicalModelRef。modelId 可含 /,按第一个 / 分割(与 lookup 同构)。
|
|
1866
|
-
* 异常形态(无 /)兜底 provider="unknown"(与 subagent-service.ts record 回读同构)。
|
|
1867
|
-
*/
|
|
1868
|
-
function splitRecordModelRef(model: string): SpawnModelRef {
|
|
1869
|
-
const slashIdx = model.indexOf("/");
|
|
1870
|
-
if (slashIdx <= 0) return { provider: "unknown", id: model };
|
|
1871
|
-
return { provider: model.slice(0, slashIdx), id: model.slice(slashIdx + 1) };
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
/** attachStdoutPump 返回的共享句柄(waitForChildExit / get_state 握手启动消费)。 */
|
|
1875
|
-
interface StdoutPumpHandles {
|
|
1876
|
-
/**
|
|
1877
|
-
* get_state RPC response 监听器注册(performGetStateHandshake / requestGetStateOnce 经此挂 resolver)。
|
|
1878
|
-
* 返回注销函数(从监听表移除该 resolver)——requestGetStateOnce 自清理消费;
|
|
1879
|
-
* performGetStateHandshake 忽略返回值(条目由 close 统一清,既有语义)。
|
|
1880
|
-
*/
|
|
1881
|
-
registerGetStateListener(id: string, resolver: (data: unknown) => void): () => void;
|
|
1882
|
-
/** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
|
|
1883
|
-
finishHandshake(r: GetStateResult): void;
|
|
1884
|
-
/** 立即放弃握手(close handler 用:子进程已退出,response 不会再来)。 */
|
|
1885
|
-
abandonHandshake(): void;
|
|
1886
|
-
/** 握手是否仍未 settle(header 加速路径 / get_state then 分支的覆盖守卫)。 */
|
|
1887
|
-
isHandshakePending(): boolean;
|
|
1888
|
-
/** 握手 settle promise(close handler await,保证回填结果对后续 identity 写入可见)。 */
|
|
1889
|
-
readonly handshakeSettled: Promise<void>;
|
|
1890
|
-
/** 处理 stdout 末尾残留行(无换行结尾的最后一段,close handler 用)。 */
|
|
1891
|
-
processTrailingLine(): void;
|
|
1892
|
-
/** [LC-9/T7②] 本子进程生命周期内 stdout invalid 行累计数(可观测性出口)。 */
|
|
1893
|
-
invalidLineCount(): number;
|
|
1894
|
-
/** 清空 get_state 监听器(子进程已退出,无更多 response)。 */
|
|
1895
|
-
clearGetStateListeners(): void;
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
/**
|
|
1899
|
-
* [T1/RC-1+RC-2] agent_end 处置决策(非 chatMode、willRetry=false)。异步化以支持惰性回补。
|
|
1900
|
-
*
|
|
1901
|
-
* ① 惰性回补:record.sessionFile 缺失(RC-1 形态:RPC mode 的 get_state 握手 7s 预算
|
|
1902
|
-
* 一次性耗尽后永不再试,sessionFile 成为永久缺失)时,现场向 idle 子进程单次
|
|
1903
|
-
* get_state(此刻 turn 已完成,探针 P-T1 实证应答 0.3-0.4ms,预算 1s 量级)。
|
|
1904
|
-
* 回填 record.sessionFile + 写 alive marker + 补 handshakeResult.sessionId(对齐
|
|
1905
|
-
* finishHandshake 的回填面)后走正常三分支——「有后代 keep-alive / 无后代 final
|
|
1906
|
-
* kill / 读不出保守不杀」不再被一次性握手失败劫持进保守分支。
|
|
1907
|
-
* ② 回补失败(超时 / 空 response / stdin 已断同步 throw)不重试:readActivePending 对
|
|
1908
|
-
* undefined 返回 error → 既有保守分支(行为不劣化)。决策点不变成第二个重试循环。
|
|
1909
|
-
*
|
|
1910
|
-
* fire-and-forget 契约:调用点在 stdout 同步回调链内,rejection 无人接 = unhandledRejection。
|
|
1911
|
-
* 内部唯一 await 对象 requestGetStateOnce 按契约永不 reject(同步写失败转空结果);
|
|
1912
|
-
* 同步段不抛(resolveSpawnWatchdogMs 的 fail-fast 已由 [F-R2] try/catch 承接)。
|
|
1913
|
-
*
|
|
1914
|
-
* 竞态边界:决策延迟窗口内 child 可能已被 abort/watchdog kill——回补前按 child.killed
|
|
1915
|
-
* 跳过(killed 后无回补意义);后续 kill 对已死句柄 no-op,timer 收尾统一 clearTimeout。
|
|
1916
|
-
*/
|
|
1917
|
-
async function runAgentEndDisposition(
|
|
1918
|
-
state: SpawnRunState,
|
|
1919
|
-
child: ChildProcessWithoutNullStreams,
|
|
1920
|
-
sessionDir: string,
|
|
1921
|
-
registerGetStateListener: AddGetStateResponseListener,
|
|
1922
|
-
): Promise<void> {
|
|
1923
|
-
const { record } = state;
|
|
1924
|
-
|
|
1925
|
-
if (!record.sessionFile && !child.killed) {
|
|
1926
|
-
await backfillSessionFileViaGetState(state, child, registerGetStateListener);
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
// [A1-3] 回补 await 的异步窗口内 child 可能已死(close / abort / watchdog)。进程已死
|
|
1930
|
-
// 则 close 收尾已完成 timer 清理与句柄移除,三分支不再执行——否则 keep-alive 分支会
|
|
1931
|
-
// re-arm 泄漏 timer、touch marker 向死 pid 写心跳,final kill / warn 在已收尾进程上
|
|
1932
|
-
// 误导排查。存活判据用 exitCode/signalCode 双 null(close 后即非 null),不用
|
|
1933
|
-
// child.killed——它只表示「收到过 kill 请求」,close 之后恒 true,区分不了生死。
|
|
1934
|
-
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
1935
|
-
|
|
1936
|
-
// ── 以下三分支与同步化前逐行一致(仅随函数迁移)──
|
|
1937
|
-
const pending = readActivePendingFromSessionFile(record.sessionFile);
|
|
1938
|
-
if (pending.count > 0 || pending.error) {
|
|
1939
|
-
keepAliveOnAgentEnd(state, child, sessionDir, pending);
|
|
1940
|
-
} else if (pending.recentUnregister) {
|
|
1941
|
-
keepAliveForWakeupGrace(state, child);
|
|
1942
|
-
} else {
|
|
1943
|
-
disarmKeepAliveNoProgressTimer(state);
|
|
1944
|
-
killChildWithEscalation(state, child, "agent_end final kill");
|
|
1945
|
-
}
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
/**
|
|
1949
|
-
* [T1/RC-1+RC-2] 惰性回补:record.sessionFile 缺失时向 idle 子进程单次 get_state,
|
|
1950
|
-
* 回填 sessionFile + 写 alive marker + 补 handshakeResult.sessionId(对齐
|
|
1951
|
-
* finishHandshake 的回填面)。回补失败不重试(决策点不变成第二个重试循环)。
|
|
1952
|
-
*/
|
|
1953
|
-
async function backfillSessionFileViaGetState(
|
|
1954
|
-
state: SpawnRunState,
|
|
1955
|
-
child: ChildProcessWithoutNullStreams,
|
|
1956
|
-
registerGetStateListener: AddGetStateResponseListener,
|
|
1957
|
-
): Promise<void> {
|
|
1958
|
-
const { record } = state;
|
|
1959
|
-
const r = await requestGetStateOnce(child, registerGetStateListener, LAZY_GET_STATE_TIMEOUT_MS);
|
|
1960
|
-
// 仅当本次回补拿到且此前仍缺失时回填(与 finishHandshake 的 !record.sessionFile 守卫一致)。
|
|
1961
|
-
if (r.sessionFile && !record.sessionFile) {
|
|
1962
|
-
record.sessionFile = r.sessionFile;
|
|
1963
|
-
if (child.pid) {
|
|
1964
|
-
writeAliveMarkerBestEffort(r.sessionFile, child.pid, r.sessionId ?? record.id);
|
|
1965
|
-
}
|
|
1966
|
-
logger.warn(
|
|
1967
|
-
`[session-runner] agent_end: sessionFile backfilled via lazy get_state (spawn handshake had failed): ${r.sessionFile}`,
|
|
1968
|
-
);
|
|
1969
|
-
}
|
|
1970
|
-
// sessionId 一并补入 handshakeResult:close 路径 LC-4 兜底查找的 lookupId 来源。
|
|
1971
|
-
if (r.sessionId && !state.handshakeResult?.sessionId) {
|
|
1972
|
-
state.handshakeResult = { ...state.handshakeResult, sessionId: r.sessionId };
|
|
1973
|
-
}
|
|
1974
|
-
}
|
|
1975
|
-
|
|
1976
|
-
/**
|
|
1977
|
-
* keep-alive 分支(有活跃后代 / 读不出保守不杀):心跳 + 清原 watchdog 换等待后代超时。
|
|
1978
|
-
* 空闲等待期间不消耗 turn(每次 agent_end 重新计时)。
|
|
1979
|
-
* [MF-4] 动态超时 = maxTurnsToWatchdogMs(maxTurns):真实后代在跑,慢任务(wave 开发
|
|
1980
|
-
* 数小时)不能被固定 2h 误杀——2h 到点 kill 会连坐 SubagentService.dispose 的
|
|
1981
|
-
* killAllSpawnedChildren 杀全部子进程,L2 重派丢在途工作。maxTurns 大则超时长。
|
|
1982
|
-
* [A1-1 挂载面三分] keepAliveMs === undefined 的三种来源语义不同,只有裸缺省挂
|
|
1983
|
-
* 无进展上界:显式 maxTurns>0 → 固定时长动态 watchdog(不变);裸缺省(maxTurns
|
|
1984
|
-
* 未传且 env 未设)→ [T2-① / P-T2 降级 B] 挂无进展检测上界;显式 maxTurns<=0(显式
|
|
1985
|
-
* 不限时,压过 env,U5)与 resolveSpawnWatchdogMs fail-fast 降级 → 维持旧「不
|
|
1986
|
-
* re-arm(等待后代不限时)」语义——opt-out 通道保留,无进展 timer 的挂载面严格
|
|
1987
|
-
* 限定裸缺省(isBareDefaultKeepAlive)。
|
|
1988
|
-
* [F-R2] resolveSpawnWatchdogMs → assertSafeTimerDelay fail-fast 的 throw 不升级为
|
|
1989
|
-
* 进程崩溃:包 try/catch 降级为「不 re-arm」(与显式 opt-out 同归「不挂 timer」,
|
|
1990
|
-
* 不落入裸缺省分支),错误经 bestEffort("error") 可见。
|
|
1991
|
-
*/
|
|
1992
|
-
function keepAliveOnAgentEnd(
|
|
1993
|
-
state: SpawnRunState,
|
|
1994
|
-
child: ChildProcessWithoutNullStreams,
|
|
1995
|
-
sessionDir: string,
|
|
1996
|
-
pending: ActivePendingResult,
|
|
1997
|
-
): void {
|
|
1998
|
-
const { record, opts } = state;
|
|
1999
|
-
// [T5② / PS-7a] keep-alive 心跳:决定保活即刷新 .alive marker(软超时基准推新,
|
|
2000
|
-
// 防 keep-alive 数小时的活记录被异进程孤儿恢复误终态;P-T5 探针裁决写盘开销可忽略)。
|
|
2001
|
-
touchAliveMarkerForHeartbeat(record.sessionFile, child.pid, record.id);
|
|
2002
|
-
if (pending.error) {
|
|
2003
|
-
logger.warn(
|
|
2004
|
-
`[session-runner] agent_end: keep alive (sessionFile unreadable, conservative): ${pending.error}`,
|
|
2005
|
-
);
|
|
2006
|
-
} else {
|
|
2007
|
-
logger.debug(
|
|
2008
|
-
`[session-runner] agent_end: keep alive, ${pending.count} active descendant(s) pending`,
|
|
2009
|
-
);
|
|
2010
|
-
}
|
|
2011
|
-
clearTimeout(state.watchdog);
|
|
2012
|
-
disarmKeepAliveNoProgressTimer(state);
|
|
2013
|
-
// 裸缺省判定必须在 try 之前做:env 原始存在性检查(不经 parse),resolveSpawnWatchdogMs
|
|
2014
|
-
// 内部的 invalid-env warn 不因此重复出声。
|
|
2015
|
-
const bareDefaultKeepAlive = isBareDefaultKeepAlive(opts.maxTurns);
|
|
2016
|
-
let keepAliveMs: number | undefined;
|
|
2017
|
-
try {
|
|
2018
|
-
keepAliveMs = resolveSpawnWatchdogMs(opts.maxTurns);
|
|
2019
|
-
} catch (err) {
|
|
2020
|
-
bestEffort(err, "resolveSpawnWatchdogMs (agent_end keep-alive re-arm)", "error");
|
|
2021
|
-
keepAliveMs = undefined;
|
|
2022
|
-
}
|
|
2023
|
-
if (keepAliveMs !== undefined) {
|
|
2024
|
-
state.watchdog = setTimeout(() => {
|
|
2025
|
-
// [T2-② / P-T2b 主路径] keep-alive 上界处置层主的两步时序前半:kill 层主;
|
|
2026
|
-
// close(确认死亡 + sessionFile 冻结为最终快照)后由 runSpawn 收尾 sweep
|
|
2027
|
-
// 活跃后代(后半)。SIGTERM 对后台化 pi 后代无级联(P-T2b NO-CASCADE 三次
|
|
2028
|
-
// 复现),补杀必须显式做,不能押注子进程自行级联。
|
|
2029
|
-
state.sweepDescendantsOnClose = true;
|
|
2030
|
-
killChildWithEscalation(state, child, "keep-alive watchdog");
|
|
2031
|
-
}, keepAliveMs);
|
|
2032
|
-
state.watchdog.unref();
|
|
2033
|
-
} else if (bareDefaultKeepAlive) {
|
|
2034
|
-
// [T2-① / P-T2 降级路径 B] 裸缺省(无 maxTurns 无 env):挂无进展检测上界。
|
|
2035
|
-
// P-T2 探针实证固定 30min 上限会误杀 96.6% 真实 keep-alive(长尾 95.5h 合法),
|
|
2036
|
-
// 上界语义改为「连续静默达 KEEP_ALIVE_NO_PROGRESS_TIMEOUT_MS 且复核无存活后代
|
|
2037
|
-
// 才处置」——子进程 stdout 活动刷新计时,后代集合变化由 fire 时惰性复核承接
|
|
2038
|
-
//(hasLiveActiveDescendant),真实 keep-alive 不被时长上限误杀,纯静默且无
|
|
2039
|
-
// 存活后代的 wedged 层主仍有界回收。回收由外部 signal / dispose / 后代自然完成
|
|
2040
|
-
// 驱动的旧兜底通道全部保留。
|
|
2041
|
-
armKeepAliveNoProgressTimer(state, child, sessionDir);
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
/**
|
|
2046
|
-
* recentUnregister 分支:差集 0 但最近有 unregister——后代刚完成,notify 唤醒可能
|
|
2047
|
-
* 在路上(竞态窗口),保持进程——父被唤醒后的下一次 agent_end 会正常判定。
|
|
2048
|
-
*/
|
|
2049
|
-
function keepAliveForWakeupGrace(
|
|
2050
|
-
state: SpawnRunState,
|
|
2051
|
-
child: ChildProcessWithoutNullStreams,
|
|
2052
|
-
): void {
|
|
2053
|
-
const { record } = state;
|
|
2054
|
-
// [T5② / PS-7a] 保活分支同样心跳(进程仍活,软超时基准应推新)。
|
|
2055
|
-
touchAliveMarkerForHeartbeat(record.sessionFile, child.pid, record.id);
|
|
2056
|
-
// [MF-3] 秒级宽限:此分支在每层「最终 turn」必命中(closeout 的 agent_end 距
|
|
2057
|
-
// 最后一次 unregister <60s),挂长超时 = 空等 2h 才 kill + 冒牌完成通知级联。
|
|
2058
|
-
// 15s 内无新 agent_end(未被唤醒)即 kill;被唤醒后下一次 agent_end 重新评估。
|
|
2059
|
-
logger.debug(
|
|
2060
|
-
"[session-runner] agent_end: keep alive, recent descendant completion (wake-up in flight)",
|
|
2061
|
-
);
|
|
2062
|
-
clearTimeout(state.watchdog);
|
|
2063
|
-
disarmKeepAliveNoProgressTimer(state);
|
|
2064
|
-
state.watchdog = setTimeout(
|
|
2065
|
-
() => killChildWithEscalation(state, child, "wakeup grace timer"),
|
|
2066
|
-
WAKEUP_GRACE_MS,
|
|
2067
|
-
);
|
|
2068
|
-
state.watchdog.unref();
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
|
-
/**
|
|
2072
|
-
* stdout pump + get_state 握手状态机(原 runSpawn 内联的 stdout data handler 整体迁入)。
|
|
2073
|
-
*
|
|
2074
|
-
* 逐行解析 stdout:header(json mode)/ SdkEvent / RPC response / UI 请求分发;
|
|
2075
|
-
* agent_end(willRetry=false)的条件 kill(keep-alive 分支重挂 state.watchdog)也在此。
|
|
2076
|
-
* 返回 StdoutPumpHandles 供 close handler 与握手启动方消费。
|
|
2077
|
-
*/
|
|
2078
|
-
function attachStdoutPump(
|
|
2079
|
-
child: ChildProcessWithoutNullStreams,
|
|
2080
|
-
state: SpawnRunState,
|
|
2081
|
-
sessionDir: string,
|
|
2082
|
-
handleSdkEvent: (raw: SdkEvent) => void,
|
|
2083
|
-
): StdoutPumpHandles {
|
|
2084
|
-
const { record, opts, ctx } = state;
|
|
2085
|
-
|
|
2086
|
-
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest
|
|
2087
|
-
const enqueueUiRequest = createUiRequestQueue(child, ctx);
|
|
2088
|
-
// FR-4: get_state RPC response 监听器(id → resolver)。
|
|
2089
|
-
// parseSpawnLine 返回 kind:"response" 时,按 command+id 匹配 resolver。
|
|
2090
|
-
const get_stateListeners = new Map<string, (data: unknown) => void>();
|
|
2091
|
-
let stdoutBuffer = "";
|
|
2092
|
-
|
|
2093
|
-
// [LC-9/T7②] stdout invalid 行可见性:per-child 计数 + debug 级前 N 条样本留痕。
|
|
2094
|
-
// 容错原则不变(invalid 行不中断流——stdout 可能有调试输出),但「事件行损坏被
|
|
2095
|
-
// 静默丢弃」曾使 LC-1 形态 (c) 完全不可排查(设计 §4.3 LC-9)。前 N 条逐条 debug,
|
|
2096
|
-
// 之后仅累计(防刷屏);总数与样本在 close 路径(processTrailingLine)聚合输出,
|
|
2097
|
-
// 并经 StdoutPumpHandles.invalidLineCount() 暴露给测试/调用方。
|
|
2098
|
-
let invalidLineCount = 0;
|
|
2099
|
-
const invalidLineSamples: string[] = [];
|
|
2100
|
-
const recordInvalidLine = (line: string, reason: string): void => {
|
|
2101
|
-
invalidLineCount++;
|
|
2102
|
-
const truncated =
|
|
2103
|
-
line.length > INVALID_LINE_SAMPLE_MAX_LENGTH
|
|
2104
|
-
? `${line.slice(0, INVALID_LINE_SAMPLE_MAX_LENGTH)}…`
|
|
2105
|
-
: line;
|
|
2106
|
-
if (invalidLineSamples.length < MAX_INVALID_LINE_SAMPLES) {
|
|
2107
|
-
invalidLineSamples.push(truncated);
|
|
2108
|
-
}
|
|
2109
|
-
if (invalidLineCount <= MAX_INVALID_LINE_SAMPLES) {
|
|
2110
|
-
logger.debug(
|
|
2111
|
-
`[session-runner] stdout invalid line #${invalidLineCount} dropped (${reason}): ${truncated}`,
|
|
2112
|
-
);
|
|
2113
|
-
}
|
|
2114
|
-
};
|
|
2115
|
-
|
|
2116
|
-
// [#18] 握手状态变量在 stdout handler 注册之前定义,消除"handler 闭包依赖同 tick
|
|
2117
|
-
// 后续 const 初始化"的隐式顺序假设——handler 现在直接引用已初始化的变量,不靠
|
|
2118
|
-
// "data 事件必然在下一 tick 才触发"的运行时不变式兜底。
|
|
2119
|
-
let settleHandshake: (() => void) | undefined;
|
|
2120
|
-
const handshakeSettled: Promise<void> = new Promise((resolveSettled) => {
|
|
2121
|
-
settleHandshake = resolveSettled;
|
|
2122
|
-
});
|
|
2123
|
-
|
|
2124
|
-
const settleHandshakeNow = (): void => {
|
|
2125
|
-
settleHandshake?.();
|
|
2126
|
-
settleHandshake = undefined;
|
|
2127
|
-
};
|
|
2128
|
-
|
|
2129
|
-
/** 握手完成统一入口:记录结果 + 回填 sessionFile + 写 alive marker + settle。 */
|
|
2130
|
-
const finishHandshake = (r: GetStateResult): void => {
|
|
2131
|
-
state.handshakeResult = r;
|
|
2132
|
-
// 仅当 header 未先行设置 record.sessionFile 时回填(RPC mode 路径)。
|
|
2133
|
-
if (r.sessionFile && !record.sessionFile) {
|
|
2134
|
-
record.sessionFile = r.sessionFile;
|
|
2135
|
-
if (child.pid) {
|
|
2136
|
-
writeAliveMarkerBestEffort(r.sessionFile, child.pid, r.sessionId ?? record.id);
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
settleHandshakeNow();
|
|
2140
|
-
};
|
|
2141
|
-
|
|
2142
|
-
/**
|
|
2143
|
-
* [圈复杂度门禁提取] header 行处理(自 data handler 的 kind 分支整体迁入,
|
|
2144
|
-
* 只移动代码不改行为):sessionFile 推导回填 + alive marker + worktree pid 补全
|
|
2145
|
-
* + header 加速路径握手。
|
|
2146
|
-
*/
|
|
2147
|
-
const handleHeaderLine = (parsed: Extract<ParsedSpawnLine, { kind: "header" }>): void => {
|
|
2148
|
-
state.sessionHeader = parsed.header;
|
|
2149
|
-
// 回填 record.sessionFile(deriveSessionFilePath 推导路径)
|
|
2150
|
-
record.sessionFile = deriveSessionFilePath(parsed.header, sessionDir);
|
|
2151
|
-
// [持久化 C] alive marker:running 期间崩溃恢复用。子进程 pid + session id。
|
|
2152
|
-
// 与 in-process 逻辑对齐(记 sessionFile + pid),改为子进程 pid。
|
|
2153
|
-
if (record.sessionFile && child.pid) {
|
|
2154
|
-
writeAliveMarkerBestEffort(record.sessionFile, child.pid, parsed.header.id);
|
|
2155
|
-
}
|
|
2156
|
-
// [全局注册表] worktree 模式:补全注册表条目的 pid。
|
|
2157
|
-
// create 时 pid 未知写 0 占位,此处拿到 child.pid 后回调 WorktreeManager.registerPid。
|
|
2158
|
-
// 取代旧的 .session mapping sidecar——注册表是 reaper 的唯一数据源。
|
|
2159
|
-
// [D5a] fire-and-forget:回调内部走跨进程锁(毫秒级),且实现方保证不
|
|
2160
|
-
// reject(锁降级兜底);stdout data 回调是同步上下文,不 await。
|
|
2161
|
-
if (opts.worktree && child.pid) {
|
|
2162
|
-
// 透传 record.sessionFile:填入 registry entry(reaper 据 pid 死活判孤儿),
|
|
2163
|
-
// first header 时 sessionFile 已回填(deriveSessionFilePath 在本分支上方)。
|
|
2164
|
-
try {
|
|
2165
|
-
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid, record.sessionFile);
|
|
2166
|
-
} catch (err) {
|
|
2167
|
-
// 同步段异常(回调本身 throw)不阻断 stdout 解析;锁内错误由回调内部 warn。
|
|
2168
|
-
bestEffort(err, "onWorktreePid callback (first header)");
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
// FR-4 加速路径:header 到达即 finishHandshake(header 已提供 sessionId,
|
|
2172
|
-
// 足以推导 sessionFile + 兜底查找,无需等 get_state response)。
|
|
2173
|
-
// [#25] buildSpawnArgs 固定 --mode rpc,RPC mode 不发 header——此分支当前不触发,
|
|
2174
|
-
// 仅为未来 mode 回切(如 json mode 调试)保留:届时 header 先到可省去 get_state 握手等待。
|
|
2175
|
-
if (settleHandshake) {
|
|
2176
|
-
finishHandshake({
|
|
2177
|
-
...(record.sessionFile ? { sessionFile: record.sessionFile } : {}),
|
|
2178
|
-
sessionId: parsed.header.id,
|
|
2179
|
-
});
|
|
2180
|
-
}
|
|
2181
|
-
};
|
|
2182
|
-
|
|
2183
|
-
/**
|
|
2184
|
-
* [圈复杂度门禁提取] agent_end 路由(自 data handler 的 event 分支迁入,只移动
|
|
2185
|
-
* 代码不改行为)。返回 true = chatMode 交棒形态——调用方据此跳过本行的
|
|
2186
|
-
* handleSdkEvent 喂入。原实现用 continue(跳过当前行剩余、继续 for(line) 后续行,
|
|
2187
|
-
* 不跳出整个 flush),提取后 return 于 for 循环体内同构。
|
|
2188
|
-
*/
|
|
2189
|
-
const routeAgentEnd = (evt: { type: "agent_end"; willRetry?: boolean }): boolean => {
|
|
2190
|
-
if (evt.willRetry) {
|
|
2191
|
-
// agent 会重试,不能 kill。
|
|
2192
|
-
return false;
|
|
2193
|
-
}
|
|
2194
|
-
if (record.chatMode) {
|
|
2195
|
-
// [V2 决策 1] chatMode:对话模式进程不因轮次死。agent_end 不 kill、不 MF-3/MF-4。
|
|
2196
|
-
// 等待 agent_settled(真空闲信号)arm idle timer + notify(onRoundSettled)。
|
|
2197
|
-
// [T2-③ / D9 两段式] agent_end 到达 = 工作段结束:中段无进展计时让位收尾段
|
|
2198
|
-
// 固定上界(清中段、已过时间不继承,从 agent_end 起独立计时 600s;未挂载 /
|
|
2199
|
-
// 已交棒幂等 no-op——首轮与热路径轮共用本 pump 接线点)。
|
|
2200
|
-
handoverMidRoundToSettled(record.id);
|
|
2201
|
-
return true;
|
|
2202
|
-
}
|
|
2203
|
-
// [recursive-orchestration] 条件 kill:读子进程 session 文件算活跃后代
|
|
2204
|
-
// (pending:register − unregister 差集)。有活跃后代(background subagent /
|
|
2205
|
-
// workflow)→ 保持进程 idle,等后代完成时 notifier triggerTurn steer 唤醒;
|
|
2206
|
-
// 无 → 正常完成,kill 触发 close → runSpawn resolve。
|
|
2207
|
-
//
|
|
2208
|
-
// [T1/RC-1] 处置决策异步化(fire-and-forget):sessionFile 缺失(RC-1 握手失败
|
|
2209
|
-
// 形态)时现场惰性 get_state 回补后再判定,见 runAgentEndDisposition。原同步
|
|
2210
|
-
// 三分支逐行迁入该函数;异步化后决策最晚 1s(回补超时预算)落地,期间子进程
|
|
2211
|
-
// 仍在原 watchdog 保护下,kill 延迟无语义影响。
|
|
2212
|
-
void runAgentEndDisposition(state, child, sessionDir, registerGetStateListener);
|
|
2213
|
-
return false;
|
|
2214
|
-
};
|
|
2215
|
-
|
|
2216
|
-
/**
|
|
2217
|
-
* [圈复杂度门禁提取] 事件行处理(自 data handler 的 event 分支迁入):
|
|
2218
|
-
* agent_end 路由(keep-alive / chatMode 交棒 / 条件 kill)+ 其余事件喂 handleSdkEvent。
|
|
2219
|
-
*/
|
|
2220
|
-
const handleEventLine = (parsed: Extract<ParsedSpawnLine, { kind: "event" }>): void => {
|
|
2221
|
-
// agent_end(willRetry=false)= agent 自然完成。rpc mode 子进程不自动退出
|
|
2222
|
-
//(runRpcMode 末尾 return new Promise(() => {}) 长驻等命令),需主动 kill
|
|
2223
|
-
// 触发 close → runSpawn resolve。willRetry=true 时 agent 会重试,不能 kill。
|
|
2224
|
-
if (isAgentEndEvt(parsed.event) && routeAgentEnd(parsed.event)) return;
|
|
2225
|
-
if (isSdkEvent(parsed.event)) handleSdkEvent(parsed.event);
|
|
2226
|
-
};
|
|
2227
|
-
|
|
2228
|
-
/**
|
|
2229
|
-
* [圈复杂度门禁提取] RPC response 行处理(自 data handler 的 response 分支迁入):
|
|
2230
|
-
* FR-4 get_state response 按 id 匹配 resolver。
|
|
2231
|
-
*/
|
|
2232
|
-
const handleResponseLine = (parsed: Extract<ParsedSpawnLine, { kind: "response" }>): void => {
|
|
2233
|
-
if (parsed.command === "get_state" && parsed.success && parsed.id) {
|
|
2234
|
-
const resolver = get_stateListeners.get(parsed.id);
|
|
2235
|
-
if (resolver) {
|
|
2236
|
-
get_stateListeners.delete(parsed.id);
|
|
2237
|
-
resolver(parsed.data);
|
|
2238
|
-
}
|
|
2239
|
-
}
|
|
2240
|
-
};
|
|
2241
|
-
|
|
2242
|
-
child.stdout.on("data", (data: string) => {
|
|
2243
|
-
// [T2-① / P-T2 降级 B] 子进程有输出 = keep-alive 仍有进展迹象:刷新静默计时。
|
|
2244
|
-
// 任何 stdout 活动(header / 事件行 / invalid 调试行)都算——keep-alive 的合法性
|
|
2245
|
-
// 由「仍在活动」定义(P-T2 探针裁决:真实 keep-alive 96.6% 超 30min,合法性不看
|
|
2246
|
-
// 时长看活动)。[A1-2] stdout 刷新面只覆盖「层主自己有输出」半边:「直接后代跑
|
|
2247
|
-
// >30min、层主静默」的合法形态刷新不到——由 no-progress fire 时的惰性复核承接
|
|
2248
|
-
//(armKeepAliveNoProgressTimer 内 hasLiveActiveDescendant)。未挂载(显式
|
|
2249
|
-
// maxTurns/env / opt-out)时 no-op。
|
|
2250
|
-
refreshKeepAliveNoProgressTimer(state, child, sessionDir);
|
|
2251
|
-
stdoutBuffer += data;
|
|
2252
|
-
const lines = stdoutBuffer.split("\n");
|
|
2253
|
-
stdoutBuffer = lines.pop() ?? ""; // 保留最后未完整行
|
|
2254
|
-
// [圈复杂度门禁提取] 各 kind 分支体整体迁入上方 handleHeaderLine /
|
|
2255
|
-
// handleEventLine / handleResponseLine(行为逐字节保持),本 handler 只留
|
|
2256
|
-
// 缓冲切分 + kind 分派。
|
|
2257
|
-
for (const line of lines) {
|
|
2258
|
-
const parsed = parseSpawnLine(line);
|
|
2259
|
-
if (!parsed) continue;
|
|
2260
|
-
if (parsed.kind === "header") {
|
|
2261
|
-
handleHeaderLine(parsed);
|
|
2262
|
-
} else if (parsed.kind === "event") {
|
|
2263
|
-
handleEventLine(parsed);
|
|
2264
|
-
} else if (parsed.kind === "response") {
|
|
2265
|
-
handleResponseLine(parsed);
|
|
2266
|
-
} else if (parsed.kind === "extension_ui_request") {
|
|
2267
|
-
// W3: 子进程发 UI 请求(ask_user)。入队 FIFO 串行处理,防止并发询问用户。
|
|
2268
|
-
enqueueUiRequest(parsed.id, parsed.request);
|
|
2269
|
-
} else {
|
|
2270
|
-
// [LC-9/T7②] invalid 行(非法 JSON / 缺 type 字段):不中断流(stdout 可能有
|
|
2271
|
-
// 调试输出),但不再静默——计数 + debug 样本留痕(防刷屏:前 N 条逐条、
|
|
2272
|
-
// 其后仅累计),close 时聚合输出总数。
|
|
2273
|
-
recordInvalidLine(parsed.raw, parsed.error);
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
});
|
|
2277
|
-
|
|
2278
|
-
/** get_state 监听器注册(返回注销函数供 requestGetStateOnce 自清理;见 StdoutPumpHandles)。 */
|
|
2279
|
-
const registerGetStateListener = (
|
|
2280
|
-
id: string,
|
|
2281
|
-
resolver: (data: unknown) => void,
|
|
2282
|
-
): (() => void) => {
|
|
2283
|
-
get_stateListeners.set(id, resolver);
|
|
2284
|
-
// 按句守卫删除:resolver 已被同 id 覆盖(理论不发生——reqId 是 UUID)时不误删新条目。
|
|
2285
|
-
return () => {
|
|
2286
|
-
if (get_stateListeners.get(id) === resolver) get_stateListeners.delete(id);
|
|
2287
|
-
};
|
|
2288
|
-
};
|
|
2289
|
-
|
|
2290
|
-
return {
|
|
2291
|
-
registerGetStateListener,
|
|
2292
|
-
finishHandshake,
|
|
2293
|
-
abandonHandshake: settleHandshakeNow,
|
|
2294
|
-
isHandshakePending: () => settleHandshake !== undefined,
|
|
2295
|
-
handshakeSettled,
|
|
2296
|
-
processTrailingLine: () => {
|
|
2297
|
-
// 处理 stdout 末尾残留行
|
|
2298
|
-
if (stdoutBuffer.trim()) {
|
|
2299
|
-
const parsed = parseSpawnLine(stdoutBuffer);
|
|
2300
|
-
if (parsed?.kind === "event" && isSdkEvent(parsed.event)) {
|
|
2301
|
-
handleSdkEvent(parsed.event);
|
|
2302
|
-
} else if (parsed?.kind === "invalid") {
|
|
2303
|
-
// [LC-9/T7②] 残留行同计 invalid 统计(处理行为不变:event 以外仍不分发)。
|
|
2304
|
-
recordInvalidLine(parsed.raw, parsed.error);
|
|
2305
|
-
}
|
|
2306
|
-
}
|
|
2307
|
-
// [LC-9/T7②] close 聚合:本子进程生命周期的 invalid 行总数在此暴露一次
|
|
2308
|
-
//(processTrailingLine 由 close handler 必经调用),样本随行——LC-1 形态 (c)
|
|
2309
|
-
//「事件行损坏被静默丢弃」的排查入口。
|
|
2310
|
-
if (invalidLineCount > 0) {
|
|
2311
|
-
logger.debug(
|
|
2312
|
-
`[session-runner] stdout had ${invalidLineCount} invalid line(s) dropped in total; sample(s): ${invalidLineSamples.join(" | ")}`,
|
|
2313
|
-
);
|
|
2314
|
-
}
|
|
2315
|
-
},
|
|
2316
|
-
invalidLineCount: () => invalidLineCount,
|
|
2317
|
-
clearGetStateListeners: () => {
|
|
2318
|
-
get_stateListeners.clear();
|
|
2319
|
-
},
|
|
2320
|
-
};
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
/**
|
|
2324
|
-
* 等待子进程退出(close/error → exitCode,原 runSpawn 内联的 exit promise 迁入)。
|
|
2325
|
-
*
|
|
2326
|
-
* [V2 决策 2] resolveRun 指向本 promise 的 resolve:chatMode 首轮 agent_settled 时
|
|
2327
|
-
* 由 handleSdkEvent 提前调 resolveRun(0),close 最终到达时 resolve(code) 是 no-op。
|
|
2328
|
-
* close handler 统一执行 cleanup(句柄移除 / 握手放弃 / 残留行处理)。
|
|
2329
|
-
*/
|
|
2330
|
-
function waitForChildExit(
|
|
2331
|
-
child: ChildProcessWithoutNullStreams,
|
|
2332
|
-
state: SpawnRunState,
|
|
2333
|
-
spawnCwd: string,
|
|
2334
|
-
pump: StdoutPumpHandles,
|
|
2335
|
-
): Promise<number> {
|
|
2336
|
-
return new Promise<number>((resolve) => {
|
|
2337
|
-
state.resolveRun = resolve;
|
|
2338
|
-
child.on("close", async (code: number | null) => {
|
|
2339
|
-
// [C1] 子进程已退出,从 orphan-tracking Map 移除(dispose 兜底无需再 kill 它)。
|
|
2340
|
-
// [M4] 按值守卫:close 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
2341
|
-
removeChildRegistration(state.record.id, child);
|
|
2342
|
-
// [T2-③ / LC-1] close:settled 等待窗口必然结束(含首轮 settled watchdog 自身
|
|
2343
|
-
// 触发 kill 后的 close——timer 已触发执行完毕,clear 幂等无害)。
|
|
2344
|
-
disarmSettledWatchdog(state.record.id);
|
|
2345
|
-
// FR-4: 清理 get_state 监听器(子进程已退出,无更多 response)
|
|
2346
|
-
pump.clearGetStateListeners();
|
|
2347
|
-
// FR-4: 子进程已退出,get_state response 不会再来。若握手仍未 settle,立即放弃
|
|
2348
|
-
//(record.sessionFile 未回填则走 findSessionFileByHeaderId 兜底)。避免子进程快速
|
|
2349
|
-
// 失败/退出场景下 close handler 阻塞等待握手内部 6s 超时。
|
|
2350
|
-
pump.abandonHandshake();
|
|
2351
|
-
// await 立即返回(上方已 settle):保证 header 加速路径或 get_state response 已
|
|
2352
|
-
// 完成的回填结果对后续 identity 写入可见。
|
|
2353
|
-
await pump.handshakeSettled;
|
|
2354
|
-
// [LC-6/T6②] 进程 close 回收其 sessionFile 的 pending 增量游标(cursors Map 按
|
|
2355
|
-
// 「进程 close」剪枝的接线点,设计 §7.2 T6②):进程死后该文件不再有 agent_end
|
|
2356
|
-
// 判定,cursor 只会滞留。必须放在 handshakeSettled 之后——sessionFile 回填完成才
|
|
2357
|
-
// 拿得到剪枝键;未回填(快速失败)跳过,此时也不存在 cursor(判定从未发生)。
|
|
2358
|
-
// error 事件路径不重复剪:spawn 失败时 Node 必发 close,此处单点覆盖。
|
|
2359
|
-
if (state.record.sessionFile) {
|
|
2360
|
-
prunePendingCursor(state.record.sessionFile);
|
|
2361
|
-
}
|
|
2362
|
-
// 处理 stdout 末尾残留行
|
|
2363
|
-
pump.processTrailingLine();
|
|
2364
|
-
resolve(code ?? 0);
|
|
2365
|
-
});
|
|
2366
|
-
child.on("error", (err: Error) => {
|
|
2367
|
-
// spawn 本身失败(command not found 等)
|
|
2368
|
-
// [worktree-reaper-fix] 拼 spawnCwd 进错误消息:ENOENT 的 err.message 只含 command 名,
|
|
2369
|
-
// 无 cwd 线索(worktree 被 reaper 误删后 cwd 指向虚空)会导致误诊——2026-08-11 事故
|
|
2370
|
-
// AI 误判"node 被卸载"的直接原因。
|
|
2371
|
-
// [M4] 按值守卫:error 事件晚于 resume spawn 到达时不误删新 child 注册。
|
|
2372
|
-
removeChildRegistration(state.record.id, child);
|
|
2373
|
-
// [S3] code 读取带运行时 guard:非 ErrnoException(普通 Error)时 code 为 undefined,
|
|
2374
|
-
// 不加 cwd hint(行为与修复前一致);仅 ENOENT 才拼 cwd。
|
|
2375
|
-
const errno = err as NodeJS.ErrnoException;
|
|
2376
|
-
const errCode = "code" in err ? errno.code : undefined;
|
|
2377
|
-
const cwdHint = errCode === "ENOENT" ? ` (cwd: ${spawnCwd})` : "";
|
|
2378
|
-
state.record.lastError = `${err.message}${cwdHint}`;
|
|
2379
|
-
resolve(SIGNAL_EXIT_CODE_THRESHOLD); // 非零退出
|
|
2380
|
-
});
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
|
|
2384
|
-
/**
|
|
2385
|
-
* [圈复杂度门禁提取] spawn 后同步装配阶段(自 runSpawn 迁入,只移动代码不改行为):
|
|
2386
|
-
* worktree pid 同步补全 + spawnedChildren 记账 + record.pid + stdio 编码 +
|
|
2387
|
-
* stdin 异步 error listener + 首条 prompt 命令。顺序敏感(见各段注释),整段迁移。
|
|
2388
|
-
*/
|
|
2389
|
-
function setupFreshChild(
|
|
2390
|
-
state: SpawnRunState,
|
|
2391
|
-
child: ChildProcessWithoutNullStreams,
|
|
2392
|
-
task: string,
|
|
2393
|
-
): void {
|
|
2394
|
-
const { record, opts, ctx } = state;
|
|
2395
|
-
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
2396
|
-
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
2397
|
-
// 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
|
|
2398
|
-
// SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
|
|
2399
|
-
// header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
|
|
2400
|
-
if (opts.worktree && child.pid) {
|
|
2401
|
-
// [D5a] void:回调可能返回 Promise(锁内 RMW),契约保证不 reject,fire-and-forget。
|
|
2402
|
-
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
2403
|
-
}
|
|
2404
|
-
// [C1] track 子进程供 dispose 兜底 kill(sync + background 均注册——sync 无 controller,
|
|
2405
|
-
// abortRunningControllers 跳过它,靠本 Map 兜底)。close/error 后按句守卫移除(已退出无需再 kill)。
|
|
2406
|
-
spawnedChildren.set(record.id, child);
|
|
2407
|
-
// [V2 决策 3] spawn 后 child.pid 同步立即可得,记录到 record 内存(lifecycle-manager
|
|
2408
|
-
// 孤儿扫描用,Step 5 接入持久化)。resume spawn 时此处同样覆盖更新(pid 可能已变)。
|
|
2409
|
-
if (child.pid !== undefined) record.pid = child.pid;
|
|
2410
|
-
|
|
2411
|
-
// [worktree-reaper-fix] 同步补全注册表 pid:spawn 返回后 child.pid 立即可得(Node.js
|
|
2412
|
-
// 同步属性),无需等任何 stdout 事件。原补全点挂在 header 分支(下方 stdout handler 内),
|
|
2413
|
-
// 而 RPC mode(buildSpawnArgs 固定 --mode rpc)不输出 header 行——pid 恒为 0,超
|
|
2414
|
-
// SPAWN_GRACE_MS 后被 reaper 当孤儿误删活 worktree(2026-08-11 cw 递归编排整树失活事故)。
|
|
2415
|
-
// header 分支调用保留:json mode 回切时仍能补全,updatePid 同 branch 覆盖写幂等,无副作用。
|
|
2416
|
-
// [S1] 防御:必须放在 spawnedChildren.add 之后(onWorktreePid 抛错时子进程已被跟踪,
|
|
2417
|
-
// dispose 兜底 kill 不会泄漏),且包 try/catch(补全失败不阻断 spawn 主流程——
|
|
2418
|
-
// 注册表写失败最坏后果是条目停留 pid=0,由 reaper 宽限回收兜底)。
|
|
2419
|
-
if (opts.worktree && child.pid) {
|
|
2420
|
-
try {
|
|
2421
|
-
// [D5a] void:回调可能返回 Promise(锁内 RMW),契约保证不 reject,fire-and-forget。
|
|
2422
|
-
void ctx.onWorktreePid?.(opts.worktree.branch, child.pid);
|
|
2423
|
-
} catch (err) {
|
|
2424
|
-
logger.warn("[worktree] worktree pid registration failed (defensive)", {
|
|
2425
|
-
branch: opts.worktree.branch,
|
|
2426
|
-
pid: child.pid,
|
|
2427
|
-
err: toErrorMessage(err),
|
|
2428
|
-
});
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
// stdout/stderr 用 utf8 编码:stream 自动按字符边界切分,避免多字节
|
|
2433
|
-
// UTF-8(CJK/emoji)跨 chunk 时 toString() 产生 U+FFFD 替换符导致 JSON.parse 失败。
|
|
2434
|
-
// [m2] 先 setEncoding 再注册 signal listener/watchdog:若 setEncoding 抛错,try/finally
|
|
2435
|
-
//(下方)只清理 tempPromptFile,watchdog/signal listener 尚未注册则无需清理——避免泄漏。
|
|
2436
|
-
child.stdout.setEncoding("utf8");
|
|
2437
|
-
child.stderr.setEncoding("utf8");
|
|
2438
|
-
|
|
2439
|
-
// [v4 A-1] 异步 stdin 'error' listener。writeStdinLine 的 try/catch 只覆盖同步 write 抛错;
|
|
2440
|
-
// 子进程退出后内核回写 EPIPE 会以异步 stream 'error' event 到达,若无 listener 会让 Node
|
|
2441
|
-
// 抛 unhandled 'error' 崩主进程(P1)。必须在首次 stdin 写入(下方 sendPromptCommand)前注册。
|
|
2442
|
-
// handler 行为:①移出 spawnedChildren 标记 dead(与 child.on('error') 同模式);
|
|
2443
|
-
// ②recordEpipeFailure 合并同步/异步计数;③logger.warn 记录一次。
|
|
2444
|
-
// [v4 A-1 裁决] handler 内**不 throw**——stream 'error' listener 内 throw 会经 Node 内部
|
|
2445
|
-
// emit() 传播为 uncaughtException 崩主进程,违背 A-1 防崩核心目标。达阈值的 throw 留给
|
|
2446
|
-
// 同步路径(PiEngine.deliverPrompt 的 catch 合并计数达 EPIPE_FAILURE_THRESHOLD 时同步 throw,不崩)。
|
|
2447
|
-
// async handler 只移句柄 + 计数 + warn;进程已 dead(移句柄),下次投递检测
|
|
2448
|
-
// dead 走冷路径,冷路径 write EPIPE 同步计数达阈值同步 throw——防死循环且不崩。
|
|
2449
|
-
child.stdin.on("error", (err: Error) => {
|
|
2450
|
-
// [M4] 按值守卫:resume spawn 已覆盖注册时不误删新 child(见 removeChildRegistration)
|
|
2451
|
-
removeChildRegistration(record.id, child);
|
|
2452
|
-
const count = recordEpipeFailure(record.id);
|
|
2453
|
-
logger.warn(`[subagents] async stdin error for ${record.id}`, {
|
|
2454
|
-
detail: err.message,
|
|
2455
|
-
epipeCount: count,
|
|
2456
|
-
threshold: EPIPE_FAILURE_THRESHOLD,
|
|
2457
|
-
hint:
|
|
2458
|
-
count >= EPIPE_FAILURE_THRESHOLD
|
|
2459
|
-
? "sync path will throw on next write EPIPE"
|
|
2460
|
-
: undefined,
|
|
2461
|
-
});
|
|
2462
|
-
});
|
|
2463
|
-
|
|
2464
|
-
// 喂 prompt 命令驱动子进程开始处理 task。pi runRpcMode 只消费 stdin RpcCommand,
|
|
2465
|
-
// 不读 positional arg;必须在 spawn 后主动写,否则子进程阻塞、totalTokens 恒 0。
|
|
2466
|
-
// 时机安全:pipe 内核缓冲不丢;pi 在 rebindSession 后才挂 stdin reader。
|
|
2467
|
-
sendPromptCommand(child, task);
|
|
2468
|
-
}
|
|
2469
|
-
|
|
2470
|
-
/**
|
|
2471
|
-
* [圈复杂度门禁提取] chatMode 首轮 settled 等待守护挂载(自 runSpawn 迁入)。
|
|
2472
|
-
*/
|
|
2473
|
-
function armChatModeSettledWatchdog(state: SpawnRunState, child: ChildProcess): void {
|
|
2474
|
-
const { record } = state;
|
|
2475
|
-
// [T2-③ / D9 两段式] chatMode 首轮 settled 等待守护(双挂载原语之首轮调用点;
|
|
2476
|
-
// 热路径调用点在 subagent-service deliverMessage)。prompt 发出后挂**中段**无进展
|
|
2477
|
-
// 检测(有效协议事件行刷新,连续静默 30min 判 wedged);agent_end 到达时经 stdout
|
|
2478
|
-
// pump 交棒**收尾段**固定上界(agent_end 后 600s,P-T2c 定案);settled 到达
|
|
2479
|
-
//(handleSdkEvent)/ close(waitForChildExit)/ resolveRun 任一发生即清——两段
|
|
2480
|
-
// 合起来覆盖 LC-1 三 wedged 形态(agent_end 永不到达 / 收尾卡死 / settled 行丢失)。
|
|
2481
|
-
// 到期处置(两段同构):kill 层主 + settledWatchdogFired 段信息(收尾据此转 failed
|
|
2482
|
-
// + 恢复指引,见 resolveRunOutcome),与被信号终止视为正常完成的既有语义区分。
|
|
2483
|
-
if (record.chatMode) {
|
|
2484
|
-
armMidRoundNoProgress(record.id, {
|
|
2485
|
-
onMidTimeout: (fire) => {
|
|
2486
|
-
logger.warn(
|
|
2487
|
-
`[session-runner] settled watchdog (mid-round) fired for ${record.id}: no valid protocol event for ${fire.waitedMs / MS_PER_SECOND / SECONDS_PER_MINUTE} min, terminating (LC-1 wedge recovery)`,
|
|
2488
|
-
);
|
|
2489
|
-
state.settledWatchdogFired = fire;
|
|
2490
|
-
killChildWithEscalation(state, child, "settled watchdog");
|
|
2491
|
-
},
|
|
2492
|
-
onSettleTimeout: (fire) => {
|
|
2493
|
-
logger.warn(
|
|
2494
|
-
`[session-runner] settled watchdog (settled phase) fired for ${record.id}: no agent_settled within ${fire.waitedMs / MS_PER_SECOND}s after agent_end, terminating (LC-1 wedge recovery)`,
|
|
2495
|
-
);
|
|
2496
|
-
state.settledWatchdogFired = fire;
|
|
2497
|
-
killChildWithEscalation(state, child, "settled watchdog");
|
|
2498
|
-
},
|
|
2499
|
-
});
|
|
2500
|
-
}
|
|
2501
|
-
}
|
|
2502
|
-
|
|
2503
|
-
/**
|
|
2504
|
-
* [圈复杂度门禁提取] abort 监听 + spawn watchdog 挂载(自 runSpawn 迁入,只移动
|
|
2505
|
-
* 代码不改行为)。返回 onAbort 供收尾 removeEventListener(注册与注销同一句柄)。
|
|
2506
|
-
*/
|
|
2507
|
-
function attachAbortAndSpawnWatchdog(state: SpawnRunState, child: ChildProcess): () => void {
|
|
2508
|
-
const { record, opts } = state;
|
|
2509
|
-
// d. signal → proc.kill 监听(一次性,替代 session.abort)
|
|
2510
|
-
// [race-F4] 用户取消同样升级:SIGTERM 后挂住 → 30s 后 SIGKILL 兑现取消语义。
|
|
2511
|
-
const onAbort = (): void => {
|
|
2512
|
-
killPiChild(child, record.id, "abort signal");
|
|
2513
|
-
};
|
|
2514
|
-
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
2515
|
-
// 前置检查:signal 在 spawn 前已 aborted 时 addEventListener 不会触发 onAbort,
|
|
2516
|
-
// 子进程会跑到自然结束。立即 kill 兑现取消语义。
|
|
2517
|
-
if (opts.signal?.aborted) onAbort();
|
|
2518
|
-
|
|
2519
|
-
// e. watchdog:子进程整体超时兜底。卡死在单 tool 内(turn_end 永不触发)时
|
|
2520
|
-
// limiter 失效,此 timer 保证最终 SIGTERM,防止 background 槽位/资源泄漏。
|
|
2521
|
-
// [M-1] timeout 基于 maxTurns 动态计算(maxTurnsToWatchdogMs):旧实现固定 30 分钟
|
|
2522
|
-
// 误杀长任务,现按 maxTurns 线性估算(每 turn ~5 分钟,下限 30 分钟)。
|
|
2523
|
-
// [预算语义对齐] maxTurns 未传/<=0 → 不挂 watchdog(不限,用户明确裁决——旧实现按
|
|
2524
|
-
// 10 turns 估出 50min 默认 SIGTERM 且 maxTurns:0 也关不掉,已废);
|
|
2525
|
-
// SPAWN_WATCHDOG_ENV 设置时按绝对时限兑底挂载(hang 泄漏防线 opt-in)。
|
|
2526
|
-
// [R0] unref:不阻止 Node 进程退出。安全性由 SubagentService.dispose 保证——
|
|
2527
|
-
// 主进程退出时(session_shutdown reason=quit)dispose 会 abort running controller
|
|
2528
|
-
// → 本监听器 kill 子进程。无此 unref,watchdog timer 会拖住 event loop 阻止退出。
|
|
2529
|
-
const watchdogMs = resolveSpawnWatchdogMs(opts.maxTurns);
|
|
2530
|
-
if (watchdogMs !== undefined) {
|
|
2531
|
-
state.watchdog = setTimeout(
|
|
2532
|
-
() => killPiChild(child, record.id, "spawn watchdog"),
|
|
2533
|
-
watchdogMs,
|
|
2534
|
-
);
|
|
2535
|
-
state.watchdog.unref();
|
|
2536
|
-
}
|
|
2537
|
-
return onAbort;
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
/**
|
|
2541
|
-
* [圈复杂度门禁提取] get_state RPC 握手启动(自 runSpawn 迁入,fire-and-forget)。
|
|
2542
|
-
*/
|
|
2543
|
-
function startGetStateHandshake(
|
|
2544
|
-
child: ChildProcessWithoutNullStreams,
|
|
2545
|
-
pump: StdoutPumpHandles,
|
|
2546
|
-
): void {
|
|
2547
|
-
// FR-4: get_state RPC 握手——spawn 后无条件启动。
|
|
2548
|
-
// RPC mode(pi --mode rpc)不向 stdout 输出 header,record.sessionFile 无法靠 header
|
|
2549
|
-
// 推导,必须通过 get_state RPC 查询子进程回填。json mode 下 header 会先到达触发提前
|
|
2550
|
-
// resolve(加速路径),握手仍启动但无害——response 到达时外层已 resolve,resolver
|
|
2551
|
-
// 因 resolved=true 直接 return。
|
|
2552
|
-
//
|
|
2553
|
-
// 时序:握手在 stdout pump(get_stateListeners 已就绪)之后启动。get_state 命令写入
|
|
2554
|
-
// stdin,pi rebindSession 后读取并返回 response,经 stdout pump 匹配 resolver 触发 resolve。
|
|
2555
|
-
// close handler await handshakeSettled,保证无论 task 多快结束,close 时 sessionFile 已回填。
|
|
2556
|
-
// [#18] 握手状态变量已在 attachStdoutPump 内部(stdout handler 注册前)定义,此处直接发起握手。
|
|
2557
|
-
// [F2] .catch 兜底:performGetStateHandshake 的 Promise executor 同步调 tryOnce →
|
|
2558
|
-
// sendGetStateCommand → writeStdinLine 在 stdin 已断(EPIPE/ERR_STREAM_DESTROYED)时同步
|
|
2559
|
-
// throw → executor 内同步异常被 Promise 构造器转为 reject。若无人接(旧实现只有 .then),
|
|
2560
|
-
// reject 无人消费 → unhandledRejection(Node 15+ 默认 mode=throw)可崩父 pi 进程。
|
|
2561
|
-
// catch 内:logger.error 留证 + pump.abandonHandshake 记录握手失败终态——close handler
|
|
2562
|
-
// await 的 handshakeSettled 立即 settle,isHandshakePending() 归 false,不阻塞收尾链路
|
|
2563
|
-
//(sessionFile 兜底由收尾时的 existsSync 校验 + findSessionFileByHeaderId 承担)。
|
|
2564
|
-
void performGetStateHandshake(child, pump.registerGetStateListener).then((r) => {
|
|
2565
|
-
// header 加速路径下 settleHandshake 已 undefined,跳过(避免覆盖 header 结果)。
|
|
2566
|
-
// 超时兜底(r 为空对象)也经此分支 settle,但 record.sessionFile 不回填。
|
|
2567
|
-
if (pump.isHandshakePending()) pump.finishHandshake(r);
|
|
2568
|
-
}).catch((err: unknown) => {
|
|
2569
|
-
const m = toErrorMessage(err);
|
|
2570
|
-
logger.error(`[session-runner] get_state handshake failed: ${m}`);
|
|
2571
|
-
pump.abandonHandshake();
|
|
2572
|
-
});
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
/**
|
|
2576
|
-
* [圈复杂度门禁提取] sessionFile 兜底反查(LC-4,自 runSpawn 收尾迁入)。
|
|
2577
|
-
*/
|
|
2578
|
-
function backfillSessionFileByLookup(state: SpawnRunState, sessionDir: string): void {
|
|
2579
|
-
const { record } = state;
|
|
2580
|
-
// [持久化 A] sessionFile 兜底校验。
|
|
2581
|
-
// identity custom entry 已改由子进程 session_start hook 写(M4 / V2 决策 5),
|
|
2582
|
-
// 父进程不再 fs 补写——fs 补写的 entry 缺 id/parentId 污染 pi _buildIndex。
|
|
2583
|
-
// 此处仅保留 sessionFile 路径兜底(deriveSessionFilePath/握手路径可能不准)。
|
|
2584
|
-
//
|
|
2585
|
-
// [T1/LC-4] findSessionFileByHeaderId 兜底查找移出 `if (record.sessionFile)` 守卫:
|
|
2586
|
-
// 原守卫条件恰等于它要兜底的缺失本身——RC-1 握手失败留下的「sessionId 有、
|
|
2587
|
-
// sessionFile 无」形态(两字段独立采集)永远不可达本兜底,下游 finalize marker /
|
|
2588
|
-
// alive marker / identity 写入全部失去依据(PS-9 放大)。现在两种形态都反查:
|
|
2589
|
-
// sessionFile 有但路径不存在(推导错 / pi 命名变化);sessionFile 无而 sessionId 有
|
|
2590
|
-
// (header 或握手部分成功,含 agent_end 惰性回补补入的 handshakeResult.sessionId)。
|
|
2591
|
-
const lookupId = state.sessionHeader?.id ?? state.handshakeResult?.sessionId;
|
|
2592
|
-
const needsLookup = record.sessionFile
|
|
2593
|
-
? !fs.existsSync(record.sessionFile)
|
|
2594
|
-
: lookupId !== undefined;
|
|
2595
|
-
if (lookupId && needsLookup) {
|
|
2596
|
-
const actual = findSessionFileByHeaderId(sessionDir, lookupId);
|
|
2597
|
-
if (actual && actual !== record.sessionFile) record.sessionFile = actual;
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
|
|
2601
|
-
/**
|
|
2602
|
-
* [圈复杂度门禁提取] keep-alive 上界处置的两步时序后半(T2-② 后代级联补杀,
|
|
2603
|
-
* 自 runSpawn 收尾迁入):close 确认死亡 + sessionFile 冻结为最终快照后 sweep。
|
|
2604
|
-
*/
|
|
2605
|
-
function sweepDescendantsOnChildClose(state: SpawnRunState, sessionDir: string): void {
|
|
2606
|
-
const { record } = state;
|
|
2607
|
-
// [T2-② / P-T2b 主路径] keep-alive 上界处置层主的两步时序后半:此刻 close 已发生
|
|
2608
|
-
//(waitForChildExit 已 resolve = 层主确认死亡),且 sessionFile 已完成 LC-4 反查
|
|
2609
|
-
// = 冻结为最终快照(pending entries 最完整,避开「kill 前采集」的垂死窗口漏项)。
|
|
2610
|
-
// 从层主 sessionFile 差集采集活跃后代清单,对清单内每个后代迭代展开至叶(递归读
|
|
2611
|
-
// 各后代的 pending 差集),kill 前做存活 + cmdline(pi/--mode rpc)校验防 pid 复用
|
|
2612
|
-
// 误杀,逐个 escalation kill(SIGTERM→SIGKILL)。同步执行:后代树规模有限 + 每
|
|
2613
|
-
// 步有界(ps 探测 3s 超时),不阻塞 runSpawn 收尾的可感时长。
|
|
2614
|
-
if (state.sweepDescendantsOnClose) {
|
|
2615
|
-
try {
|
|
2616
|
-
const sweep = sweepDescendantsOfSession(record.sessionFile, sessionDir, "keep-alive watchdog");
|
|
2617
|
-
if (sweep.killed.length > 0 || sweep.skipped.length > 0) {
|
|
2618
|
-
logger.warn(
|
|
2619
|
-
`[session-runner] descendant sweep (keep-alive watchdog): killed=[${sweep.killed.join(", ")}] skipped=${JSON.stringify(sweep.skipped)}`,
|
|
2620
|
-
);
|
|
2621
|
-
}
|
|
2622
|
-
} catch (err) {
|
|
2623
|
-
// sweep 整体失败不掩盖层主自身的收尾结果(best-effort 可见)
|
|
2624
|
-
bestEffort(err, "descendant sweep (keep-alive watchdog)", "error");
|
|
2625
|
-
}
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2628
|
-
|
|
2629
|
-
/**
|
|
2630
|
-
* [圈复杂度门禁提取] 成功/失败判定(三来源:exitCode + record.lastError + abort
|
|
2631
|
-
* 原因;自 runSpawn 迁入,四分支行为逐字节保持)。
|
|
2632
|
-
*/
|
|
2633
|
-
function resolveRunOutcome(
|
|
2634
|
-
state: SpawnRunState,
|
|
2635
|
-
exitCode: number,
|
|
2636
|
-
stderrBuffer: string,
|
|
2637
|
-
): { success: boolean; error: string | undefined } {
|
|
2638
|
-
const { record, opts } = state;
|
|
2639
|
-
let success: boolean;
|
|
2640
|
-
let error: string | undefined;
|
|
2641
|
-
if (state.settledWatchdogFired) {
|
|
2642
|
-
// [T2-③ / D9] settled watchdog 到期回收 ≠ 正常完成:被信号终止视为正常完成的
|
|
2643
|
-
// 既有语义(maxTurns 达限 kill)不适用于本形态——runSpawn 以错误返回(设计
|
|
2644
|
-
// §6.2),错误消息含恢复指引(S-B 验收判据)。closedReason 的 "watchdog" 映射
|
|
2645
|
-
// 由 finalize 侧按 error 标记承接(ClosedReason 枚举封闭,不在此擅自扩枚举)。
|
|
2646
|
-
// 文案按触发段分叉窗长语义(D9 失败路径总表:中段标注 mid-round no-progress)。
|
|
2647
|
-
const fire = state.settledWatchdogFired;
|
|
2648
|
-
const windowDesc =
|
|
2649
|
-
fire.phase === "mid-round"
|
|
2650
|
-
? `no valid protocol event for ${fire.waitedMs / MS_PER_SECOND / SECONDS_PER_MINUTE} min after prompt (mid-round no-progress)`
|
|
2651
|
-
: `no agent_settled within ${fire.waitedMs / MS_PER_SECOND}s after agent_end (settled phase)`;
|
|
2652
|
-
success = false;
|
|
2653
|
-
error =
|
|
2654
|
-
`subagent did not reach agent_settled (${windowDesc}; settled watchdog); the process was terminated to bound the wait. ` +
|
|
2655
|
-
`Recovery: check state with subagents action:'list', then re-send your message to continue.`;
|
|
2656
|
-
} else if (record.lastError) {
|
|
2657
|
-
// LLM/provider error 或 abort error 已收口进 record.lastError
|
|
2658
|
-
success = false;
|
|
2659
|
-
error = record.lastError;
|
|
2660
|
-
} else if (exitCode !== 0 && exitCode < SIGNAL_EXIT_CODE_THRESHOLD) {
|
|
2661
|
-
// 非信号退出的非零 exit code = 子进程自身报错
|
|
2662
|
-
success = false;
|
|
2663
|
-
error = stderrBuffer.trim() || `pi subprocess exited with code ${exitCode}`;
|
|
2664
|
-
} else if (opts.signal?.aborted) {
|
|
2665
|
-
// 用户/调用方 signal 取消(非 maxTurns)——不算成功,但也不算 error
|
|
2666
|
-
success = false;
|
|
2667
|
-
error = undefined;
|
|
2668
|
-
} else {
|
|
2669
|
-
// exitCode === 0 或被信号终止(maxTurns 达限 kill)——均视为正常完成
|
|
2670
|
-
success = true;
|
|
2671
|
-
error = record.lastError;
|
|
2672
|
-
}
|
|
2673
|
-
return { success, error };
|
|
2674
|
-
}
|
|
2675
|
-
|
|
2676
|
-
/**
|
|
2677
|
-
* spawn pi 子进程执行 session。
|
|
2678
|
-
*
|
|
2679
|
-
* 契约与 run() 一致:正常路径不抛错(prompt 失败/turn-limit abort/子进程崩溃
|
|
2680
|
-
* 均合成 failed AgentResult 返回)。创建期异常(spawn 本身失败)会抛。
|
|
2681
|
-
*
|
|
2682
|
-
* [拆分] 各执行阶段(事件累积 / 参数准备 / stdout pump / 退出等待)拆到上方
|
|
2683
|
-
* 模块级函数,闭包状态经 SpawnRunState 传递——只移动代码不改行为。
|
|
2684
|
-
*/
|
|
2685
|
-
export async function runSpawn(
|
|
2686
|
-
record: ExecutionRecord,
|
|
2687
|
-
task: string,
|
|
2688
|
-
opts: RunOptions,
|
|
2689
|
-
ctx: SessionRunnerContext,
|
|
2690
|
-
/** resume 选项(M1 基建):重开已结束的 session 继续对话。undefined = 新 session。 */
|
|
2691
|
-
resume?: SpawnResumeOpts,
|
|
2692
|
-
): Promise<AgentResult> {
|
|
2693
|
-
const startTime = Date.now();
|
|
2694
|
-
|
|
2695
|
-
// [M1 resume 基建] resume 时提前锁定 record.sessionFile:spawn 前已知目标文件,
|
|
2696
|
-
// handshake 的 finishHandshake 内 `!record.sessionFile` 守卫天然跳过回填,
|
|
2697
|
-
// sessionFile 用 resume.sessionFile 不被覆盖(RPC mode 无 header,header 分支也不触发)。
|
|
2698
|
-
if (resume) {
|
|
2699
|
-
record.sessionFile = resume.sessionFile;
|
|
2700
|
-
}
|
|
2701
|
-
|
|
2702
|
-
// 各阶段共享状态(原闭包变量收拢:proc / watchdog / sessionHeader / handshakeResult / resolveRun)
|
|
2703
|
-
const state: SpawnRunState = {
|
|
2704
|
-
record,
|
|
2705
|
-
opts,
|
|
2706
|
-
ctx,
|
|
2707
|
-
proc: undefined,
|
|
2708
|
-
watchdog: undefined,
|
|
2709
|
-
sessionHeader: undefined,
|
|
2710
|
-
handshakeResult: undefined,
|
|
2711
|
-
resolveRun: undefined,
|
|
2712
|
-
keepAliveNoProgressTimer: undefined,
|
|
2713
|
-
sweepDescendantsOnClose: false,
|
|
2714
|
-
settledWatchdogFired: undefined,
|
|
2715
|
-
};
|
|
2716
|
-
|
|
2717
|
-
// a/b. 事件累积器(pendingTools 寄存器 + turnLimiter + handleSdkEvent/agentEvent 闭包)
|
|
2718
|
-
const handleSdkEvent = createSpawnEventHandlers(state);
|
|
2719
|
-
|
|
2720
|
-
// [审查项#2] 原 c.(schema 指令拼 task 末尾)已删:resolver 经 appendSystemPrompt
|
|
2721
|
-
// 单点注入,task 不再被每 agent 变化的指令后缀污染(可缓存 + 省 ~730 tokens/子进程)。
|
|
2722
|
-
|
|
2723
|
-
// d. session 目录(与 in-process 一致:list/恢复可发现同一目录)
|
|
2724
|
-
// [MF-3] 用 ctx.rootCwd(贯穿真 ROOT)而非 ctx.mainCwd 编码:worktree 模式下 mainCwd 是
|
|
2725
|
-
// 子进程的 checkout 路径,按它编码会让深层 record 落到 enc(worktree) 段,ROOT 磁盘重建
|
|
2726
|
-
// 扫不到 → 全树可见性深度 ≥ 2 断裂。rootCwd 与 store 构造同源(subagent-service 同键),
|
|
2727
|
-
// 保证 runSpawn 写入的 session 文件就在本进程/ROOT store 扫描的目录里。
|
|
2728
|
-
//
|
|
2729
|
-
// [单写者不变量·MF-8|第五轮元审查结论] session JSONL 完整性依赖「每 session 单写
|
|
2730
|
-
// 进程」架构不变量:本目录是子进程专属 sessionDir,session 文件写入方仅此子进程
|
|
2731
|
-
//(单进程单线程);主进程只读(扫描/重建/统计),绝不写。pi 0.84.4 的写入原语
|
|
2732
|
-
//(session-manager.js,完整锚点见 buildSpawnArgs 注释 / PS-18):_persist
|
|
2733
|
-
//(:724-753)首写 wx flag + 后续 appendFileSync 追加;compaction 为 append-only
|
|
2734
|
-
// 追加(appendCompaction :803-818),截断重写 _rewriteFile(:693-705)只在加载期
|
|
2735
|
-
//(归一 :627 / 迁移 :634 / branch :1143)触发——第二写进程会破坏全部这些写入的
|
|
2736
|
-
// 原子性(尾部丢 entry / 交错截断,见 v4 A-5/P7 双写者事故)。 resume/
|
|
2737
|
-
// 续聊走冷路径重开同一文件时也必须先确认旧进程已死(resumesInFlight 守卫),
|
|
2738
|
-
// 本质仍是单写者。
|
|
2739
|
-
const sessionDir = getSubagentSessionDir(ctx.agentDir, ctx.rootCwd);
|
|
2740
|
-
fs.mkdirSync(sessionDir, { recursive: true });
|
|
2741
|
-
|
|
2742
|
-
// e. worktree 模式:checkout 路径作为 spawn cwd(隔离文件系统)
|
|
2743
|
-
// worktree checkout 已由 worktree-manager 在 execute 前创建,此处只取路径。
|
|
2744
|
-
const spawnCwd = opts.worktree?.path ?? ctx.cwd;
|
|
2745
|
-
|
|
2746
|
-
// f. fork source:父 session 文件路径(--fork 参数)。
|
|
2747
|
-
// [v8.5 B] opts.forkSource 显式覆盖优先(fork-from 指定旧 subagent session 作源);
|
|
2748
|
-
// 否则回退旧语义 opts.fork → 主 session 文件。两者均未设则无继承。
|
|
2749
|
-
const forkSource = opts.forkSource ?? (opts.fork ? ctx.mainSessionFile : undefined);
|
|
2750
|
-
|
|
2751
|
-
// g. appendSystemPrompt 落盘(env block + agent body + 调用方片段)
|
|
2752
|
-
const tempPromptFile = await writeAppendSystemPromptFile(record, opts, ctx);
|
|
2753
|
-
|
|
2754
|
-
// h. 子进程环境变量(fork depth / 身份贯穿 / identity / worktree 标志 / schemaEnv)
|
|
2755
|
-
const childEnv = buildChildEnv(record, opts, ctx);
|
|
2756
|
-
|
|
2757
|
-
// i. 组装 args + 解析 pi 可执行入口
|
|
2758
|
-
const invocation = buildSpawnInvocation(opts, ctx, resume, tempPromptFile, sessionDir, forkSource);
|
|
2759
|
-
|
|
2760
|
-
// 累积 stderr(错误诊断用)
|
|
2761
|
-
let stderrBuffer = "";
|
|
2762
|
-
|
|
2763
|
-
try {
|
|
2764
|
-
const child = spawn(invocation.command, invocation.args, {
|
|
2765
|
-
cwd: spawnCwd,
|
|
2766
|
-
shell: false,
|
|
2767
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
2768
|
-
env: childEnv,
|
|
2769
|
-
});
|
|
2770
|
-
state.proc = child;
|
|
2771
|
-
// [圈复杂度门禁提取] spawn 后同步装配(worktree pid 补全 ×2 / 记账 / record.pid /
|
|
2772
|
-
// stdio 编码 / stdin error listener / prompt 命令)整段迁入 setupFreshChild。
|
|
2773
|
-
setupFreshChild(state, child, task);
|
|
2774
|
-
|
|
2775
|
-
// [圈复杂度门禁提取] chatMode 首轮 settled 守护挂载迁入 armChatModeSettledWatchdog。
|
|
2776
|
-
armChatModeSettledWatchdog(state, child);
|
|
2777
|
-
|
|
2778
|
-
// [圈复杂度门禁提取] abort 监听 + spawn watchdog 挂载迁入 attachAbortAndSpawnWatchdog,
|
|
2779
|
-
// 返回 onAbort 供下方收尾 removeEventListener(注册与注销同一句柄)。
|
|
2780
|
-
const onAbort = attachAbortAndSpawnWatchdog(state, child);
|
|
2781
|
-
|
|
2782
|
-
// [recursive-orchestration] agent_end 有活跃后代时的等待超时(不 kill 分支的兑底)。
|
|
2783
|
-
// 层主 subagent 空闲等待后代完成(steer 唤醒)期间不产生 turn,原 watchdog 已清;
|
|
2784
|
-
// 此后代卡死(永不完成)时此 timer 保证进程最终回收。超时 kill → finalize 视为正常
|
|
2785
|
-
// 完成 → 通知父(父查 cw status 发现未 closed 会走 L2/L3 重派,见 planning-agent 模板)。
|
|
2786
|
-
|
|
2787
|
-
// stdout pump:逐行解析 → handleSdkEvent / enqueueUiRequest + get_state 握手状态机
|
|
2788
|
-
const pump = attachStdoutPump(child, state, sessionDir, handleSdkEvent);
|
|
2789
|
-
|
|
2790
|
-
// [圈复杂度门禁提取] get_state 握手启动(fire-and-forget + F2 catch 兜底)迁入
|
|
2791
|
-
// startGetStateHandshake。
|
|
2792
|
-
startGetStateHandshake(child, pump);
|
|
2793
|
-
|
|
2794
|
-
child.stderr.on("data", (data: string) => {
|
|
2795
|
-
// 截断防 OOM:失控子进程持续打 stderr 会耗尽父进程内存。保留尾部便于诊断。
|
|
2796
|
-
stderrBuffer = (stderrBuffer + data).slice(-STDERR_MAX_CHARS);
|
|
2797
|
-
});
|
|
2798
|
-
|
|
2799
|
-
// 等待子进程退出
|
|
2800
|
-
const exitCode = await waitForChildExit(child, state, spawnCwd, pump);
|
|
2801
|
-
|
|
2802
|
-
opts.signal?.removeEventListener("abort", onAbort);
|
|
2803
|
-
clearTimeout(state.watchdog);
|
|
2804
|
-
// [D3-①] SIGKILL 升级 timer 的清理由公共 kill-chain 内部承担(exit 事件 settle
|
|
2805
|
-
// 等待 promise → raceTimeout clearTimeout;且链持有 child 闭包,不误伤后续同
|
|
2806
|
-
// state 子进程)——旧 escalationTimer 兜底句柄随旧私有杀链一并删除。
|
|
2807
|
-
// [T2-①③] 收尾兜底清新增 timer(正常清除点已覆盖;防收尾路径遗漏泄漏)
|
|
2808
|
-
disarmKeepAliveNoProgressTimer(state);
|
|
2809
|
-
disarmSettledWatchdog(record.id);
|
|
2810
|
-
|
|
2811
|
-
// [圈复杂度门禁提取] sessionFile 兜底反查(LC-4)迁入 backfillSessionFileByLookup。
|
|
2812
|
-
backfillSessionFileByLookup(state, sessionDir);
|
|
2813
|
-
|
|
2814
|
-
// [圈复杂度门禁提取] 后代级联补杀 sweep(T2-② 两步时序后半)迁入
|
|
2815
|
-
// sweepDescendantsOnChildClose。
|
|
2816
|
-
sweepDescendantsOnChildClose(state, sessionDir);
|
|
2817
|
-
|
|
2818
|
-
// 判定成功/失败(三来源:exitCode + record.lastError + abort 原因)
|
|
2819
|
-
// [圈复杂度门禁提取] 四分支判定迁入 resolveRunOutcome(行为逐字节保持)。
|
|
2820
|
-
const { success, error } = resolveRunOutcome(state, exitCode, stderrBuffer);
|
|
2821
|
-
|
|
2822
|
-
// g. collectResult(完全复用——全部从 record 读)
|
|
2823
|
-
// [F-1] schemaExpected:schema/schemaEnv 任一存在即要求结构化产出(耦合形态
|
|
2824
|
-
// 两者同设;解耦形态仅 schemaEnv 也要——tool 已注册,产出预期相同)。无有效
|
|
2825
|
-
// parsedOutput 时由 collectResult 标注失败,不再静默 success。
|
|
2826
|
-
return collectResult(record, {
|
|
2827
|
-
startTime,
|
|
2828
|
-
success,
|
|
2829
|
-
error,
|
|
2830
|
-
sessionId: state.sessionHeader?.id ?? record.id,
|
|
2831
|
-
sessionFile: record.sessionFile,
|
|
2832
|
-
schemaExpected: opts.schema !== undefined || opts.schemaEnv !== undefined,
|
|
2833
|
-
});
|
|
2834
|
-
} finally {
|
|
2835
|
-
// h. 清理临时 prompt 文件
|
|
2836
|
-
if (tempPromptFile) {
|
|
2837
|
-
await cleanupTempPrompt(tempPromptFile);
|
|
2838
|
-
}
|
|
2839
|
-
}
|
|
2840
|
-
}
|