@zhushanwen/pi-subagent-workflow 7.4.0 → 8.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/README.md +15 -0
- package/package.json +7 -4
- package/scripts/rfl.mjs +308 -0
- package/skills/workflow-script-format/SKILL.md +1 -1
- package/src/execution/__tests__/__fixtures__/truncline.snapshot.json +1 -0
- 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 +197 -15
- 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 +37 -10
- 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 +442 -54
- 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 +629 -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 +300 -90
- package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +17 -13
- package/src/execution/__tests__/worktree-reconcile.integration.test.ts +181 -0
- package/src/execution/__tests__/worktree-registry.test.ts +72 -34
- package/src/execution/agent-result-mapper.ts +4 -1
- package/src/execution/argv-mirror.ts +21 -2
- package/src/execution/channel-registry-access.ts +3 -1
- package/src/execution/execution-record.ts +126 -9
- package/src/execution/finalize-record.ts +99 -13
- package/src/execution/idle-gc.ts +47 -0
- package/src/execution/lifecycle-manager.ts +491 -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-entry.ts +118 -0
- package/src/execution/record-store.ts +844 -108
- package/src/execution/session-pending.ts +121 -49
- package/src/execution/session-reconstructor.ts +224 -7
- package/src/execution/session-runner.ts +713 -316
- 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 +917 -138
- package/src/execution/temp-prompt.ts +8 -3
- package/src/execution/turn-limiter.ts +14 -0
- package/src/execution/types.ts +218 -19
- package/src/execution/worktree-manager.ts +449 -59
- package/src/execution/worktree-registry.ts +97 -29
- package/src/index.ts +318 -20
- 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 +17 -4
- package/src/interface/__tests__/tool-render.test.ts +10 -8
- package/src/interface/__tests__/tool-workflow-script-generate.test.ts +103 -26
- package/src/interface/__tests__/tool-workflow-throw-paths.test.ts +179 -0
- 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 +53 -20
- 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 +81 -16
- package/src/interface/subagents.ts +2 -1
- package/src/interface/tool-render.ts +21 -27
- package/src/interface/tool-workflow-script.ts +29 -33
- package/src/interface/tool-workflow.ts +67 -100
- package/src/interface/views/WorkflowsView.ts +89 -32
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +264 -0
- package/src/interface/views/detail-content.ts +1 -1
- package/src/interface/views/format.ts +3 -3
- package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +333 -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 +144 -1
- package/src/orchestration/__tests__/jsonl-run-store-loadall-sources.test.ts +171 -0
- package/src/orchestration/__tests__/jsonl-run-store-session-file.test.ts +820 -19
- package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +0 -2
- package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +96 -0
- package/src/orchestration/__tests__/lifecycle.test.ts +332 -149
- package/src/orchestration/__tests__/review-fix-loop-e2e.test.ts +1108 -19
- package/src/orchestration/__tests__/skill-discovery.test.ts +130 -61
- package/src/orchestration/__tests__/test-mocks.ts +197 -0
- package/src/orchestration/__tests__/worker-returnmeta-passthrough.test.ts +164 -0
- package/src/orchestration/__tests__/worker-script-template-snapshot.test.ts +110 -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 +38 -40
- 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 +137 -30
- package/src/orchestration/execute-agent-call.ts +44 -10
- package/src/orchestration/jsonl-run-store.ts +397 -71
- package/src/orchestration/launcher.ts +7 -1
- package/src/orchestration/lifecycle.ts +145 -133
- 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 +38 -11
- 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 +79 -0
- package/src/shared/__tests__/schema-jsonify.test.ts +81 -0
- package/src/shared/agent-ref.ts +22 -1
- package/src/shared/resource-discovery.ts +162 -59
- package/src/shared/schema-jsonify.ts +56 -0
- package/workflows/review-fix-loop-utils.cjs +542 -32
- package/workflows/review-fix-loop.js +462 -109
|
@@ -18,11 +18,12 @@ import * as path from "node:path";
|
|
|
18
18
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
19
19
|
|
|
20
20
|
import { writeAliveMarker } from "../alive-store.ts";
|
|
21
|
-
import { createRecord } from "../execution-record.ts";
|
|
21
|
+
import { completeRecord, createRecord, tryTransition } from "../execution-record.ts";
|
|
22
22
|
import { writeFinalized } from "../finalized-marker.ts";
|
|
23
23
|
import type { ManifestRecord } from "../manifest-store.ts";
|
|
24
24
|
import { ManifestStore } from "../manifest-store.ts";
|
|
25
25
|
import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
|
|
26
|
+
import { SUBAGENT_RECORD_CUSTOM_TYPE } from "../record-entry.ts";
|
|
26
27
|
import type { StatusFilter } from "../record-store.ts";
|
|
27
28
|
import { RecordStore } from "../record-store.ts";
|
|
28
29
|
import { writeCancelledTombstone } from "../tombstone-store.ts";
|
|
@@ -37,6 +38,8 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
|
|
|
37
38
|
task: "t",
|
|
38
39
|
startedAt: 1000,
|
|
39
40
|
rootSessionId: "sess-current",
|
|
41
|
+
// 对齐生产 register 路径(subagent-service createRecord:one-shot 显式 false)
|
|
42
|
+
chatMode: false,
|
|
40
43
|
});
|
|
41
44
|
return { ...base, ...over };
|
|
42
45
|
}
|
|
@@ -47,7 +50,7 @@ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
|
|
|
47
50
|
*/
|
|
48
51
|
function writeSessionJsonl(
|
|
49
52
|
filePath: string,
|
|
50
|
-
identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number },
|
|
53
|
+
identity: { id: string; agent: string; mode: "sync" | "background"; task: string; startedAt: number; rootSessionId?: string; parentRecordId?: string; depth?: number; lastTs?: number; chatMode?: boolean },
|
|
51
54
|
assistantText = "result text",
|
|
52
55
|
): void {
|
|
53
56
|
const lastTs = identity.lastTs ?? identity.startedAt + 1000;
|
|
@@ -64,6 +67,7 @@ function writeSessionJsonl(
|
|
|
64
67
|
if (identity.rootSessionId !== undefined) identityData.rootSessionId = identity.rootSessionId;
|
|
65
68
|
if (identity.parentRecordId !== undefined) identityData.parentRecordId = identity.parentRecordId;
|
|
66
69
|
if (identity.depth !== undefined) identityData.depth = identity.depth;
|
|
70
|
+
if (identity.chatMode !== undefined) identityData.chatMode = identity.chatMode;
|
|
67
71
|
const identityEntry = JSON.stringify({
|
|
68
72
|
type: "custom",
|
|
69
73
|
id: "id-1",
|
|
@@ -89,13 +93,19 @@ function writeSessionJsonl(
|
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
describe("RecordStore", () => {
|
|
96
|
+
let rootDir: string;
|
|
92
97
|
let tmpDir: string;
|
|
93
98
|
|
|
94
99
|
beforeEach(() => {
|
|
95
|
-
tmpDir
|
|
100
|
+
// [DS3] 两层布局:tmpDir 深一层(对齐生产 <enc> 段结构),sessions-index.json 落
|
|
101
|
+
// dirname(tmpDir)=rootDir 内随 afterEach 清理(含 MF-3/S-20 的嵌套 <enc> 段索引)。
|
|
102
|
+
rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "rs-test-"));
|
|
103
|
+
tmpDir = path.join(rootDir, "sessions");
|
|
104
|
+
fs.mkdirSync(tmpDir);
|
|
96
105
|
});
|
|
97
106
|
afterEach(() => {
|
|
98
|
-
|
|
107
|
+
// maxRetries:fire-and-forget 的 sessions-index 写可能与删除并发(ENOTEMPTY 竞态)
|
|
108
|
+
fs.rmSync(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
|
|
99
109
|
});
|
|
100
110
|
|
|
101
111
|
// ============================================================
|
|
@@ -104,7 +114,7 @@ describe("RecordStore", () => {
|
|
|
104
114
|
describe("archive 立即移除", () => {
|
|
105
115
|
it("archive 后 record 立即从内存移除(不再 linger)", () => {
|
|
106
116
|
const store = new RecordStore(tmpDir);
|
|
107
|
-
const r = makeRecord({ id: "sync-1", mode: "sync", status: "
|
|
117
|
+
const r = makeRecord({ id: "sync-1", mode: "sync", status: "closed" });
|
|
108
118
|
store.register(r);
|
|
109
119
|
expect(store.getMutable("sync-1")).toBeDefined();
|
|
110
120
|
store.archive(r);
|
|
@@ -113,7 +123,7 @@ describe("RecordStore", () => {
|
|
|
113
123
|
|
|
114
124
|
it("background record 同样立即移除(不再 FIFO)", () => {
|
|
115
125
|
const store = new RecordStore(tmpDir);
|
|
116
|
-
const r = makeRecord({ id: "bg-1", mode: "background", status: "
|
|
126
|
+
const r = makeRecord({ id: "bg-1", mode: "background", status: "closed" });
|
|
117
127
|
store.register(r);
|
|
118
128
|
store.archive(r);
|
|
119
129
|
expect(store.getMutable("bg-1")).toBeUndefined();
|
|
@@ -131,20 +141,25 @@ describe("RecordStore", () => {
|
|
|
131
141
|
expect(ids).toContain("run-1");
|
|
132
142
|
});
|
|
133
143
|
|
|
134
|
-
it("磁盘 session.jsonl
|
|
144
|
+
it("磁盘 session.jsonl 重建的 record 出现在结果中(无 sidecar → running, SP-2 跨重启可恢复)", () => {
|
|
135
145
|
const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
|
|
136
146
|
writeSessionJsonl(sessionFile, {
|
|
137
147
|
id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
|
|
138
148
|
});
|
|
139
|
-
// 无 sidecar → 四分支兜底
|
|
149
|
+
// 无 sidecar → 四分支兜底 running(v4 B-1:旧 idle 折入 running,SP-2 跨重启可恢复)
|
|
140
150
|
const store = new RecordStore(tmpDir);
|
|
141
151
|
const found = store.collectRecords(100).find((r) => r.id === "bg-1");
|
|
142
152
|
expect(found).toBeDefined();
|
|
143
|
-
expect(found?.status).toBe("
|
|
153
|
+
expect(found?.status).toBe("running");
|
|
144
154
|
expect(found?.agent).toBe("worker");
|
|
145
|
-
|
|
146
|
-
expect(found?.
|
|
147
|
-
expect(found?.result).
|
|
155
|
+
// [perf] 列表是 light(头部 identity,详情字段缺省)——turns/tokens/result 走 getFullRecord 懒加载
|
|
156
|
+
expect(found?.turns).toBe(0);
|
|
157
|
+
expect(found?.result).toBeUndefined();
|
|
158
|
+
const full = store.getFullRecord("bg-1");
|
|
159
|
+
expect(full?.turns).toBe(1);
|
|
160
|
+
expect(full?.totalTokens).toBe(30);
|
|
161
|
+
expect(full?.result).toBe("result text");
|
|
162
|
+
expect(full?.status).toBe("running"); // 状态矩阵在全量路径同样套用
|
|
148
163
|
});
|
|
149
164
|
|
|
150
165
|
it("磁盘 session.jsonl + .finalized sidecar → done", () => {
|
|
@@ -156,10 +171,10 @@ describe("RecordStore", () => {
|
|
|
156
171
|
const store = new RecordStore(tmpDir);
|
|
157
172
|
const found = store.collectRecords(100).find((r) => r.id === "bg-2");
|
|
158
173
|
expect(found).toBeDefined();
|
|
159
|
-
expect(found?.status).toBe("
|
|
174
|
+
expect(found?.status).toBe("closed");
|
|
160
175
|
});
|
|
161
176
|
|
|
162
|
-
it("statusFilter='running'
|
|
177
|
+
it("statusFilter='running' 含内存 + 磁盘跨重启 running(v4 B-1:旧 idle 折入 running)", () => {
|
|
163
178
|
const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
|
|
164
179
|
writeSessionJsonl(sessionFile, {
|
|
165
180
|
id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
|
|
@@ -168,7 +183,8 @@ describe("RecordStore", () => {
|
|
|
168
183
|
store.register(makeRecord({ id: "run-1", mode: "background", startedAt: 1000 }));
|
|
169
184
|
const filter: StatusFilter = "running";
|
|
170
185
|
const ids = store.collectRecords(100, filter).map((r) => r.id);
|
|
171
|
-
|
|
186
|
+
// 内存 run-1 + 磁盘跨重启 bg-1 都是 running(旧 idle 折入);startedAt desc → bg-1(5000) 先
|
|
187
|
+
expect(ids).toEqual(["bg-1", "run-1"]);
|
|
172
188
|
});
|
|
173
189
|
|
|
174
190
|
it("statusFilter='all'(默认)返回内存 + 磁盘", () => {
|
|
@@ -191,7 +207,7 @@ describe("RecordStore", () => {
|
|
|
191
207
|
const store = new RecordStore(tmpDir);
|
|
192
208
|
store.register(makeRecord({ id: "dup-1", mode: "background", status: "running", startedAt: 5000 }));
|
|
193
209
|
const found = store.collectRecords(100).find((r) => r.id === "dup-1");
|
|
194
|
-
expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘
|
|
210
|
+
expect(found?.status).toBe("running"); // 内存 running 覆盖磁盘 idle(SP-2)
|
|
195
211
|
});
|
|
196
212
|
|
|
197
213
|
it("[MF-3/S-20] 跨进程组合:子进程(worktree)写入 enc(ROOT) 的深层 record 被 ROOT store 重建,同 rootSessionId 全树可见、他树被排除", () => {
|
|
@@ -235,7 +251,7 @@ describe("RecordStore", () => {
|
|
|
235
251
|
// cancelled tombstone override
|
|
236
252
|
// ============================================================
|
|
237
253
|
describe("cancelled tombstone", () => {
|
|
238
|
-
it("有 .cancelled sidecar → status override 为 cancelled", () => {
|
|
254
|
+
it("有 .cancelled sidecar → status override 为 closed + closedReason=cancelled", () => {
|
|
239
255
|
const sessionFile = path.join(tmpDir, "2026-01-01-uuid-a.jsonl");
|
|
240
256
|
writeSessionJsonl(sessionFile, {
|
|
241
257
|
id: "bg-1", agent: "worker", mode: "background", task: "do it", startedAt: 5000,
|
|
@@ -245,7 +261,9 @@ describe("RecordStore", () => {
|
|
|
245
261
|
});
|
|
246
262
|
const store = new RecordStore(tmpDir);
|
|
247
263
|
const found = store.collectRecords(100).find((r) => r.id === "bg-1");
|
|
248
|
-
|
|
264
|
+
// v4 B-1:cancelled 折入 closed,closedReason='cancelled' 保留用户取消语义
|
|
265
|
+
expect(found?.status).toBe("closed");
|
|
266
|
+
expect(found?.closedReason).toBe("cancelled");
|
|
249
267
|
expect(found?.error).toBe("cancelled by user");
|
|
250
268
|
});
|
|
251
269
|
});
|
|
@@ -254,17 +272,19 @@ describe("RecordStore", () => {
|
|
|
254
272
|
// compareRecords 排序稳定性(内存 running record)
|
|
255
273
|
// ============================================================
|
|
256
274
|
describe("compareRecords 排序", () => {
|
|
257
|
-
it("status priority(running <
|
|
275
|
+
it("status priority(running < closed)", () => {
|
|
258
276
|
const store = new RecordStore(tmpDir);
|
|
259
|
-
// 内存 running record
|
|
277
|
+
// 内存 running record
|
|
260
278
|
const running = makeRecord({ id: "run-1", mode: "background", startedAt: 3000, status: "running" });
|
|
261
279
|
store.register(running);
|
|
262
|
-
// 磁盘
|
|
263
|
-
|
|
280
|
+
// 磁盘 closed record(.finalized sidecar → closed,v4 B-1 统一终态)
|
|
281
|
+
const doneFile = path.join(tmpDir, "a.jsonl");
|
|
282
|
+
writeSessionJsonl(doneFile, {
|
|
264
283
|
id: "done-1", agent: "w", mode: "background", task: "t", startedAt: 5000,
|
|
265
284
|
});
|
|
285
|
+
writeFinalized(doneFile);
|
|
266
286
|
const ids = store.collectRecords(100).map((r) => r.id);
|
|
267
|
-
expect(ids[0]).toBe("run-1"); // running
|
|
287
|
+
expect(ids[0]).toBe("run-1"); // running(0) 排在 closed(3) 前
|
|
268
288
|
});
|
|
269
289
|
|
|
270
290
|
it("同 status 时 startedAt desc(新→旧)", () => {
|
|
@@ -275,30 +295,55 @@ describe("RecordStore", () => {
|
|
|
275
295
|
writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
|
|
276
296
|
id: "new", agent: "w", mode: "background", task: "t", startedAt: 9000,
|
|
277
297
|
});
|
|
278
|
-
// 两个都是
|
|
298
|
+
// 两个都是 running(磁盘重建,无 sidecar → SP-2 兜底 running,旧 idle 折入),按 startedAt desc
|
|
279
299
|
const ids = store.collectRecords(100).map((r) => r.id);
|
|
280
300
|
expect(ids).toEqual(["new", "old"]);
|
|
281
301
|
});
|
|
282
302
|
});
|
|
283
303
|
|
|
304
|
+
// ============================================================
|
|
305
|
+
// running 态 record(M2-A chatMode 续聊;v4 B-1:旧 idle 折入 running)
|
|
306
|
+
// ============================================================
|
|
307
|
+
describe("running 态 record(旧 idle 折入,等待续聊)", () => {
|
|
308
|
+
it("getMutable 能查到 running record(不筛 status,running record 留内存不 archive)", () => {
|
|
309
|
+
const store = new RecordStore(tmpDir);
|
|
310
|
+
const r = makeRecord({ id: "idle-1", status: "running" });
|
|
311
|
+
store.register(r);
|
|
312
|
+
// running record 不 archive → getMutable 仍可查(续聊定位依赖此)
|
|
313
|
+
expect(store.getMutable("idle-1")).toBe(r);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("running record 经 collectRecords(statusFilter=all) 可见", () => {
|
|
317
|
+
const store = new RecordStore(tmpDir);
|
|
318
|
+
store.register(makeRecord({ id: "idle-2", status: "running" }));
|
|
319
|
+
const ids = store.collectRecords(100, "all").map((r) => r.id);
|
|
320
|
+
expect(ids).toContain("idle-2");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("running record 经 collectRecords(statusFilter=running) 可见(v4 B-1:旧 idle 折入 running)", () => {
|
|
324
|
+
const store = new RecordStore(tmpDir);
|
|
325
|
+
store.register(makeRecord({ id: "idle-3", status: "running" }));
|
|
326
|
+
const ids = store.collectRecords(100, "running").map((r) => r.id);
|
|
327
|
+
expect(ids).toContain("idle-3");
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
|
|
284
332
|
// ============================================================
|
|
285
333
|
// 重建缓存
|
|
286
334
|
// ============================================================
|
|
287
335
|
describe("重建缓存", () => {
|
|
288
|
-
it("
|
|
336
|
+
it("新 session.jsonl 无需内存事件即立即可见([perf] stat 校验取代 notifyChange 整体失效)", () => {
|
|
289
337
|
const store = new RecordStore(tmpDir);
|
|
290
338
|
// 首次 collect:空目录
|
|
291
339
|
expect(store.collectRecords(100)).toHaveLength(0);
|
|
292
|
-
// 写新 session.jsonl
|
|
340
|
+
// 写新 session.jsonl(模拟外部进程/子进程写入——不经本进程任何内存事件)
|
|
293
341
|
writeSessionJsonl(path.join(tmpDir, "new.jsonl"), {
|
|
294
342
|
id: "bg-1", agent: "w", mode: "background", task: "t", startedAt: 1000,
|
|
295
343
|
});
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
store.register(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
|
|
300
|
-
store.archive(makeRecord({ id: "trigger", mode: "sync", startedAt: 2000 }));
|
|
301
|
-
// 现在 bg-1 可见
|
|
344
|
+
// [perf] 旧实现:reconCache 建立后只能靠 register/archive 触发 notifyChange 失效,
|
|
345
|
+
// 否则永远 stale(外部进程写入的文件对本进程不可见)。新实现:每次 collectRecords
|
|
346
|
+
// 都 readdir + stat 校验,新文件立即可见(同时修复跨进程可见性)。
|
|
302
347
|
const ids = store.collectRecords(100).map((r) => r.id);
|
|
303
348
|
expect(ids).toContain("bg-1");
|
|
304
349
|
});
|
|
@@ -336,14 +381,16 @@ describe("RecordStore", () => {
|
|
|
336
381
|
}
|
|
337
382
|
|
|
338
383
|
// ── 分支 1: .cancelled ──
|
|
339
|
-
it(".cancelled sidecar → cancelled", () => {
|
|
384
|
+
it(".cancelled sidecar → closed + closedReason=cancelled", () => {
|
|
340
385
|
const sessionFile = writeBaseSession();
|
|
341
386
|
writeCancelledTombstone(sessionFile, {
|
|
342
387
|
id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 6000,
|
|
343
388
|
});
|
|
344
389
|
const store = new RecordStore(tmpDir);
|
|
345
390
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
346
|
-
|
|
391
|
+
// v4 B-1:cancelled 折入 closed(closedReason='cancelled' 区分用户取消)
|
|
392
|
+
expect(found?.status).toBe("closed");
|
|
393
|
+
expect(found?.closedReason).toBe("cancelled");
|
|
347
394
|
expect(found?.error).toBe("cancelled by user");
|
|
348
395
|
expect(found?.endedAt).toBe(6000);
|
|
349
396
|
});
|
|
@@ -354,7 +401,7 @@ describe("RecordStore", () => {
|
|
|
354
401
|
writeFinalized(sessionFile);
|
|
355
402
|
const store = new RecordStore(tmpDir);
|
|
356
403
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
357
|
-
expect(found?.status).toBe("
|
|
404
|
+
expect(found?.status).toBe("closed");
|
|
358
405
|
});
|
|
359
406
|
|
|
360
407
|
// ── 分支 2: .finalized failed ──
|
|
@@ -386,7 +433,7 @@ describe("RecordStore", () => {
|
|
|
386
433
|
writeFinalized(sessionFile);
|
|
387
434
|
const store = new RecordStore(tmpDir);
|
|
388
435
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
389
|
-
expect(found?.status).toBe("
|
|
436
|
+
expect(found?.status).toBe("closed");
|
|
390
437
|
});
|
|
391
438
|
|
|
392
439
|
// ── 分支 3: .alive + 活 pid → running + externalInstance ──
|
|
@@ -401,27 +448,27 @@ describe("RecordStore", () => {
|
|
|
401
448
|
expect(found?.externalInstance).toEqual(marker);
|
|
402
449
|
});
|
|
403
450
|
|
|
404
|
-
// ── 分支 3→4: .alive + 死 pid →
|
|
405
|
-
it(".alive + 死 pid →
|
|
451
|
+
// ── 分支 3→4: .alive + 死 pid → running(SP-2:跨重启可恢复)──
|
|
452
|
+
it(".alive + 死 pid → running(SP-2 跨重启可恢复)", () => {
|
|
406
453
|
const sessionFile = writeBaseSession();
|
|
407
454
|
const marker: AliveMarker = { pid: 9999999, id: SESSION_ID, startedAt: STARTED_AT };
|
|
408
455
|
writeAliveMarker(sessionFile, marker);
|
|
409
456
|
const store = new RecordStore(tmpDir);
|
|
410
457
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
411
|
-
expect(found?.status).toBe("
|
|
458
|
+
expect(found?.status).toBe("running");
|
|
412
459
|
expect(found?.externalInstance).toBeUndefined();
|
|
413
460
|
});
|
|
414
461
|
|
|
415
|
-
// ── 分支 4: 都无 sidecar →
|
|
416
|
-
it("无任何 sidecar →
|
|
462
|
+
// ── 分支 4: 都无 sidecar → running(SP-2:跨重启可恢复)──
|
|
463
|
+
it("无任何 sidecar → running(SP-2 跨重启可恢复)", () => {
|
|
417
464
|
writeBaseSession();
|
|
418
465
|
const store = new RecordStore(tmpDir);
|
|
419
466
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
420
|
-
expect(found?.status).toBe("
|
|
467
|
+
expect(found?.status).toBe("running");
|
|
421
468
|
});
|
|
422
469
|
|
|
423
|
-
// ── 分支 4: >24h 软超时 →
|
|
424
|
-
it(">24h 软超时 →
|
|
470
|
+
// ── 分支 4: >24h 软超时 → running(SP-2:跨重启可恢复,无视探活)──
|
|
471
|
+
it(">24h 软超时 → running(SP-2,即使 pid 存活)", () => {
|
|
425
472
|
const sessionFile = writeBaseSession();
|
|
426
473
|
// startedAt 设为 25 小时前
|
|
427
474
|
const oldStartedAt = Date.now() - 25 * 60 * 60 * 1000;
|
|
@@ -436,7 +483,7 @@ describe("RecordStore", () => {
|
|
|
436
483
|
|
|
437
484
|
const store = new RecordStore(tmpDir);
|
|
438
485
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
439
|
-
expect(found?.status).toBe("
|
|
486
|
+
expect(found?.status).toBe("running");
|
|
440
487
|
expect(found?.externalInstance).toBeUndefined();
|
|
441
488
|
});
|
|
442
489
|
|
|
@@ -449,18 +496,21 @@ describe("RecordStore", () => {
|
|
|
449
496
|
});
|
|
450
497
|
const store = new RecordStore(tmpDir);
|
|
451
498
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
452
|
-
|
|
499
|
+
// v4 B-1:.cancelled 优先级不变,但 status 折入 closed(closedReason='cancelled')
|
|
500
|
+
expect(found?.status).toBe("closed");
|
|
501
|
+
expect(found?.closedReason).toBe("cancelled");
|
|
453
502
|
});
|
|
454
503
|
|
|
455
504
|
// ── 回归:旧 .cancelled 单分支行为不变 ──
|
|
456
|
-
it("旧 .cancelled
|
|
505
|
+
it("旧 .cancelled 单分支行为不变(回归,status 折入 closed)", () => {
|
|
457
506
|
const sessionFile = writeBaseSession();
|
|
458
507
|
writeCancelledTombstone(sessionFile, {
|
|
459
508
|
id: SESSION_ID, status: "cancelled", agent: "worker", startedAt: STARTED_AT, endedAt: 7000,
|
|
460
509
|
});
|
|
461
510
|
const store = new RecordStore(tmpDir);
|
|
462
511
|
const found = store.collectRecords(100).find((r) => r.id === SESSION_ID);
|
|
463
|
-
expect(found?.status).toBe("
|
|
512
|
+
expect(found?.status).toBe("closed");
|
|
513
|
+
expect(found?.closedReason).toBe("cancelled");
|
|
464
514
|
expect(found?.error).toBe("cancelled by user");
|
|
465
515
|
expect(found?.endedAt).toBe(7000);
|
|
466
516
|
});
|
|
@@ -546,7 +596,7 @@ describe("RecordStore", () => {
|
|
|
546
596
|
// endedAt 重建(问题 2 修复:终态耗时不再随墙钟增长)
|
|
547
597
|
// ============================================================
|
|
548
598
|
describe("endedAt 重建(耗时不再无限增长)", () => {
|
|
549
|
-
it(".finalized → endedAt 为最后 entry
|
|
599
|
+
it(".finalized → light endedAt 用 mtime 近似,全量 endedAt 为最后 entry 时间戳", () => {
|
|
550
600
|
const sessionFile = path.join(tmpDir, "fin.jsonl");
|
|
551
601
|
writeSessionJsonl(sessionFile, {
|
|
552
602
|
id: "bg-1", agent: "w", mode: "background", task: "t",
|
|
@@ -555,10 +605,15 @@ describe("RecordStore", () => {
|
|
|
555
605
|
writeFinalized(sessionFile);
|
|
556
606
|
const store = new RecordStore(tmpDir);
|
|
557
607
|
const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
|
|
558
|
-
|
|
608
|
+
// [perf] light 分支 2:endedAt 用 jsonl mtime 近似(finalize 后文件不再变化,
|
|
609
|
+
// 与最后 entry ts 差 <1s)——不是 entry ts 9000,也不是随墙钟无限增长的 now 基准。
|
|
610
|
+
expect(found?.endedAt).toBeDefined();
|
|
611
|
+
expect(found?.endedAt).toBeGreaterThan(5000);
|
|
612
|
+
// 全量(getFullRecord):精确用最后 entry 时间戳(非 now)。
|
|
613
|
+
expect(store.getFullRecord("bg-1")?.endedAt).toBe(9000);
|
|
559
614
|
});
|
|
560
615
|
|
|
561
|
-
it("无 sidecar(
|
|
616
|
+
it("无 sidecar(running, SP-2)→ endedAt 保持 undefined(非终态)", () => {
|
|
562
617
|
const sessionFile = path.join(tmpDir, "crash.jsonl");
|
|
563
618
|
writeSessionJsonl(sessionFile, {
|
|
564
619
|
id: "bg-1", agent: "w", mode: "background", task: "t",
|
|
@@ -566,8 +621,9 @@ describe("RecordStore", () => {
|
|
|
566
621
|
});
|
|
567
622
|
const store = new RecordStore(tmpDir);
|
|
568
623
|
const found = store.collectRecords(100, "all", "sess-A").find((r) => r.id === "bg-1");
|
|
569
|
-
expect(found?.status).toBe("
|
|
570
|
-
|
|
624
|
+
expect(found?.status).toBe("running");
|
|
625
|
+
// SP-2: running 是非终态(旧 idle 折入),endedAt 保持 undefined(待续聊)
|
|
626
|
+
expect(found?.endedAt).toBeUndefined();
|
|
571
627
|
});
|
|
572
628
|
|
|
573
629
|
it(".alive running → endedAt 保持 undefined(running 耗时继续增长是正确的)", () => {
|
|
@@ -602,7 +658,7 @@ describe("RecordStore", () => {
|
|
|
602
658
|
id: "broken-1",
|
|
603
659
|
rootSessionId: "sess-current",
|
|
604
660
|
agentName: "worker",
|
|
605
|
-
status: "crashed", // 越界值(union 不含 "crashed")
|
|
661
|
+
status: "crashed", // 越界值(union 不含 "crashed",SP-1 后 valid statuses = running/closed/cancelled)
|
|
606
662
|
createdAt: 1000,
|
|
607
663
|
} as unknown as ManifestRecord;
|
|
608
664
|
}
|
|
@@ -666,4 +722,336 @@ describe("RecordStore", () => {
|
|
|
666
722
|
);
|
|
667
723
|
});
|
|
668
724
|
});
|
|
725
|
+
|
|
726
|
+
// ============================================================
|
|
727
|
+
// SP-2: reconstructAll 兜底分支输出 running(非 crashed;v4 两态后旧 idle 折入 running)
|
|
728
|
+
// ============================================================
|
|
729
|
+
describe("SP-2: reconstructAll 兜底 running(跨重启恢复)", () => {
|
|
730
|
+
// TC-1: 兜底分支输出 running(非 crashed)
|
|
731
|
+
it("TC-1: 无 sidecar + 死 pid → reconstructAll 输出 running(可冷路径 resume)", () => {
|
|
732
|
+
const sessionFile = path.join(tmpDir, "sp2-tc1.jsonl");
|
|
733
|
+
writeSessionJsonl(sessionFile, {
|
|
734
|
+
id: "sa-sp2-1", agent: "worker", mode: "background", task: "cross-restart task",
|
|
735
|
+
startedAt: 1000, rootSessionId: "sess-sp2",
|
|
736
|
+
});
|
|
737
|
+
// 无任何 sidecar marker → 分支 4 兜底
|
|
738
|
+
const store = new RecordStore(tmpDir);
|
|
739
|
+
const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-1");
|
|
740
|
+
expect(found).toBeDefined();
|
|
741
|
+
expect(found?.status).toBe("running");
|
|
742
|
+
// SP-2: running 是非终态(旧 idle 折入),endedAt 保持 undefined
|
|
743
|
+
expect(found?.endedAt).toBeUndefined();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// TC-2: 有 .finalized marker 的 record 仍归档为 done(不回归)
|
|
747
|
+
it("TC-2: .finalized marker → done(分支 1/2 不回归)", () => {
|
|
748
|
+
const sessionFile = path.join(tmpDir, "sp2-tc2.jsonl");
|
|
749
|
+
writeSessionJsonl(sessionFile, {
|
|
750
|
+
id: "sa-sp2-2", agent: "worker", mode: "background", task: "finalized task",
|
|
751
|
+
startedAt: 2000, rootSessionId: "sess-sp2",
|
|
752
|
+
});
|
|
753
|
+
writeFinalized(sessionFile);
|
|
754
|
+
const store = new RecordStore(tmpDir);
|
|
755
|
+
const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-2");
|
|
756
|
+
expect(found).toBeDefined();
|
|
757
|
+
// 分支 2 仍归档为 done,不受 SP-2 影响
|
|
758
|
+
expect(found?.status).toBe("closed");
|
|
759
|
+
expect(found?.endedAt).toBeDefined();
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
// TC-2b: .cancelled marker → closed+cancelled(分支 1 不回归)
|
|
763
|
+
it("TC-2b: .cancelled marker → closed+cancelled reason(分支 1 不回归)", () => {
|
|
764
|
+
const sessionFile = path.join(tmpDir, "sp2-tc2b.jsonl");
|
|
765
|
+
writeSessionJsonl(sessionFile, {
|
|
766
|
+
id: "sa-sp2-2b", agent: "worker", mode: "background", task: "cancelled task",
|
|
767
|
+
startedAt: 3000, rootSessionId: "sess-sp2",
|
|
768
|
+
});
|
|
769
|
+
writeCancelledTombstone(sessionFile, {
|
|
770
|
+
id: "sa-sp2-2b", status: "cancelled", agent: "worker", startedAt: 3000, endedAt: 4000,
|
|
771
|
+
});
|
|
772
|
+
const store = new RecordStore(tmpDir);
|
|
773
|
+
const found = store.collectRecords(100, "all", "sess-sp2").find((r) => r.id === "sa-sp2-2b");
|
|
774
|
+
expect(found).toBeDefined();
|
|
775
|
+
expect(found?.status).toBe("closed");
|
|
776
|
+
expect(found?.closedReason).toBe("cancelled");
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
// ============================================================
|
|
781
|
+
// 孤儿终态恢复(residual-fixes):分支 4 兜底 record 的判定与落盘
|
|
782
|
+
// ============================================================
|
|
783
|
+
describe("recoverOrphanRecords 孤儿终态恢复", () => {
|
|
784
|
+
/** 带真实磁盘 fixture + appendEntry 捕获的 store。 */
|
|
785
|
+
function makeRecoveryStore(): { store: RecordStore; appended: Array<{ customType: string; data: Record<string, unknown> }> } {
|
|
786
|
+
const appended: Array<{ customType: string; data: Record<string, unknown> }> = [];
|
|
787
|
+
const store = new RecordStore(tmpDir, undefined, {
|
|
788
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
789
|
+
appended.push({ customType, data: data as Record<string, unknown> });
|
|
790
|
+
},
|
|
791
|
+
} as never);
|
|
792
|
+
return { store, appended };
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
it("末行完整 → closed 终态 entry + .finalized sidecar(防重锚)", () => {
|
|
796
|
+
const sessionFile = path.join(tmpDir, "orphan-done.jsonl");
|
|
797
|
+
writeSessionJsonl(sessionFile, {
|
|
798
|
+
id: "sa-orphan-1", agent: "worker", mode: "background", task: "orphan done",
|
|
799
|
+
startedAt: 1000, rootSessionId: "sess-orphan",
|
|
800
|
+
});
|
|
801
|
+
const { store, appended } = makeRecoveryStore();
|
|
802
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
803
|
+
|
|
804
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-1");
|
|
805
|
+
expect(entry?.customType).toBe("subagent-record");
|
|
806
|
+
expect(entry?.data.status).toBe("closed");
|
|
807
|
+
expect(entry?.data.closedReason).toBe("gc");
|
|
808
|
+
expect(entry?.data.endedAt).toEqual(expect.any(Number));
|
|
809
|
+
expect(entry?.data.error).toBeUndefined();
|
|
810
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
|
|
811
|
+
// 防重:sidecar 使下次重建走分支 2(closed),不再进判定
|
|
812
|
+
const again = [...appended];
|
|
813
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
814
|
+
expect(appended.length).toBe(again.length);
|
|
815
|
+
const found = store.collectRecords(100, "all", "sess-orphan").find((r) => r.id === "sa-orphan-1");
|
|
816
|
+
expect(found?.status).toBe("closed");
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("末行截断 → closed + error(保守方向)", () => {
|
|
820
|
+
const sessionFile = path.join(tmpDir, "orphan-truncated.jsonl");
|
|
821
|
+
writeSessionJsonl(sessionFile, {
|
|
822
|
+
id: "sa-orphan-2", agent: "worker", mode: "background", task: "orphan truncated",
|
|
823
|
+
startedAt: 2000, rootSessionId: "sess-orphan",
|
|
824
|
+
});
|
|
825
|
+
// 制造截断:append 半行 JSON(无换行结尾)
|
|
826
|
+
fs.appendFileSync(sessionFile, '{"type":"message","id":"msg-2","pare', "utf-8");
|
|
827
|
+
const { store, appended } = makeRecoveryStore();
|
|
828
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
829
|
+
|
|
830
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-2");
|
|
831
|
+
expect(entry?.data.status).toBe("closed");
|
|
832
|
+
expect(entry?.data.error).toContain("truncated");
|
|
833
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
it("末行 >64KB 完整 JSON → 不误判截断,判 done(V1 探针实测回归)", () => {
|
|
837
|
+
const sessionFile = path.join(tmpDir, "orphan-longline.jsonl");
|
|
838
|
+
writeSessionJsonl(sessionFile, {
|
|
839
|
+
id: "sa-orphan-5", agent: "worker", mode: "background", task: "orphan long line",
|
|
840
|
+
startedAt: 5000, rootSessionId: "sess-orphan",
|
|
841
|
+
});
|
|
842
|
+
// 真实库实测形态:末行为超长完整 entry(subagent-identity 的 task 内嵌大 payload,
|
|
843
|
+
// 28/4822 个文件末行 65KB-776KB)——固定 64KB 尾窗曾把这类行从中间切开误判截断。
|
|
844
|
+
const bigEntry = JSON.stringify({
|
|
845
|
+
type: "custom", id: "id-big", parentId: null,
|
|
846
|
+
timestamp: new Date(5000).toISOString(), customType: "subagent-identity",
|
|
847
|
+
data: { id: "sa-orphan-5", agent: "worker", mode: "background", task: "x".repeat(70 * 1024), startedAt: 5000 },
|
|
848
|
+
});
|
|
849
|
+
fs.appendFileSync(sessionFile, bigEntry + "\n", "utf-8");
|
|
850
|
+
const { store, appended } = makeRecoveryStore();
|
|
851
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
852
|
+
|
|
853
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-5");
|
|
854
|
+
expect(entry?.data.status).toBe("closed");
|
|
855
|
+
expect(entry?.data.error).toBeUndefined();
|
|
856
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(true);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("chatMode 孤儿 → 不终态化,落 resumable entry(可续聊产品语义)", () => {
|
|
860
|
+
const sessionFile = path.join(tmpDir, "orphan-chat.jsonl");
|
|
861
|
+
writeSessionJsonl(sessionFile, {
|
|
862
|
+
id: "sa-orphan-3", agent: "worker", mode: "background", task: "orphan chat",
|
|
863
|
+
startedAt: 3000, rootSessionId: "sess-orphan", chatMode: true,
|
|
864
|
+
});
|
|
865
|
+
const { store, appended } = makeRecoveryStore();
|
|
866
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
867
|
+
|
|
868
|
+
const entry = appended.find((c) => c.data.id === "sa-orphan-3");
|
|
869
|
+
expect(entry?.data.status).toBe("running");
|
|
870
|
+
expect(entry?.data.resumable).toBe(true);
|
|
871
|
+
expect(entry?.data.chatMode).toBe(true);
|
|
872
|
+
// chat 分流不写终态 sidecar
|
|
873
|
+
expect(fs.existsSync(`${sessionFile}.finalized`)).toBe(false);
|
|
874
|
+
// orphanJudged 缓存:同 store 重复调用不重复 append
|
|
875
|
+
const count = appended.length;
|
|
876
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
877
|
+
expect(appended.length).toBe(count);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("非分支 4 record(closed / 活进程)不进判定", () => {
|
|
881
|
+
const closedFile = path.join(tmpDir, "orphan-closed.jsonl");
|
|
882
|
+
writeSessionJsonl(closedFile, {
|
|
883
|
+
id: "sa-orphan-4", agent: "worker", mode: "background", task: "already closed",
|
|
884
|
+
startedAt: 4000, rootSessionId: "sess-orphan",
|
|
885
|
+
});
|
|
886
|
+
writeFinalized(closedFile);
|
|
887
|
+
const { store, appended } = makeRecoveryStore();
|
|
888
|
+
store.recoverOrphanRecords("sess-orphan");
|
|
889
|
+
expect(appended.find((c) => c.data.id === "sa-orphan-4")).toBeUndefined();
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
/** 写主 session fixture(含指定 subagent-record entry 序列)。 */
|
|
893
|
+
function writeMainSession(entries: Array<Record<string, unknown>>): string {
|
|
894
|
+
const mainFile = path.join(tmpDir, "main-session.jsonl");
|
|
895
|
+
const lines = entries.map((d) => JSON.stringify({ type: "custom", id: `e-${Math.random()}`, parentId: null, customType: "subagent-record", data: d }));
|
|
896
|
+
fs.writeFileSync(mainFile, lines.join("\n") + "\n", "utf-8");
|
|
897
|
+
return mainFile;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
it("entry-born 孤儿(无子文件,spawn 窗口期死亡)→ closed+gc+error(E2E 实测回归)", () => {
|
|
901
|
+
const mainFile = writeMainSession([
|
|
902
|
+
{ v: 1, id: "sa-entryonly-1", agent: "worker", task: "spawn interrupted", slug: "s", status: "running", mode: "background", startedAt: 6000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
903
|
+
]);
|
|
904
|
+
const { store, appended } = makeRecoveryStore();
|
|
905
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
906
|
+
|
|
907
|
+
const entry = appended.find((c) => c.data.id === "sa-entryonly-1");
|
|
908
|
+
expect(entry?.data.status).toBe("closed");
|
|
909
|
+
expect(entry?.data.closedReason).toBe("gc");
|
|
910
|
+
expect(entry?.data.error).toContain("no child session file");
|
|
911
|
+
// 防重:orphanJudged 缓存拦截二次判定
|
|
912
|
+
const count = appended.length;
|
|
913
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
914
|
+
expect(appended.length).toBe(count);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
it("entry-born chatMode 孤儿 → 不终态化,落 resumable entry", () => {
|
|
918
|
+
const mainFile = writeMainSession([
|
|
919
|
+
{ v: 1, id: "sa-entryonly-2", agent: "worker", task: "chat spawn interrupted", slug: "s", status: "running", mode: "background", startedAt: 7000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [], chatMode: true },
|
|
920
|
+
]);
|
|
921
|
+
const { store, appended } = makeRecoveryStore();
|
|
922
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
923
|
+
|
|
924
|
+
const entry = appended.find((c) => c.data.id === "sa-entryonly-2");
|
|
925
|
+
expect(entry?.data.status).toBe("running");
|
|
926
|
+
expect(entry?.data.resumable).toBe(true);
|
|
927
|
+
expect(entry?.data.error).toBeUndefined();
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it("有子文件锚 / 末条已终态 / 他 session 的 entry-born id 不进判定", () => {
|
|
931
|
+
const anchoredFile = path.join(tmpDir, "orphan-anchored.jsonl");
|
|
932
|
+
writeSessionJsonl(anchoredFile, {
|
|
933
|
+
id: "sa-anchored", agent: "worker", mode: "background", task: "has file",
|
|
934
|
+
startedAt: 8000, rootSessionId: "sess-orphan",
|
|
935
|
+
});
|
|
936
|
+
const mainFile = writeMainSession([
|
|
937
|
+
{ v: 1, id: "sa-anchored", agent: "worker", task: "has file", slug: "s", status: "running", mode: "background", startedAt: 8000, rootSessionId: "sess-orphan", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
938
|
+
{ v: 1, id: "sa-settled", agent: "worker", task: "settled", slug: "s", status: "closed", mode: "background", startedAt: 8100, rootSessionId: "sess-orphan", depth: 0, turns: 1, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
939
|
+
{ v: 1, id: "sa-foreign", agent: "worker", task: "other session", slug: "s", status: "running", mode: "background", startedAt: 8200, rootSessionId: "sess-other", depth: 0, turns: 0, totalTokens: 0, model: "m", eventLog: [], displayItems: [] },
|
|
940
|
+
]);
|
|
941
|
+
const { store, appended } = makeRecoveryStore();
|
|
942
|
+
store.recoverEntryOnlyOrphans(mainFile, "sess-orphan");
|
|
943
|
+
expect(appended.find((c) => c.data.id === "sa-anchored")).toBeUndefined();
|
|
944
|
+
expect(appended.find((c) => c.data.id === "sa-settled")).toBeUndefined();
|
|
945
|
+
expect(appended.find((c) => c.data.id === "sa-foreign")).toBeUndefined();
|
|
946
|
+
});
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
// ============================================================
|
|
950
|
+
// W16 [D4]:subagent-record 自描述 appendEntry 上报(状态迁移点)
|
|
951
|
+
// ============================================================
|
|
952
|
+
describe("W16 subagent-record 自描述 appendEntry 上报", () => {
|
|
953
|
+
/** appendEntry 捕获(RecordStorePi 最小实现)。 */
|
|
954
|
+
interface AppendedCall {
|
|
955
|
+
customType: string;
|
|
956
|
+
data: unknown;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/** 构造带 appendEntry 捕获的 store(pi 注入通道,对齐生产 setPi 后形态)。 */
|
|
960
|
+
function makeStoreWithPi(): { store: RecordStore; appended: AppendedCall[] } {
|
|
961
|
+
const appended: AppendedCall[] = [];
|
|
962
|
+
const store = new RecordStore(tmpDir, undefined, {
|
|
963
|
+
appendEntry: (customType: string, data: unknown) => {
|
|
964
|
+
appended.push({ customType, data });
|
|
965
|
+
},
|
|
966
|
+
});
|
|
967
|
+
return { store, appended };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** unknown → 对象的运行时 guard(taste/no-unsafe-cast:断言前先收窄)。 */
|
|
971
|
+
function asEntryData(d: unknown): Record<string, unknown> {
|
|
972
|
+
if (typeof d !== "object" || d === null) throw new Error("entry data is not an object");
|
|
973
|
+
return d as Record<string, unknown>;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
it("register:append subagent-record entry,data = v1 + SubagentRecord 完整快照 schema", () => {
|
|
977
|
+
const { store, appended } = makeStoreWithPi();
|
|
978
|
+
store.register(makeRecord());
|
|
979
|
+
|
|
980
|
+
expect(appended).toHaveLength(1);
|
|
981
|
+
// 写点字面量与 record-entry.ts 常量等值(钉住双源一致性)
|
|
982
|
+
expect(appended[0]?.customType).toBe(SUBAGENT_RECORD_CUSTOM_TYPE);
|
|
983
|
+
// [E2E 实测教训] 必须先 JSON 序列化再断言:appendEntry 捕获的是内存对象,
|
|
984
|
+
// undefined 值的键名仍在(旧 27-key 断言因此漏检 recordToSubagent 丢
|
|
985
|
+
// chatMode 的 bug);真实 JSONL 会丢 undefined 值键,此处对齐生产行为。
|
|
986
|
+
const data = asEntryData(JSON.parse(JSON.stringify(appended[0]?.data)));
|
|
987
|
+
// register 时点序列化存活字段(undefined 值字段按生产序列化丢弃):
|
|
988
|
+
// chatMode 必须显式在场(one-shot=false)——renderer isDone 判据依赖它。
|
|
989
|
+
expect(Object.keys(data).sort()).toEqual([
|
|
990
|
+
"agent", "chatMode", "depth", "displayItems", "eventLog",
|
|
991
|
+
"id", "mode", "model", "rootSessionId", "round", "startedAt",
|
|
992
|
+
"status", "task", "totalTokens", "turns", "v", "worktree",
|
|
993
|
+
]);
|
|
994
|
+
expect(data).toMatchObject({
|
|
995
|
+
v: 1,
|
|
996
|
+
id: "r1",
|
|
997
|
+
agent: "worker",
|
|
998
|
+
task: "t",
|
|
999
|
+
status: "running",
|
|
1000
|
+
mode: "sync",
|
|
1001
|
+
startedAt: 1000,
|
|
1002
|
+
rootSessionId: "sess-current",
|
|
1003
|
+
chatMode: false,
|
|
1004
|
+
turns: 0,
|
|
1005
|
+
totalTokens: 0,
|
|
1006
|
+
model: "m",
|
|
1007
|
+
eventLog: [],
|
|
1008
|
+
displayItems: [],
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("archive:append 终态完整快照(result/endedAt/closedReason);one-shot 生命周期共 2 次 append", () => {
|
|
1013
|
+
const { store, appended } = makeStoreWithPi();
|
|
1014
|
+
const r = makeRecord();
|
|
1015
|
+
store.register(r);
|
|
1016
|
+
// 模拟正常终态路径:tryTransition CAS → completeRecord 冻结 → archive(D-017 时序)
|
|
1017
|
+
tryTransition(r, "closed", "gc");
|
|
1018
|
+
completeRecord(r, { text: "task done", turns: 1, durationMs: 500, success: true, sessionId: "r1", toolCalls: [] }, "closed", "gc");
|
|
1019
|
+
store.archive(r);
|
|
1020
|
+
|
|
1021
|
+
// 探针基线(单测级):one-shot 生命周期 = register + archive = 2 次 append
|
|
1022
|
+
expect(appended).toHaveLength(2);
|
|
1023
|
+
const data = asEntryData(appended[1]?.data);
|
|
1024
|
+
expect(data).toMatchObject({
|
|
1025
|
+
v: 1,
|
|
1026
|
+
id: "r1",
|
|
1027
|
+
status: "closed",
|
|
1028
|
+
closedReason: "gc",
|
|
1029
|
+
result: "task done",
|
|
1030
|
+
endedAt: expect.any(Number) as number,
|
|
1031
|
+
});
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
it("reportRecordTransition:类外恢复写点上报(chatMode 续轮 round 携带)", () => {
|
|
1035
|
+
const { store, appended } = makeStoreWithPi();
|
|
1036
|
+
const r = makeRecord({ chatMode: true, round: 1 });
|
|
1037
|
+
store.reportRecordTransition(r);
|
|
1038
|
+
|
|
1039
|
+
expect(appended).toHaveLength(1);
|
|
1040
|
+
expect(appended[0]?.customType).toBe(SUBAGENT_RECORD_CUSTOM_TYPE);
|
|
1041
|
+
expect(asEntryData(appended[0]?.data)).toMatchObject({
|
|
1042
|
+
v: 1,
|
|
1043
|
+
id: "r1",
|
|
1044
|
+
status: "running",
|
|
1045
|
+
round: 1,
|
|
1046
|
+
});
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it("pi 未注入(session_start 前):三写点均安全降级不抛错", () => {
|
|
1050
|
+
const store = new RecordStore(tmpDir);
|
|
1051
|
+
const r = makeRecord();
|
|
1052
|
+
expect(() => store.register(r)).not.toThrow();
|
|
1053
|
+
expect(() => store.archive(r)).not.toThrow();
|
|
1054
|
+
expect(() => store.reportRecordTransition(r)).not.toThrow();
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
669
1057
|
});
|