@zhushanwen/pi-subagent-workflow 7.3.4 → 8.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -12
- package/agents/analyst.md +61 -0
- package/agents/coder.md +70 -0
- package/agents/debugger.md +67 -0
- package/agents/doc-reviewer.md +3 -3
- package/agents/explorer.md +50 -18
- package/agents/general-purpose.md +19 -8
- package/agents/orchestrator.md +37 -32
- package/agents/planner.md +45 -11
- package/agents/researcher.md +53 -11
- package/agents/reviewer.md +74 -0
- package/package.json +1 -1
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- package/src/execution/__tests__/agent-registry.test.ts +13 -11
- package/src/execution/__tests__/ask-user-transit-e2e.test.ts +10 -4
- package/src/execution/__tests__/before-agent-start-injection.test.ts +132 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +15 -15
- package/src/execution/__tests__/chatmode-first-round-closure-service.test.ts +365 -0
- package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +190 -0
- package/src/execution/__tests__/chatmode-round-notify-real-chain.test.ts +215 -0
- package/src/execution/__tests__/conversation-wiring.test.ts +198 -0
- package/src/execution/__tests__/crash-recovery.test.ts +8 -2
- package/src/execution/__tests__/delivery-methods.test.ts +385 -0
- package/src/execution/__tests__/epipe-fallback.test.ts +241 -0
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +49 -2
- package/src/execution/__tests__/execute-nesting.test.ts +20 -72
- package/src/execution/__tests__/execution-record.test.ts +199 -0
- package/src/execution/__tests__/finalize-record.test.ts +170 -14
- package/src/execution/__tests__/format.test.ts +131 -7
- package/src/execution/__tests__/gc-timer.test.ts +184 -0
- package/src/execution/__tests__/get-record-for-action-restart.test.ts +254 -0
- package/src/execution/__tests__/helpers/spawn-mock.ts +25 -7
- package/src/execution/__tests__/index-session-start-identity.test.ts +371 -0
- package/src/execution/__tests__/index-session-start.test.ts +257 -5
- package/src/execution/__tests__/lifecycle-manager-lock.test.ts +211 -0
- package/src/execution/__tests__/lifecycle-manager.test.ts +337 -0
- package/src/execution/__tests__/lifecycle-predicates.test.ts +116 -0
- package/src/execution/__tests__/list-component.test.ts +59 -5
- package/src/execution/__tests__/list-fields.test.ts +109 -0
- package/src/execution/__tests__/model-resolver.test.ts +38 -1
- package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +287 -0
- package/src/execution/__tests__/nested-visibility.test.ts +325 -0
- package/src/execution/__tests__/notifier-flush.test.ts +209 -7
- package/src/execution/__tests__/one-shot-upgrade.test.ts +205 -0
- package/src/execution/__tests__/parent-child-matrix.test.ts +336 -0
- package/src/execution/__tests__/record-store.test.ts +158 -52
- package/src/execution/__tests__/recursive-visibility-baseline.test.ts +11 -12
- package/src/execution/__tests__/recursive-visibility-env.test.ts +18 -20
- package/src/execution/__tests__/resource-policy.test.ts +109 -0
- package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +267 -0
- package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +253 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +18 -25
- package/src/execution/__tests__/run-spawn-integration.test.ts +29 -25
- package/src/execution/__tests__/run-spawn-resume.test.ts +322 -0
- package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +14 -11
- package/src/execution/__tests__/session-pending.test.ts +61 -2
- package/src/execution/__tests__/session-reconstructor.test.ts +4 -4
- package/src/execution/__tests__/session-runner-epipe.test.ts +178 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +15 -21
- package/src/execution/__tests__/session-start-reaper.test.ts +10 -8
- package/src/execution/__tests__/spawn-args.test.ts +127 -49
- package/src/execution/__tests__/spawn-worktree-guidance.test.ts +1 -0
- package/src/execution/__tests__/spawned-children.test.ts +92 -0
- package/src/execution/__tests__/status-refactor.test.ts +345 -0
- package/src/execution/__tests__/stdin-writer.test.ts +97 -0
- package/src/execution/__tests__/subagent-service-message-close.test.ts +598 -0
- package/src/execution/__tests__/subagent-service-parent-guard.test.ts +180 -0
- package/src/execution/__tests__/subagent-service.test.ts +49 -11
- package/src/execution/__tests__/timeout-integration.test.ts +27 -13
- package/src/execution/__tests__/tool-action.test.ts +12 -10
- package/src/execution/__tests__/truncline-snapshot.test.ts +81 -0
- package/src/execution/__tests__/turn-limiter-semantics.test.ts +194 -0
- package/src/execution/__tests__/worktree-manager.test.ts +292 -89
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +13 -12
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +90 -13
- package/src/execution/host-mode.ts +1 -1
- package/src/execution/lifecycle-manager.ts +484 -0
- package/src/execution/lifecycle-predicates.ts +65 -0
- package/src/execution/manifest-store.ts +61 -16
- package/src/execution/model-resolver.ts +26 -5
- package/src/execution/notifier.ts +69 -12
- package/src/execution/pi-invocation.ts +21 -1
- package/src/execution/record-store.ts +554 -107
- package/src/execution/session-pending.ts +116 -45
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +290 -75
- package/src/execution/sessions-index.ts +304 -0
- package/src/execution/stdin-writer.ts +93 -7
- package/src/execution/stream-sink.ts +20 -3
- package/src/execution/subagent-service.ts +867 -138
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +204 -22
- package/src/execution/worktree-manager.ts +128 -49
- package/src/execution/worktree-registry.ts +13 -2
- package/src/index.ts +277 -19
- package/src/injectors/subagent-list-injector.ts +26 -8
- package/src/injectors/workflow-list-injector.ts +25 -8
- package/src/interface/__tests__/subagent-tool-prompt.test.ts +18 -5
- package/src/interface/__tests__/tool-render.test.ts +15 -13
- package/src/interface/bg-notify-render.ts +32 -8
- package/src/interface/command-actions.ts +26 -7
- package/src/interface/commands.ts +21 -22
- package/src/interface/format.ts +44 -17
- package/src/interface/gui-mappers.ts +6 -8
- package/src/interface/helpers.ts +170 -10
- package/src/interface/list-component.ts +53 -14
- package/src/interface/subagent-actions.ts +235 -17
- package/src/interface/subagent-tool.ts +82 -17
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +11 -24
- package/src/interface/tool-workflow.ts +20 -35
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/__tests__/detail-content-session-file.test.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +325 -0
- package/src/orchestration/__tests__/args-validator.test.ts +1 -1
- package/src/orchestration/__tests__/config-loader.test.ts +38 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +394 -4
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +4 -4
- package/src/orchestration/__tests__/execute-agent-call.test.ts +95 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +657 -18
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/skill-discovery.test.ts +157 -0
- package/src/orchestration/__tests__/test-mocks.ts +191 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +98 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +0 -2
- package/src/orchestration/__tests__/workflow-script-lint-memo.test.ts +110 -0
- package/src/orchestration/__tests__/workflows-e2e.test.ts +1 -1
- package/src/orchestration/agent-opts-resolver.ts +4 -1
- package/src/orchestration/args-validator.ts +2 -2
- package/src/orchestration/config-loader.ts +30 -1
- package/src/orchestration/error-recovery.ts +133 -29
- package/src/orchestration/execute-agent-call.ts +31 -7
- package/src/orchestration/jsonl-run-store.ts +287 -40
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +135 -132
- package/src/orchestration/models/__tests__/trace.test.ts +408 -0
- package/src/orchestration/models/budget.ts +1 -1
- package/src/orchestration/models/run-runtime.ts +15 -17
- package/src/orchestration/models/run-spec.ts +2 -2
- package/src/orchestration/models/run-state.ts +3 -3
- package/src/orchestration/models/trace.ts +95 -15
- package/src/orchestration/models/types.ts +8 -9
- package/src/orchestration/models/workflow-run.ts +50 -71
- package/src/orchestration/models/workflow-script.ts +32 -1
- package/src/orchestration/skill-discovery.ts +30 -0
- package/src/orchestration/worker-handle.ts +1 -1
- package/src/orchestration/worker-host.ts +1 -1
- package/src/orchestration/worker-script-builder.ts +29 -10
- package/src/shared/__tests__/agent-ref.test.ts +34 -0
- package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +280 -0
- package/src/shared/__tests__/resource-discovery.test.ts +55 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +16 -0
- package/src/shared/resource-discovery.ts +147 -58
- package/src/shared/schema-jsonify.ts +53 -0
- package/workflows/README.md +4 -4
- package/agents/code-reviewer.md +0 -47
- package/agents/context-builder.md +0 -21
- package/agents/oracle.md +0 -34
- package/agents/worker.md +0 -20
|
@@ -91,6 +91,7 @@ vi.mock("../subagent-service.ts", () => ({
|
|
|
91
91
|
initSession = mockInitSession;
|
|
92
92
|
setUiRequestHandler = mockSetUiRequestHandler;
|
|
93
93
|
recoverManifestTmpFiles = mockRecoverManifestTmpFiles;
|
|
94
|
+
startGcTimer = vi.fn();
|
|
94
95
|
getStreamSink = () => null;
|
|
95
96
|
dispose = vi.fn();
|
|
96
97
|
},
|
|
@@ -124,16 +125,38 @@ vi.mock("../session-file-gc.ts", () => ({
|
|
|
124
125
|
maybeCleanupExpiredSessionFiles: vi.fn(),
|
|
125
126
|
}));
|
|
126
127
|
|
|
127
|
-
// JsonlRunStore mock:loadAll 默认空数组(session_start
|
|
128
|
+
// JsonlRunStore mock:loadAll 默认空数组(session_start 后段不抛即可)。
|
|
129
|
+
// dispose/flushPendingSaves:session_shutdown handler 会调 state.store.dispose()(W2C5);
|
|
130
|
+
// dispose 用 hoisted spy 以便 W2TC16 断言(instance.dispose 即 spy)。
|
|
131
|
+
const { mockStoreDispose, mockStoreFlushPendingSaves } = vi.hoisted(() => ({
|
|
132
|
+
mockStoreDispose: vi.fn(async () => {}),
|
|
133
|
+
mockStoreFlushPendingSaves: vi.fn(async () => {}),
|
|
134
|
+
}));
|
|
128
135
|
vi.mock("../../orchestration/jsonl-run-store.ts", () => ({
|
|
129
136
|
JsonlRunStore: class {
|
|
130
137
|
loadAll = mockLoadAll;
|
|
131
138
|
save = vi.fn(async () => {});
|
|
139
|
+
dispose = mockStoreDispose;
|
|
140
|
+
flushPendingSaves = mockStoreFlushPendingSaves;
|
|
132
141
|
},
|
|
133
142
|
}));
|
|
134
143
|
|
|
144
|
+
// lifecycle mock:仅替换 terminateRunningRuns 为 hoisted spy(W2TC16 断言
|
|
145
|
+
// terminate→dispose 顺序用),其余导出(scheduleTimeBudget/runWorkflow/abortRun/
|
|
146
|
+
// evictDoneRunsBeyondCap)经 importOriginal 保留真实实现——本文件不执行真实
|
|
147
|
+
// workflow,替换只影响 shutdown handler 的 terminate 调用点。
|
|
148
|
+
const { mockTerminateRunningRuns } = vi.hoisted(() => ({
|
|
149
|
+
mockTerminateRunningRuns: vi.fn(async () => {}),
|
|
150
|
+
}));
|
|
151
|
+
vi.mock("../../orchestration/lifecycle.ts", async (importOriginal) => {
|
|
152
|
+
const actual = await importOriginal<typeof import("../../orchestration/lifecycle.ts")>();
|
|
153
|
+
return { ...actual, terminateRunningRuns: mockTerminateRunningRuns };
|
|
154
|
+
});
|
|
155
|
+
|
|
135
156
|
// interface 层 mock:避免触发真实 pi.registerTool(pi 是 Proxy,真实模块访问 pi
|
|
136
157
|
// 属性时可能抛错)。路径相对 src/execution/__tests/ → ../../interface/...
|
|
158
|
+
// registerWorkflowTool / registerWorkflowsCommand 用 hoisted spy——W3TC7/8 需在
|
|
159
|
+
// it 内读 mock.calls 捕获 lazyDeps 与 runs getter(对齐 mockStoreDispose 先例)。
|
|
137
160
|
vi.mock("../../interface/subagent-tool.ts", () => ({
|
|
138
161
|
registerSubagentTool: vi.fn(),
|
|
139
162
|
}));
|
|
@@ -143,30 +166,39 @@ vi.mock("../../interface/subagents.ts", () => ({
|
|
|
143
166
|
vi.mock("../../interface/bg-notify-render.ts", () => ({
|
|
144
167
|
renderBgNotifyMessage: vi.fn(),
|
|
145
168
|
}));
|
|
169
|
+
const { mockRegisterWorkflowTool, mockRegisterWorkflowsCommand } = vi.hoisted(() => ({
|
|
170
|
+
mockRegisterWorkflowTool: vi.fn(),
|
|
171
|
+
mockRegisterWorkflowsCommand: vi.fn(),
|
|
172
|
+
}));
|
|
146
173
|
vi.mock("../../interface/tool-workflow.ts", () => ({
|
|
147
|
-
registerWorkflowTool:
|
|
174
|
+
registerWorkflowTool: mockRegisterWorkflowTool,
|
|
148
175
|
}));
|
|
149
176
|
vi.mock("../../interface/tool-workflow-script.ts", () => ({
|
|
150
177
|
registerWorkflowScriptTool: vi.fn(),
|
|
151
178
|
}));
|
|
152
179
|
vi.mock("../../interface/commands.ts", () => ({
|
|
153
|
-
registerWorkflowsCommand:
|
|
180
|
+
registerWorkflowsCommand: mockRegisterWorkflowsCommand,
|
|
154
181
|
}));
|
|
155
182
|
|
|
156
183
|
// ── import 被测工厂 ──
|
|
157
184
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
158
185
|
|
|
159
186
|
import subagentsExtension from "../../index.ts";
|
|
187
|
+
import { Budget } from "../../orchestration/models/budget.ts";
|
|
188
|
+
import { Trace } from "../../orchestration/models/trace.ts";
|
|
189
|
+
import { WorkflowRun } from "../../orchestration/models/workflow-run.ts";
|
|
160
190
|
|
|
161
191
|
// ── helpers ──
|
|
162
192
|
|
|
163
|
-
/** 创建可观察的 mock ExtensionAPI,捕获 session_start handler。
|
|
193
|
+
/** 创建可观察的 mock ExtensionAPI,捕获 session_start / session_shutdown handler。
|
|
164
194
|
* Proxy 兜底:未显式处理的 pi.xxx 返回 noop,避免抛错。 */
|
|
165
195
|
function createMockPi(): {
|
|
166
196
|
pi: ExtensionAPI;
|
|
167
197
|
getSessionStartHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
198
|
+
getSessionShutdownHandler: () => ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
168
199
|
} {
|
|
169
200
|
let sessionStartHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
201
|
+
let sessionShutdownHandler: ((event: unknown, ctx: unknown) => Promise<void>) | undefined;
|
|
170
202
|
const events = { emit: vi.fn() };
|
|
171
203
|
const noop = (): void => {
|
|
172
204
|
/* mock */
|
|
@@ -178,6 +210,9 @@ function createMockPi(): {
|
|
|
178
210
|
if (event === "session_start") {
|
|
179
211
|
sessionStartHandler = handler as (event: unknown, ctx: unknown) => Promise<void>;
|
|
180
212
|
}
|
|
213
|
+
if (event === "session_shutdown") {
|
|
214
|
+
sessionShutdownHandler = handler as (event: unknown, ctx: unknown) => Promise<void>;
|
|
215
|
+
}
|
|
181
216
|
};
|
|
182
217
|
}
|
|
183
218
|
if (prop === "events") return events;
|
|
@@ -186,7 +221,11 @@ function createMockPi(): {
|
|
|
186
221
|
return noop;
|
|
187
222
|
},
|
|
188
223
|
});
|
|
189
|
-
return {
|
|
224
|
+
return {
|
|
225
|
+
pi,
|
|
226
|
+
getSessionStartHandler: () => sessionStartHandler,
|
|
227
|
+
getSessionShutdownHandler: () => sessionShutdownHandler,
|
|
228
|
+
};
|
|
190
229
|
}
|
|
191
230
|
|
|
192
231
|
/** 最小 ExtensionContext mock。mode 由参数控制(决定 handler 注入行为)。 */
|
|
@@ -253,6 +292,7 @@ describe("session_start UI handler 注入链路(SR-3)", () => {
|
|
|
253
292
|
// ADR-035:与 SubagentService mock class 形状一致——session_start
|
|
254
293
|
// 会调 service.recoverManifestTmpFiles(),缺方法会抛 TypeError 被吞为 console.warn
|
|
255
294
|
recoverManifestTmpFiles: mockRecoverManifestTmpFiles,
|
|
295
|
+
startGcTimer: vi.fn(),
|
|
256
296
|
getStreamSink: () => null,
|
|
257
297
|
dispose: vi.fn(),
|
|
258
298
|
};
|
|
@@ -339,3 +379,215 @@ describe("session_start UI handler 注入链路(SR-3)", () => {
|
|
|
339
379
|
expect(mockRecoverManifestTmpFiles).toHaveBeenCalledTimes(1);
|
|
340
380
|
});
|
|
341
381
|
});
|
|
382
|
+
|
|
383
|
+
describe("session_shutdown: store.dispose 接线(W2TC16)", () => {
|
|
384
|
+
it("W2TC16: session_shutdown 触发后 store 实例 dispose 被调用(terminate 完成之后、sessionState 清理之前)", async () => {
|
|
385
|
+
// 预热:session_start 建立一个 sessionState 条目(含 mock store 实例)。
|
|
386
|
+
// loadAll 注入 running run 使其进入 sessionState.runs——duck-typed 对象 +
|
|
387
|
+
// no-op transition:session_start 的 kill-9 恢复会把 running run 转 done,failed
|
|
388
|
+
// (真实 WorkflowRun.reconstruct 无法保持 running),no-op transition 吞掉该转换
|
|
389
|
+
// 让 run 以 running 进入 sessionState.runs。运行时形状由消费路径保证:handler
|
|
390
|
+
// 只读 state.status(string)与 transition(可调用),duck typing 满足。
|
|
391
|
+
mockStoreDispose.mockClear();
|
|
392
|
+
const runningRun = {
|
|
393
|
+
runId: "wf-w2tc16-1",
|
|
394
|
+
state: { status: "running", error: undefined as string | undefined },
|
|
395
|
+
transition: vi.fn(),
|
|
396
|
+
} as unknown as WorkflowRun;
|
|
397
|
+
mockLoadAll.mockResolvedValue([runningRun]);
|
|
398
|
+
const { pi, getSessionStartHandler, getSessionShutdownHandler } = createMockPi();
|
|
399
|
+
subagentsExtension(pi);
|
|
400
|
+
|
|
401
|
+
const startHandler = getSessionStartHandler();
|
|
402
|
+
expect(startHandler).toBeDefined();
|
|
403
|
+
await startHandler!({ type: "session_start" }, createMockCtx("tui"));
|
|
404
|
+
|
|
405
|
+
// terminate gate:deferred 模拟 terminateRunningRuns 内部 `await store.save(run)`
|
|
406
|
+
// 落盘边界——resolve 前 dispose 不得发生(terminate 未完成 = failed 状态未落盘,
|
|
407
|
+
// 此刻 dispose 刷 pending 批会把未定稿的 running 尾巴刷出去,重启后 kill-9
|
|
408
|
+
// 恢复误判)。
|
|
409
|
+
let resolveTerminate = (): void => {};
|
|
410
|
+
mockTerminateRunningRuns.mockImplementationOnce(async () => {
|
|
411
|
+
await new Promise<void>((r) => {
|
|
412
|
+
resolveTerminate = r;
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
// 触发 session_shutdown(event 触发模式对齐本文件 session_start 既有写法)
|
|
417
|
+
const shutdownHandler = getSessionShutdownHandler();
|
|
418
|
+
expect(shutdownHandler).toBeDefined();
|
|
419
|
+
const shutdownDone = shutdownHandler!({ type: "session_shutdown" }, createMockCtx("tui"));
|
|
420
|
+
|
|
421
|
+
// terminate 未完成(save 未落盘)→ dispose 未被调(await 边界真实生效)
|
|
422
|
+
await Promise.resolve();
|
|
423
|
+
expect(mockStoreDispose).not.toHaveBeenCalled();
|
|
424
|
+
|
|
425
|
+
resolveTerminate();
|
|
426
|
+
await shutdownDone;
|
|
427
|
+
|
|
428
|
+
// 每 sessionState 条目一次:session_start 建了 1 个 session → terminate 调用 1 次
|
|
429
|
+
expect(mockTerminateRunningRuns).toHaveBeenCalledTimes(1);
|
|
430
|
+
// 原因串字面值(session_shutdown 路径,接口契约锁定)
|
|
431
|
+
expect(mockTerminateRunningRuns.mock.calls[0]?.[1]).toBe("Session shutdown: run terminated");
|
|
432
|
+
// dispose 在 terminate 完成后被调(每 session 1 次)
|
|
433
|
+
expect(mockStoreDispose).toHaveBeenCalledTimes(1);
|
|
434
|
+
|
|
435
|
+
// 顺序断言「sessionState 清理之前」(标题后半):sessionState.delete 是 Map
|
|
436
|
+
// 同步操作、无外部可观察 spy——用第二次 shutdown 的幂等性间接证明:第一次
|
|
437
|
+
// handler 内条目已删(delete 在 dispose await 之后执行),重复 shutdown 遍历
|
|
438
|
+
// 空 Map 不再二次 terminate/dispose。若清理被跳过,此处会涨到 2 次。
|
|
439
|
+
await shutdownHandler!({ type: "session_shutdown" }, createMockCtx("tui"));
|
|
440
|
+
expect(mockTerminateRunningRuns).toHaveBeenCalledTimes(1);
|
|
441
|
+
expect(mockStoreDispose).toHaveBeenCalledTimes(1);
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ── W-N: done run 淘汰接线(loadAll 裁剪 + onRunDone 裁剪) ──
|
|
446
|
+
|
|
447
|
+
/** fixture 基准时刻(过去时刻,保证 now 恒晚于全部 fixture completedAt)。 */
|
|
448
|
+
const W3_T0 = Date.parse("2020-01-01T00:00:00.000Z");
|
|
449
|
+
function w3IsoAt(min: number): string {
|
|
450
|
+
return new Date(W3_T0 + min * 60_000).toISOString();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** loadAll 重水合输入 fixture:done run(status done reason completed + completedAt)。 */
|
|
454
|
+
function makeLoadedDoneRun(runId: string, completedAt: string): WorkflowRun {
|
|
455
|
+
return WorkflowRun.reconstruct(
|
|
456
|
+
runId,
|
|
457
|
+
{
|
|
458
|
+
scriptSource: "execute() {}",
|
|
459
|
+
args: {},
|
|
460
|
+
scriptName: "test",
|
|
461
|
+
scriptPath: "/fake/test.js",
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
status: "done",
|
|
465
|
+
reason: "completed",
|
|
466
|
+
budget: new Budget({ maxTokens: 1000 }),
|
|
467
|
+
calls: new Map(),
|
|
468
|
+
trace: new Trace(),
|
|
469
|
+
errorLogs: [],
|
|
470
|
+
},
|
|
471
|
+
{ startedAt: completedAt, completedAt },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** loadAll 重水合输入 fixture:running run(kill-9 恢复输入,reconstruct 跳 I1 合法)。 */
|
|
476
|
+
function makeLoadedRunningRun(runId: string): WorkflowRun {
|
|
477
|
+
return WorkflowRun.reconstruct(
|
|
478
|
+
runId,
|
|
479
|
+
{
|
|
480
|
+
scriptSource: "execute() {}",
|
|
481
|
+
args: {},
|
|
482
|
+
scriptName: "test",
|
|
483
|
+
scriptPath: "/fake/test.js",
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
status: "running",
|
|
487
|
+
budget: new Budget({ maxTokens: 1000 }),
|
|
488
|
+
calls: new Map(),
|
|
489
|
+
trace: new Trace(),
|
|
490
|
+
errorLogs: [],
|
|
491
|
+
},
|
|
492
|
+
{ startedAt: w3IsoAt(0) },
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
describe("W-3: done run 淘汰接线(loadAll 裁剪 + onRunDone 裁剪)", () => {
|
|
497
|
+
it("W3TC7: session_start loadAll 灌入后裁剪到 K(21 done + 1 running kill-9 恢复)", async () => {
|
|
498
|
+
// 21 个 done(completedAt 递增)+ 1 个 running(kill-9 恢复输入)
|
|
499
|
+
const doneRuns = Array.from({ length: 21 }, (_, i) =>
|
|
500
|
+
makeLoadedDoneRun(`wf-loaded-${i}`, w3IsoAt(i)));
|
|
501
|
+
const runningRun = makeLoadedRunningRun("wf-recovered-1");
|
|
502
|
+
mockLoadAll.mockResolvedValue([...doneRuns, runningRun]);
|
|
503
|
+
|
|
504
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
505
|
+
subagentsExtension(pi);
|
|
506
|
+
const handler = getSessionStartHandler();
|
|
507
|
+
expect(handler).toBeDefined();
|
|
508
|
+
await handler!({ type: "session_start" }, createMockCtx("tui"));
|
|
509
|
+
|
|
510
|
+
// runs Map 观察点 = registerWorkflowsCommand 第二参数的 runs getter
|
|
511
|
+
//(工厂入口同步调用,mock.calls[0] 恒存在)
|
|
512
|
+
const getRuns = mockRegisterWorkflowsCommand.mock.calls[0]?.[1] as
|
|
513
|
+
| (() => Map<string, WorkflowRun>)
|
|
514
|
+
| undefined;
|
|
515
|
+
expect(typeof getRuns).toBe("function");
|
|
516
|
+
const runs = getRuns!();
|
|
517
|
+
|
|
518
|
+
// kill-9 恢复链把 running 转 done,failed(既有语义)后共 22 done → 裁到 20
|
|
519
|
+
expect(runs.size).toBe(20);
|
|
520
|
+
// 恢复 run 的 completedAt 为 transition 时刻(now,晚于全部 fixture 过去时间戳)
|
|
521
|
+
// → 必在保留端
|
|
522
|
+
expect(runs.has("wf-recovered-1")).toBe(true);
|
|
523
|
+
expect(runs.get("wf-recovered-1")?.state.status).toBe("done");
|
|
524
|
+
expect(runs.get("wf-recovered-1")?.state.reason).toBe("failed");
|
|
525
|
+
// 被淘汰 2 个 = fixture 最旧两个
|
|
526
|
+
expect(runs.has("wf-loaded-0")).toBe(false);
|
|
527
|
+
expect(runs.has("wf-loaded-1")).toBe(false);
|
|
528
|
+
for (let i = 2; i < 21; i++) {
|
|
529
|
+
expect(runs.has(`wf-loaded-${i}`)).toBe(true);
|
|
530
|
+
}
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("W3TC8: onRunDone 回调接线——notify → track → evict,本轮 run 不被自身触发的裁剪淘汰", async () => {
|
|
534
|
+
// session_start 预热:恰好 20 个 done(completedAt t0..t19)
|
|
535
|
+
const doneRuns = Array.from({ length: 20 }, (_, i) =>
|
|
536
|
+
makeLoadedDoneRun(`wf-pre-${i}`, w3IsoAt(i)));
|
|
537
|
+
mockLoadAll.mockResolvedValue(doneRuns);
|
|
538
|
+
|
|
539
|
+
const { pi, getSessionStartHandler } = createMockPi();
|
|
540
|
+
subagentsExtension(pi);
|
|
541
|
+
const handler = getSessionStartHandler();
|
|
542
|
+
expect(handler).toBeDefined();
|
|
543
|
+
await handler!({ type: "session_start" }, createMockCtx("tui"));
|
|
544
|
+
|
|
545
|
+
// lazyDeps = registerWorkflowTool 第二参数(getter 转发 makeDeps 的 onRunDone)
|
|
546
|
+
const lazyDeps = mockRegisterWorkflowTool.mock.calls[0]?.[1] as
|
|
547
|
+
| { onRunDone: (run: WorkflowRun) => void }
|
|
548
|
+
| undefined;
|
|
549
|
+
expect(lazyDeps).toBeDefined();
|
|
550
|
+
|
|
551
|
+
// 本轮 newDoneRun:completedAt = now(晚于全部 fixture 过去时间戳)
|
|
552
|
+
const newDoneRun = WorkflowRun.reconstruct(
|
|
553
|
+
"wf-current-1",
|
|
554
|
+
{
|
|
555
|
+
scriptSource: "execute() {}",
|
|
556
|
+
args: {},
|
|
557
|
+
scriptName: "test",
|
|
558
|
+
scriptPath: "/fake/test.js",
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
status: "done",
|
|
562
|
+
reason: "completed",
|
|
563
|
+
budget: new Budget({ maxTokens: 1000 }),
|
|
564
|
+
calls: new Map(),
|
|
565
|
+
trace: new Trace(),
|
|
566
|
+
errorLogs: [],
|
|
567
|
+
},
|
|
568
|
+
{ startedAt: w3IsoAt(999), completedAt: new Date().toISOString() },
|
|
569
|
+
);
|
|
570
|
+
|
|
571
|
+
// 模拟真实流程的注册步骤:lifecycle.ts runWorkflow 在创建时 deps.runs.set(runId, run)
|
|
572
|
+
//(onRunDone 回调只裁剪不注册——本轮 run 必须先在 Map 中,「保留端」断言才有对象)
|
|
573
|
+
const getRuns = mockRegisterWorkflowsCommand.mock.calls[0]?.[1] as
|
|
574
|
+
| (() => Map<string, WorkflowRun>)
|
|
575
|
+
| undefined;
|
|
576
|
+
expect(typeof getRuns).toBe("function");
|
|
577
|
+
const runs = getRuns!();
|
|
578
|
+
runs.set("wf-current-1", newDoneRun);
|
|
579
|
+
|
|
580
|
+
// 回调不抛错(notifyDone 对 mock pi Proxy 安全:sendMessage 返回 noop;
|
|
581
|
+
// toGuiCtx(tui ctx) isGuiCapable false 走无 __gui__ 分支)
|
|
582
|
+
expect(() => lazyDeps!.onRunDone(newDoneRun)).not.toThrow();
|
|
583
|
+
|
|
584
|
+
// 21 done 裁 1:size 回到 20
|
|
585
|
+
expect(runs.size).toBe(20);
|
|
586
|
+
// 被淘汰的是 t0 最旧 run 而非本轮 run(completedAt 最新恒在保留端)
|
|
587
|
+
expect(runs.has("wf-current-1")).toBe(true);
|
|
588
|
+
expect(runs.has("wf-pre-0")).toBe(false);
|
|
589
|
+
for (let i = 1; i < 20; i++) {
|
|
590
|
+
expect(runs.has(`wf-pre-${i}`)).toBe(true);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// acquireActivateLock 30s 超时兜底单测 —— v4-lifecycle-convergence.md §3.3 A-2。
|
|
2
|
+
//
|
|
3
|
+
// 验证:前序 holder 持锁不 release(崩溃/死锁模拟)时,waiter
|
|
4
|
+
// - 等待 29999ms 仍 pending(未误伤正常排队)
|
|
5
|
+
// - 到 30001ms 抛含恢复指引的超时错误('activation timed out' + 'retry action: message')
|
|
6
|
+
//
|
|
7
|
+
// 测试策略:纯 Promise 链 + setTimeout 超时,用 vi.useFakeTimers() 控时,
|
|
8
|
+
// advanceTimersByTimeAsync 同时推进 fake timer 与 microtask flush。
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
_getActivateLockTailCountForTest,
|
|
14
|
+
_resetLifecycleState,
|
|
15
|
+
acquireActivateLock,
|
|
16
|
+
} from "../lifecycle-manager.ts";
|
|
17
|
+
|
|
18
|
+
describe("acquireActivateLock 30s 超时兜底 (v4 A-2)", () => {
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
_resetLifecycleState();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
_resetLifecycleState();
|
|
26
|
+
vi.useRealTimers();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("waiter 等待 29999ms 仍 pending;到 30001ms 抛含恢复指引的超时错误", async () => {
|
|
30
|
+
// 第一个 holder 持锁后不 release(模拟崩溃/死锁)
|
|
31
|
+
await acquireActivateLock("sa-stuck");
|
|
32
|
+
|
|
33
|
+
// 第二个 waiter 必然排队(前序未 release)
|
|
34
|
+
let errMsg: string | null = null;
|
|
35
|
+
let gotRelease: (() => void) | null = null;
|
|
36
|
+
const waiter = acquireActivateLock("sa-stuck")
|
|
37
|
+
.then((release) => {
|
|
38
|
+
gotRelease = release;
|
|
39
|
+
})
|
|
40
|
+
.catch((err: unknown) => {
|
|
41
|
+
errMsg = err instanceof Error ? err.message : String(err);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// flush microtask:waiter 进入 prev.then 等待链
|
|
45
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
46
|
+
expect(gotRelease).toBe(null); // 尚未拿到锁
|
|
47
|
+
expect(errMsg).toBe(null);
|
|
48
|
+
|
|
49
|
+
// 29999ms:未满 30s,waiter 仍 pending
|
|
50
|
+
await vi.advanceTimersByTimeAsync(29999);
|
|
51
|
+
expect(gotRelease).toBe(null);
|
|
52
|
+
expect(errMsg).toBe(null);
|
|
53
|
+
|
|
54
|
+
// 再推进 2ms(累计 30001ms):超时 timer 触发 → reject
|
|
55
|
+
await vi.advanceTimersByTimeAsync(2);
|
|
56
|
+
await waiter;
|
|
57
|
+
|
|
58
|
+
// 超时:未拿到锁,抛含恢复指引的错误
|
|
59
|
+
expect(gotRelease).toBe(null);
|
|
60
|
+
expect(errMsg).not.toBe(null);
|
|
61
|
+
expect(errMsg).toContain("activation timed out");
|
|
62
|
+
expect(errMsg).toContain("retry action: message");
|
|
63
|
+
expect(errMsg).toContain("sa-stuck");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("首次 acquire(无前序 holder)立即返回 release,不触发超时", async () => {
|
|
67
|
+
// 首次 acquire:prev=Promise.resolve(),立即 resolve,timer 被 clearTimeout 取消
|
|
68
|
+
const release = await acquireActivateLock("sa-fresh");
|
|
69
|
+
expect(typeof release).toBe("function");
|
|
70
|
+
|
|
71
|
+
// 推进远超 30s,不应有任何未捕获的超时 reject(timer 已清除)
|
|
72
|
+
await vi.advanceTimersByTimeAsync(60000);
|
|
73
|
+
release();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("[review 修复] 超时后前序 release → 链可推进:二次 acquire 立即获锁(锁链不瘫痪)", async () => {
|
|
77
|
+
// 瘫痪路径回归:waiter 超时 reject 后,若其 current 永久 pending,tail 永久
|
|
78
|
+
// pending → 前序 release 后后续 acquire 也只能 30s 超时(只能重启恢复)。
|
|
79
|
+
// 修复后超时回调 resolve 自身 current 放行链尾:release1 后 tail settle,
|
|
80
|
+
// 二次 acquire 的 prev 已 settle → 立即拿到 releaseFn。
|
|
81
|
+
const release1 = await acquireActivateLock("sa-recover");
|
|
82
|
+
|
|
83
|
+
let waiterErr: string | null = null;
|
|
84
|
+
const waiter = acquireActivateLock("sa-recover").catch((err: unknown) => {
|
|
85
|
+
waiterErr = err instanceof Error ? err.message : String(err);
|
|
86
|
+
});
|
|
87
|
+
await vi.advanceTimersByTimeAsync(0); // waiter 挂链
|
|
88
|
+
await vi.advanceTimersByTimeAsync(30001); // 超时 reject(+ 超时放行链尾)
|
|
89
|
+
await waiter;
|
|
90
|
+
expect(waiterErr).toContain("activation timed out");
|
|
91
|
+
|
|
92
|
+
release1(); // 前序释放 → tail settle(current 已被超时放行 resolve)
|
|
93
|
+
await vi.advanceTimersByTimeAsync(0); // flush microtask 链
|
|
94
|
+
|
|
95
|
+
// 二次 acquire:prev 是已 settle 的 tail → 立即拿到锁,不等 30s
|
|
96
|
+
const release2 = await acquireActivateLock("sa-recover");
|
|
97
|
+
expect(typeof release2).toBe("function");
|
|
98
|
+
release2();
|
|
99
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("[review 修复] 前序持续持锁时超时不放行后续 waiter 提前获锁(互斥保持)", async () => {
|
|
103
|
+
// 超时放行只 resolve 自身 current(tail 仍等 prev);前序未 release 时,
|
|
104
|
+
// 新 acquire 依然排队等待,不会与前序 holder 形成双写者。
|
|
105
|
+
const release1 = await acquireActivateLock("sa-mutex");
|
|
106
|
+
|
|
107
|
+
const waiter1 = acquireActivateLock("sa-mutex").catch(() => {});
|
|
108
|
+
await vi.advanceTimersByTimeAsync(30001); // waiter1 超时放行自身 current
|
|
109
|
+
await waiter1;
|
|
110
|
+
|
|
111
|
+
// 前序仍持锁:新 acquire 的 prev(= waiter1 的 tail)等待 release1,不提前获锁
|
|
112
|
+
let gotRelease2 = false;
|
|
113
|
+
const waiter2 = acquireActivateLock("sa-mutex").then(() => {
|
|
114
|
+
gotRelease2 = true;
|
|
115
|
+
});
|
|
116
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
117
|
+
expect(gotRelease2).toBe(false); // 未提前获锁
|
|
118
|
+
|
|
119
|
+
release1();
|
|
120
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
121
|
+
await waiter2; // release1 后链推进,waiter2 获锁
|
|
122
|
+
expect(gotRelease2).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("[review 修复] 超时者对称自清:前序 release 推进链尾后 Map 条目回收;前序持锁期间不提前删", async () => {
|
|
126
|
+
// round2 INFO 残留:超时者 releaseFn 永不被调用,原实现条目滞留 Map 直到下次
|
|
127
|
+
// 同 recordId acquire 覆盖。修复后挂 tail 尾部自清——tail settle(前序已
|
|
128
|
+
// release)时回收;tail pending(前序仍持锁)时回调不执行,条目保留(后续
|
|
129
|
+
// acquire 仍排队等待,互斥不被破坏)。
|
|
130
|
+
const release1 = await acquireActivateLock("sa-timeout-gc");
|
|
131
|
+
|
|
132
|
+
const waiter = acquireActivateLock("sa-timeout-gc").catch(() => {});
|
|
133
|
+
await vi.advanceTimersByTimeAsync(0); // waiter 挂链,Map 链尾已覆盖为 waiter 的 tail
|
|
134
|
+
expect(_getActivateLockTailCountForTest()).toBe(1);
|
|
135
|
+
|
|
136
|
+
await vi.advanceTimersByTimeAsync(30001); // 超时:reject + settleCurrent + tail.then 自清排队
|
|
137
|
+
await waiter;
|
|
138
|
+
// 前序仍持锁 → tail 仍 pending → 自清回调未执行 → 条目仍在
|
|
139
|
+
expect(_getActivateLockTailCountForTest()).toBe(1);
|
|
140
|
+
|
|
141
|
+
release1(); // 前序释放 → tail settle → 自清回调执行(identity 匹配,无新 waiter)
|
|
142
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
143
|
+
expect(_getActivateLockTailCountForTest()).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("activateLockTails tail-identity 自清(release 后回收)", () => {
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
vi.useFakeTimers();
|
|
150
|
+
_resetLifecycleState();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
afterEach(() => {
|
|
154
|
+
_resetLifecycleState();
|
|
155
|
+
vi.useRealTimers();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("W3TC14: 无 waiter 时 release 后 Map 空 + release 后可再次 acquire", async () => {
|
|
159
|
+
const release = await acquireActivateLock("sa-gc");
|
|
160
|
+
// 自清新增的 test-only 观察点:acquire 后 Map 有 1 条链尾
|
|
161
|
+
expect(_getActivateLockTailCountForTest()).toBe(1);
|
|
162
|
+
|
|
163
|
+
release();
|
|
164
|
+
// 自清经 queueMicrotask 异步执行——advanceTimersByTimeAsync(0) flush microtask
|
|
165
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
166
|
+
// 无 waiter:Map 中链尾即本链尾,identity 匹配 → delete 回收
|
|
167
|
+
expect(_getActivateLockTailCountForTest()).toBe(0);
|
|
168
|
+
|
|
169
|
+
// release 后可再次 acquire:prev 回落 Promise.resolve(),立即 resolve 返回 release
|
|
170
|
+
const release2 = await acquireActivateLock("sa-gc");
|
|
171
|
+
expect(typeof release2).toBe("function");
|
|
172
|
+
release2();
|
|
173
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
174
|
+
expect(_getActivateLockTailCountForTest()).toBe(0);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("W3TC15: 有 waiter 排队不删、最后释放者回收;不同 recordId 链独立", async () => {
|
|
178
|
+
const release1 = await acquireActivateLock("sa-q");
|
|
179
|
+
|
|
180
|
+
// 发起 acquire#2 同 recordId(不 await,.then 捕获 release2)
|
|
181
|
+
let release2: (() => void) | null = null;
|
|
182
|
+
const waiter = acquireActivateLock("sa-q").then((r) => {
|
|
183
|
+
release2 = r;
|
|
184
|
+
});
|
|
185
|
+
// #2 进入等待链——此刻 Map 链尾已被 #2 覆盖
|
|
186
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
187
|
+
|
|
188
|
+
release1();
|
|
189
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
190
|
+
// ES7 核心:waiter 的链尾仍在——identity 不匹配不删
|
|
191
|
+
expect(_getActivateLockTailCountForTest()).toBe(1);
|
|
192
|
+
|
|
193
|
+
// acquire#2 在 release1 后正常 resolve(等待链不被 delete 破坏——waiter 持
|
|
194
|
+
// acquire 时刻捕获的 prev Promise 引用而非 Map 查询)
|
|
195
|
+
await waiter;
|
|
196
|
+
expect(typeof release2).toBe("function");
|
|
197
|
+
|
|
198
|
+
// 另一 recordId 独立链:同时持有时各自独立计数
|
|
199
|
+
const releaseA = await acquireActivateLock("sa-a");
|
|
200
|
+
expect(_getActivateLockTailCountForTest()).toBe(2); // sa-q + sa-a
|
|
201
|
+
|
|
202
|
+
release2!();
|
|
203
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
204
|
+
// 最后释放者回收:release2 的 identity 匹配 → sa-q 条目回收,sa-a 仍在
|
|
205
|
+
expect(_getActivateLockTailCountForTest()).toBe(1);
|
|
206
|
+
|
|
207
|
+
releaseA();
|
|
208
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
209
|
+
expect(_getActivateLockTailCountForTest()).toBe(0);
|
|
210
|
+
});
|
|
211
|
+
});
|