@zhushanwen/subagent-core 0.7.1 → 0.9.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/dist/{chunk-VURUAGMM.js → chunk-LZJOEDGQ.js} +603 -154
- package/dist/engine-discovery-scan-Da4Dkzsi.d.cts +818 -0
- package/dist/engine-discovery-scan-Da4Dkzsi.d.ts +818 -0
- package/dist/execution/engine/engine-discovery-scan.cjs +325 -154
- package/dist/execution/engine/engine-discovery-scan.d.cts +1 -2
- package/dist/execution/engine/engine-discovery-scan.d.ts +1 -2
- package/dist/execution/engine/engine-discovery-scan.js +1 -1
- package/dist/execution/relay-env.js +5 -4
- package/dist/index.cjs +9799 -7954
- package/dist/index.d.cts +1896 -1360
- package/dist/index.d.ts +1896 -1360
- package/dist/index.js +8952 -7513
- package/dist.bundle/index.cjs +7897 -6054
- package/package.json +5 -5
- package/src/__tests__/manifest-store.test.ts +16 -15
- package/src/__tests__/record-store-last-line.test.ts +12 -0
- package/src/__tests__/robustness-medium-batch1.test.ts +10 -14
- package/src/__tests__/robustness-medium-batch2.test.ts +7 -2
- package/src/core/logger.ts +1 -1
- package/src/execution/__tests__/alive-store.test.ts +117 -1
- package/src/execution/__tests__/batch-finalized.test.ts +206 -0
- package/src/execution/__tests__/chat-engine-routing.test.ts +117 -1
- package/src/execution/__tests__/{cold-resurrect.test.ts → cold-lookup.test.ts} +133 -33
- package/src/execution/__tests__/collect-coordinator-service.test.ts +7 -5
- package/src/execution/__tests__/conversation-continuation.test.ts +1452 -0
- package/src/execution/__tests__/delivery-methods.test.ts +90 -176
- package/src/execution/__tests__/dialog-queue.test.ts +70 -3
- package/src/execution/__tests__/dispose-manifest-recovery.test.ts +475 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +3 -3
- package/src/execution/__tests__/execute-nesting.test.ts +8 -5
- package/src/execution/__tests__/execution-record.test.ts +8 -148
- package/src/execution/__tests__/finalize-record.test.ts +295 -129
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +21 -29
- package/src/execution/__tests__/helpers/fake-engine-port.ts +13 -68
- package/src/execution/__tests__/helpers/legacy-sidecar.ts +33 -0
- package/src/execution/__tests__/helpers/subagent-service-mocks.ts +9 -5
- package/src/execution/__tests__/idle-gc.test.ts +60 -0
- package/src/execution/__tests__/inflight-production-wiring.test.ts +223 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +3 -254
- package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +43 -22
- package/src/execution/__tests__/model-config-service.test.ts +111 -0
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +10 -6
- package/src/execution/__tests__/nested-visibility.test.ts +13 -1
- package/src/execution/__tests__/notify-ledger.test.ts +42 -1
- package/src/execution/__tests__/rebuild-indexes.test.ts +329 -0
- package/src/execution/__tests__/record-binding.test.ts +613 -0
- package/src/execution/__tests__/record-origin.test.ts +318 -0
- package/src/execution/__tests__/record-store-intent-api.test.ts +600 -0
- package/src/execution/__tests__/record-store-orphan-revive.test.ts +71 -3
- package/src/execution/__tests__/record-store.test.ts +72 -54
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +8 -5
- package/src/execution/__tests__/round-supervisor-workflow-origin.test.ts +205 -0
- package/src/execution/__tests__/run-orchestration-write-lease.test.ts +293 -0
- package/src/execution/__tests__/run-orchestration.test.ts +199 -0
- package/src/execution/__tests__/session-file-gc.test.ts +35 -4
- package/src/execution/__tests__/session-reconstructor.test.ts +78 -0
- package/src/execution/__tests__/state-marker.test.ts +288 -0
- package/src/execution/__tests__/stream-sink-retirement.test.ts +8 -5
- package/src/execution/__tests__/subagent-actions-core.test.ts +67 -16
- package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +0 -1
- package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -11
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +17 -11
- package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +130 -53
- package/src/execution/__tests__/subagent-service.test.ts +17 -215
- package/src/execution/__tests__/subprocess-agent-runner-no-progress-killall.test.ts +138 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +105 -660
- package/src/execution/__tests__/sync-collect-recovery.test.ts +23 -16
- package/src/execution/__tests__/terminal-write-latency.test.ts +79 -0
- package/src/execution/__tests__/workflow-agent-dispatch.test.ts +782 -0
- package/src/execution/alive-store.ts +37 -28
- package/src/execution/{cold-resurrect.ts → cold-lookup.ts} +48 -45
- package/src/execution/config.ts +1 -1
- package/src/execution/conversation-continuation.ts +541 -0
- package/src/execution/dialog-queue.ts +60 -12
- package/src/execution/engine/__tests__/common/capability-gate.test.ts +1 -1
- package/src/execution/engine/__tests__/common/journal-wiring.test.ts +16 -1
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/fake-engine-protocol.mjs +17 -51
- package/src/execution/engine/__tests__/conformance/__fixtures__/engine-protocol/smoke-run.fixture.json +0 -5
- package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +1 -1
- package/src/execution/engine/__tests__/conformance/protocol-blackbox.test.ts +5 -9
- package/src/execution/engine/__tests__/conformance/registry-fork-filter.test.ts +55 -53
- package/src/execution/engine/__tests__/engine-discovery-scan.test.ts +0 -3
- package/src/execution/engine/__tests__/engine-discovery.test.ts +0 -3
- package/src/execution/engine/__tests__/inflight-snapshot.test.ts +180 -0
- package/src/execution/engine/__tests__/model-prompt.test.ts +0 -3
- package/src/execution/engine/__tests__/registry.test.ts +0 -1
- package/src/execution/engine/__tests__/routing.test.ts +0 -1
- package/src/execution/engine/__tests__/run-failure-worktree-cleanup.test.ts +0 -3
- package/src/execution/engine/client/__tests__/__fixtures__/fake-engine.mjs +0 -3
- package/src/execution/engine/client/__tests__/engine-client-reap.test.ts +212 -0
- package/src/execution/engine/client/__tests__/mirror.test.ts +73 -1
- package/src/execution/engine/client/__tests__/protocol-closure.test.ts +2 -9
- package/src/execution/engine/client/__tests__/remote-engine.test.ts +46 -12
- package/src/execution/engine/client/engine-client.ts +111 -26
- package/src/execution/engine/client/mirror.ts +9 -0
- package/src/execution/engine/client/remote-engine.ts +52 -34
- package/src/execution/engine/client/reverse-router.ts +6 -27
- package/src/execution/engine/common/__tests__/run-signals.test.ts +79 -0
- package/src/execution/engine/common/capability-gate.ts +27 -0
- package/src/execution/engine/common/data-dir.ts +3 -2
- package/src/execution/engine/common/errors.ts +1 -1
- package/src/execution/engine/common/journal-wiring.ts +18 -9
- package/src/execution/engine/common/run-signals.ts +83 -0
- package/src/execution/engine/d8-compat.ts +1 -1
- package/src/execution/engine/host/__tests__/host-bridge.test.ts +3 -18
- package/src/execution/engine/host/host-bridge.ts +26 -41
- package/src/execution/engine/host/pi-host-binding.ts +5 -5
- package/src/execution/engine/host/spawned-children.ts +1 -1
- package/src/execution/engine/inflight-snapshot.ts +92 -0
- package/src/execution/engine/port.ts +52 -54
- package/src/execution/engine/types.ts +11 -14
- package/src/execution/execution-record.ts +15 -47
- package/src/execution/finalize-record.ts +94 -197
- package/src/execution/idle-gc.ts +9 -3
- package/src/execution/lifecycle-manager.ts +25 -361
- package/src/execution/lifecycle-predicates.ts +22 -19
- package/src/execution/manifest-store.ts +40 -42
- package/src/execution/model-config-service.ts +1 -1
- package/src/execution/notifier.ts +39 -1
- package/src/execution/notify-host.ts +13 -4
- package/src/execution/path-encoding.ts +6 -0
- package/src/execution/record-entry.ts +20 -2
- package/src/execution/record-store.ts +925 -160
- package/src/execution/relay-env.ts +8 -3
- package/src/execution/round-supervisor/domain.ts +17 -5
- package/src/execution/round-supervisor/service-binding.test.ts +89 -53
- package/src/execution/round-supervisor/service-binding.ts +71 -21
- package/src/execution/round-supervisor/supervisor.ts +23 -9
- package/src/execution/service/record-access.ts +547 -0
- package/src/execution/service/record-lifecycle.ts +542 -0
- package/src/execution/service/run-orchestration.ts +1647 -0
- package/src/execution/service/service-bootstrap.ts +111 -0
- package/src/execution/service/service-constants.ts +27 -0
- package/src/execution/service/session-baselines.ts +401 -0
- package/src/execution/service/sync-collect-domain.ts +442 -0
- package/src/execution/service/workflow-dispatch.ts +567 -0
- package/src/execution/session-file-gc.ts +11 -8
- package/src/execution/session-reconstructor.ts +44 -1
- package/src/execution/sessions-index.ts +20 -3
- package/src/execution/settled-watchdog.ts +86 -43
- package/src/execution/state-marker.ts +516 -0
- package/src/execution/stream-sink.ts +1 -1
- package/src/execution/subagent-actions-core.ts +53 -31
- package/src/execution/subagent-service.ts +643 -2955
- package/src/execution/subprocess-agent-runner.ts +46 -323
- package/src/execution/types.ts +60 -43
- package/src/execution/ui-request-handler-factory.ts +5 -0
- package/src/execution/worktree-manager.ts +1 -1
- package/src/execution/worktree-registry.ts +12 -8
- package/src/index.ts +16 -5
- package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +2 -3
- package/src/orchestration/__tests__/agent-call-stream.test.ts +24 -49
- package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +4 -4
- package/src/orchestration/__tests__/file-run-store.test.ts +5 -7
- package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +2 -3
- package/src/orchestration/__tests__/run-snapshot.test.ts +27 -43
- package/src/orchestration/__tests__/worker-message-pump-workflow-dispatch.test.ts +274 -0
- package/src/orchestration/execute-agent-call.ts +1 -1
- package/src/orchestration/file-run-store.ts +1 -1
- package/src/orchestration/models/ports.ts +20 -13
- package/src/orchestration/models/types.ts +11 -13
- package/src/orchestration/run-snapshot.ts +18 -22
- package/src/orchestration/worker-message-pump.ts +33 -57
- package/src/shared/atomic-write.ts +10 -8
- package/dist/chunk-T2SYBW3I.js +0 -24
- package/dist/engine-discovery-scan-ocpM8FmI.d.cts +0 -1608
- package/dist/engine-discovery-scan-ocpM8FmI.d.ts +0 -1608
- package/src/execution/__tests__/chat-round-first-round-watchdog.test.ts +0 -255
- package/src/execution/__tests__/finalized-marker.test.ts +0 -104
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +0 -211
- package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +0 -488
- package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +0 -75
- package/src/execution/__tests__/tombstone-store.test.ts +0 -73
- package/src/execution/engine/__tests__/conformance/chat-round-protocol.test.ts +0 -230
- package/src/execution/finalized-marker.ts +0 -70
- package/src/execution/tombstone-store.ts +0 -72
|
@@ -0,0 +1,1452 @@
|
|
|
1
|
+
// src/execution/__tests__/conversation-continuation.test.ts
|
|
2
|
+
//
|
|
3
|
+
// [H1 U2] ConversationContinuation 单测族(§5 U2 验收清单逐项对应用例)+ SubagentService
|
|
4
|
+
// 集成面(one-shot 四分支回归 / closeAfterRound 退役 / 引擎死亡单发通知 / stale-child
|
|
5
|
+
// 兜底 / 收割链之外的 chat 编排面)。
|
|
6
|
+
//
|
|
7
|
+
// 设计权威源:docs/design/subagent-chat-run-unification.md §3.4(伪码即实现契约)/
|
|
8
|
+
// §3.3 D4 状态迁移表 / D5 双写点 gate / D7 轮末分流。Continuation 单测 = mock host
|
|
9
|
+
//(编排能力全注入,次序断言用 order 账本);集成面 = registerFakePiEngine 协议替身
|
|
10
|
+
//(与 delivery-methods.test.ts 同源 setup 形态)。
|
|
11
|
+
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
17
|
+
|
|
18
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
19
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
|
|
22
|
+
|
|
23
|
+
const { killChildSpy } = vi.hoisted(() => ({ killChildSpy: vi.fn() }));
|
|
24
|
+
vi.mock("../engine/host/spawned-children.ts", async (importOriginal) => {
|
|
25
|
+
const actual = await importOriginal<typeof import("../engine/host/spawned-children.ts")>();
|
|
26
|
+
return { ...actual, killRecordChildWithEscalation: killChildSpy };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
import { ConversationContinuation } from "../conversation-continuation.ts";
|
|
30
|
+
import type {
|
|
31
|
+
ContinuationDispatchInput,
|
|
32
|
+
ContinuationHost,
|
|
33
|
+
} from "../conversation-continuation.ts";
|
|
34
|
+
import type { RoundSettlementOutcome } from "../finalize-record.ts";
|
|
35
|
+
import { createNotifier, type BgNotifyRecord } from "../notifier.ts";
|
|
36
|
+
import { bindNotifyLedgerHost } from "../notify-ledger.ts";
|
|
37
|
+
import type { AgentOutcome, EngineCapabilities, EngineHandle } from "../engine/types.ts";
|
|
38
|
+
import type { EnginePort, EngineRunResult, RunContext } from "../engine/port.ts";
|
|
39
|
+
import { registerFakePiEngine, type FakePiEnginePort } from "./helpers/fake-engine-port.ts";
|
|
40
|
+
import { clearEngines, registerEngine } from "../engine/registry.ts";
|
|
41
|
+
import { createRecord } from "../execution-record.ts";
|
|
42
|
+
import { ModelConfigService } from "../model-config-service.ts";
|
|
43
|
+
import type { RecordStore } from "../record-store.ts";
|
|
44
|
+
import { SubagentService } from "../subagent-service.ts";
|
|
45
|
+
import type { PiLike } from "../subagent-service.ts";
|
|
46
|
+
import {
|
|
47
|
+
armMidRoundNoProgress,
|
|
48
|
+
getSettledWatchdogPhase,
|
|
49
|
+
hasSettledWatchdog,
|
|
50
|
+
_resetSettledWatchdogsForTest,
|
|
51
|
+
_setMidRoundNoProgressWindowMsForTest,
|
|
52
|
+
} from "../settled-watchdog.ts";
|
|
53
|
+
import { _resetLifecycleState } from "../lifecycle-manager.ts";
|
|
54
|
+
import {
|
|
55
|
+
_resetCoreSpawnedChildrenMirrorForTest,
|
|
56
|
+
registerSpawnedChildForRecord,
|
|
57
|
+
} from "../engine/host/spawned-children.ts";
|
|
58
|
+
import type { ExecutionRecord } from "../types.ts";
|
|
59
|
+
import { SUBAGENT_RECORD_CUSTOM_TYPE, type SubagentRecordEntryData } from "../record-entry.ts";
|
|
60
|
+
|
|
61
|
+
// ============================================================
|
|
62
|
+
// Continuation 单测(mock host)
|
|
63
|
+
// ============================================================
|
|
64
|
+
|
|
65
|
+
interface HostCalls {
|
|
66
|
+
order: string[];
|
|
67
|
+
dispatched: Array<ContinuationDispatchInput & { recordId: string }>;
|
|
68
|
+
finalized: Array<{ recordId: string; outcome: RoundSettlementOutcome }>;
|
|
69
|
+
routed: string[];
|
|
70
|
+
notified: BgNotifyRecord[];
|
|
71
|
+
killStale: string[];
|
|
72
|
+
killedRound: Array<{ recordId: string; source: string }>;
|
|
73
|
+
revived: string[];
|
|
74
|
+
/** [U2b 修复轮/D2] 轮始簿记委托达点(markRoundStarted host 成员)。 */
|
|
75
|
+
roundStarts: string[];
|
|
76
|
+
closed: string[];
|
|
77
|
+
gateAllows: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeRecord(overrides: Partial<ExecutionRecord> & { id?: string } = {}): ExecutionRecord {
|
|
81
|
+
const { id = "sa-cont", ...rest } = overrides;
|
|
82
|
+
const r = createRecord(id, {
|
|
83
|
+
agent: "general-purpose",
|
|
84
|
+
model: "prov/model-1",
|
|
85
|
+
thinkingLevel: "low",
|
|
86
|
+
mode: "background",
|
|
87
|
+
task: "initial task",
|
|
88
|
+
slug: "cont",
|
|
89
|
+
startedAt: 1000,
|
|
90
|
+
rootSessionId: "root-session",
|
|
91
|
+
chatMode: true,
|
|
92
|
+
controller: new AbortController(),
|
|
93
|
+
});
|
|
94
|
+
Object.assign(r, rest);
|
|
95
|
+
return r;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function makeHost(record: ExecutionRecord, overrides: Partial<HostCalls> = {}): {
|
|
99
|
+
host: ContinuationHost;
|
|
100
|
+
calls: HostCalls;
|
|
101
|
+
} {
|
|
102
|
+
const calls: HostCalls = {
|
|
103
|
+
order: [],
|
|
104
|
+
dispatched: [],
|
|
105
|
+
finalized: [],
|
|
106
|
+
routed: [],
|
|
107
|
+
notified: [],
|
|
108
|
+
killStale: [],
|
|
109
|
+
killedRound: [],
|
|
110
|
+
revived: [],
|
|
111
|
+
roundStarts: [],
|
|
112
|
+
closed: [],
|
|
113
|
+
gateAllows: true,
|
|
114
|
+
...overrides,
|
|
115
|
+
};
|
|
116
|
+
const host: ContinuationHost = {
|
|
117
|
+
dispatchChatRound: (rec, input) => {
|
|
118
|
+
calls.order.push(`dispatch:${rec.id}`);
|
|
119
|
+
calls.dispatched.push({ ...input, recordId: rec.id });
|
|
120
|
+
},
|
|
121
|
+
finalizeRoundOutcome: async (rec, outcome) => {
|
|
122
|
+
calls.order.push(`finalize:${outcome.kind}`);
|
|
123
|
+
calls.finalized.push({ recordId: rec.id, outcome });
|
|
124
|
+
// 模拟真实 doFinalizeRoundToIdle 的 round+1(dedup key 断言依赖)。
|
|
125
|
+
// 刻意不清 closedReason(真实实现清)——notifyGate 门用例需要构造
|
|
126
|
+
// 「running record + closedReason 残留」的门语义形态(真实链路该形态
|
|
127
|
+
// 由 finalize 清除前的窗口构成,门是防御性第二闸)。
|
|
128
|
+
record.round = (record.round ?? 0) + 1;
|
|
129
|
+
},
|
|
130
|
+
routeRecord: (rec) => {
|
|
131
|
+
calls.order.push(`route:${rec.id}`);
|
|
132
|
+
calls.routed.push(rec.id);
|
|
133
|
+
},
|
|
134
|
+
notifyRecord: (n) => {
|
|
135
|
+
calls.order.push("notify");
|
|
136
|
+
calls.notified.push(n);
|
|
137
|
+
},
|
|
138
|
+
killStaleChild: async (id) => {
|
|
139
|
+
calls.order.push(`killStale:${id}`);
|
|
140
|
+
calls.killStale.push(id);
|
|
141
|
+
},
|
|
142
|
+
killRoundChild: (id, source) => {
|
|
143
|
+
calls.killedRound.push({ recordId: id, source });
|
|
144
|
+
},
|
|
145
|
+
upgradeGateAllows: () => calls.gateAllows,
|
|
146
|
+
reviveClosedRecord: (rec) => {
|
|
147
|
+
calls.revived.push(rec.id);
|
|
148
|
+
},
|
|
149
|
+
markRoundStarted: (rec) => {
|
|
150
|
+
// [U2b 修复轮/D2] mock host 记委托达点;record 实际清除语义由真实 store 链
|
|
151
|
+
// 在集成面验证(下方「轮始 markRoundStarted 接线」用例)。
|
|
152
|
+
calls.order.push(`roundStart:${rec.id}`);
|
|
153
|
+
calls.roundStarts.push(rec.id);
|
|
154
|
+
},
|
|
155
|
+
closeNow: async (rec) => {
|
|
156
|
+
calls.closed.push(rec.id);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
return { host, calls };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function makeOutcome(partial: Partial<AgentOutcome> = {}): AgentOutcome {
|
|
163
|
+
return { content: "", engineId: "pi", ...partial } as AgentOutcome;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
describe("ConversationContinuation — D4 状态迁移表:closed 可重连终态 revive 格(含非 chatMode 升级两成员)", () => {
|
|
167
|
+
it.each(["disconnected", "parent-shutdown"] as const)(
|
|
168
|
+
"closed + %s + 非 chatMode → D5 gate 放行 → 升级 chatMode=true + revive + 续聊轮派发(resume 锚点)",
|
|
169
|
+
async (reason) => {
|
|
170
|
+
const record = makeRecord({ id: `sa-revive-${reason}`, chatMode: false, sessionFile: "/tmp/s.jsonl" });
|
|
171
|
+
record.status = "closed";
|
|
172
|
+
record.closedReason = reason;
|
|
173
|
+
const { host, calls } = makeHost(record);
|
|
174
|
+
const cont = new ConversationContinuation(record, host);
|
|
175
|
+
|
|
176
|
+
cont.onMessage("continue please");
|
|
177
|
+
|
|
178
|
+
// 升级置位(D4 revive 格——v4 显式化:水合保留持久化 chatMode 后的升级语义)
|
|
179
|
+
expect(record.chatMode).toBe(true);
|
|
180
|
+
expect(record.status).toBe("running");
|
|
181
|
+
expect(record.closedReason).toBeUndefined();
|
|
182
|
+
expect(calls.revived).toEqual([record.id]);
|
|
183
|
+
// 续聊轮派发:resume 锚点 = record.sessionFile
|
|
184
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
185
|
+
expect(calls.dispatched[0]!.task).toBe("continue please");
|
|
186
|
+
expect(calls.dispatched[0]!.resume?.sessionRef["sessionFile"]).toBe("/tmp/s.jsonl");
|
|
187
|
+
expect(calls.dispatched[0]!.resume?.poolKey).toBe("shared");
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
it("closed 可重连终态 + 非 chatMode + D5 gate 不过(unsupported 引擎)→ 硬拒 + fork/重派指引,chatMode 不置位", async () => {
|
|
192
|
+
const record = makeRecord({ id: "sa-gate-deny", chatMode: false, sessionFile: "/tmp/s.jsonl", engine: "zcode" });
|
|
193
|
+
record.status = "closed";
|
|
194
|
+
record.closedReason = "disconnected";
|
|
195
|
+
const { host, calls } = makeHost(record, { gateAllows: false });
|
|
196
|
+
const cont = new ConversationContinuation(record, host);
|
|
197
|
+
|
|
198
|
+
let gateError: (Error & { recovery?: string }) | undefined;
|
|
199
|
+
try {
|
|
200
|
+
cont.onMessage("hi");
|
|
201
|
+
} catch (err) {
|
|
202
|
+
gateError = err as Error & { recovery?: string };
|
|
203
|
+
}
|
|
204
|
+
// message = 失败原因(capabilities.conversation = 'unsupported' 依据)
|
|
205
|
+
expect(gateError?.message).toContain("cannot be upgraded to a resumable conversation");
|
|
206
|
+
// recovery = fork/重派指引(D5:避免升级后续聊行为悬空)
|
|
207
|
+
expect(gateError?.recovery).toContain("fork-from");
|
|
208
|
+
expect(gateError?.recovery).toContain("action:'start'");
|
|
209
|
+
expect(record.chatMode).toBe(false);
|
|
210
|
+
expect(record.status).toBe("closed");
|
|
211
|
+
expect(calls.revived).toEqual([]);
|
|
212
|
+
expect(calls.dispatched.length).toBe(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("closed 可重连终态 + chatMode 已置位 → 不经 gate 直接 revive(gate 判据不触达)", async () => {
|
|
216
|
+
const record = makeRecord({ id: "sa-revive-chat", chatMode: true, sessionFile: "/tmp/s.jsonl" });
|
|
217
|
+
record.status = "closed";
|
|
218
|
+
record.closedReason = "parent-shutdown";
|
|
219
|
+
const { host, calls } = makeHost(record);
|
|
220
|
+
const cont = new ConversationContinuation(record, host);
|
|
221
|
+
|
|
222
|
+
cont.onMessage("hello again");
|
|
223
|
+
|
|
224
|
+
expect(record.status).toBe("running");
|
|
225
|
+
expect(calls.revived).toEqual([record.id]);
|
|
226
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("closed user-close → 硬拒(D4 closed 硬拒格:nothing can reattach)", () => {
|
|
230
|
+
const record = makeRecord({ id: "sa-user-close", chatMode: true });
|
|
231
|
+
record.status = "closed";
|
|
232
|
+
record.closedReason = "user-close";
|
|
233
|
+
const { host, calls } = makeHost(record);
|
|
234
|
+
const cont = new ConversationContinuation(record, host);
|
|
235
|
+
|
|
236
|
+
expect(() => cont.onMessage("hi")).toThrow(/cannot be messaged or resumed/);
|
|
237
|
+
expect(calls.dispatched.length).toBe(0);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("closed parent-new(不可重连集外)→ 硬拒(fork-from 语义由 endedMessageGuard 分流)", () => {
|
|
241
|
+
const record = makeRecord({ id: "sa-parent-new", chatMode: true });
|
|
242
|
+
record.status = "closed";
|
|
243
|
+
record.closedReason = "parent-new";
|
|
244
|
+
const { host } = makeHost(record);
|
|
245
|
+
const cont = new ConversationContinuation(record, host);
|
|
246
|
+
|
|
247
|
+
expect(() => cont.onMessage("hi")).toThrow(/cannot be messaged or resumed/);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("ConversationContinuation — D2 打断 / abort 不终态化 / 单飞", () => {
|
|
252
|
+
it("轮在途 message → abort 在途轮 signal + 入队(record 不终态化、不二次派发)", async () => {
|
|
253
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
254
|
+
const { host, calls } = makeHost(record);
|
|
255
|
+
const cont = new ConversationContinuation(record, host);
|
|
256
|
+
cont.startFirstRound("round 1");
|
|
257
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
258
|
+
const round1Signal = calls.dispatched[0]!.signal;
|
|
259
|
+
|
|
260
|
+
cont.onMessage("interrupt: change angle");
|
|
261
|
+
|
|
262
|
+
// D2:打断 = 轮级 abort(非 record 级 cancel——record 保持 running)
|
|
263
|
+
expect(round1Signal.aborted).toBe(true);
|
|
264
|
+
expect(record.status).toBe("running");
|
|
265
|
+
expect(cont.pendingCount).toBe(1);
|
|
266
|
+
expect(calls.dispatched.length).toBe(1); // 单飞:无二次派发
|
|
267
|
+
expect(calls.order.filter((s) => s.startsWith("dispatch")).length).toBe(1);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("abort 收敛(onRunSettled 失败形态)→ drain 聚合队列为下一轮(单写者前置满足后才派发)", async () => {
|
|
271
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
272
|
+
const { host, calls } = makeHost(record);
|
|
273
|
+
const cont = new ConversationContinuation(record, host);
|
|
274
|
+
cont.startFirstRound("round 1");
|
|
275
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
276
|
+
cont.onMessage("msg A");
|
|
277
|
+
cont.onMessage("msg B");
|
|
278
|
+
|
|
279
|
+
// 被打断轮以失败形态收敛(abort 合成 error outcome)
|
|
280
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "engine_run_failed: run aborted" }));
|
|
281
|
+
|
|
282
|
+
// 失败簿记 + drain:两条聚合为一轮输入
|
|
283
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(2));
|
|
284
|
+
expect(calls.dispatched[1]!.task).toBe("msg A\n\nmsg B");
|
|
285
|
+
expect(cont.pendingCount).toBe(0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("record 终态化后 onRunSettled → 整体 early-return:不簿记、不通知、不 drain(close 抢先)", async () => {
|
|
289
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
290
|
+
record.status = "closed";
|
|
291
|
+
record.closedReason = "user-close";
|
|
292
|
+
const { host, calls } = makeHost(record);
|
|
293
|
+
const cont = new ConversationContinuation(record, host);
|
|
294
|
+
|
|
295
|
+
// 成功形态应答(close 抢先后的迟到应答)
|
|
296
|
+
cont.onRunSettled(makeOutcome({ content: "late" }));
|
|
297
|
+
expect(calls.finalized.length).toBe(0);
|
|
298
|
+
expect(calls.routed.length).toBe(0);
|
|
299
|
+
expect(calls.notified.length).toBe(0);
|
|
300
|
+
expect(cont.pendingCount).toBe(0);
|
|
301
|
+
|
|
302
|
+
// 失败形态应答(迟到)同样 early-return
|
|
303
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "late failure" }));
|
|
304
|
+
expect(calls.finalized.length).toBe(0);
|
|
305
|
+
expect(calls.notified.length).toBe(0);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("close 语义(abortAndClearQueue)→ abort 在途 + 清空队列(终态化由 host.closeNow 承接)", async () => {
|
|
309
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
310
|
+
const { host, calls } = makeHost(record);
|
|
311
|
+
const cont = new ConversationContinuation(record, host);
|
|
312
|
+
cont.startFirstRound("round 1");
|
|
313
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
314
|
+
const signal = calls.dispatched[0]!.signal;
|
|
315
|
+
cont.onMessage("queued during round");
|
|
316
|
+
|
|
317
|
+
cont.abortAndClearQueue();
|
|
318
|
+
|
|
319
|
+
expect(signal.aborted).toBe(true);
|
|
320
|
+
expect(cont.pendingCount).toBe(0);
|
|
321
|
+
expect(record.status).toBe("running"); // 终态化归 closeNow(编排方调用)
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("ConversationContinuation — 回调边缘路径(S9 补测:abandoned/reject 兼底窗作废/drain 丢弃/closed 硬拒)", () => {
|
|
326
|
+
it("onRoundAbandoned:先 clearActiveRound 后 drain(排队消息在 abandoned 收敛后聚合续派)", async () => {
|
|
327
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
328
|
+
const { host, calls } = makeHost(record);
|
|
329
|
+
const cont = new ConversationContinuation(record, host);
|
|
330
|
+
cont.startFirstRound("round 1");
|
|
331
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
332
|
+
cont.onMessage("queued msg");
|
|
333
|
+
expect(cont.pendingCount).toBe(1);
|
|
334
|
+
|
|
335
|
+
// acquire 被打断(无 run 产生):不终态化、不通知,直接 drain
|
|
336
|
+
cont.onRoundAbandoned();
|
|
337
|
+
|
|
338
|
+
expect(record.status).toBe("running");
|
|
339
|
+
expect(calls.notified.length).toBe(0);
|
|
340
|
+
expect(calls.finalized.length).toBe(0);
|
|
341
|
+
// 次序证明:drain 派发发生(若 clearActiveRound 未先执行,dispatchRoundGuarded
|
|
342
|
+
// 的 activeRunId 双保险会 early-return,第二条派发不可达)
|
|
343
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(2));
|
|
344
|
+
expect(calls.dispatched[1]!.task).toBe("queued msg");
|
|
345
|
+
expect(cont.pendingCount).toBe(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("兼底窗终态抢先作废:killStale await 窗内 close → 轮作废(零派发零簿记,队列清空)", async () => {
|
|
349
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
350
|
+
const { host, calls } = makeHost(record);
|
|
351
|
+
const cont = new ConversationContinuation(record, host);
|
|
352
|
+
// startFirstRound 的派发在 killStale await 之后才发生 —— 同步段仅占位
|
|
353
|
+
cont.startFirstRound("doomed round");
|
|
354
|
+
record.status = "closed"; // close/cancel 在兼底窗内抢先终态化
|
|
355
|
+
record.closedReason = "user-close";
|
|
356
|
+
|
|
357
|
+
await vi.waitFor(() => expect(calls.killStale.length).toBe(1));
|
|
358
|
+
// await 窗返回后:status ≠ running → clearActiveRound + 队列清空,不派发不簿记
|
|
359
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
360
|
+
expect(calls.dispatched.length).toBe(0);
|
|
361
|
+
expect(calls.roundStarts.length).toBe(0);
|
|
362
|
+
expect(cont.pendingCount).toBe(0);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("drain 派发守卫 throw → 队列丢弃 + warn + 丢弃通知(drain-drop dedup key,A2 转错误面)", async () => {
|
|
366
|
+
// 无 sessionFile:首轮派发(firstRound=true)不触锚点守卫;drain 以 firstRound=false
|
|
367
|
+
// 派发时锚点守卫 throw → catch 转丢弃面
|
|
368
|
+
const record = makeRecord({ sessionFile: undefined });
|
|
369
|
+
const { host, calls } = makeHost(record);
|
|
370
|
+
const cont = new ConversationContinuation(record, host);
|
|
371
|
+
cont.startFirstRound("round 1");
|
|
372
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
373
|
+
cont.onMessage("queued while anchorless");
|
|
374
|
+
|
|
375
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "engine_run_failed: run aborted" }));
|
|
376
|
+
|
|
377
|
+
// 失败轮末 → drain → 守卫 throw → warn + 丢弃通知(dedup key 含 drain-drop)
|
|
378
|
+
await vi.waitFor(() => expect(loggerMock.warn).toHaveBeenCalled());
|
|
379
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(expect.stringContaining("queued message dispatch rejected"));
|
|
380
|
+
expect(calls.dispatched.length).toBe(1); // 零续派
|
|
381
|
+
const drop = calls.notified.at(-1);
|
|
382
|
+
expect(drop?.dedupKey).toContain("drain-drop");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("closed record 发 message → 硬拒 throw 文案含 Recovery 指引(D4 closed 硬拒格)", () => {
|
|
386
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
387
|
+
record.status = "closed";
|
|
388
|
+
record.closedReason = "user-close";
|
|
389
|
+
const { host } = makeHost(record);
|
|
390
|
+
const cont = new ConversationContinuation(record, host);
|
|
391
|
+
|
|
392
|
+
expect(() => cont.onMessage("hello?")).toThrow(/Recovery: start a new subagent/);
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
describe("ConversationContinuation — 轮末分流(D7)与通知面", () => {
|
|
397
|
+
it("成功轮:doFinalizeRoundToIdle(success) → notifyGate 门 → route(route 晚于簿记——order 断言)", async () => {
|
|
398
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
399
|
+
const { host, calls } = makeHost(record);
|
|
400
|
+
const cont = new ConversationContinuation(record, host);
|
|
401
|
+
|
|
402
|
+
cont.onRunSettled(makeOutcome({ content: "round text" }));
|
|
403
|
+
|
|
404
|
+
await vi.waitFor(() => expect(calls.routed.length).toBe(1));
|
|
405
|
+
expect(calls.finalized).toEqual([{ recordId: record.id, outcome: { kind: "success", content: "round text" } }]);
|
|
406
|
+
const finalizeIdx = calls.order.indexOf("finalize:success");
|
|
407
|
+
const routeIdx = calls.order.indexOf(`route:${record.id}`);
|
|
408
|
+
expect(finalizeIdx).toBeGreaterThanOrEqual(0);
|
|
409
|
+
expect(routeIdx).toBeGreaterThan(finalizeIdx); // route 晚于轮终簿记
|
|
410
|
+
expect(calls.notified.length).toBe(0); // 成功通知经 route(record.result 权威),非独立载荷
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("失败轮:lastError 簿记(failed outcome)+ 独立载荷失败通知(不经 route)+ round 同样 +1(dedup 分离)", async () => {
|
|
414
|
+
const record = makeRecord({ id: "sa-fail", sessionFile: "/tmp/s.jsonl", round: 1 });
|
|
415
|
+
const { host, calls } = makeHost(record);
|
|
416
|
+
const cont = new ConversationContinuation(record, host);
|
|
417
|
+
|
|
418
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "engine_crashed: child died" }));
|
|
419
|
+
|
|
420
|
+
await vi.waitFor(() => expect(calls.notified.length).toBe(1));
|
|
421
|
+
// 失败通知:独立构造载荷(closed+failed 形态——正文 = 失败摘要 + 恢复指引)
|
|
422
|
+
const notify = calls.notified[0]!;
|
|
423
|
+
expect(notify.status).toBe("closed");
|
|
424
|
+
expect(notify.outcome).toBe("failed");
|
|
425
|
+
expect(notify.error).toContain("round did not complete: engine_crashed: child died");
|
|
426
|
+
expect(notify.error).toContain("Recovery");
|
|
427
|
+
expect(notify.error).toContain("action:'message'");
|
|
428
|
+
// dedup key = record:round:round 已随簿记 +1 → 失败通知与上一轮成功通知 key 分离
|
|
429
|
+
expect(record.round).toBe(2);
|
|
430
|
+
expect(notify.round).toBe(2);
|
|
431
|
+
// 不经 route(record)——其正文恒读 record.result = 前值,直接复用会以旧正文冒充失败通知
|
|
432
|
+
expect(calls.routed.length).toBe(0);
|
|
433
|
+
expect(calls.finalized[0]!.outcome).toEqual({ kind: "failed", reason: "engine_crashed: child died" });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("成功分支 notifyGate 门:parent-new 编排性关闭的迟到应答不注入(route 不调)", async () => {
|
|
437
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
438
|
+
const { host, calls } = makeHost(record);
|
|
439
|
+
const cont = new ConversationContinuation(record, host);
|
|
440
|
+
// 编排性关闭竞态窗(closedReason 残留于 running record 的门语义构造)
|
|
441
|
+
record.closedReason = "parent-new";
|
|
442
|
+
|
|
443
|
+
cont.onRunSettled(makeOutcome({ content: "late round" }));
|
|
444
|
+
|
|
445
|
+
await vi.waitFor(() => expect(calls.finalized.length).toBe(1));
|
|
446
|
+
expect(calls.routed.length).toBe(0); // 门拦——不注入可能已切换的 session
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("失败分支 notifyGate 门:cancelled 竞态窗不双发(cancelBackground 自行 notify)", async () => {
|
|
450
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
451
|
+
const { host, calls } = makeHost(record);
|
|
452
|
+
const cont = new ConversationContinuation(record, host);
|
|
453
|
+
record.closedReason = "cancelled";
|
|
454
|
+
|
|
455
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "aborted" }));
|
|
456
|
+
|
|
457
|
+
await vi.waitFor(() => expect(calls.finalized.length).toBe(1));
|
|
458
|
+
expect(calls.notified.length).toBe(0); // 门拦——防双发
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("失败分支 notifyGate 门:parent-fork 竞态窗不注入(防僵尸回执)", async () => {
|
|
462
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
463
|
+
const { host, calls } = makeHost(record);
|
|
464
|
+
const cont = new ConversationContinuation(record, host);
|
|
465
|
+
record.closedReason = "parent-fork";
|
|
466
|
+
|
|
467
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "boom" }));
|
|
468
|
+
|
|
469
|
+
await vi.waitFor(() => expect(calls.finalized.length).toBe(1));
|
|
470
|
+
expect(calls.notified.length).toBe(0);
|
|
471
|
+
expect(calls.routed.length).toBe(0);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("settle 交棒次序:onRunSettled 内 noteRoundSettledFromProtocol 先于轮终簿记(watchdog phase 观察)", async () => {
|
|
475
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
476
|
+
const { host, calls } = makeHost(record);
|
|
477
|
+
// 模拟泛化主干 arm(acquire 后挂中段——真实 arm 点在 kickOffChatRound)
|
|
478
|
+
armMidRoundNoProgress(record.id, {
|
|
479
|
+
onMidTimeout: () => {},
|
|
480
|
+
onSettleTimeout: () => {},
|
|
481
|
+
});
|
|
482
|
+
expect(getSettledWatchdogPhase(record.id)).toBe("mid-round");
|
|
483
|
+
|
|
484
|
+
// finalize mock 内观察:交棒(mid-round → settled)先于簿记
|
|
485
|
+
let phaseAtFinalize: string | undefined;
|
|
486
|
+
const origFinalize = host.finalizeRoundOutcome;
|
|
487
|
+
const spyHost: ContinuationHost = {
|
|
488
|
+
...host,
|
|
489
|
+
finalizeRoundOutcome: async (rec, outcome) => {
|
|
490
|
+
phaseAtFinalize = getSettledWatchdogPhase(rec.id);
|
|
491
|
+
await origFinalize(rec, outcome);
|
|
492
|
+
},
|
|
493
|
+
};
|
|
494
|
+
const cont = new ConversationContinuation(record, spyHost);
|
|
495
|
+
cont.onRunSettled(makeOutcome({ content: "text" }));
|
|
496
|
+
|
|
497
|
+
await vi.waitFor(() => expect(calls.finalized.length).toBe(1));
|
|
498
|
+
expect(phaseAtFinalize).toBe("settled"); // 交棒先于簿记(watchdog 停表早于状态写)
|
|
499
|
+
// 轮终簿记后两段一并清(不残留 armed——收尾段 fire 会对已收敛轮误发 kill)
|
|
500
|
+
await vi.waitFor(() => expect(hasSettledWatchdog(record.id)).toBe(false));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("成功分支不写 roundBaseTurnIndex(base 死记账退役——负向断言)", async () => {
|
|
504
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl", turnCount: 5 });
|
|
505
|
+
const { host, calls } = makeHost(record);
|
|
506
|
+
const cont = new ConversationContinuation(record, host);
|
|
507
|
+
|
|
508
|
+
cont.onRunSettled(makeOutcome({ content: "round text" }));
|
|
509
|
+
|
|
510
|
+
await vi.waitFor(() => expect(calls.routed.length).toBe(1));
|
|
511
|
+
// [H1 U6] base 死记账(roundBaseTurnIndex)已随字段退役——负向断言锚点改为
|
|
512
|
+
// turns 记账不触碰(round 推进不携带 base 副作用)。
|
|
513
|
+
expect(record.turnCount).toBe(5);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("stale-child 兜底先于派发(红线②——killStaleChild 在 dispatch 之前的 order 断言)", async () => {
|
|
517
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
518
|
+
const { host, calls } = makeHost(record);
|
|
519
|
+
const cont = new ConversationContinuation(record, host);
|
|
520
|
+
|
|
521
|
+
cont.onMessage("next round");
|
|
522
|
+
|
|
523
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
524
|
+
expect(calls.killStale).toEqual([record.id]);
|
|
525
|
+
expect(calls.order.indexOf(`killStale:${record.id}`)).toBeLessThan(
|
|
526
|
+
calls.order.indexOf(`dispatch:${record.id}`),
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("watchdog fire → killRoundChild + abort 轮 signal(run 收敛后失败分支统一收口)", async () => {
|
|
531
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl" });
|
|
532
|
+
const { host, calls } = makeHost(record);
|
|
533
|
+
const cont = new ConversationContinuation(record, host);
|
|
534
|
+
cont.startFirstRound("round 1");
|
|
535
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
536
|
+
const signal = calls.dispatched[0]!.signal;
|
|
537
|
+
|
|
538
|
+
cont.onWatchdogFire({ phase: "mid-round", waitedMs: 1000 });
|
|
539
|
+
|
|
540
|
+
expect(calls.killedRound).toEqual([{ recordId: record.id, source: "settled watchdog (mid-round)" }]);
|
|
541
|
+
expect(signal.aborted).toBe(true);
|
|
542
|
+
expect(record.status).toBe("running"); // kill 本身不终态化——收口归 run 应答
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("派发前轮始簿记([U2b/D2] 归口 store.markRoundStarted——host 委托达点先于 dispatch)", async () => {
|
|
546
|
+
const record = makeRecord({ sessionFile: "/tmp/s.jsonl", round: 1 });
|
|
547
|
+
record.result = "上一轮增量";
|
|
548
|
+
record.resumable = true;
|
|
549
|
+
const { host, calls } = makeHost(record);
|
|
550
|
+
const cont = new ConversationContinuation(record, host);
|
|
551
|
+
|
|
552
|
+
cont.onMessage("next");
|
|
553
|
+
|
|
554
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
555
|
+
// 轮始簿记经 host.markRoundStarted 委托 store 原语(status=running + result/
|
|
556
|
+
// resumable 清除 + 迁移上报一体——原三行现场写消灭);record 实际清除语义由
|
|
557
|
+
// 真实 store 链在集成面验证。委托先于 dispatch(spinner 恢复时序锚点)。
|
|
558
|
+
expect(calls.roundStarts).toEqual([record.id]);
|
|
559
|
+
expect(calls.order.indexOf(`roundStart:${record.id}`)).toBeLessThan(
|
|
560
|
+
calls.order.indexOf(`dispatch:${record.id}`),
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
it("[A2] drain 守卫失败 → 不 throw(无 unhandled rejection)+ 队列丢弃失败通知 + queue 清空(锚点缺失触发链)+ 丢弃通知独立 dedup 身份过真实去重链", async () => {
|
|
565
|
+
// 可达触发链:首轮在途 message 打断入队 → 首轮崩溃(合成 outcome 无 sessionFile
|
|
566
|
+
// ——mock host 不回填锚点)→ 失败 settle → drain 以 firstRound=false 走锚点守卫
|
|
567
|
+
// → throw。修复前 throw 逃逸 settleRoundFailed 的 void promise = unhandled
|
|
568
|
+
// rejection(Node ≥15 默认崩宿主)且队列消息静默丢失。
|
|
569
|
+
const record = makeRecord({ id: "sa-drain-guard" }); // sessionFile undefined(首轮未回填)
|
|
570
|
+
const { host, calls } = makeHost(record);
|
|
571
|
+
|
|
572
|
+
// 通知面接真实 notifier + ledger(生产装配形态:ContinuationHost.notifyRecord →
|
|
573
|
+
// notifyHost.notify → createNotifier().notify → ledger 四步链)。纯 push mock 不过
|
|
574
|
+
// 真实去重——修复前丢弃通知与同轮失败单发同 key(`id:round`)会被 ledger 幂等吞
|
|
575
|
+
// (sentMessages 恒 1 条),本用例的 2 条断言即该 bug 的回归锚。
|
|
576
|
+
// 元素形态 = 两列合一(ledger 写账 entry 带 data;送达落盘 entry 带 content 等,
|
|
577
|
+
// 与 notify-ledger.test.ts makeLedgerHost 的 entries/sessionEntries 共享数组同构)。
|
|
578
|
+
const ledgerEntries: Array<{
|
|
579
|
+
type: string;
|
|
580
|
+
customType: string;
|
|
581
|
+
data?: unknown;
|
|
582
|
+
content?: string;
|
|
583
|
+
display?: boolean;
|
|
584
|
+
details?: unknown;
|
|
585
|
+
}> = [];
|
|
586
|
+
const delivered: Array<{ customType: string; content: string; display: boolean; details?: unknown }> = [];
|
|
587
|
+
bindNotifyLedgerHost({
|
|
588
|
+
appendLedgerEntry: (customType, data) => {
|
|
589
|
+
ledgerEntries.push({ type: "custom", customType, data });
|
|
590
|
+
},
|
|
591
|
+
readSessionEntries: () => ledgerEntries,
|
|
592
|
+
isIdle: () => true,
|
|
593
|
+
onAgentSettled: () => {},
|
|
594
|
+
// 送达即落盘 custom_message entry(ledger 回执扫描输入,模拟 pi 落盘)。
|
|
595
|
+
sendDelivery: (message) => {
|
|
596
|
+
delivered.push(message);
|
|
597
|
+
ledgerEntries.push({
|
|
598
|
+
type: "custom_message",
|
|
599
|
+
customType: message.customType,
|
|
600
|
+
content: message.content,
|
|
601
|
+
display: message.display,
|
|
602
|
+
details: message.details,
|
|
603
|
+
});
|
|
604
|
+
},
|
|
605
|
+
});
|
|
606
|
+
const notifier = createNotifier({
|
|
607
|
+
sendMessage: () => {}, // ledger 路径送达出口 = sendDelivery;本端口仅内核兜底路径消费
|
|
608
|
+
hasRunningBackground: () => false,
|
|
609
|
+
isIdle: () => true,
|
|
610
|
+
});
|
|
611
|
+
const origNotifyRecord = host.notifyRecord;
|
|
612
|
+
host.notifyRecord = (n) => {
|
|
613
|
+
origNotifyRecord(n);
|
|
614
|
+
notifier.notify(n);
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const cont = new ConversationContinuation(record, host);
|
|
619
|
+
cont.startFirstRound("round 1"); // firstRound=true,无锚点守卫
|
|
620
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
621
|
+
cont.onMessage("queued while running");
|
|
622
|
+
expect(cont.pendingCount).toBe(1);
|
|
623
|
+
|
|
624
|
+
// 首轮失败收敛 → settleRoundFailed 尾部 drain 触发锚点守卫 throw(修复后就地转错误面)
|
|
625
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "engine_crashed: child died" }));
|
|
626
|
+
|
|
627
|
+
// 第一条 = 失败单发(既有语义);第二条 = drain 守卫失败的队列丢弃通知
|
|
628
|
+
await vi.waitFor(() => expect(calls.notified.length).toBe(2));
|
|
629
|
+
const dropped = calls.notified[1]!;
|
|
630
|
+
expect(dropped.status).toBe("closed");
|
|
631
|
+
expect(dropped.outcome).toBe("failed");
|
|
632
|
+
expect(dropped.error).toContain("queued message could not be dispatched");
|
|
633
|
+
expect(dropped.error).toContain("no transcript anchor");
|
|
634
|
+
// 独立 dedup 身份:与同轮失败单发 key(`id:round`)区分,避免被永久去重吞掉
|
|
635
|
+
expect(dropped.dedupKey).toBe("sa-drain-guard:1:drain-drop");
|
|
636
|
+
// 真实去重链上两条都可达(修复前第二条同 key 被 ledger 吞——本断言红),
|
|
637
|
+
// 且第二条 notifyId 独立、正文为丢弃文案(key 独立性在投递产物上可见)
|
|
638
|
+
await vi.waitFor(() => expect(delivered).toHaveLength(2));
|
|
639
|
+
expect(delivered[0]!.details).toMatchObject({ notifyId: "sa-drain-guard:1" });
|
|
640
|
+
expect(delivered[1]!.details).toMatchObject({ notifyId: "sa-drain-guard:1:drain-drop" });
|
|
641
|
+
expect(delivered[1]!.content).toContain("queued message could not be dispatched");
|
|
642
|
+
// 队列清空 + record 保持 running-resumable + 无僵尸轮派发
|
|
643
|
+
expect(cont.pendingCount).toBe(0);
|
|
644
|
+
expect(record.status).toBe("running");
|
|
645
|
+
expect(calls.dispatched.length).toBe(1);
|
|
646
|
+
// 队列丢弃留痕(warn)
|
|
647
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
648
|
+
expect.stringContaining("queued message dispatch rejected"),
|
|
649
|
+
);
|
|
650
|
+
} finally {
|
|
651
|
+
// dispose 摘除 ledger 模块级绑定——防泄漏到同文件后续用例
|
|
652
|
+
notifier.dispose();
|
|
653
|
+
}
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it("[A2] drain 守卫失败 + notifyGate 门拦(cancelled 竞态窗)→ 通知不发(防双发语义一致)", async () => {
|
|
657
|
+
const record = makeRecord({ id: "sa-drain-gate" });
|
|
658
|
+
record.closedReason = "cancelled"; // 门拦竞态窗构造(与既有门用例同形态)
|
|
659
|
+
const { host, calls } = makeHost(record);
|
|
660
|
+
const cont = new ConversationContinuation(record, host);
|
|
661
|
+
cont.startFirstRound("round 1");
|
|
662
|
+
await vi.waitFor(() => expect(calls.dispatched.length).toBe(1));
|
|
663
|
+
cont.onMessage("queued while running");
|
|
664
|
+
cont.onRunSettled(makeOutcome({ content: "", error: "boom" }));
|
|
665
|
+
|
|
666
|
+
// 失败单发被门拦(既有语义)→ drain 守卫失败的通知同被门拦:notified 恒 0
|
|
667
|
+
await vi.waitFor(() => expect(calls.finalized.length).toBe(1));
|
|
668
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
669
|
+
expect(calls.notified.length).toBe(0);
|
|
670
|
+
expect(cont.pendingCount).toBe(0);
|
|
671
|
+
expect(calls.dispatched.length).toBe(1);
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// ============================================================
|
|
676
|
+
// SubagentService 集成面(fake engine 协议替身)
|
|
677
|
+
// ============================================================
|
|
678
|
+
|
|
679
|
+
function makePi(): PiLike {
|
|
680
|
+
return {
|
|
681
|
+
appendEntry: vi.fn(),
|
|
682
|
+
events: { emit: vi.fn() },
|
|
683
|
+
sendMessage: vi.fn(),
|
|
684
|
+
} as unknown as PiLike;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
interface ServiceInternals {
|
|
688
|
+
store: RecordStore;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function makeService(): {
|
|
692
|
+
agentDir: string;
|
|
693
|
+
service: SubagentService;
|
|
694
|
+
store: RecordStore;
|
|
695
|
+
pi: PiLike;
|
|
696
|
+
fake: FakePiEnginePort;
|
|
697
|
+
} {
|
|
698
|
+
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "cont-integration-"));
|
|
699
|
+
clearEngines();
|
|
700
|
+
const fake = registerFakePiEngine();
|
|
701
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
702
|
+
// execute() 路径的 pi 链三层解析需要 registry(首轮派发用例消费)
|
|
703
|
+
modelService.initModel({
|
|
704
|
+
modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true },
|
|
705
|
+
sessionId: "root-session",
|
|
706
|
+
ctxModel: { id: "m", name: "M", provider: "prov", reasoning: false },
|
|
707
|
+
});
|
|
708
|
+
const service = new SubagentService({ cwd: agentDir, modelService });
|
|
709
|
+
const pi = makePi();
|
|
710
|
+
service.initSession({ pi, sessionId: "root-session" });
|
|
711
|
+
const store = (service as unknown as ServiceInternals).store;
|
|
712
|
+
return { agentDir, service, store, pi, fake };
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** chatMode 续聊 record(首轮已完成,等待续聊)。 */
|
|
716
|
+
function makeChatRecord(id: string, agentDir: string): ExecutionRecord {
|
|
717
|
+
const record = createRecord(id, {
|
|
718
|
+
agent: "general-purpose",
|
|
719
|
+
model: "prov/model-1",
|
|
720
|
+
thinkingLevel: "low",
|
|
721
|
+
mode: "background",
|
|
722
|
+
task: "initial task",
|
|
723
|
+
slug: "cont",
|
|
724
|
+
startedAt: 1000,
|
|
725
|
+
rootSessionId: "root-session",
|
|
726
|
+
chatMode: true,
|
|
727
|
+
controller: new AbortController(),
|
|
728
|
+
});
|
|
729
|
+
record.status = "running";
|
|
730
|
+
record.round = 1;
|
|
731
|
+
record.sessionFile = path.join(agentDir, `${id}-session.jsonl`);
|
|
732
|
+
fs.writeFileSync(record.sessionFile, "{}\n", "utf-8");
|
|
733
|
+
return record;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
describe("集成:chat 轮末分流(D7)——成功轮 / 失败轮 / 空正文兜底", () => {
|
|
737
|
+
let agentDir: string;
|
|
738
|
+
let service: SubagentService;
|
|
739
|
+
let store: RecordStore;
|
|
740
|
+
let pi: PiLike;
|
|
741
|
+
let fake: FakePiEnginePort;
|
|
742
|
+
|
|
743
|
+
beforeEach(() => {
|
|
744
|
+
vi.restoreAllMocks();
|
|
745
|
+
killChildSpy.mockClear();
|
|
746
|
+
({ agentDir, service, store, pi, fake } = makeService());
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
afterEach(() => {
|
|
750
|
+
service.dispose();
|
|
751
|
+
clearEngines();
|
|
752
|
+
_resetLifecycleState();
|
|
753
|
+
_resetSettledWatchdogsForTest();
|
|
754
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
755
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("成功轮:round+1 + result = 本轮 content + route 晚于簿记(次序断言)+ record 保持 running-resumable", async () => {
|
|
759
|
+
const record = makeChatRecord("sa-round-ok", agentDir);
|
|
760
|
+
store.register(record);
|
|
761
|
+
const routeOrder: string[] = [];
|
|
762
|
+
const coord = (service as unknown as {
|
|
763
|
+
collectCoordinator: { route(r: ExecutionRecord): unknown };
|
|
764
|
+
}).collectCoordinator;
|
|
765
|
+
const storeSpy = vi.spyOn(store, "reportRecordTransition");
|
|
766
|
+
const original = coord.route.bind(coord);
|
|
767
|
+
Object.assign(coord, {
|
|
768
|
+
route: (r: ExecutionRecord) => {
|
|
769
|
+
routeOrder.push(`route:round=${r.round}`);
|
|
770
|
+
original(r);
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
await service.chatActions.deliverChatMessage(record, "round two");
|
|
775
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
776
|
+
fake.runs[0]!.settle({ content: "round two reply" });
|
|
777
|
+
|
|
778
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
779
|
+
expect(record.status).toBe("running");
|
|
780
|
+
expect(record.resumable).toBe(true);
|
|
781
|
+
expect(record.result).toBe("round two reply");
|
|
782
|
+
// route 晚于轮终簿记(簿记 = reportRecordTransition 携带新 round;route 观察到 round=2)
|
|
783
|
+
expect(storeSpy).toHaveBeenCalled();
|
|
784
|
+
expect(routeOrder).toEqual(["route:round=2"]);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it("空 content 成功轮 → result = '(no output this round)',lastError 不混入正文(D7 ⑤)", async () => {
|
|
788
|
+
const record = makeChatRecord("sa-round-empty", agentDir);
|
|
789
|
+
record.lastError = "stale engine_crashed: previous failure";
|
|
790
|
+
store.register(record);
|
|
791
|
+
|
|
792
|
+
await service.chatActions.deliverChatMessage(record, "again");
|
|
793
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
794
|
+
fake.runs[0]!.settle({ content: "" });
|
|
795
|
+
|
|
796
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
797
|
+
expect(record.result).toBe("(no output this round)");
|
|
798
|
+
expect(record.result).not.toContain("stale engine_crashed");
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("[U2b 修复轮/D2] 轮始 markRoundStarted 接线(真实 store 链):派发即清上一轮 result/resumable,轮终 round 累加链不断", async () => {
|
|
802
|
+
const record = makeChatRecord("sa-round-start", agentDir);
|
|
803
|
+
record.result = "round 1 text";
|
|
804
|
+
record.resumable = true;
|
|
805
|
+
store.register(record);
|
|
806
|
+
|
|
807
|
+
await service.chatActions.deliverChatMessage(record, "round two");
|
|
808
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
809
|
+
// 轮在途:轮始簿记(store.markRoundStarted)已清执行态信号——isStreaming 公式
|
|
810
|
+
//(result undefined 才显示 streaming)经归口原语达成,status 重申 running。
|
|
811
|
+
expect(record.result).toBeUndefined();
|
|
812
|
+
expect(record.resumable).toBeUndefined();
|
|
813
|
+
expect(record.status).toBe("running");
|
|
814
|
+
// 轮终链不断:markRoundIdle round+1 + 新一轮 result 写入(round 累加链回归锚)
|
|
815
|
+
fake.runs[0]!.settle({ content: "round two reply" });
|
|
816
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
817
|
+
expect(record.result).toBe("round two reply");
|
|
818
|
+
expect(record.resumable).toBe(true);
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("失败轮:round 同样 +1 + result = 前值 ?? 失败摘要 + lastError 写入 + 失败通知单发(正文带失败摘要与恢复指引)", async () => {
|
|
822
|
+
const record = makeChatRecord("sa-round-fail", agentDir);
|
|
823
|
+
record.result = "first round output"; // 前值(有最后成功正文则保留)
|
|
824
|
+
store.register(record);
|
|
825
|
+
|
|
826
|
+
await service.chatActions.deliverChatMessage(record, "round two");
|
|
827
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
828
|
+
fake.runs[0]!.settle({ content: "", error: "engine_round_crashed: child died", exitCode: null });
|
|
829
|
+
|
|
830
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
831
|
+
// D7 失败轮写入规则:result = 前值 ?? 失败摘要。Continuation 主链轮始已清
|
|
832
|
+
// result([U2b/D2] markRoundStarted 归口——isStreaming 语义),前值分支
|
|
833
|
+
// 不可达 → 失败摘要;lastError 写失败原因
|
|
834
|
+
expect(record.result).toBe("round did not complete: engine_round_crashed: child died");
|
|
835
|
+
expect(record.lastError).toBe("engine_round_crashed: child died");
|
|
836
|
+
expect(record.status).toBe("running");
|
|
837
|
+
expect(record.resumable).toBe(true);
|
|
838
|
+
// 失败通知:独立载荷(正文 = 失败摘要 + 恢复指引),dedup key = id:2
|
|
839
|
+
await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(1));
|
|
840
|
+
const calls = (pi.sendMessage as unknown as ReturnType<typeof vi.fn>).mock.calls as Array<
|
|
841
|
+
[{ content?: string; details?: { notifyId?: string; round?: number } }]
|
|
842
|
+
>;
|
|
843
|
+
const content = calls[0]?.[0]?.content ?? "";
|
|
844
|
+
expect(content).toContain("failed");
|
|
845
|
+
expect(content).toContain("round did not complete: engine_round_crashed: child died");
|
|
846
|
+
expect(content).toContain("Recovery");
|
|
847
|
+
const details = calls[0]?.[0]?.details as { notifyId?: string } | undefined;
|
|
848
|
+
expect(details?.notifyId).toBe(`${record.id}:2`);
|
|
849
|
+
// 失败通知不经 route(record)——route 零调用(防旧正文冒充失败通知)。
|
|
850
|
+
// [H1 U6] roundBaseTurnIndex 负向断言随 base 死记账字段退役删除。
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
it("首轮失败(无前值)→ result = 失败摘要(非 undefined——renderer hasRunning 判据保持)", async () => {
|
|
854
|
+
const handle = await service.execute({ task: "first round", slug: "t", conversation: true });
|
|
855
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
856
|
+
fake.runs[0]!.fail(new Error("spawn prepare failed: model not available"));
|
|
857
|
+
|
|
858
|
+
const record = store.getMutable(handle.subagentId);
|
|
859
|
+
await vi.waitFor(() => expect(record?.resumable).toBe(true));
|
|
860
|
+
expect(record?.status).toBe("running");
|
|
861
|
+
expect(record?.result).toBe("round did not complete: spawn prepare failed: model not available");
|
|
862
|
+
expect(record?.result).toBeDefined();
|
|
863
|
+
// 失败通知单发
|
|
864
|
+
await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(1));
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
describe("集成:close 抢先(S7)与 closeAfterRound 退役(D4 close = abort + 清队列 + 立即终态化)", () => {
|
|
869
|
+
let agentDir: string;
|
|
870
|
+
let service: SubagentService;
|
|
871
|
+
let store: RecordStore;
|
|
872
|
+
let pi: PiLike;
|
|
873
|
+
let fake: FakePiEnginePort;
|
|
874
|
+
|
|
875
|
+
beforeEach(() => {
|
|
876
|
+
vi.restoreAllMocks();
|
|
877
|
+
killChildSpy.mockClear();
|
|
878
|
+
({ agentDir, service, store, pi, fake } = makeService());
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
afterEach(() => {
|
|
882
|
+
service.dispose();
|
|
883
|
+
clearEngines();
|
|
884
|
+
_resetLifecycleState();
|
|
885
|
+
_resetSettledWatchdogsForTest();
|
|
886
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
887
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it("轮在途 close(force:false) → 立即终态化 closed/user-close + notifyClosed + 无挂起标志(closeAfterRound 退役)", async () => {
|
|
891
|
+
const record = makeChatRecord("sa-close-mid", agentDir);
|
|
892
|
+
store.register(record);
|
|
893
|
+
await service.chatActions.deliverChatMessage(record, "long round");
|
|
894
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
895
|
+
|
|
896
|
+
await service["closeSubagent"](record, false);
|
|
897
|
+
|
|
898
|
+
// D4:close = abort 在途 + 立即终态化(不等轮终——closeAfterRound 挂起标志退役)
|
|
899
|
+
expect(record.status).toBe("closed");
|
|
900
|
+
expect(record.closedReason).toBe("user-close");
|
|
901
|
+
expect(record.closeAfterRound).toBeUndefined();
|
|
902
|
+
// notifyClosed 送达(正文空串占位 + totalRounds)
|
|
903
|
+
await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(1));
|
|
904
|
+
// 迟到的轮终应答:onRunSettled 整体 early-return——无回滚、无追加通知、无 drain
|
|
905
|
+
const notifyCountAfterClose = (pi.sendMessage as unknown as ReturnType<typeof vi.fn>).mock.calls.length;
|
|
906
|
+
fake.runs[0]!.settle({ content: "late round text" });
|
|
907
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
908
|
+
expect(record.status).toBe("closed");
|
|
909
|
+
expect(record.closedReason).toBe("user-close");
|
|
910
|
+
expect((pi.sendMessage as unknown as ReturnType<typeof vi.fn>).mock.calls.length).toBe(
|
|
911
|
+
notifyCountAfterClose,
|
|
912
|
+
);
|
|
913
|
+
// 队列消息被 close 清空(close 前到达的排队消息不派发)
|
|
914
|
+
await service.chatActions.deliverChatMessage(record, "after close").catch(() => {});
|
|
915
|
+
expect(fake.runs.length).toBe(1); // 无僵尸轮
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
it("close 抢先后队列消息不派发、Continuation 实例随终态化清理", async () => {
|
|
919
|
+
const record = makeChatRecord("sa-close-queue", agentDir);
|
|
920
|
+
store.register(record);
|
|
921
|
+
await service.chatActions.deliverChatMessage(record, "round");
|
|
922
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
923
|
+
// 在途轮打断入队
|
|
924
|
+
await service.chatActions.deliverChatMessage(record, "queued msg");
|
|
925
|
+
// [R4 深绑改写] continuations 队列已迁 RunOrchestration 聚合——读取路径改经
|
|
926
|
+
// 聚合实例(断言对象与强度不变)。
|
|
927
|
+
const conts = (
|
|
928
|
+
service as unknown as { runOrchestration: { continuations: Map<string, { pendingCount: number }> } }
|
|
929
|
+
).runOrchestration.continuations;
|
|
930
|
+
expect(conts.get(record.id)?.pendingCount).toBe(1);
|
|
931
|
+
|
|
932
|
+
await service["closeSubagent"](record, false);
|
|
933
|
+
|
|
934
|
+
expect(record.status).toBe("closed");
|
|
935
|
+
// [H1 U2] Continuation 实例随终态化清理(onRecordFinalizedCleanup 汇聚点——
|
|
936
|
+
// closeChatIdle → doFinalizeRecord → onFinalized)
|
|
937
|
+
expect(conts.has(record.id)).toBe(false);
|
|
938
|
+
fake.runs[0]!.settle({ content: "late" });
|
|
939
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
940
|
+
expect(fake.runs.length).toBe(1); // 队列已清——无 drain 派发
|
|
941
|
+
});
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
describe("集成:one-shot(非 chatMode)settleOneShotOutcome 四分支零变化回归(G3)", () => {
|
|
945
|
+
let agentDir: string;
|
|
946
|
+
let service: SubagentService;
|
|
947
|
+
let store: RecordStore;
|
|
948
|
+
|
|
949
|
+
beforeEach(() => {
|
|
950
|
+
({ agentDir, service, store } = makeService());
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
afterEach(() => {
|
|
954
|
+
service.dispose();
|
|
955
|
+
clearEngines();
|
|
956
|
+
_resetLifecycleState();
|
|
957
|
+
_resetSettledWatchdogsForTest();
|
|
958
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
959
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
function makeOneShotRecord(id: string): ExecutionRecord {
|
|
963
|
+
const record = createRecord(id, {
|
|
964
|
+
agent: "general-purpose",
|
|
965
|
+
model: "prov/model-1",
|
|
966
|
+
mode: "background",
|
|
967
|
+
task: "t",
|
|
968
|
+
slug: "oneshot",
|
|
969
|
+
startedAt: 1000,
|
|
970
|
+
rootSessionId: "root-session",
|
|
971
|
+
controller: new AbortController(),
|
|
972
|
+
});
|
|
973
|
+
store.register(record);
|
|
974
|
+
return record;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
async function settleOneShot(
|
|
978
|
+
record: ExecutionRecord,
|
|
979
|
+
result: { text: string; success: boolean; error?: string },
|
|
980
|
+
aborted: boolean,
|
|
981
|
+
): Promise<void> {
|
|
982
|
+
// [R4 深绑改写] settleOneShotOutcome 本体已迁 RunOrchestration 聚合(顶部
|
|
983
|
+
// D7 origin==="workflow" CAS 抢锁分支原样随迁)——bracket 调用改经聚合实例。
|
|
984
|
+
await (
|
|
985
|
+
service as unknown as {
|
|
986
|
+
runOrchestration: {
|
|
987
|
+
settleOneShotOutcome: (
|
|
988
|
+
r: ExecutionRecord,
|
|
989
|
+
result: { text: string; success: boolean; error?: string },
|
|
990
|
+
aborted: boolean,
|
|
991
|
+
) => Promise<void>;
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
).runOrchestration.settleOneShotOutcome(record, result as never, aborted);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
it("分支①:成功(无挂起)→ doFinalizeRoundToIdle 保持 running-resumable(SP-5——成功轮不终态化)", async () => {
|
|
998
|
+
const record = makeOneShotRecord("sa-oneshot-ok");
|
|
999
|
+
await settleOneShot(record, { text: "done text", success: true }, false);
|
|
1000
|
+
expect(record.status).toBe("running");
|
|
1001
|
+
expect(record.resumable).toBe(true);
|
|
1002
|
+
expect(record.result).toBe("done text");
|
|
1003
|
+
expect(record.closedReason).toBeUndefined();
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it("分支②:成功 + closeAfterRound → 消费挂起标志终态化 closed/user-close", async () => {
|
|
1007
|
+
const record = makeOneShotRecord("sa-oneshot-close");
|
|
1008
|
+
record.closeAfterRound = true;
|
|
1009
|
+
await settleOneShot(record, { text: "done", success: true }, false);
|
|
1010
|
+
expect(record.status).toBe("closed");
|
|
1011
|
+
expect(record.closedReason).toBe("user-close");
|
|
1012
|
+
expect(record.closeAfterRound).toBeUndefined();
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("分支③:失败 + closeAfterRound → 消费挂起标志终态化 closed/gc(含本轮 result)", async () => {
|
|
1016
|
+
const record = makeOneShotRecord("sa-oneshot-fail-close");
|
|
1017
|
+
record.closeAfterRound = true;
|
|
1018
|
+
await settleOneShot(record, { text: "", success: false, error: "boom" }, false);
|
|
1019
|
+
expect(record.status).toBe("closed");
|
|
1020
|
+
expect(record.closedReason).toBe("gc");
|
|
1021
|
+
expect(record.closeAfterRound).toBeUndefined();
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
it("分支④:失败/取消(无挂起)→ doFinalizeRecord 终态化 closed", async () => {
|
|
1025
|
+
const record = makeOneShotRecord("sa-oneshot-fail");
|
|
1026
|
+
await settleOneShot(record, { text: "", success: false, error: "boom" }, false);
|
|
1027
|
+
expect(record.status).toBe("closed");
|
|
1028
|
+
expect(record.closedReason).toBe("gc");
|
|
1029
|
+
});
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
describe("集成:引擎死亡 → Continuation 单发失败通知(D8 监督豁免维持回归)", () => {
|
|
1033
|
+
let agentDir: string;
|
|
1034
|
+
let service: SubagentService;
|
|
1035
|
+
let store: RecordStore;
|
|
1036
|
+
let pi: PiLike;
|
|
1037
|
+
let fake: FakePiEnginePort;
|
|
1038
|
+
|
|
1039
|
+
beforeEach(() => {
|
|
1040
|
+
vi.restoreAllMocks();
|
|
1041
|
+
killChildSpy.mockClear();
|
|
1042
|
+
({ agentDir, service, store, pi, fake } = makeService());
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
afterEach(() => {
|
|
1046
|
+
service.dispose();
|
|
1047
|
+
clearEngines();
|
|
1048
|
+
_resetLifecycleState();
|
|
1049
|
+
_resetSettledWatchdogsForTest();
|
|
1050
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
1051
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("chatMode 轮 run reject(engine_crashed)→ 失败通知单发 + record 保持 running-resumable(不走监督器接管)", async () => {
|
|
1055
|
+
const record = makeChatRecord("sa-engine-death", agentDir);
|
|
1056
|
+
store.register(record);
|
|
1057
|
+
const adoptSpy = vi.spyOn(
|
|
1058
|
+
(service as unknown as { roundSupervisor: { adoptOnProcessDeath: (r: ExecutionRecord, m: string) => void } })
|
|
1059
|
+
.roundSupervisor,
|
|
1060
|
+
"adoptOnProcessDeath",
|
|
1061
|
+
);
|
|
1062
|
+
|
|
1063
|
+
await service.chatActions.deliverChatMessage(record, "risky round");
|
|
1064
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1065
|
+
fake.runs[0]!.fail(
|
|
1066
|
+
Object.assign(new Error("engine process exited unexpectedly: signal SIGKILL"), {
|
|
1067
|
+
name: "EngineSdkError",
|
|
1068
|
+
}),
|
|
1069
|
+
);
|
|
1070
|
+
|
|
1071
|
+
// MF-6:record 保持 running-resumable(容器不被销毁——用户再 message 自动 resume)
|
|
1072
|
+
await vi.waitFor(() => expect(record.resumable).toBe(true));
|
|
1073
|
+
expect(record.status).toBe("running");
|
|
1074
|
+
// D8 豁免维持:chatMode 不进监督域接管链(adopt 调用 gate = chatMode !== true)
|
|
1075
|
+
expect(adoptSpy).not.toHaveBeenCalled();
|
|
1076
|
+
// 单发:Continuation 失败通知恰一条(不存在 supervisor merged notice 双发面)
|
|
1077
|
+
await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(1));
|
|
1078
|
+
const calls = (pi.sendMessage as unknown as ReturnType<typeof vi.fn>).mock.calls as Array<
|
|
1079
|
+
[{ content?: string }]
|
|
1080
|
+
>;
|
|
1081
|
+
expect(calls[0]?.[0]?.content).toContain("engine process exited unexpectedly");
|
|
1082
|
+
});
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
describe("集成:stale-child 派发前兜底(红线②)", () => {
|
|
1086
|
+
let agentDir: string;
|
|
1087
|
+
let service: SubagentService;
|
|
1088
|
+
let store: RecordStore;
|
|
1089
|
+
let pi: PiLike;
|
|
1090
|
+
let fake: FakePiEnginePort;
|
|
1091
|
+
|
|
1092
|
+
beforeEach(() => {
|
|
1093
|
+
vi.restoreAllMocks();
|
|
1094
|
+
killChildSpy.mockClear();
|
|
1095
|
+
({ agentDir, service, store, pi, fake } = makeService());
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
afterEach(() => {
|
|
1099
|
+
service.dispose();
|
|
1100
|
+
clearEngines();
|
|
1101
|
+
vi.useRealTimers();
|
|
1102
|
+
_resetLifecycleState();
|
|
1103
|
+
_resetSettledWatchdogsForTest();
|
|
1104
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
1105
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it("镜像在途子进程活着 → kill 记账 + 协议 cancel + 有界退出窗后派发(无活项 = 直接派发)", async () => {
|
|
1109
|
+
const record = makeChatRecord("sa-stale-child", agentDir);
|
|
1110
|
+
store.register(record);
|
|
1111
|
+
// 模拟上一轮子进程滞留(引擎存活期状态错配——Continuation 无在途 run 但镜像有活项)
|
|
1112
|
+
registerSpawnedChildForRecord(record.id, { pid: 999999, killed: false } as never);
|
|
1113
|
+
|
|
1114
|
+
await service.chatActions.deliverChatMessage(record, "next round");
|
|
1115
|
+
|
|
1116
|
+
// kill 记账(镜像置死——[H1 U6] 协议 cancel 帧随 interact 面退役,真实杀链 =
|
|
1117
|
+
// 轮级 abort signal → cancel 帧 + 引擎轮末收割)
|
|
1118
|
+
expect(killChildSpy).toHaveBeenCalledWith(record.id, "stale-child guard (dispatch)");
|
|
1119
|
+
// 有界退出窗(STALE_CHILD_EXIT_WAIT_MS=300ms)后派发——双写窗收敛
|
|
1120
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1121
|
+
expect(fake.runs[0]!.task.prompt).toBe("next round");
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
it("镜像无活项 → 派发不经兜底窗(无 kill、无 cancel)", async () => {
|
|
1125
|
+
const record = makeChatRecord("sa-no-stale", agentDir);
|
|
1126
|
+
store.register(record);
|
|
1127
|
+
|
|
1128
|
+
await service.chatActions.deliverChatMessage(record, "next round");
|
|
1129
|
+
|
|
1130
|
+
expect(killChildSpy).not.toHaveBeenCalledWith(record.id, "stale-child guard (dispatch)");
|
|
1131
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
describe("集成:[A1] one-shot(非 chatMode)pi background 轮楔死熔断回归(G3 行为零变化恢复)", () => {
|
|
1136
|
+
let agentDir: string;
|
|
1137
|
+
let service: SubagentService;
|
|
1138
|
+
let store: RecordStore;
|
|
1139
|
+
let pi: PiLike;
|
|
1140
|
+
let fake: FakePiEnginePort;
|
|
1141
|
+
|
|
1142
|
+
beforeEach(() => {
|
|
1143
|
+
vi.restoreAllMocks();
|
|
1144
|
+
killChildSpy.mockClear();
|
|
1145
|
+
({ agentDir, service, store, pi, fake } = makeService());
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
afterEach(() => {
|
|
1149
|
+
service.dispose();
|
|
1150
|
+
clearEngines();
|
|
1151
|
+
_resetLifecycleState();
|
|
1152
|
+
_resetSettledWatchdogsForTest();
|
|
1153
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
1154
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it("one-shot 轮 arm 恢复(轮开跑即挂中段守护)+ fire → kill + abort + 终态化 closed/gc + 失败通知单发", async () => {
|
|
1158
|
+
// 测试注入秒级窗(M6 seam)——fire 走真实 timer 全链(arm → mid-round 到期 → 处置)。
|
|
1159
|
+
// 窗值须大于 vi.waitFor 轮询间隔(50ms),保证 arm 断言先于 fire 到期。
|
|
1160
|
+
_setMidRoundNoProgressWindowMsForTest(120);
|
|
1161
|
+
const handle = await service.execute({ task: "wedged task", slug: "oneshot-wedge" });
|
|
1162
|
+
const record = store.getMutable(handle.subagentId);
|
|
1163
|
+
expect(record).toBeDefined();
|
|
1164
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1165
|
+
|
|
1166
|
+
// arm 恢复断言(回归锚点:H1 重构曾误删——修复前本断言红,楔死 run 无恢复计时)
|
|
1167
|
+
await vi.waitFor(() => expect(hasSettledWatchdog(record!.id)).toBe(true));
|
|
1168
|
+
expect(getSettledWatchdogPhase(record!.id)).toBe("mid-round");
|
|
1169
|
+
|
|
1170
|
+
// fire(在途 run 永悬 = 楔死形态)→ 处置:kill + abort 轮 signal + CAS 终态化
|
|
1171
|
+
await vi.waitFor(() => expect(record!.status).toBe("closed"));
|
|
1172
|
+
expect(record!.closedReason).toBe("gc");
|
|
1173
|
+
expect(killChildSpy).toHaveBeenCalledWith(record!.id, "settled watchdog (one-shot)");
|
|
1174
|
+
expect(record!.controller?.signal.aborted).toBe(true);
|
|
1175
|
+
// 终态失败文案(旧 onHotPathSettledWatchdogTimeout 非 chatMode 分支同文——含恢复指引)
|
|
1176
|
+
expect(record!.error).toContain("subagent did not reach agent_settled");
|
|
1177
|
+
expect(record!.error).toContain("settled watchdog");
|
|
1178
|
+
expect(record!.error).toContain("Recovery");
|
|
1179
|
+
// 失败通知发出(独立载荷过 notifyGate 门 → notifyHost.notify)
|
|
1180
|
+
await vi.waitFor(() => expect(pi.sendMessage).toHaveBeenCalledTimes(1));
|
|
1181
|
+
const calls = (pi.sendMessage as unknown as ReturnType<typeof vi.fn>).mock.calls as Array<
|
|
1182
|
+
[{ content?: string; details?: { notifyId?: string; outcome?: string } }]
|
|
1183
|
+
>;
|
|
1184
|
+
expect(calls[0]?.[0]?.content).toContain(`Subagent "general-purpose" (${record!.id}) failed`);
|
|
1185
|
+
expect(calls[0]?.[0]?.content).toContain("settled watchdog");
|
|
1186
|
+
expect(calls[0]?.[0]?.content).toContain("Recovery");
|
|
1187
|
+
expect(calls[0]?.[0]?.details?.outcome).toBe("failed");
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("chat 轮路径零变化:continuation 轮 arm 仍走 onWatchdogFire(fire 不触发 one-shot 处置)", async () => {
|
|
1191
|
+
_setMidRoundNoProgressWindowMsForTest(300);
|
|
1192
|
+
const record = makeChatRecord("sa-chat-wedge", agentDir);
|
|
1193
|
+
store.register(record);
|
|
1194
|
+
await service.chatActions.deliverChatMessage(record, "long round");
|
|
1195
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1196
|
+
await vi.waitFor(() => expect(hasSettledWatchdog(record.id)).toBe(true));
|
|
1197
|
+
|
|
1198
|
+
// fire → Continuation.onWatchdogFire(kill + abort,收口归 run 应答)——record 不终态化
|
|
1199
|
+
await vi.waitFor(() => expect(killChildSpy).toHaveBeenCalledWith(record.id, "settled watchdog (mid-round)"));
|
|
1200
|
+
// abort 的是轮级 signal(run ctx.signal——引擎侧杀链驱动),非 record 级 controller
|
|
1201
|
+
expect(fake.runs[0]!.ctx.signal?.aborted).toBe(true);
|
|
1202
|
+
expect(record.status).toBe("running");
|
|
1203
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
1204
|
+
});
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
describe("集成:[A5] D5 gate 判定本体直测(canUpgradeToConversation——此前仅被 mock)", () => {
|
|
1208
|
+
let agentDir: string;
|
|
1209
|
+
let service: SubagentService;
|
|
1210
|
+
|
|
1211
|
+
beforeEach(() => {
|
|
1212
|
+
({ agentDir, service } = makeService());
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
afterEach(() => {
|
|
1216
|
+
service.dispose();
|
|
1217
|
+
clearEngines();
|
|
1218
|
+
_resetLifecycleState();
|
|
1219
|
+
_resetSettledWatchdogsForTest();
|
|
1220
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
1221
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
it("registry 注册 engine capabilities.conversation='unsupported' 的 stub → false", () => {
|
|
1225
|
+
const fake = registerFakePiEngine();
|
|
1226
|
+
const caps = { ...fake.capabilities(), conversation: "unsupported" as const };
|
|
1227
|
+
registerEngine("stub-unsupported", () => ({ id: "stub-unsupported", capabilities: () => caps }) as never);
|
|
1228
|
+
|
|
1229
|
+
expect(service.canUpgradeToConversation({ engine: "stub-unsupported" })).toBe(false);
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
it("引擎未注册 → fail-closed false(catch 分支);engine 缺省 → 默认引擎(pi conversation=native)放行", () => {
|
|
1233
|
+
expect(service.canUpgradeToConversation({ engine: "no-such-engine" })).toBe(false);
|
|
1234
|
+
expect(service.canUpgradeToConversation({})).toBe(true);
|
|
1235
|
+
});
|
|
1236
|
+
});
|
|
1237
|
+
|
|
1238
|
+
// ============================================================
|
|
1239
|
+
// 集成:live usage 喂入(H2 Gate B 修复)——chat 轮 / pi one-shot / 非 pi 引擎
|
|
1240
|
+
// ============================================================
|
|
1241
|
+
//
|
|
1242
|
+
// [H2 Gate B] W3 删 inproc pi 引擎(session-runner.ts agentEvent 出口)时,协议化
|
|
1243
|
+
// service 侧 chat 轮与 tool one-shot 的 live reducer 喂入(updateFromEvent)一并断链
|
|
1244
|
+
// ——record.turns/totalTokens 恒 0,而 journal-replay / session-view-service 重放路径
|
|
1245
|
+
// 保真(live 落后于 replay 的倒挂)。修复 = runWorkflowEngineTask observedEvent 同款
|
|
1246
|
+
// 喂入恢复(reducer 与重放同源,C5 守护)。本组用例锁三形态:
|
|
1247
|
+
// 1. chat 轮(kickOffChatRound chatMode 分支):实时累积 + 轮终 entry 保真 + 跨轮持续
|
|
1248
|
+
// (Continuation 轮间共用同一 record 实例)+ close 终态 entry 保真;
|
|
1249
|
+
// 2. pi one-shot(kickOffChatRound 非 chatMode 分支——修复前连 onEvent 都不传):
|
|
1250
|
+
// 实时累积 + outcome 写入不重置(completeRecord 只读不重置契约);
|
|
1251
|
+
// 3. 非 pi 引擎(kickOffEngineRun → runEngineTask——修复前事件只喂 journal):
|
|
1252
|
+
// 实时累积 + 终态 entry 保真(非 pi one-shot 一次 run 即终态化)。
|
|
1253
|
+
// 红锚:任一形态喂入行移除即转红(totalTokens 恒 0)。
|
|
1254
|
+
|
|
1255
|
+
/** 非 pi 引擎替身(run 挂起捕获;settle 由用例驱动——runEngineTask 喂入用例专用)。 */
|
|
1256
|
+
class FeedCaptureEngine implements EnginePort {
|
|
1257
|
+
readonly id = "zcode";
|
|
1258
|
+
readonly runs: Array<{
|
|
1259
|
+
ctx: RunContext;
|
|
1260
|
+
emitEvent: (event: unknown) => void;
|
|
1261
|
+
settle: (content: string) => void;
|
|
1262
|
+
}> = [];
|
|
1263
|
+
|
|
1264
|
+
capabilities(): EngineCapabilities {
|
|
1265
|
+
return {
|
|
1266
|
+
schemaEnforcement: "emulated",
|
|
1267
|
+
steer: "unsupported",
|
|
1268
|
+
conversation: "unsupported",
|
|
1269
|
+
personaInjection: "prompt",
|
|
1270
|
+
eventGranularity: "stream",
|
|
1271
|
+
sandbox: "none",
|
|
1272
|
+
sessionRead: "full",
|
|
1273
|
+
resume: "cold",
|
|
1274
|
+
interrupt: "kill-only",
|
|
1275
|
+
permissionMode: "native",
|
|
1276
|
+
maxTurns: false,
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
async probe(): Promise<{ ok: true; engineVersion: string; checks: Array<{ name: string; ok: true }> }> {
|
|
1281
|
+
return { ok: true, engineVersion: "fake-zcode", checks: [{ name: "bin", ok: true }] };
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
run(_task: unknown, ctx: RunContext): Promise<EngineRunResult> {
|
|
1285
|
+
return new Promise<EngineRunResult>((resolve) => {
|
|
1286
|
+
this.runs.push({
|
|
1287
|
+
ctx,
|
|
1288
|
+
emitEvent: (event) => ctx.onEvent?.(event as never),
|
|
1289
|
+
settle: (content) =>
|
|
1290
|
+
resolve({
|
|
1291
|
+
handle: {
|
|
1292
|
+
data: {
|
|
1293
|
+
v: 1,
|
|
1294
|
+
engineId: this.id,
|
|
1295
|
+
sessionRef: { recordId: ctx.taskId },
|
|
1296
|
+
poolKey: "shared",
|
|
1297
|
+
adapterVersion: "feed-capture-engine",
|
|
1298
|
+
} satisfies EngineHandle["data"],
|
|
1299
|
+
},
|
|
1300
|
+
outcome: { content, engineId: this.id },
|
|
1301
|
+
}),
|
|
1302
|
+
});
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async read(): Promise<{ engineId: string; turns: never[]; source: "outcome-only" }> {
|
|
1307
|
+
return { engineId: this.id, turns: [], source: "outcome-only" };
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
describe("集成:live usage 喂入(H2 Gate B)——chat 轮 / pi one-shot / 非 pi 引擎", () => {
|
|
1312
|
+
let agentDir: string;
|
|
1313
|
+
let service: SubagentService;
|
|
1314
|
+
let store: RecordStore;
|
|
1315
|
+
let pi: PiLike;
|
|
1316
|
+
let fake: FakePiEnginePort;
|
|
1317
|
+
let entries: SubagentRecordEntryData[];
|
|
1318
|
+
let prevDataDirEnv: string | undefined;
|
|
1319
|
+
|
|
1320
|
+
beforeEach(() => {
|
|
1321
|
+
vi.restoreAllMocks();
|
|
1322
|
+
killChildSpy.mockClear();
|
|
1323
|
+
prevDataDirEnv = process.env["XYZ_AGENT_DATA_DIR"];
|
|
1324
|
+
// journal 落盘隔离(非 pi 引擎用例 wireEventJournal 写盘;测试红线:不触真实数据目录)
|
|
1325
|
+
agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "cont-usage-feed-"));
|
|
1326
|
+
process.env.XYZ_AGENT_DATA_DIR = path.join(agentDir, "engine-data");
|
|
1327
|
+
clearEngines();
|
|
1328
|
+
fake = registerFakePiEngine();
|
|
1329
|
+
const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
|
|
1330
|
+
modelService.initModel({
|
|
1331
|
+
modelRegistry: { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true },
|
|
1332
|
+
sessionId: "root-session",
|
|
1333
|
+
ctxModel: { id: "m", name: "M", provider: "prov", reasoning: false },
|
|
1334
|
+
});
|
|
1335
|
+
service = new SubagentService({ cwd: agentDir, modelService });
|
|
1336
|
+
pi = makePi();
|
|
1337
|
+
entries = [];
|
|
1338
|
+
(pi.appendEntry as ReturnType<typeof vi.fn>).mockImplementation(
|
|
1339
|
+
(customType: string, data: unknown) => {
|
|
1340
|
+
if (customType === SUBAGENT_RECORD_CUSTOM_TYPE) entries.push(data as SubagentRecordEntryData);
|
|
1341
|
+
},
|
|
1342
|
+
);
|
|
1343
|
+
service.initSession({ pi, sessionId: "root-session" });
|
|
1344
|
+
store = (service as unknown as ServiceInternals).store;
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
afterEach(() => {
|
|
1348
|
+
service.dispose();
|
|
1349
|
+
clearEngines();
|
|
1350
|
+
_resetLifecycleState();
|
|
1351
|
+
_resetSettledWatchdogsForTest();
|
|
1352
|
+
_resetCoreSpawnedChildrenMirrorForTest();
|
|
1353
|
+
if (prevDataDirEnv === undefined) delete process.env["XYZ_AGENT_DATA_DIR"];
|
|
1354
|
+
else process.env["XYZ_AGENT_DATA_DIR"] = prevDataDirEnv;
|
|
1355
|
+
fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
/** 本 record 的 entry 序列(appendEntry 捕获投影)。 */
|
|
1359
|
+
function entriesFor(id: string): SubagentRecordEntryData[] {
|
|
1360
|
+
return entries.filter((e) => (e as { id?: string }).id === id);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
it("chat 轮:message_end(usage) → totalTokens/turnCount 实时累积;轮终 entry 保真;跨轮持续;close 终态 entry 保真", async () => {
|
|
1364
|
+
const record = makeChatRecord("sa-usage-chat", agentDir);
|
|
1365
|
+
store.register(record);
|
|
1366
|
+
|
|
1367
|
+
await service.chatActions.deliverChatMessage(record, "round one");
|
|
1368
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1369
|
+
|
|
1370
|
+
// 引擎协议事件(pi-subagent-cli spawn-event-translator 的协议产物形态)
|
|
1371
|
+
fake.runs[0]!.emitEvent({
|
|
1372
|
+
type: "message_end",
|
|
1373
|
+
usage: { input: 100, output: 50, cacheRead: 20, cacheWrite: 10, cost: 0.5 },
|
|
1374
|
+
});
|
|
1375
|
+
fake.runs[0]!.emitEvent({ type: "text_delta", delta: "working" });
|
|
1376
|
+
fake.runs[0]!.emitEvent({ type: "turn_end" });
|
|
1377
|
+
fake.runs[0]!.emitEvent({ type: "message_end", usage: { input: 7, output: 3, cacheRead: 0, cacheWrite: 0 } });
|
|
1378
|
+
|
|
1379
|
+
// live record 实时累积(修复前:轮实际消耗 LLM 而 record 恒 0)
|
|
1380
|
+
expect(record.totalTokens).toBe(190); // (100+50+20+10) + (7+3)
|
|
1381
|
+
expect(record.turnCount).toBe(1);
|
|
1382
|
+
|
|
1383
|
+
// 轮终 settle → 轮终簿记 entry 携带非零 usage(chat 域轮终不终态——终态 = close)
|
|
1384
|
+
fake.runs[0]!.settle({ content: "round one reply" });
|
|
1385
|
+
await vi.waitFor(() => expect(record.round).toBe(2));
|
|
1386
|
+
expect(record.totalTokens).toBe(190);
|
|
1387
|
+
const roundEntry = entriesFor(record.id).at(-1);
|
|
1388
|
+
expect(roundEntry).toMatchObject({ id: record.id, totalTokens: 190 });
|
|
1389
|
+
|
|
1390
|
+
// 跨轮持续:Continuation 轮间共用同一 record 实例,第二轮继续累积
|
|
1391
|
+
await service.chatActions.deliverChatMessage(record, "round two");
|
|
1392
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(2));
|
|
1393
|
+
fake.runs[1]!.emitEvent({
|
|
1394
|
+
type: "message_end",
|
|
1395
|
+
usage: { input: 5, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
|
1396
|
+
});
|
|
1397
|
+
fake.runs[1]!.emitEvent({ type: "turn_end" });
|
|
1398
|
+
expect(record.totalTokens).toBe(200);
|
|
1399
|
+
|
|
1400
|
+
// close 终态 → 终态 entry totalTokens/turns 保真(list / 重启重建源读到的形态)
|
|
1401
|
+
fake.runs[1]!.settle({ content: "round two reply" });
|
|
1402
|
+
await vi.waitFor(() => expect(record.round).toBe(3));
|
|
1403
|
+
await service["closeSubagent"](record, false);
|
|
1404
|
+
await vi.waitFor(() => expect(record.status).toBe("closed"));
|
|
1405
|
+
const finalEntry = entriesFor(record.id).at(-1);
|
|
1406
|
+
expect(finalEntry).toMatchObject({ id: record.id, status: "closed", totalTokens: 200, turns: 2 });
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
it("pi one-shot:message_end(usage) → totalTokens/turnCount 实时累积;outcome 写入不重置", async () => {
|
|
1410
|
+
const handle = await service.execute({ task: "oneshot usage", slug: "oneshot-usage" });
|
|
1411
|
+
await vi.waitFor(() => expect(fake.runs.length).toBe(1));
|
|
1412
|
+
|
|
1413
|
+
fake.runs[0]!.emitEvent({
|
|
1414
|
+
type: "message_end",
|
|
1415
|
+
usage: { input: 100, output: 50, cacheRead: 20, cacheWrite: 10, cost: 0.5 },
|
|
1416
|
+
});
|
|
1417
|
+
fake.runs[0]!.emitEvent({ type: "turn_end" });
|
|
1418
|
+
|
|
1419
|
+
const record = store.getMutable(handle.subagentId);
|
|
1420
|
+
expect(record).toBeDefined();
|
|
1421
|
+
expect(record!.totalTokens).toBe(180);
|
|
1422
|
+
expect(record!.turnCount).toBe(1);
|
|
1423
|
+
|
|
1424
|
+
// settle → outcome 字段写入不重置 turns/totalTokens(completeRecord 只读契约)
|
|
1425
|
+
fake.runs[0]!.settle({ content: "done" });
|
|
1426
|
+
await vi.waitFor(() => expect(record!.result).toBe("done"));
|
|
1427
|
+
expect(record!.totalTokens).toBe(180);
|
|
1428
|
+
expect(record!.turnCount).toBe(1);
|
|
1429
|
+
});
|
|
1430
|
+
|
|
1431
|
+
it("非 pi 引擎(runEngineTask):message_end(usage) → totalTokens 实时累积;终态 entry 保真", async () => {
|
|
1432
|
+
const zcode = new FeedCaptureEngine();
|
|
1433
|
+
registerEngine("zcode", () => zcode);
|
|
1434
|
+
|
|
1435
|
+
const handle = await service.execute({ task: "zcode usage", slug: "zc-usage", engine: "zcode" });
|
|
1436
|
+
await vi.waitFor(() => expect(zcode.runs.length).toBe(1));
|
|
1437
|
+
|
|
1438
|
+
zcode.runs[0]!.emitEvent({
|
|
1439
|
+
type: "message_end",
|
|
1440
|
+
usage: { input: 30, output: 12, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
|
1441
|
+
});
|
|
1442
|
+
const record = store.getMutable(handle.subagentId);
|
|
1443
|
+
expect(record).toBeDefined();
|
|
1444
|
+
expect(record!.totalTokens).toBe(42); // 修复前:事件只喂 journal,record 恒 0
|
|
1445
|
+
|
|
1446
|
+
// 非 pi one-shot 一次 run 即终态化(finalizeEngineOutcome → closed/gc + entry 落盘)
|
|
1447
|
+
zcode.runs[0]!.settle("done");
|
|
1448
|
+
await vi.waitFor(() => expect(record!.status).toBe("closed"));
|
|
1449
|
+
const finalEntry = entriesFor(record!.id).at(-1);
|
|
1450
|
+
expect(finalEntry).toMatchObject({ id: record!.id, status: "closed", totalTokens: 42 });
|
|
1451
|
+
});
|
|
1452
|
+
});
|