@zhushanwen/pi-subagent-workflow 0.1.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/agents/context-builder.md +17 -0
- package/agents/general-purpose.md +16 -0
- package/agents/oracle.md +17 -0
- package/agents/planner.md +17 -0
- package/agents/researcher.md +17 -0
- package/agents/reviewer.md +17 -0
- package/agents/scout.md +17 -0
- package/agents/worker.md +16 -0
- package/examples/README.md +43 -0
- package/examples/chain.example.js +92 -0
- package/examples/map-reduce.example.js +99 -0
- package/examples/parallel.example.js +82 -0
- package/examples/scatter-gather.example.js +106 -0
- package/index.ts +1 -0
- package/package.json +66 -0
- package/skills/workflow-script-format/SKILL.md +328 -0
- package/src/execution/__tests__/agent-registry.test.ts +164 -0
- package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
- package/src/execution/__tests__/alive-store.test.ts +147 -0
- package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
- package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
- package/src/execution/__tests__/config.test.ts +110 -0
- package/src/execution/__tests__/crash-recovery.test.ts +311 -0
- package/src/execution/__tests__/execute-nesting.test.ts +359 -0
- package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
- package/src/execution/__tests__/execution-record.test.ts +959 -0
- package/src/execution/__tests__/finalized-marker.test.ts +82 -0
- package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
- package/src/execution/__tests__/format.test.ts +320 -0
- package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
- package/src/execution/__tests__/list-component.test.ts +347 -0
- package/src/execution/__tests__/model-resolver.test.ts +356 -0
- package/src/execution/__tests__/output-collector.test.ts +61 -0
- package/src/execution/__tests__/path-encoding.test.ts +75 -0
- package/src/execution/__tests__/pi-invocation.test.ts +73 -0
- package/src/execution/__tests__/record-store.test.ts +545 -0
- package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
- package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
- package/src/execution/__tests__/sdk-contract.test.ts +272 -0
- package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
- package/src/execution/__tests__/session-file-gc.test.ts +247 -0
- package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
- package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
- package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
- package/src/execution/__tests__/spawn-args.test.ts +244 -0
- package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
- package/src/execution/__tests__/subagent-service.test.ts +678 -0
- package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
- package/src/execution/__tests__/temp-prompt.test.ts +53 -0
- package/src/execution/__tests__/timeout-integration.test.ts +381 -0
- package/src/execution/__tests__/tombstone-store.test.ts +73 -0
- package/src/execution/__tests__/tool-action.test.ts +330 -0
- package/src/execution/__tests__/turn-limiter.test.ts +65 -0
- package/src/execution/__tests__/worktree-manager.test.ts +423 -0
- package/src/execution/__tests__/worktree-registry.test.ts +161 -0
- package/src/execution/agent-registry.ts +252 -0
- package/src/execution/agent-result-mapper.ts +84 -0
- package/src/execution/alive-store.ts +92 -0
- package/src/execution/best-effort.ts +30 -0
- package/src/execution/concurrency-pool.ts +84 -0
- package/src/execution/config.ts +73 -0
- package/src/execution/execute-options-mapper.ts +86 -0
- package/src/execution/execution-record.ts +778 -0
- package/src/execution/finalized-marker.ts +51 -0
- package/src/execution/model-config-service.ts +225 -0
- package/src/execution/model-resolver.ts +247 -0
- package/src/execution/notifier.ts +168 -0
- package/src/execution/output-collector.ts +88 -0
- package/src/execution/path-encoding.ts +34 -0
- package/src/execution/pi-invocation.ts +70 -0
- package/src/execution/record-store.ts +350 -0
- package/src/execution/session-context-resolver.ts +64 -0
- package/src/execution/session-file-gc.ts +98 -0
- package/src/execution/session-reconstructor.ts +450 -0
- package/src/execution/session-runner.ts +725 -0
- package/src/execution/spawn-event-adapter.ts +150 -0
- package/src/execution/subagent-service.ts +973 -0
- package/src/execution/subprocess-agent-runner.ts +108 -0
- package/src/execution/temp-prompt.ts +57 -0
- package/src/execution/tombstone-store.ts +72 -0
- package/src/execution/turn-limiter.ts +88 -0
- package/src/execution/types.ts +634 -0
- package/src/execution/worktree-manager.ts +285 -0
- package/src/execution/worktree-registry.ts +144 -0
- package/src/index.ts +454 -0
- package/src/interface/bg-notify-render.ts +286 -0
- package/src/interface/commands.ts +157 -0
- package/src/interface/format.ts +501 -0
- package/src/interface/gui-adapter.ts +136 -0
- package/src/interface/helpers.ts +110 -0
- package/src/interface/list-component.ts +643 -0
- package/src/interface/list-shared.ts +84 -0
- package/src/interface/list-view.ts +373 -0
- package/src/interface/reentry-guard.ts +30 -0
- package/src/interface/subagent-actions.ts +294 -0
- package/src/interface/subagent-tool.ts +294 -0
- package/src/interface/subagents.ts +30 -0
- package/src/interface/tool-render.ts +333 -0
- package/src/interface/tool-workflow-script.ts +351 -0
- package/src/interface/tool-workflow.ts +485 -0
- package/src/interface/views/WorkflowsView.ts +944 -0
- package/src/interface/views/detail-content.ts +298 -0
- package/src/interface/views/format.ts +320 -0
- package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
- package/src/orchestration/__tests__/config-loader.test.ts +381 -0
- package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
- package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
- package/src/orchestration/__tests__/script-lint.test.ts +347 -0
- package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
- package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
- package/src/orchestration/agent-opts-resolver.ts +128 -0
- package/src/orchestration/concurrency-gate.ts +69 -0
- package/src/orchestration/config-loader.ts +313 -0
- package/src/orchestration/error-recovery.ts +578 -0
- package/src/orchestration/execute-agent-call.ts +174 -0
- package/src/orchestration/jsonl-run-store.ts +292 -0
- package/src/orchestration/launcher.ts +368 -0
- package/src/orchestration/lifecycle.ts +373 -0
- package/src/orchestration/models/__tests__/budget.test.ts +367 -0
- package/src/orchestration/models/agent-call.ts +76 -0
- package/src/orchestration/models/budget.ts +148 -0
- package/src/orchestration/models/ports.ts +165 -0
- package/src/orchestration/models/run-runtime.ts +91 -0
- package/src/orchestration/models/run-spec.ts +54 -0
- package/src/orchestration/models/run-state.ts +44 -0
- package/src/orchestration/models/trace.ts +102 -0
- package/src/orchestration/models/types.ts +242 -0
- package/src/orchestration/models/workflow-run.ts +275 -0
- package/src/orchestration/models/workflow-script-registry.ts +32 -0
- package/src/orchestration/models/workflow-script.ts +90 -0
- package/src/orchestration/node-ops.ts +192 -0
- package/src/orchestration/script-lint.ts +387 -0
- package/src/orchestration/skill-discovery.ts +60 -0
- package/src/orchestration/worker-handle.ts +115 -0
- package/src/orchestration/worker-host.ts +93 -0
- package/src/orchestration/worker-script-builder.ts +281 -0
- package/src/orchestration/workflow-files.ts +85 -0
- package/src/orchestration/workflow-script-registry-impl.ts +128 -0
- package/src/shared/__tests__/resource-discovery.test.ts +226 -0
- package/src/shared/agent-event.ts +13 -0
- package/src/shared/resource-discovery.ts +535 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// src/__tests__/sdk-contract.test.ts
|
|
2
|
+
//
|
|
3
|
+
// SDK 契约测试:验证扩展对 Pi SDK 的消费符合 [MANDATORY] checklist。
|
|
4
|
+
// 关闭 CI sdk-contract-audit job 的前向引用(该 job 直接跑此文件)。
|
|
5
|
+
//
|
|
6
|
+
// 核心断言([MANDATORY] checklist):
|
|
7
|
+
// 1. registerSubagentsCommand 注册名为 "subagents" 的命令,handler 是 (args, ctx)
|
|
8
|
+
// 2. registerSubagentTool 注册名为 "subagent" 的工具,schema 存在
|
|
9
|
+
// 3. pending:unregister 事件携带 result/error/patchFile 时消费侧 sendMessage(T2 后替代 notifier)
|
|
10
|
+
// 4. session_start handler 类型签名 (event, ctx) → 编译期保证(stub 精确类型)
|
|
11
|
+
//
|
|
12
|
+
// 不导入 index.ts(它经 getAgentDir 值导入触发 alias 解析失败——alias 指向
|
|
13
|
+
// .d.ts-only stub)。改测叶子注册函数。session_start 的
|
|
14
|
+
// (event, ctx) 双参数契约由 tsconfig 的精确 stub 类型在编译期强制(见
|
|
15
|
+
// shared/types/mariozechner/index.d.ts 注释:modelRegistry/cwd/ui 不在 event 上)。
|
|
16
|
+
|
|
17
|
+
import { describe, expect, it, vi } from "vitest";
|
|
18
|
+
|
|
19
|
+
// registerSubagentTool 经 subagent-tool.ts 值导入 StringEnum(pi-ai)+ Type(typebox)。
|
|
20
|
+
// shared/types stub 是 .d.ts(仅类型),pnpm optional-peer-dep 插件拦截值导入 → vi.mock 兜底。
|
|
21
|
+
vi.mock("@mariozechner/pi-ai", () => ({
|
|
22
|
+
StringEnum: (values: string[]) => ({ type: "string", enum: values }),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("@earendil-works/pi-ai", () => ({
|
|
25
|
+
StringEnum: (values: string[]) => ({ type: "string", enum: values }),
|
|
26
|
+
}));
|
|
27
|
+
vi.mock("@sinclair/typebox", () => ({
|
|
28
|
+
Type: {
|
|
29
|
+
Object: (props: Record<string, unknown>) => ({ type: "object", properties: props }),
|
|
30
|
+
Optional: (schema: unknown) => ({ ...schema as object, optional: true }),
|
|
31
|
+
String: () => ({ type: "string" }),
|
|
32
|
+
Boolean: () => ({ type: "boolean" }),
|
|
33
|
+
Number: () => ({ type: "number" }),
|
|
34
|
+
Array: (items: unknown) => ({ type: "array", items }),
|
|
35
|
+
Record: (key: unknown, value: unknown) => ({ type: "object", additionalProperties: value, key }),
|
|
36
|
+
Unknown: () => ({ type: "unknown" }),
|
|
37
|
+
Union: (members: unknown[]) => ({ type: "union", members }),
|
|
38
|
+
Literal: (value: unknown) => ({ type: "literal", value }),
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// Mock getSubagentService:execute plumb-through 契约测试需要拦截 service.execute 调用。
|
|
43
|
+
const { mockServiceExecute } = vi.hoisted(() => ({
|
|
44
|
+
mockServiceExecute: vi.fn(),
|
|
45
|
+
}));
|
|
46
|
+
vi.mock("../subagent-service.ts", () => ({
|
|
47
|
+
getSubagentService: () => ({ execute: mockServiceExecute }),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import { registerWorkflowsCommand } from "../../interface/commands.ts";
|
|
51
|
+
import { registerSubagentTool } from "../../interface/subagent-tool.ts";
|
|
52
|
+
import { registerSubagentsCommand } from "../../interface/subagents.ts";
|
|
53
|
+
import type { LauncherDeps } from "../../orchestration/launcher.ts";
|
|
54
|
+
import { mockExtensionApi } from "./helpers/mock-extension-api.ts";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 构造最小 LauncherDeps mock(契约测试只捕获 handler,从不调用它,
|
|
58
|
+
* 故 deps 字段全为占位 no-op 即可——registerWorkflowsCommand 注册时不读 deps,
|
|
59
|
+
* 仅存入 handler 闭包)。
|
|
60
|
+
*
|
|
61
|
+
* 用 `as unknown as LauncherDeps` 与 orchestration/__tests__ 同模式(该类型字段多,
|
|
62
|
+
* 全字段构造冗余;占位足够此处断言注册名 + handler arity 的需要)。
|
|
63
|
+
*/
|
|
64
|
+
function makeLauncherDepsStub(): LauncherDeps {
|
|
65
|
+
return {
|
|
66
|
+
registry: { get: vi.fn() },
|
|
67
|
+
runs: new Map(),
|
|
68
|
+
store: { save: vi.fn(), loadAll: vi.fn(async () => []) },
|
|
69
|
+
workerHost: { start: vi.fn() },
|
|
70
|
+
runner: { run: vi.fn() },
|
|
71
|
+
log: vi.fn(),
|
|
72
|
+
eventBus: { emit: vi.fn() },
|
|
73
|
+
} as unknown as LauncherDeps;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ============================================================
|
|
77
|
+
// /subagents command 契约
|
|
78
|
+
// ============================================================
|
|
79
|
+
describe("/subagents command contract [MANDATORY]", () => {
|
|
80
|
+
it("registers a command named 'subagents'", () => {
|
|
81
|
+
let registeredName: string | undefined;
|
|
82
|
+
const pi = mockExtensionApi({
|
|
83
|
+
registerCommand: (name: string) => { registeredName = name; },
|
|
84
|
+
});
|
|
85
|
+
registerSubagentsCommand(pi);
|
|
86
|
+
expect(registeredName).toBe("subagents");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("handler accepts (args, ctx) — two parameters", () => {
|
|
90
|
+
let capturedHandler: ((...args: unknown[]) => unknown) | undefined;
|
|
91
|
+
const pi = mockExtensionApi({
|
|
92
|
+
registerCommand: (_name: string, command: { handler: (...args: unknown[]) => unknown }) => {
|
|
93
|
+
capturedHandler = command.handler;
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
registerSubagentsCommand(pi);
|
|
97
|
+
expect(capturedHandler).toBeDefined();
|
|
98
|
+
// function.length 反映必填参数数(ctx 至少是第 2 个)
|
|
99
|
+
expect(capturedHandler!.length).toBeGreaterThanOrEqual(2);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ============================================================
|
|
104
|
+
// /workflows command 契约 [MANDATORY]
|
|
105
|
+
// ============================================================
|
|
106
|
+
//
|
|
107
|
+
// registerWorkflowsCommand(api, getRuns, deps) 注册名为 "workflows" 的命令,
|
|
108
|
+
// handler 是 (args, ctx)。与 /subagents 同构的注册契约——此处锁住注册名 + handler arity,
|
|
109
|
+
// 防止重构时改名或丢参数导致 /workflows 命令失效。
|
|
110
|
+
describe("/workflows command contract [MANDATORY]", () => {
|
|
111
|
+
it("registers a command named 'workflows'", () => {
|
|
112
|
+
let registeredName: string | undefined;
|
|
113
|
+
const pi = mockExtensionApi({
|
|
114
|
+
registerCommand: (name: string) => { registeredName = name; },
|
|
115
|
+
});
|
|
116
|
+
// getRuns / deps 契约测试不调用 handler,传最小合法值即可
|
|
117
|
+
registerWorkflowsCommand(pi, () => new Map(), makeLauncherDepsStub());
|
|
118
|
+
expect(registeredName).toBe("workflows");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("handler accepts (args, ctx) — two parameters", () => {
|
|
122
|
+
let capturedHandler: ((...args: unknown[]) => unknown) | undefined;
|
|
123
|
+
const pi = mockExtensionApi({
|
|
124
|
+
registerCommand: (_name: string, command: { handler: (...args: unknown[]) => unknown }) => {
|
|
125
|
+
capturedHandler = command.handler;
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
registerWorkflowsCommand(pi, () => new Map(), makeLauncherDepsStub());
|
|
129
|
+
expect(capturedHandler).toBeDefined();
|
|
130
|
+
// function.length 反映必填参数数(args + ctx = 2)
|
|
131
|
+
expect(capturedHandler!.length).toBeGreaterThanOrEqual(2);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ============================================================
|
|
136
|
+
// subagent tool 契约
|
|
137
|
+
// ============================================================
|
|
138
|
+
describe("subagent tool contract [MANDATORY]", () => {
|
|
139
|
+
it("registers a tool named 'subagent' with a parameters schema", () => {
|
|
140
|
+
let registeredTool: { name: string; parameters: unknown } | undefined;
|
|
141
|
+
const pi = mockExtensionApi({
|
|
142
|
+
registerTool: (tool: unknown) => { registeredTool = tool as { name: string; parameters: unknown }; },
|
|
143
|
+
});
|
|
144
|
+
registerSubagentTool(pi);
|
|
145
|
+
expect(registeredTool?.name).toBe("subagent");
|
|
146
|
+
expect(registeredTool?.parameters).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("tool has execute, renderCall, renderResult callbacks", () => {
|
|
150
|
+
let registeredTool: Record<string, unknown> | undefined;
|
|
151
|
+
const pi = mockExtensionApi({
|
|
152
|
+
registerTool: (tool: unknown) => { registeredTool = tool as Record<string, unknown>; },
|
|
153
|
+
});
|
|
154
|
+
registerSubagentTool(pi);
|
|
155
|
+
expect(typeof registeredTool?.execute).toBe("function");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// SDK 契约:ToolDefinition.execute 是 5 参数 (toolCallId, params, signal, onUpdate, ctx)。
|
|
159
|
+
// ctx 是第 5 个参数,runtime 通过 wrapToolDefinition(ctxFactory) 注入。
|
|
160
|
+
// 此测试验证 ctx.model 被 plumb 到 service.execute 的 ctxModel 参数。
|
|
161
|
+
// 回归保护:subagent-tool.ts execute 把 _ctx?.model 传给 startHandler 的第 5 参 ctxModel。
|
|
162
|
+
it("execute passes ctx.model as ctxModel (SDK 5-param contract)", async () => {
|
|
163
|
+
let capturedExecute: ((...args: never[]) => Promise<unknown>) | undefined;
|
|
164
|
+
const pi = mockExtensionApi({
|
|
165
|
+
registerTool: (tool: unknown) => {
|
|
166
|
+
capturedExecute = (tool as { execute: (...args: never[]) => Promise<unknown> }).execute;
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
registerSubagentTool(pi);
|
|
170
|
+
expect(capturedExecute).toBeDefined();
|
|
171
|
+
|
|
172
|
+
mockServiceExecute.mockReset();
|
|
173
|
+
mockServiceExecute.mockResolvedValue({
|
|
174
|
+
mode: "background",
|
|
175
|
+
subagentId: "bg-test",
|
|
176
|
+
sessionFile: "/test/session.jsonl",
|
|
177
|
+
});
|
|
178
|
+
const ctxModel = { id: "test-model", name: "Test", provider: "test", reasoning: false };
|
|
179
|
+
const ctx = { model: ctxModel } as object;
|
|
180
|
+
|
|
181
|
+
await capturedExecute!(
|
|
182
|
+
"call-1",
|
|
183
|
+
{ action: "start", startParam: { task: "test task" } },
|
|
184
|
+
undefined,
|
|
185
|
+
undefined,
|
|
186
|
+
ctx,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(mockServiceExecute).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(mockServiceExecute).toHaveBeenCalledWith(
|
|
191
|
+
expect.objectContaining({ ctxModel }),
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// [MF#5] fork/worktree/cwd 参数传递链路契约(acceptance #8):
|
|
196
|
+
// tool execute → startHandler(service, startParam) → service.execute({fork, worktree, cwd, ...})。
|
|
197
|
+
// 回归保护:subagent-actions.ts startHandler L152-166 把 input.fork/worktree/cwd 透传给
|
|
198
|
+
// service.execute。若任一字段在 handler 内漏传(如重构改名/删行),子 agent 静默丢失隔离模式。
|
|
199
|
+
// 此测试锁住「startParam.fork/worktree/cwd → service.execute 同名参数」端到端透传。
|
|
200
|
+
it("execute plumbs startParam.fork/worktree/cwd to service.execute (chain contract)", async () => {
|
|
201
|
+
let capturedExecute: ((...args: never[]) => Promise<unknown>) | undefined;
|
|
202
|
+
const pi = mockExtensionApi({
|
|
203
|
+
registerTool: (tool: unknown) => {
|
|
204
|
+
capturedExecute = (tool as { execute: (...args: never[]) => Promise<unknown> }).execute;
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
registerSubagentTool(pi);
|
|
208
|
+
expect(capturedExecute).toBeDefined();
|
|
209
|
+
|
|
210
|
+
mockServiceExecute.mockReset();
|
|
211
|
+
mockServiceExecute.mockResolvedValue({
|
|
212
|
+
mode: "background",
|
|
213
|
+
subagentId: "bg-fork-wt",
|
|
214
|
+
sessionFile: "/test/session.jsonl",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
await capturedExecute!(
|
|
218
|
+
"call-fork-wt",
|
|
219
|
+
{
|
|
220
|
+
action: "start",
|
|
221
|
+
startParam: {
|
|
222
|
+
task: "isolated work",
|
|
223
|
+
fork: true,
|
|
224
|
+
worktree: true,
|
|
225
|
+
cwd: "/x",
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
undefined,
|
|
229
|
+
undefined,
|
|
230
|
+
undefined,
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// service.execute 收到完整的 fork/worktree/cwd 三参数透传
|
|
234
|
+
expect(mockServiceExecute).toHaveBeenCalledTimes(1);
|
|
235
|
+
expect(mockServiceExecute).toHaveBeenCalledWith(
|
|
236
|
+
expect.objectContaining({
|
|
237
|
+
fork: true,
|
|
238
|
+
worktree: true,
|
|
239
|
+
cwd: "/x",
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// sync 路径已删除(T2 Wave 0)。background 的 content JSON 只含 bgResponse
|
|
245
|
+
// (status/mode/message),不含 record 派生投影——投影发生在 notifier 注入的完成消息。
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ============================================================
|
|
249
|
+
// pending:unregister 事件契约 [MANDATORY](T2 后 notifier 职责转移至此)
|
|
250
|
+
// ============================================================
|
|
251
|
+
//
|
|
252
|
+
// T2 后 background 完成通知改由 pending-notifications 扩展消费 pending:unregister
|
|
253
|
+
// 事件后调 pi.sendMessage。subagent-service 的 emitPendingUnregister 在终态路径
|
|
254
|
+
// 携带 result/error/patchFile,消费侧据此构造 customType:"subagent-bg-notify" 消息
|
|
255
|
+
// 并以 triggerTurn:true + deliverAs:"followUp" 注入。
|
|
256
|
+
//
|
|
257
|
+
// 此处的契约由 pending-notifications 的单元测试覆盖(断言 sendMessage 调用参数),
|
|
258
|
+
// 本套件不再直接测试 BgNotifier(已删除)。
|
|
259
|
+
|
|
260
|
+
// ============================================================
|
|
261
|
+
// session_start handler 双参数契约 — 编译期保证
|
|
262
|
+
// ============================================================
|
|
263
|
+
describe("session_start handler signature (compile-time guarantee)", () => {
|
|
264
|
+
it("ExtensionHandler<SessionStartEvent> is (event, ctx) two-param — enforced by stub type", () => {
|
|
265
|
+
// 此测试是编译期断言:shared/types/mariozechner/index.d.ts:111 声明
|
|
266
|
+
// ExtensionHandler<E, R> = (event: E, ctx: ExtensionContext) => Promise<R|void> | R | void;
|
|
267
|
+
// 且 SessionStartEvent 注释明确 modelRegistry/cwd/ui 不在 event 上(在 ctx)。
|
|
268
|
+
// index.ts:66 `pi.on("session_start", (_event, ctx) => {...})` 通过此类型检查即证明契约。
|
|
269
|
+
// tsc --noEmit 零错误 = 此契约成立。此 it() 占位让套件非空(实际断言在编译期)。
|
|
270
|
+
expect(true).toBe(true);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// src/__tests__/session-context-resolver.test.ts
|
|
2
|
+
//
|
|
3
|
+
// 覆盖 resolveSessionContext 全部 5 种输入组合 + ForkDepthExceededError 边界。
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { encodeCwd } from "../path-encoding.ts";
|
|
9
|
+
import { resolveSessionContext } from "../session-context-resolver.ts";
|
|
10
|
+
import { ForkDepthExceededError } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
const AGENT_DIR = "/home/user/.pi/agent";
|
|
13
|
+
const MAIN_CWD = "/home/user/project";
|
|
14
|
+
const SESSION_FILE = "session-abc.jsonl";
|
|
15
|
+
|
|
16
|
+
function expectedSessionDir(mainCwd: string): string {
|
|
17
|
+
// [MF#1] 既有布局:subagents/<enc>/sessions/
|
|
18
|
+
return path.join(AGENT_DIR, "subagents", encodeCwd(mainCwd), "sessions");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("resolveSessionContext", () => {
|
|
22
|
+
// ── 边界 1: fork=false, worktree=false ──
|
|
23
|
+
it("fork=false, worktree=false → shouldFork=false, effectiveCwd=mainCwd", () => {
|
|
24
|
+
const result = resolveSessionContext({
|
|
25
|
+
fork: false,
|
|
26
|
+
worktree: false,
|
|
27
|
+
mainCwd: MAIN_CWD,
|
|
28
|
+
agentDir: AGENT_DIR,
|
|
29
|
+
});
|
|
30
|
+
expect(result.shouldFork).toBe(false);
|
|
31
|
+
expect(result.forkSource).toBeUndefined();
|
|
32
|
+
expect(result.effectiveCwd).toBe(MAIN_CWD);
|
|
33
|
+
expect(result.sessionDir).toBe(expectedSessionDir(MAIN_CWD));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// ── 边界 2: fork=true, worktree=false ──
|
|
37
|
+
it("fork=true, worktree=false → shouldFork=true, forkSource=mainSessionFile", () => {
|
|
38
|
+
const result = resolveSessionContext({
|
|
39
|
+
fork: true,
|
|
40
|
+
worktree: false,
|
|
41
|
+
mainCwd: MAIN_CWD,
|
|
42
|
+
mainSessionFile: SESSION_FILE,
|
|
43
|
+
agentDir: AGENT_DIR,
|
|
44
|
+
});
|
|
45
|
+
expect(result.shouldFork).toBe(true);
|
|
46
|
+
expect(result.forkSource).toBe(SESSION_FILE);
|
|
47
|
+
expect(result.effectiveCwd).toBe(MAIN_CWD);
|
|
48
|
+
expect(result.sessionDir).toBe(expectedSessionDir(MAIN_CWD));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// ── 边界 3: fork=false, worktree=true ──
|
|
52
|
+
it("fork=false, worktreePath 提供 → effectiveCwd=worktreePath", () => {
|
|
53
|
+
const worktreePath = "/tmp/pi-sub-run-42";
|
|
54
|
+
const result = resolveSessionContext({
|
|
55
|
+
fork: false,
|
|
56
|
+
mainCwd: MAIN_CWD,
|
|
57
|
+
agentDir: AGENT_DIR,
|
|
58
|
+
worktreePath,
|
|
59
|
+
});
|
|
60
|
+
expect(result.shouldFork).toBe(false);
|
|
61
|
+
expect(result.forkSource).toBeUndefined();
|
|
62
|
+
expect(result.effectiveCwd).toBe(worktreePath);
|
|
63
|
+
expect(result.sessionDir).toBe(expectedSessionDir(MAIN_CWD));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// ── 边界 4: fork=true, worktree=true ──
|
|
67
|
+
it("fork=true, worktreePath 提供 → shouldFork=true + effectiveCwd=worktreePath", () => {
|
|
68
|
+
const worktreePath = "/tmp/pi-sub-bg-7-abc";
|
|
69
|
+
const result = resolveSessionContext({
|
|
70
|
+
fork: true,
|
|
71
|
+
mainCwd: MAIN_CWD,
|
|
72
|
+
mainSessionFile: SESSION_FILE,
|
|
73
|
+
agentDir: AGENT_DIR,
|
|
74
|
+
worktreePath,
|
|
75
|
+
});
|
|
76
|
+
expect(result.shouldFork).toBe(true);
|
|
77
|
+
expect(result.forkSource).toBe(SESSION_FILE);
|
|
78
|
+
expect(result.effectiveCwd).toBe(worktreePath);
|
|
79
|
+
expect(result.sessionDir).toBe(expectedSessionDir(MAIN_CWD));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── 边界 5: fork=true + parentForkDepth>=10 → 抛错 ──
|
|
83
|
+
it("fork=true + parentForkDepth=10 → throws ForkDepthExceededError", () => {
|
|
84
|
+
expect(() =>
|
|
85
|
+
resolveSessionContext({
|
|
86
|
+
fork: true,
|
|
87
|
+
mainCwd: MAIN_CWD,
|
|
88
|
+
mainSessionFile: SESSION_FILE,
|
|
89
|
+
parentForkDepth: 10,
|
|
90
|
+
agentDir: AGENT_DIR,
|
|
91
|
+
}),
|
|
92
|
+
).toThrow(ForkDepthExceededError);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("fork=true + parentForkDepth=15 → throws ForkDepthExceededError", () => {
|
|
96
|
+
expect(() =>
|
|
97
|
+
resolveSessionContext({
|
|
98
|
+
fork: true,
|
|
99
|
+
mainCwd: MAIN_CWD,
|
|
100
|
+
parentForkDepth: 15,
|
|
101
|
+
agentDir: AGENT_DIR,
|
|
102
|
+
}),
|
|
103
|
+
).toThrow(ForkDepthExceededError);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── depth=9 不抛 ──
|
|
107
|
+
it("fork=true + parentForkDepth=9 → does NOT throw", () => {
|
|
108
|
+
const result = resolveSessionContext({
|
|
109
|
+
fork: true,
|
|
110
|
+
mainCwd: MAIN_CWD,
|
|
111
|
+
mainSessionFile: SESSION_FILE,
|
|
112
|
+
parentForkDepth: 9,
|
|
113
|
+
agentDir: AGENT_DIR,
|
|
114
|
+
});
|
|
115
|
+
expect(result.shouldFork).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── sessionDir 始终用 mainCwd 编码(非 effectiveCwd)──
|
|
119
|
+
it("sessionDir always encoded from mainCwd even when worktreePath overrides effectiveCwd", () => {
|
|
120
|
+
const mainCwd = "/Users/alice/code/my-app";
|
|
121
|
+
const result = resolveSessionContext({
|
|
122
|
+
fork: false,
|
|
123
|
+
mainCwd,
|
|
124
|
+
agentDir: AGENT_DIR,
|
|
125
|
+
worktreePath: "/tmp/pi-sub-run-1",
|
|
126
|
+
});
|
|
127
|
+
// effectiveCwd 是 worktree checkout,但 sessionDir 仍基于 mainCwd
|
|
128
|
+
expect(result.effectiveCwd).not.toBe(mainCwd);
|
|
129
|
+
expect(result.sessionDir).toBe(expectedSessionDir(mainCwd));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── [MF#5] fork=true 但主 session 文件不可用 → 抛错(不静默降级)──
|
|
133
|
+
it("fork=true + mainSessionFile 缺失 → throws(不静默降级到 from-scratch)", () => {
|
|
134
|
+
expect(() =>
|
|
135
|
+
resolveSessionContext({
|
|
136
|
+
fork: true,
|
|
137
|
+
mainCwd: MAIN_CWD,
|
|
138
|
+
// mainSessionFile 故意不传
|
|
139
|
+
agentDir: AGENT_DIR,
|
|
140
|
+
}),
|
|
141
|
+
).toThrow(/main session file is unavailable/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── 默认值:fork/worktree 未传 → undefined → false ──
|
|
145
|
+
it("fork/worktree omitted → shouldFork=false", () => {
|
|
146
|
+
const result = resolveSessionContext({
|
|
147
|
+
mainCwd: MAIN_CWD,
|
|
148
|
+
agentDir: AGENT_DIR,
|
|
149
|
+
});
|
|
150
|
+
expect(result.shouldFork).toBe(false);
|
|
151
|
+
expect(result.forkSource).toBeUndefined();
|
|
152
|
+
expect(result.effectiveCwd).toBe(MAIN_CWD);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── cwd 覆盖 mainCwd(worktree=false 时)──
|
|
156
|
+
it("explicit cwd overrides mainCwd when worktree=false", () => {
|
|
157
|
+
const customCwd = "/tmp/custom-cwd";
|
|
158
|
+
const result = resolveSessionContext({
|
|
159
|
+
mainCwd: MAIN_CWD,
|
|
160
|
+
cwd: customCwd,
|
|
161
|
+
agentDir: AGENT_DIR,
|
|
162
|
+
});
|
|
163
|
+
expect(result.effectiveCwd).toBe(customCwd);
|
|
164
|
+
// sessionDir 仍用 mainCwd
|
|
165
|
+
expect(result.sessionDir).toBe(expectedSessionDir(MAIN_CWD));
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// src/__tests__/session-file-gc.test.ts
|
|
2
|
+
//
|
|
3
|
+
// 锁定 maybeCleanupExpiredSessionFiles 的 TTL 边界(数据安全风险):
|
|
4
|
+
// - 概率门(CLEANUP_PROBABILITY = 1/20)
|
|
5
|
+
// - TTL 边界(mtimeMs 恰好 = now - TTL_MS)
|
|
6
|
+
// - 只删 .jsonl,不删 .txt 等
|
|
7
|
+
// - 递归 walk 子目录
|
|
8
|
+
// - best-effort 吞错(只读目录不外抛)
|
|
9
|
+
//
|
|
10
|
+
// 边界算错会误删未过期 session 文件或不清理——数据安全风险。
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as os from "node:os";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
16
|
+
|
|
17
|
+
import * as aliveStore from "../alive-store.ts";
|
|
18
|
+
import { maybeCleanupExpiredSessionFiles } from "../session-file-gc.ts";
|
|
19
|
+
|
|
20
|
+
// ============================================================
|
|
21
|
+
// helpers
|
|
22
|
+
// ============================================================
|
|
23
|
+
|
|
24
|
+
let tmpAgentDir: string;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "subagents-gc-test-"));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
fs.rmSync(tmpAgentDir, { recursive: true, force: true });
|
|
32
|
+
vi.restoreAllMocks();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** 强制 Math.random 返回 0(< CLEANUP_PROBABILITY → 必触发清理)。 */
|
|
36
|
+
function forceCleanupTrigger(): void {
|
|
37
|
+
vi.spyOn(Math, "random").mockReturnValue(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 强制 Math.random 返回 1(>= CLEANUP_PROBABILITY → 跳过清理)。 */
|
|
41
|
+
function forceCleanupSkip(): void {
|
|
42
|
+
vi.spyOn(Math, "random").mockReturnValue(1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 创建一个 .jsonl 文件,mtime 设为指定天数前。 */
|
|
46
|
+
function createSessionFile(relPath: string, daysAgo: number): string {
|
|
47
|
+
const fullPath = path.join(tmpAgentDir, "subagents", relPath);
|
|
48
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
49
|
+
fs.writeFileSync(fullPath, `{"id":"${relPath}"}\n`, "utf-8");
|
|
50
|
+
// 设置 mtime:daysAgo 天前
|
|
51
|
+
const targetTime = Date.now() / 1000 - daysAgo * 86400;
|
|
52
|
+
fs.utimesSync(fullPath, targetTime, targetTime);
|
|
53
|
+
return fullPath;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================
|
|
57
|
+
// maybeCleanupExpiredSessionFiles
|
|
58
|
+
// ============================================================
|
|
59
|
+
|
|
60
|
+
describe("maybeCleanupExpiredSessionFiles", () => {
|
|
61
|
+
it("skips cleanup when random >= CLEANUP_PROBABILITY (probability gate)", () => {
|
|
62
|
+
forceCleanupSkip();
|
|
63
|
+
const oldFile = createSessionFile("old.jsonl", 31); // 31 天前,超 TTL
|
|
64
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
65
|
+
// 概率门跳过 → 文件仍在
|
|
66
|
+
expect(fs.existsSync(oldFile)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("triggers cleanup when random < CLEANUP_PROBABILITY", () => {
|
|
70
|
+
forceCleanupTrigger();
|
|
71
|
+
const oldFile = createSessionFile("old.jsonl", 31);
|
|
72
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
73
|
+
expect(fs.existsSync(oldFile)).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("deletes files older than 30 days (TTL)", () => {
|
|
77
|
+
forceCleanupTrigger();
|
|
78
|
+
const old = createSessionFile("old.jsonl", 31);
|
|
79
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
80
|
+
expect(fs.existsSync(old)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("preserves files younger than 30 days", () => {
|
|
84
|
+
forceCleanupTrigger();
|
|
85
|
+
const young = createSessionFile("young.jsonl", 29);
|
|
86
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
87
|
+
expect(fs.existsSync(young)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does NOT delete non-.jsonl files even if old (e.g. .txt)", () => {
|
|
91
|
+
forceCleanupTrigger();
|
|
92
|
+
const oldTxt = createSessionFile("old.txt", 31);
|
|
93
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
94
|
+
// .txt 不在清理范围
|
|
95
|
+
expect(fs.existsSync(oldTxt)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("recursively walks subdirectories", () => {
|
|
99
|
+
forceCleanupTrigger();
|
|
100
|
+
// 子目录中的旧文件
|
|
101
|
+
const oldNested = createSessionFile(
|
|
102
|
+
path.join("encoded-cwd-1", "sessions", "sess-old.jsonl"),
|
|
103
|
+
31,
|
|
104
|
+
);
|
|
105
|
+
const youngNested = createSessionFile(
|
|
106
|
+
path.join("encoded-cwd-1", "sessions", "sess-young.jsonl"),
|
|
107
|
+
5,
|
|
108
|
+
);
|
|
109
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
110
|
+
expect(fs.existsSync(oldNested)).toBe(false);
|
|
111
|
+
expect(fs.existsSync(youngNested)).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no-op when subagents dir does not exist (no throw)", () => {
|
|
115
|
+
forceCleanupTrigger();
|
|
116
|
+
// tmpAgentDir 下没有 subagents/ 目录
|
|
117
|
+
expect(() => maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd")).not.toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("best-effort: never throws when 'subagents' is a file not a dir (readdirSync ENOTDIR)", () => {
|
|
121
|
+
// ESM 不允许 spyOn 命名空间导出,改用真实错误场景:
|
|
122
|
+
// 创建一个名为 subagents 的普通文件(非目录),existsSync 返 true 但 readdirSync 抛 ENOTDIR
|
|
123
|
+
forceCleanupTrigger();
|
|
124
|
+
const fileNotDir = path.join(tmpAgentDir, "subagents");
|
|
125
|
+
fs.writeFileSync(fileNotDir, "not a directory", "utf-8");
|
|
126
|
+
// walkAndClean 内部 readdirSync 抛错 → 被其 try/catch 吞掉 → 不外抛
|
|
127
|
+
expect(() => maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd")).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("TTL boundary: file just under 30 days is NOT deleted (strict greater-than semantics)", () => {
|
|
131
|
+
// walkAndClean 条件: now - stat.mtimeMs > TTL_MS(严格大于)
|
|
132
|
+
// 设 mtime 为 30 天前减去 2 分钟 → now - mtimeMs = TTL_MS - 2min + ε < TTL_MS → 保留
|
|
133
|
+
// 用 2 分钟缓冲消除「设 mtime 与 Date.now() 调用之间的几 ms 时间差」造成的抖动
|
|
134
|
+
forceCleanupTrigger();
|
|
135
|
+
const boundary = createSessionFile("boundary.jsonl", 0);
|
|
136
|
+
// 覆盖 mtime 为「30 天 - 2 分钟」前
|
|
137
|
+
const targetTime = Date.now() / 1000 - (30 * 86400 - 120);
|
|
138
|
+
fs.utimesSync(boundary, targetTime, targetTime);
|
|
139
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
140
|
+
// 严格 < TTL_MS → 不删(边界保留)
|
|
141
|
+
expect(fs.existsSync(boundary)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles empty subagents dir (no files, no throw)", () => {
|
|
145
|
+
forceCleanupTrigger();
|
|
146
|
+
// 创建空 subagents 目录
|
|
147
|
+
fs.mkdirSync(path.join(tmpAgentDir, "subagents"), { recursive: true });
|
|
148
|
+
expect(() => maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd")).not.toThrow();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ---- .finalized sidecar 清理 ----
|
|
152
|
+
|
|
153
|
+
it("deletes .finalized sidecar along with expired .jsonl", () => {
|
|
154
|
+
forceCleanupTrigger();
|
|
155
|
+
const jsonl = createSessionFile("sess-finalized.jsonl", 31);
|
|
156
|
+
const finalized = `${jsonl}.finalized`;
|
|
157
|
+
fs.writeFileSync(finalized, "", "utf-8");
|
|
158
|
+
const targetTime = Date.now() / 1000 - 31 * 86400;
|
|
159
|
+
fs.utimesSync(finalized, targetTime, targetTime);
|
|
160
|
+
|
|
161
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
162
|
+
|
|
163
|
+
expect(fs.existsSync(jsonl)).toBe(false);
|
|
164
|
+
expect(fs.existsSync(finalized)).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("cleans orphan .finalized sidecar when .jsonl already gone", () => {
|
|
168
|
+
forceCleanupTrigger();
|
|
169
|
+
const subagentsDir = path.join(tmpAgentDir, "subagents");
|
|
170
|
+
fs.mkdirSync(subagentsDir, { recursive: true });
|
|
171
|
+
const orphan = path.join(subagentsDir, "dead-session.jsonl.finalized");
|
|
172
|
+
fs.writeFileSync(orphan, "", "utf-8");
|
|
173
|
+
const targetTime = Date.now() / 1000 - 31 * 86400;
|
|
174
|
+
fs.utimesSync(orphan, targetTime, targetTime);
|
|
175
|
+
|
|
176
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
177
|
+
|
|
178
|
+
expect(fs.existsSync(orphan)).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// ---- .alive sidecar + pid 探活 ----
|
|
182
|
+
|
|
183
|
+
it("preserves expired .jsonl when .alive sidecar indicates live process (D-024)", () => {
|
|
184
|
+
forceCleanupTrigger();
|
|
185
|
+
const jsonl = createSessionFile("sess-alive.jsonl", 31);
|
|
186
|
+
const alivePath = `${jsonl}.alive`;
|
|
187
|
+
fs.writeFileSync(alivePath, JSON.stringify({ pid: 1, id: "s1", startedAt: Date.now() }), "utf-8");
|
|
188
|
+
|
|
189
|
+
vi.spyOn(aliveStore, "readAliveMarker").mockReturnValue({ pid: 1, id: "s1", startedAt: Date.now() });
|
|
190
|
+
vi.spyOn(aliveStore, "isProcessAlive").mockReturnValue(true);
|
|
191
|
+
|
|
192
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
193
|
+
|
|
194
|
+
expect(fs.existsSync(jsonl)).toBe(true);
|
|
195
|
+
expect(fs.existsSync(alivePath)).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("deletes expired .jsonl + .alive when process is dead", () => {
|
|
199
|
+
forceCleanupTrigger();
|
|
200
|
+
const jsonl = createSessionFile("sess-dead.jsonl", 31);
|
|
201
|
+
const alivePath = `${jsonl}.alive`;
|
|
202
|
+
fs.writeFileSync(alivePath, JSON.stringify({ pid: 99999, id: "s2", startedAt: Date.now() }), "utf-8");
|
|
203
|
+
|
|
204
|
+
vi.spyOn(aliveStore, "readAliveMarker").mockReturnValue({ pid: 99999, id: "s2", startedAt: Date.now() });
|
|
205
|
+
vi.spyOn(aliveStore, "isProcessAlive").mockReturnValue(false);
|
|
206
|
+
|
|
207
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
208
|
+
|
|
209
|
+
expect(fs.existsSync(jsonl)).toBe(false);
|
|
210
|
+
expect(fs.existsSync(alivePath)).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("cleans orphan .alive sidecar when .jsonl already gone", () => {
|
|
214
|
+
forceCleanupTrigger();
|
|
215
|
+
const subagentsDir = path.join(tmpAgentDir, "subagents");
|
|
216
|
+
fs.mkdirSync(subagentsDir, { recursive: true });
|
|
217
|
+
const orphan = path.join(subagentsDir, "dead-session.jsonl.alive");
|
|
218
|
+
fs.writeFileSync(orphan, JSON.stringify({ pid: 99999, id: "s3", startedAt: Date.now() }), "utf-8");
|
|
219
|
+
const targetTime = Date.now() / 1000 - 31 * 86400;
|
|
220
|
+
fs.utimesSync(orphan, targetTime, targetTime);
|
|
221
|
+
|
|
222
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
223
|
+
|
|
224
|
+
expect(fs.existsSync(orphan)).toBe(false);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("deletes .jsonl + all sidecars (.cancelled, .finalized, .alive) together", () => {
|
|
228
|
+
forceCleanupTrigger();
|
|
229
|
+
const jsonl = createSessionFile("sess-all-sidecars.jsonl", 31);
|
|
230
|
+
const cancelled = `${jsonl}.cancelled`;
|
|
231
|
+
const finalized = `${jsonl}.finalized`;
|
|
232
|
+
const alive = `${jsonl}.alive`;
|
|
233
|
+
fs.writeFileSync(cancelled, "", "utf-8");
|
|
234
|
+
fs.writeFileSync(finalized, "", "utf-8");
|
|
235
|
+
fs.writeFileSync(alive, JSON.stringify({ pid: 99999, id: "s4", startedAt: Date.now() }), "utf-8");
|
|
236
|
+
|
|
237
|
+
vi.spyOn(aliveStore, "readAliveMarker").mockReturnValue({ pid: 99999, id: "s4", startedAt: Date.now() });
|
|
238
|
+
vi.spyOn(aliveStore, "isProcessAlive").mockReturnValue(false);
|
|
239
|
+
|
|
240
|
+
maybeCleanupExpiredSessionFiles(tmpAgentDir, "/cwd");
|
|
241
|
+
|
|
242
|
+
expect(fs.existsSync(jsonl)).toBe(false);
|
|
243
|
+
expect(fs.existsSync(cancelled)).toBe(false);
|
|
244
|
+
expect(fs.existsSync(finalized)).toBe(false);
|
|
245
|
+
expect(fs.existsSync(alive)).toBe(false);
|
|
246
|
+
});
|
|
247
|
+
});
|