@zhushanwen/subagent-core 0.2.0 → 0.4.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 +45 -2
- package/agents/analyst.md +60 -0
- package/agents/coder.md +69 -0
- package/agents/debugger.md +66 -0
- package/agents/doc-reviewer.md +49 -0
- package/agents/explorer.md +63 -0
- package/agents/general-purpose.md +32 -0
- package/agents/orchestrator.md +61 -0
- package/agents/planner.md +53 -0
- package/agents/researcher.md +67 -0
- package/agents/reviewer.md +73 -0
- package/dist/chunk-4KN4TTG7.js +240 -0
- package/dist/chunk-APZY4IME.js +27 -0
- package/dist/chunk-X7SZ5HLQ.js +66 -0
- package/dist/execution/engine/engines/zcode/constants.cjs +65 -0
- package/dist/execution/engine/engines/zcode/constants.d.cts +93 -1
- package/dist/execution/engine/engines/zcode/constants.d.ts +93 -1
- package/dist/execution/engine/engines/zcode/constants.js +43 -1
- package/dist/execution/engine/engines/zcode/reader.d.cts +1 -1
- package/dist/execution/engine/engines/zcode/reader.d.ts +1 -1
- package/dist/execution/engine/engines/zcode/reader.js +4 -234
- package/dist/execution/engine/paths.js +7 -19
- package/dist/index.cjs +14709 -100
- package/dist/index.d.cts +4053 -200
- package/dist/index.d.ts +4053 -200
- package/dist/index.js +14358 -102
- package/dist/{types-BxyAidGf.d.cts → types-DpUO16pj.d.cts} +464 -1
- package/dist/{types-BxyAidGf.d.ts → types-DpUO16pj.d.ts} +464 -1
- package/package.json +6 -3
- package/src/__tests__/review-fix-loop-script.test.ts +90 -2
- package/src/__tests__/review-fix-loop-utils.test.ts +21 -0
- package/src/__tests__/smoke.test.ts +9 -2
- package/src/execution/__tests__/agent-profile.test.ts +232 -0
- package/src/execution/__tests__/agents-assembly.test.ts +219 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +71 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +1 -0
- package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
- package/src/execution/__tests__/delivery-methods.test.ts +2 -0
- package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
- package/src/execution/__tests__/dialog-queue.test.ts +199 -1
- package/src/execution/__tests__/epipe-fallback.test.ts +2 -0
- package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
- package/src/execution/__tests__/finalize-record.test.ts +106 -0
- package/src/execution/__tests__/gc-timer.test.ts +2 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +2 -0
- package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
- package/src/execution/__tests__/keep-alive-no-progress.test.ts +378 -0
- package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
- package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
- package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
- package/src/execution/__tests__/notify-ledger.test.ts +294 -0
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
- package/src/execution/__tests__/record-store.test.ts +83 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +4 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +1 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +407 -17
- package/src/execution/__tests__/run-spawn-integration.test.ts +1 -1
- package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +12 -4
- package/src/execution/__tests__/service-kill-escalation.test.ts +89 -0
- package/src/execution/__tests__/session-pending.test.ts +228 -0
- package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
- package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
- package/src/execution/__tests__/session-runner-epipe.test.ts +1 -0
- package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +226 -0
- package/src/execution/__tests__/settled-watchdog.test.ts +289 -0
- package/src/execution/__tests__/spawned-children.test.ts +153 -10
- package/src/execution/__tests__/subagent-actions-core.test.ts +997 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +71 -10
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +266 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +3 -0
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +316 -0
- package/src/execution/__tests__/timeout-integration.test.ts +7 -7
- package/src/execution/__tests__/ui-request-handler-factory.test.ts +95 -1
- package/src/execution/__tests__/ui-request-queue.test.ts +25 -0
- package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
- package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
- package/src/execution/agent-registry.ts +225 -1
- package/src/execution/agents-assembly.ts +88 -0
- package/src/execution/concurrency-pool.ts +71 -9
- package/src/execution/dialog-queue.ts +101 -3
- package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +49 -2
- package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +87 -1
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +26 -0
- package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +83 -1
- package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +131 -0
- package/src/execution/engine/__tests__/registry.test.ts +90 -1
- package/src/execution/engine/engine-discovery.ts +12 -16
- package/src/execution/engine/engines/pi/task-spec-mapper.ts +3 -3
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +274 -0
- package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
- package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +472 -0
- package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +387 -0
- package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +780 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +815 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +462 -0
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +119 -1
- package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +136 -6
- package/src/execution/engine/engines/zcode/appserver-home.ts +442 -0
- package/src/execution/engine/engines/zcode/appserver-probe.ts +141 -0
- package/src/execution/engine/engines/zcode/connection.ts +585 -0
- package/src/execution/engine/engines/zcode/constants.ts +129 -0
- package/src/execution/engine/engines/zcode/golden-sample.ts +54 -8
- package/src/execution/engine/engines/zcode/preparer.ts +25 -22
- package/src/execution/engine/engines/zcode/session-channel.ts +656 -0
- package/src/execution/engine/engines/zcode/zcode-engine.ts +909 -41
- package/src/execution/engine/host-task-spec.ts +3 -3
- package/src/execution/engine/port.ts +41 -1
- package/src/execution/engine/registry.ts +53 -0
- package/src/execution/finalize-record.ts +63 -5
- package/src/execution/get-state-handshake.ts +85 -10
- package/src/execution/lifecycle-manager.ts +27 -3
- package/src/execution/manifest-store.ts +53 -62
- package/src/execution/notifier.ts +10 -10
- package/src/execution/notify-ledger.ts +117 -16
- package/src/execution/record-entry.ts +8 -2
- package/src/execution/record-store.ts +29 -15
- package/src/execution/session-pending.ts +213 -62
- package/src/execution/session-runner.ts +974 -128
- package/src/execution/sessions-index.ts +10 -55
- package/src/execution/settled-watchdog.ts +99 -0
- package/src/execution/subagent-actions-core.ts +686 -0
- package/src/execution/subagent-service.ts +396 -49
- package/src/execution/ui-request-handler-factory.ts +27 -4
- package/src/execution/worktree-git-ops.ts +397 -0
- package/src/execution/worktree-manager.ts +72 -10
- package/src/execution/worktree-registry.ts +6 -12
- package/src/index.ts +413 -6
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -4
- package/src/orchestration/__tests__/args-meta.test.ts +358 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +1 -8
- package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +382 -0
- package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
- package/src/orchestration/__tests__/file-run-store.test.ts +389 -0
- package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
- package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +77 -0
- package/src/orchestration/__tests__/run-snapshot.test.ts +353 -0
- package/src/orchestration/__tests__/script-generate.test.ts +314 -0
- package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
- package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
- package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
- package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
- package/src/orchestration/agent-opts-resolver.ts +7 -7
- package/src/orchestration/args-meta.ts +198 -0
- package/src/orchestration/error-recovery.ts +416 -146
- package/src/orchestration/execute-agent-call.ts +9 -9
- package/src/orchestration/file-run-store.ts +327 -0
- package/src/orchestration/launcher.ts +35 -26
- package/src/orchestration/lifecycle.ts +355 -79
- package/src/orchestration/models/agent-call.ts +7 -7
- package/src/orchestration/models/budget.ts +5 -5
- package/src/orchestration/models/run-runtime.ts +13 -13
- package/src/orchestration/models/trace.ts +8 -8
- package/src/orchestration/models/workflow-run.ts +27 -27
- package/src/orchestration/models/workflow-script.ts +4 -4
- package/src/orchestration/run-snapshot.ts +266 -0
- package/src/orchestration/script-generate.ts +154 -0
- package/src/orchestration/script-lint.ts +33 -33
- package/src/orchestration/worker-handle.ts +10 -10
- package/src/orchestration/worker-host.ts +10 -10
- package/src/orchestration/worker-script-builder.ts +401 -346
- package/src/orchestration/workflow-files.ts +37 -11
- package/src/orchestration/workflow-run-summary.ts +69 -0
- package/src/orchestration/workflow-script-registry-impl.ts +31 -9
- package/src/shared/__tests__/agent-ref.test.ts +209 -2
- package/src/shared/__tests__/atomic-write.test.ts +267 -0
- package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
- package/src/shared/__tests__/injection-render.test.ts +518 -0
- package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
- package/src/shared/__tests__/resource-discovery.test.ts +3 -2
- package/src/shared/agent-ref.ts +143 -2
- package/src/shared/atomic-write.ts +320 -0
- package/src/shared/bounded-serialize.ts +154 -0
- package/src/shared/injection-render.ts +279 -0
- package/src/shared/meta-parser.ts +41 -1
- package/src/shared/resource-discovery.ts +104 -37
- package/src/shared/resource-meta.ts +14 -0
- package/src/shared/xml-injection.ts +9 -9
- package/workflows/README.md +9 -9
- package/workflows/chain.js +4 -2
- package/workflows/map-reduce.js +5 -3
- package/workflows/parallel.js +5 -3
- package/workflows/review-fix-loop-utils.cjs +14 -3
- package/workflows/review-fix-loop.js +18 -7
- package/workflows/scatter-gather.js +4 -2
- package/dist/chunk-3VOERJPJ.js +0 -22
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [u-svc / T5② + T4②] session-runner 侧两措施(真实模块):
|
|
4
|
+
// - T5②/PS-7a:keep-alive 期 agent_end 心跳——覆盖写 .alive marker 刷新软超时基准
|
|
5
|
+
// (P-T5 探针裁决写盘开销可忽略,主路径落地);
|
|
6
|
+
// - T4②/PS-4:agent_settled 的 armIdleTimer fail-fast 降级语义修正——「挂
|
|
7
|
+
// DEFAULT_IDLE_TIMEOUT_MS + warn」而非旧「不挂」(保住通知放行门 + 进程回收两个
|
|
8
|
+
// 不变量,兜底可见)。
|
|
9
|
+
//
|
|
10
|
+
// mock 结构与 keep-alive-no-progress.test.ts / run-spawn-chatmode-settled.test.ts 一致
|
|
11
|
+
//(FakeChild + 受控 session-pending;lifecycle-manager partial mock:armIdleTimer 可注入
|
|
12
|
+
// fail-fast,其余导出(hasIdleTimer/_resetLifecycleState/DEFAULT)为真实实现)。
|
|
13
|
+
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
20
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
21
|
+
}));
|
|
22
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
23
|
+
|
|
24
|
+
const { writeAliveMarkerMock, readAliveMarkerMock, armIdleTimerMock } = vi.hoisted(() => ({
|
|
25
|
+
writeAliveMarkerMock: vi.fn(),
|
|
26
|
+
readAliveMarkerMock: vi.fn<() => undefined>(() => undefined),
|
|
27
|
+
armIdleTimerMock: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock("node:child_process", async () => {
|
|
31
|
+
const { FakeChild } = await import("./helpers/spawn-mock.ts");
|
|
32
|
+
return {
|
|
33
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
34
|
+
execFile: vi.fn(
|
|
35
|
+
(
|
|
36
|
+
_cmd: string,
|
|
37
|
+
_args: readonly string[],
|
|
38
|
+
_opts: unknown,
|
|
39
|
+
cb: (err: Error | null, stdout?: string, stderr?: string) => void,
|
|
40
|
+
) => cb(new Error("execFile not configured in this test")),
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
vi.mock("node:fs", async () => {
|
|
46
|
+
const actual = await import("node:fs");
|
|
47
|
+
return {
|
|
48
|
+
default: {
|
|
49
|
+
...actual,
|
|
50
|
+
mkdirSync: vi.fn(),
|
|
51
|
+
existsSync: vi.fn(() => false),
|
|
52
|
+
appendFileSync: vi.fn(),
|
|
53
|
+
writeFileSync: vi.fn(),
|
|
54
|
+
readdirSync: vi.fn(() => []),
|
|
55
|
+
},
|
|
56
|
+
mkdirSync: vi.fn(),
|
|
57
|
+
existsSync: vi.fn(() => false),
|
|
58
|
+
appendFileSync: vi.fn(),
|
|
59
|
+
writeFileSync: vi.fn(),
|
|
60
|
+
readdirSync: vi.fn(() => []),
|
|
61
|
+
promises: actual.promises,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
vi.mock("../alive-store.ts", () => ({
|
|
66
|
+
writeAliveMarker: writeAliveMarkerMock,
|
|
67
|
+
readAliveMarker: readAliveMarkerMock,
|
|
68
|
+
isProcessAlive: vi.fn(() => false),
|
|
69
|
+
ALIVE_SOFT_TIMEOUT_MS: 3_600_000,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
// keep-alive 心跳用例:count=1(有活跃后代 → keep-alive 分支)。
|
|
73
|
+
vi.mock("../session-pending.ts", () => ({
|
|
74
|
+
readActivePendingFromSessionFile: vi.fn(() => ({ count: 1, recentUnregister: false })),
|
|
75
|
+
prunePendingCursor: vi.fn(),
|
|
76
|
+
listActivePendingFromSessionFile: vi.fn(() => ({ items: [] })),
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
vi.mock("../temp-prompt.ts", () => ({
|
|
80
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
81
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
82
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
83
|
+
}),
|
|
84
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
vi.mock("../lifecycle-manager.ts", async (importOriginal) => {
|
|
88
|
+
const actual = await importOriginal<typeof import("../lifecycle-manager.ts")>();
|
|
89
|
+
return { ...actual, armIdleTimer: armIdleTimerMock };
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
import { DEFAULT_IDLE_TIMEOUT_MS, hasIdleTimer, _resetLifecycleState } from "../lifecycle-manager.ts";
|
|
93
|
+
import { runSpawn } from "../session-runner.ts";
|
|
94
|
+
import {
|
|
95
|
+
emitStdoutLine,
|
|
96
|
+
type FakeChild,
|
|
97
|
+
lastSpawnedChild as lastSpawnedChildOf,
|
|
98
|
+
makeCtx,
|
|
99
|
+
makeOpts,
|
|
100
|
+
makeRecord,
|
|
101
|
+
waitForSpawn as waitForSpawnOf,
|
|
102
|
+
} from "./helpers/spawn-mock.ts";
|
|
103
|
+
|
|
104
|
+
const mockSpawn = vi.mocked(spawn);
|
|
105
|
+
|
|
106
|
+
const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
|
|
107
|
+
const waitForSpawn = (timeoutMs = 1000): Promise<void> => waitForSpawnOf(mockSpawn, timeoutMs);
|
|
108
|
+
|
|
109
|
+
const flushPump = async (): Promise<void> => {
|
|
110
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
describe("T5② alive marker heartbeat on keep-alive agent_end", () => {
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
vi.clearAllMocks();
|
|
116
|
+
writeAliveMarkerMock.mockClear();
|
|
117
|
+
readAliveMarkerMock.mockReturnValue(undefined);
|
|
118
|
+
_resetLifecycleState();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
vi.restoreAllMocks();
|
|
123
|
+
vi.useRealTimers();
|
|
124
|
+
_resetLifecycleState();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("rewrites the alive marker each keep-alive agent_end (soft-timeout base refreshed)", async () => {
|
|
128
|
+
const record = makeRecord("sa-heartbeat");
|
|
129
|
+
// 预设 sessionFile(跳过 lazy get_state 回补;不 emit header——RPC mode 无 header,
|
|
130
|
+
// emit 会经 deriveSessionFilePath 覆盖预设路径)
|
|
131
|
+
record.sessionFile = "/tmp/fake-heartbeat-session.jsonl";
|
|
132
|
+
const promise = runSpawn(record, "Task: heartbeat", makeOpts(), makeCtx());
|
|
133
|
+
await waitForSpawn();
|
|
134
|
+
const child = lastSpawnedChild();
|
|
135
|
+
|
|
136
|
+
// 第一次 keep-alive agent_end → 心跳(保 pid/id、刷新 startedAt)
|
|
137
|
+
emitStdoutLine(child, { type: "agent_end", willRetry: false });
|
|
138
|
+
await flushPump();
|
|
139
|
+
expect(writeAliveMarkerMock).toHaveBeenCalledTimes(1);
|
|
140
|
+
expect(writeAliveMarkerMock).toHaveBeenCalledWith(
|
|
141
|
+
"/tmp/fake-heartbeat-session.jsonl",
|
|
142
|
+
expect.objectContaining({ pid: child.pid, id: record.id, startedAt: expect.any(Number) }),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// 第二次 agent_end(后代仍在)→ 再次心跳(软超时基准持续推新)
|
|
146
|
+
emitStdoutLine(child, { type: "agent_end", willRetry: false });
|
|
147
|
+
await flushPump();
|
|
148
|
+
expect(writeAliveMarkerMock).toHaveBeenCalledTimes(2);
|
|
149
|
+
|
|
150
|
+
child.stdout.end();
|
|
151
|
+
child.stderr.end();
|
|
152
|
+
child.emit("close", 143);
|
|
153
|
+
await promise;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("keeps the existing marker id when refreshing (readAliveMarker hit)", async () => {
|
|
157
|
+
readAliveMarkerMock.mockReturnValue({ pid: 1, id: "sess-orig-id", startedAt: 1 } as never);
|
|
158
|
+
const record = makeRecord("sa-heartbeat-id");
|
|
159
|
+
record.sessionFile = "/tmp/fake-heartbeat-session-2.jsonl";
|
|
160
|
+
const promise = runSpawn(record, "Task: heartbeat id", makeOpts(), makeCtx());
|
|
161
|
+
await waitForSpawn();
|
|
162
|
+
const child = lastSpawnedChild();
|
|
163
|
+
|
|
164
|
+
emitStdoutLine(child, { type: "agent_end", willRetry: false });
|
|
165
|
+
await flushPump();
|
|
166
|
+
expect(writeAliveMarkerMock).toHaveBeenLastCalledWith(
|
|
167
|
+
"/tmp/fake-heartbeat-session-2.jsonl",
|
|
168
|
+
expect.objectContaining({ id: "sess-orig-id" }),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
child.stdout.end();
|
|
172
|
+
child.stderr.end();
|
|
173
|
+
child.emit("close", 143);
|
|
174
|
+
await promise;
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe("T4② armIdleTimer fail-fast falls back to DEFAULT + warn", () => {
|
|
179
|
+
beforeEach(() => {
|
|
180
|
+
vi.clearAllMocks();
|
|
181
|
+
_resetLifecycleState();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
vi.restoreAllMocks();
|
|
186
|
+
vi.useRealTimers();
|
|
187
|
+
_resetLifecycleState();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("agent_settled with failing armIdleTimer still arms the timer at DEFAULT duration with a warning", async () => {
|
|
191
|
+
// 首次调用(record.idleTimeoutMs 通道)注入 fail-fast,后续转发真实实现
|
|
192
|
+
const actual = await vi.importActual<typeof import("../lifecycle-manager.ts")>("../lifecycle-manager.ts");
|
|
193
|
+
armIdleTimerMock.mockImplementationOnce(() => {
|
|
194
|
+
throw new Error("idleTimeoutMs = 3000000000 exceeds the Node setTimeout limit");
|
|
195
|
+
});
|
|
196
|
+
armIdleTimerMock.mockImplementation(
|
|
197
|
+
(recordId: string, onTimeout: () => void, timeoutMs?: number) =>
|
|
198
|
+
actual.armIdleTimer(recordId, onTimeout, timeoutMs),
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const record = makeRecord("sa-settled-fallback");
|
|
202
|
+
record.chatMode = true;
|
|
203
|
+
record.idleTimeoutMs = 600_000;
|
|
204
|
+
const promise = runSpawn(record, "Task: settled fallback", makeOpts(), makeCtx());
|
|
205
|
+
await waitForSpawn();
|
|
206
|
+
const child = lastSpawnedChild();
|
|
207
|
+
|
|
208
|
+
emitStdoutLine(child, { type: "agent_settled" });
|
|
209
|
+
await flushPump();
|
|
210
|
+
|
|
211
|
+
// 两次 arm:第一次(非法/失败通道)+ 降级(DEFAULT)
|
|
212
|
+
expect(armIdleTimerMock).toHaveBeenCalledTimes(2);
|
|
213
|
+
expect(armIdleTimerMock).toHaveBeenLastCalledWith(record.id, expect.any(Function), DEFAULT_IDLE_TIMEOUT_MS);
|
|
214
|
+
// 不变量保住:timer 确实 armed(通知放行门 + 进程回收有效)
|
|
215
|
+
expect(hasIdleTimer(record.id)).toBe(true);
|
|
216
|
+
// 降级可见
|
|
217
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
218
|
+
expect.stringContaining("fell back to DEFAULT_IDLE_TIMEOUT_MS"),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
child.stdout.end();
|
|
222
|
+
child.stderr.end();
|
|
223
|
+
child.emit("close", 143);
|
|
224
|
+
await promise;
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// src/execution/__tests__/settled-watchdog.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [T2-③ / LC-1] chatMode settled 等待固定硬上限。
|
|
4
|
+
//
|
|
5
|
+
// 设计:docs/design/subagent-core-unbounded-wait-audit.md §7.2 T2-③;10min 默认值
|
|
6
|
+
// 由 P-T2c 探针定案(probe/p-t2c-report.md,正常形态 ≈0ms、余量 4 个数量级)。
|
|
7
|
+
//
|
|
8
|
+
// 覆盖三面:
|
|
9
|
+
// 1. 原语单测(settled-watchdog.ts):唯一常量值 + arm 到期触发 + disarm 清除 +
|
|
10
|
+
// 重复 arm 以新窗口覆盖旧计时(旧窗不清则提前触发,断言可证伪)。
|
|
11
|
+
// 2. 首轮挂载集成(runSpawn chatMode):prompt 发出后挂载;advance 到
|
|
12
|
+
// SETTLED_WATCHDOG_TIMEOUT_MS 触发 kill → close → runSpawn 以错误返回且错误含
|
|
13
|
+
// 恢复指引(S-B 验收判据)。
|
|
14
|
+
// 3. 清除点:settled 到达(含 resolveRun 同点)/ close 任一发生即清——挂载点消费
|
|
15
|
+
// 同一导出常量(advance 常量值可触发 = 同源断言)。
|
|
16
|
+
//
|
|
17
|
+
// [时序约束] runSpawn 内部的前置 await 均为微任务级(mock),集成用例在调用 runSpawn
|
|
18
|
+
// **之前**启用 fake timers,再用 advanceTimersByTimeAsync(1) 循环 flush 到 spawn 被调
|
|
19
|
+
// ——settled watchdog 挂载于 spawn 时,先 waitForSpawn(真实 setTimeout 轮询)后
|
|
20
|
+
// useFakeTimers 的话 timer 已挂在真实时钟上,fake 时钟 advance 推不到(MF-3 先例
|
|
21
|
+
// 注释的同一原理)。
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
27
|
+
|
|
28
|
+
vi.mock("node:child_process", async () => {
|
|
29
|
+
const { FakeChild } = await import("./helpers/spawn-mock.ts");
|
|
30
|
+
return {
|
|
31
|
+
spawn: vi.fn(() => new FakeChild()),
|
|
32
|
+
execFile: vi.fn(
|
|
33
|
+
(
|
|
34
|
+
_cmd: string,
|
|
35
|
+
_args: readonly string[],
|
|
36
|
+
_opts: unknown,
|
|
37
|
+
cb: (err: Error | null, stdout?: string, stderr?: string) => void,
|
|
38
|
+
) => cb(new Error("execFile not configured in this test")),
|
|
39
|
+
),
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
vi.mock("node:fs", async () => {
|
|
44
|
+
const actual = await import("node:fs");
|
|
45
|
+
return {
|
|
46
|
+
default: {
|
|
47
|
+
...actual,
|
|
48
|
+
mkdirSync: vi.fn(),
|
|
49
|
+
existsSync: vi.fn(() => false),
|
|
50
|
+
appendFileSync: vi.fn(),
|
|
51
|
+
writeFileSync: vi.fn(),
|
|
52
|
+
readdirSync: vi.fn(() => []),
|
|
53
|
+
},
|
|
54
|
+
mkdirSync: vi.fn(),
|
|
55
|
+
existsSync: vi.fn(() => false),
|
|
56
|
+
appendFileSync: vi.fn(),
|
|
57
|
+
writeFileSync: vi.fn(),
|
|
58
|
+
readdirSync: vi.fn(() => []),
|
|
59
|
+
promises: actual.promises,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
vi.mock("../alive-store.ts", () => ({ writeAliveMarker: vi.fn() }));
|
|
64
|
+
|
|
65
|
+
// chatMode agent_end 早返回(continue),不读 session-pending;统一 count=0 防干扰。
|
|
66
|
+
vi.mock("../session-pending.ts", () => ({
|
|
67
|
+
readActivePendingFromSessionFile: vi.fn(() => ({ count: 0, recentUnregister: false })),
|
|
68
|
+
prunePendingCursor: vi.fn(),
|
|
69
|
+
listActivePendingFromSessionFile: vi.fn(() => ({ items: [] })),
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
vi.mock("../temp-prompt.ts", () => ({
|
|
73
|
+
writePromptToTempFile: vi.fn(async (agent: string) => {
|
|
74
|
+
const safeName = agent.replace(/[^\w.-]+/g, "_");
|
|
75
|
+
return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
|
|
76
|
+
}),
|
|
77
|
+
cleanupTempPrompt: vi.fn(async () => {}),
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
import { runSpawn } from "../session-runner.ts";
|
|
81
|
+
import {
|
|
82
|
+
_resetSettledWatchdogsForTest,
|
|
83
|
+
armSettledWatchdog,
|
|
84
|
+
disarmSettledWatchdog,
|
|
85
|
+
hasSettledWatchdog,
|
|
86
|
+
SETTLED_WATCHDOG_TIMEOUT_MS,
|
|
87
|
+
} from "../settled-watchdog.ts";
|
|
88
|
+
import { _resetLifecycleState } from "../lifecycle-manager.ts";
|
|
89
|
+
import { createRecord } from "../execution-record.ts";
|
|
90
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
91
|
+
import {
|
|
92
|
+
emitStdoutLine,
|
|
93
|
+
type FakeChild,
|
|
94
|
+
lastSpawnedChild as lastSpawnedChildOf,
|
|
95
|
+
makeCtx,
|
|
96
|
+
makeOpts,
|
|
97
|
+
makeRecord,
|
|
98
|
+
} from "./helpers/spawn-mock.ts";
|
|
99
|
+
|
|
100
|
+
const mockSpawn = vi.mocked(spawn);
|
|
101
|
+
const lastSpawnedChild = (): FakeChild => lastSpawnedChildOf(mockSpawn);
|
|
102
|
+
|
|
103
|
+
function sessionHeaderLine(): Record<string, unknown> {
|
|
104
|
+
return { type: "session", id: "sess-sw", timestamp: "2026-09-01T00:00:00.000Z", cwd: "/tmp/test" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 构造 chatMode record(idleTimeoutMs 拉长到 1h,防 idle timer 干扰 10min 窗口断言)。 */
|
|
108
|
+
function makeChatModeRecord(id = "sa-chat-sw"): ExecutionRecord {
|
|
109
|
+
const record = createRecord(id, {
|
|
110
|
+
agent: "general-purpose",
|
|
111
|
+
model: "test-model",
|
|
112
|
+
mode: "sync",
|
|
113
|
+
task: "chat task",
|
|
114
|
+
slug: "chat",
|
|
115
|
+
startedAt: 1_000_000,
|
|
116
|
+
rootSessionId: "root-session",
|
|
117
|
+
parentRecordId: undefined,
|
|
118
|
+
depth: 0,
|
|
119
|
+
chatMode: true,
|
|
120
|
+
});
|
|
121
|
+
record.idleTimeoutMs = 3_600_000;
|
|
122
|
+
return record;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* fake timers 下的 spawn 等待:advanceTimersByTimeAsync(1) 循环 flush 微任务链
|
|
127
|
+
*(runSpawn 前置 await 均为 mock 的微任务级),直到 spawn 被调。
|
|
128
|
+
*/
|
|
129
|
+
async function waitForSpawnUnderFakeTimers(baseline: number, maxIters = 50): Promise<void> {
|
|
130
|
+
for (let i = 0; i < maxIters && mockSpawn.mock.results.length <= baseline; i++) {
|
|
131
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
132
|
+
}
|
|
133
|
+
if (mockSpawn.mock.results.length <= baseline) {
|
|
134
|
+
throw new Error("spawn was not called under fake timers");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("[T2-③] settled-watchdog 原语", () => {
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
vi.clearAllMocks();
|
|
141
|
+
_resetSettledWatchdogsForTest();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
_resetSettledWatchdogsForTest();
|
|
146
|
+
vi.useRealTimers();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("唯一常量值 = 10min(P-T2c 定案,双挂载点共享此导出)", () => {
|
|
150
|
+
expect(SETTLED_WATCHDOG_TIMEOUT_MS).toBe(10 * 60 * 1000);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("arm 后 advance 到 10min 触发 onTimeout(到期自删记账);disarm 后不再触发", async () => {
|
|
154
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
155
|
+
const onTimeout = vi.fn();
|
|
156
|
+
armSettledWatchdog("sa-1", onTimeout);
|
|
157
|
+
expect(hasSettledWatchdog("sa-1")).toBe(true);
|
|
158
|
+
|
|
159
|
+
await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS - 1);
|
|
160
|
+
expect(onTimeout).not.toHaveBeenCalled();
|
|
161
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
162
|
+
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
163
|
+
// 到期回调先自删条目(对齐 armIdleTimer 语义)
|
|
164
|
+
expect(hasSettledWatchdog("sa-1")).toBe(false);
|
|
165
|
+
|
|
166
|
+
// disarm 已触发/不存在的 record:幂等 no-op
|
|
167
|
+
disarmSettledWatchdog("sa-1");
|
|
168
|
+
disarmSettledWatchdog("never-armed");
|
|
169
|
+
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("重复 arm 以新窗口覆盖旧计时——旧窗口不清会提前触发(断言可证伪)", async () => {
|
|
173
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
174
|
+
const onTimeout = vi.fn();
|
|
175
|
+
armSettledWatchdog("sa-2", onTimeout);
|
|
176
|
+
await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS / 2);
|
|
177
|
+
// 重挂(新一轮 prompt):旧窗口必须作废
|
|
178
|
+
armSettledWatchdog("sa-2", onTimeout);
|
|
179
|
+
|
|
180
|
+
// 若旧窗未清:旧 timer 会在重挂后 5min 触发(此处 advance 内)→ 断言失败
|
|
181
|
+
await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS - 1);
|
|
182
|
+
expect(onTimeout).not.toHaveBeenCalled();
|
|
183
|
+
await vi.advanceTimersByTimeAsync(1);
|
|
184
|
+
expect(onTimeout).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("[T2-③] runSpawn 首轮挂载(chatMode)", () => {
|
|
189
|
+
beforeEach(() => {
|
|
190
|
+
vi.clearAllMocks();
|
|
191
|
+
vi.stubEnv("XYZ_SUBAGENT_SPAWN_WATCHDOG_MS", "");
|
|
192
|
+
_resetLifecycleState();
|
|
193
|
+
_resetSettledWatchdogsForTest();
|
|
194
|
+
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
afterEach(() => {
|
|
198
|
+
vi.restoreAllMocks();
|
|
199
|
+
vi.unstubAllEnvs();
|
|
200
|
+
vi.useRealTimers();
|
|
201
|
+
_resetLifecycleState();
|
|
202
|
+
_resetSettledWatchdogsForTest();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("settled 永不到达:10min 硬上限到期 kill 层主 → runSpawn 以错误返回且含恢复指引", async () => {
|
|
206
|
+
const record = makeChatModeRecord();
|
|
207
|
+
const baseline = mockSpawn.mock.results.length;
|
|
208
|
+
const promise = runSpawn(record, "Chat: wedge", makeOpts(), makeCtx());
|
|
209
|
+
await waitForSpawnUnderFakeTimers(baseline);
|
|
210
|
+
const child = lastSpawnedChild();
|
|
211
|
+
|
|
212
|
+
// 挂载点消费同一导出常量:advance 常量值量级可触发即同源断言。
|
|
213
|
+
// [余量] fake timers 对 Date mock 有 ms 级舍入(实测 advance(SET-1) 会触发),
|
|
214
|
+
// 断言边界留 1s 余量——对 10min 上限语义无损,对计时器实现细节稳健。
|
|
215
|
+
emitStdoutLine(child, sessionHeaderLine());
|
|
216
|
+
await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS - 1_000);
|
|
217
|
+
expect(child.killed).toBe(false);
|
|
218
|
+
await vi.advanceTimersByTimeAsync(2_000);
|
|
219
|
+
expect(child.killed).toBe(true);
|
|
220
|
+
expect(child.killSignal).toBe("SIGTERM");
|
|
221
|
+
|
|
222
|
+
// kill → close:runSpawn 以错误返回(设计 §6.2 首轮窗口形态)
|
|
223
|
+
child.stdout.end();
|
|
224
|
+
child.stderr.end();
|
|
225
|
+
child.emit("close", 143);
|
|
226
|
+
const result = await promise;
|
|
227
|
+
expect(result.success).toBe(false);
|
|
228
|
+
expect(result.error).toContain("settled watchdog");
|
|
229
|
+
expect(result.error).toContain("agent_settled");
|
|
230
|
+
// S-B 验收判据:错误消息含恢复指引
|
|
231
|
+
expect(result.error).toContain("Recovery:");
|
|
232
|
+
expect(result.error).toContain("action:'list'");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("settled 到达:硬上限即清(含 resolveRun 同点)——advance 10min 不误杀已正常轮转的层主", async () => {
|
|
236
|
+
const record = makeChatModeRecord();
|
|
237
|
+
const baseline = mockSpawn.mock.results.length;
|
|
238
|
+
const promise = runSpawn(record, "Chat: normal", makeOpts(), makeCtx());
|
|
239
|
+
await waitForSpawnUnderFakeTimers(baseline);
|
|
240
|
+
const child = lastSpawnedChild();
|
|
241
|
+
|
|
242
|
+
emitStdoutLine(child, sessionHeaderLine());
|
|
243
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
244
|
+
emitStdoutLine(child, { type: "agent_settled" });
|
|
245
|
+
await new Promise((r) => setImmediate(r));
|
|
246
|
+
|
|
247
|
+
// 首轮窗口结束(resolveRun 同点清除硬上限);idle timer 被拉长到 1h 不干扰本窗口
|
|
248
|
+
expect(hasSettledWatchdog(record.id)).toBe(false);
|
|
249
|
+
await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS + 60_000);
|
|
250
|
+
// settled watchdog 已清:子进程未被本上界 kill(若回归未清,此处 killed=true)
|
|
251
|
+
expect(child.killed).toBe(false);
|
|
252
|
+
|
|
253
|
+
// 收尾
|
|
254
|
+
child.stdout.end();
|
|
255
|
+
child.stderr.end();
|
|
256
|
+
child.emit("close", 0);
|
|
257
|
+
const result = await promise;
|
|
258
|
+
expect(result.success).toBe(true);
|
|
259
|
+
expect(result.error).toBeUndefined();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("挂载面限定 chatMode:非 chatMode 首轮不挂 settled 上界", async () => {
|
|
263
|
+
const record = makeRecord(); // 非 chatMode
|
|
264
|
+
const baseline = mockSpawn.mock.results.length;
|
|
265
|
+
const promise = runSpawn(record, "Task: not chat", makeOpts(), makeCtx());
|
|
266
|
+
await waitForSpawnUnderFakeTimers(baseline);
|
|
267
|
+
const child = lastSpawnedChild();
|
|
268
|
+
|
|
269
|
+
emitStdoutLine(child, sessionHeaderLine());
|
|
270
|
+
await new Promise((r) => setImmediate(r));
|
|
271
|
+
expect(hasSettledWatchdog(record.id)).toBe(false);
|
|
272
|
+
|
|
273
|
+
// 非 chatMode:即使静默超过 10min 也不被 settled 上界 kill(spawn watchdog 同为
|
|
274
|
+
// 未挂载——裸缺省 + env 清),agent_end 之前的执行期不受 T2-③ 触及
|
|
275
|
+
await vi.advanceTimersByTimeAsync(SETTLED_WATCHDOG_TIMEOUT_MS + 60_000);
|
|
276
|
+
expect(child.killed).toBe(false);
|
|
277
|
+
|
|
278
|
+
// agent_end(count=0)→ final kill(既有语义)
|
|
279
|
+
emitStdoutLine(child, { type: "agent_end", messages: [], willRetry: false });
|
|
280
|
+
await new Promise((r) => setImmediate(r));
|
|
281
|
+
expect(child.killed).toBe(true);
|
|
282
|
+
|
|
283
|
+
child.stdout.end();
|
|
284
|
+
child.stderr.end();
|
|
285
|
+
child.emit("close", 143);
|
|
286
|
+
const result = await promise;
|
|
287
|
+
expect(result.success).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
});
|