@ryanfw/prompt-orchestration-pipeline 1.3.2 → 1.3.3
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/docs/http-api.md +66 -0
- package/package.json +1 -1
- package/src/api/__tests__/index.test.ts +311 -3
- package/src/api/index.ts +94 -45
- package/src/config/__tests__/statuses.test.ts +41 -0
- package/src/config/statuses.ts +20 -1
- package/src/core/__tests__/config.test.ts +31 -1
- package/src/core/__tests__/job-concurrency.test.ts +60 -0
- package/src/core/__tests__/job-view.test.ts +859 -0
- package/src/core/__tests__/orchestrator.test.ts +242 -0
- package/src/core/__tests__/runner-liveness.test.ts +865 -0
- package/src/core/__tests__/single-derivation.test.ts +159 -0
- package/src/core/config.ts +19 -0
- package/src/core/job-concurrency.ts +6 -1
- package/src/core/job-view.ts +329 -0
- package/src/core/orchestrator.ts +78 -6
- package/src/core/runner-liveness.ts +276 -0
- package/src/core/status-writer.ts +24 -5
- package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
- package/src/ui/client/adapters/job-adapter.ts +10 -0
- package/src/ui/client/types.ts +2 -0
- package/src/ui/components/JobTable.tsx +29 -12
- package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
- package/src/ui/components/types.ts +2 -0
- package/src/ui/dist/assets/{index--RH3sAt3.js → index-L6cvsCAx.js} +25 -12
- package/src/ui/dist/assets/{index--RH3sAt3.js.map → index-L6cvsCAx.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +57 -1
- package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
- package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
- package/src/ui/server/config-bridge.ts +3 -4
- package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +2 -6
- package/src/ui/server/endpoints/job-control-endpoints.ts +7 -68
- package/src/ui/server/endpoints/job-endpoints.ts +6 -0
- package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
- package/src/ui/state/__tests__/snapshot.test.ts +51 -0
- package/src/ui/state/__tests__/types.test.ts +98 -5
- package/src/ui/state/snapshot.ts +1 -1
- package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
- package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
- package/src/ui/state/transformers/list-transformer.ts +7 -3
- package/src/ui/state/transformers/status-transformer.ts +36 -170
- package/src/ui/state/types.ts +9 -47
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, it, spyOn } from "bun:test";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
classifyStaleRunningStatus,
|
|
9
|
+
isProcessAlive,
|
|
10
|
+
reconcileInterruptedJob,
|
|
11
|
+
reconcileOrphanedCurrentJobs,
|
|
12
|
+
type StaleRunningInput,
|
|
13
|
+
} from "../runner-liveness";
|
|
14
|
+
import { readJobStatusResult, STATUS_FILENAME } from "../status-writer";
|
|
15
|
+
import type { StatusSnapshot } from "../status-writer";
|
|
16
|
+
|
|
17
|
+
function makeStatusSnapshot(overrides: Partial<StatusSnapshot> = {}): StatusSnapshot {
|
|
18
|
+
return {
|
|
19
|
+
id: "job-classifier",
|
|
20
|
+
state: "running",
|
|
21
|
+
current: "research",
|
|
22
|
+
currentStage: null,
|
|
23
|
+
lastUpdated: "2026-04-01T10:00:00.000Z",
|
|
24
|
+
tasks: {
|
|
25
|
+
research: { state: "running" },
|
|
26
|
+
},
|
|
27
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const BASE_INPUT = {
|
|
33
|
+
pid: null,
|
|
34
|
+
pidAlive: false,
|
|
35
|
+
staleAfterMs: 30_000,
|
|
36
|
+
} satisfies Omit<StaleRunningInput, "status" | "nowMs">;
|
|
37
|
+
|
|
38
|
+
describe("classifyStaleRunningStatus", () => {
|
|
39
|
+
it("returns not_running when task-derived status is not running", () => {
|
|
40
|
+
const decision = classifyStaleRunningStatus({
|
|
41
|
+
...BASE_INPUT,
|
|
42
|
+
status: makeStatusSnapshot({
|
|
43
|
+
state: "pending",
|
|
44
|
+
current: null,
|
|
45
|
+
tasks: { research: { state: "done" }, analysis: { state: "pending" } },
|
|
46
|
+
}),
|
|
47
|
+
nowMs: Date.parse("2026-04-01T10:01:00.000Z"),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
expect(decision).toEqual({ stale: false, reason: "not_running" });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns live_pid when task-derived status is running and the PID is alive", () => {
|
|
54
|
+
const decision = classifyStaleRunningStatus({
|
|
55
|
+
...BASE_INPUT,
|
|
56
|
+
pid: 123,
|
|
57
|
+
pidAlive: true,
|
|
58
|
+
status: makeStatusSnapshot(),
|
|
59
|
+
nowMs: Date.parse("2026-04-01T10:01:00.000Z"),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(decision).toEqual({ stale: false, reason: "live_pid" });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns fresh_status when running status is newer than the grace window", () => {
|
|
66
|
+
const decision = classifyStaleRunningStatus({
|
|
67
|
+
...BASE_INPUT,
|
|
68
|
+
status: makeStatusSnapshot(),
|
|
69
|
+
nowMs: Date.parse("2026-04-01T10:00:10.000Z"),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(decision).toEqual({ stale: false, reason: "fresh_status" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("treats missing or unparseable lastUpdated with no live PID as stale", () => {
|
|
76
|
+
const decision = classifyStaleRunningStatus({
|
|
77
|
+
...BASE_INPUT,
|
|
78
|
+
status: makeStatusSnapshot({ lastUpdated: "" }),
|
|
79
|
+
nowMs: Date.parse("2026-04-01T10:01:00.000Z"),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
expect(decision).toMatchObject({ stale: true, ageMs: 0 });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns stale when running status is old and no live runner exists", () => {
|
|
86
|
+
const decision = classifyStaleRunningStatus({
|
|
87
|
+
...BASE_INPUT,
|
|
88
|
+
status: makeStatusSnapshot(),
|
|
89
|
+
nowMs: Date.parse("2026-04-01T10:01:00.000Z"),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(decision).toEqual({ stale: true, runningTaskId: "research", ageMs: 60_000 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns stale when age equals exactly the grace window (boundary)", () => {
|
|
96
|
+
const lastUpdated = "2026-04-01T10:00:00.000Z";
|
|
97
|
+
const nowMs = Date.parse(lastUpdated) + 30_000;
|
|
98
|
+
const decision = classifyStaleRunningStatus({
|
|
99
|
+
...BASE_INPUT,
|
|
100
|
+
status: makeStatusSnapshot({ lastUpdated }),
|
|
101
|
+
nowMs,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(decision).toMatchObject({ stale: true, ageMs: 30_000 });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("isProcessAlive", () => {
|
|
109
|
+
it("returns false without probing invalid pids", () => {
|
|
110
|
+
const killSpy = spyOn(process, "kill").mockImplementation((() => {
|
|
111
|
+
throw new Error("process.kill should not be called");
|
|
112
|
+
}) as typeof process.kill);
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
expect(isProcessAlive(0)).toBe(false);
|
|
116
|
+
expect(isProcessAlive(-1)).toBe(false);
|
|
117
|
+
expect(isProcessAlive(1.5)).toBe(false);
|
|
118
|
+
expect(killSpy).not.toHaveBeenCalled();
|
|
119
|
+
} finally {
|
|
120
|
+
killSpy.mockRestore();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("treats EPERM from process.kill(pid, 0) as alive", () => {
|
|
125
|
+
const killSpy = spyOn(process, "kill").mockImplementation((() => {
|
|
126
|
+
const err = new Error("operation not permitted") as NodeJS.ErrnoException;
|
|
127
|
+
err.code = "EPERM";
|
|
128
|
+
throw err;
|
|
129
|
+
}) as typeof process.kill);
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
expect(isProcessAlive(123)).toBe(true);
|
|
133
|
+
expect(killSpy).toHaveBeenCalledWith(123, 0);
|
|
134
|
+
} finally {
|
|
135
|
+
killSpy.mockRestore();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const NOW_MS = Date.parse("2026-06-19T12:00:00.000Z");
|
|
141
|
+
const LOCK_TIMEOUT_MS = 1000;
|
|
142
|
+
const DEAD_PID = 999_999;
|
|
143
|
+
|
|
144
|
+
interface Fixture {
|
|
145
|
+
dataDir: string;
|
|
146
|
+
jobDir: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function makeFixture(opts: {
|
|
150
|
+
jobId?: string;
|
|
151
|
+
snapshot?: Record<string, unknown> | null;
|
|
152
|
+
runnerPid?: number;
|
|
153
|
+
lease?: Record<string, unknown> | null;
|
|
154
|
+
}): Promise<Fixture> {
|
|
155
|
+
const jobId = opts.jobId ?? "job-reconcile";
|
|
156
|
+
const dataDir = await mkdtemp(join(tmpdir(), "pop-reconcile-"));
|
|
157
|
+
const jobDir = join(dataDir, "current", jobId);
|
|
158
|
+
await mkdir(jobDir, { recursive: true });
|
|
159
|
+
|
|
160
|
+
if (opts.snapshot !== null) {
|
|
161
|
+
const snapshot = opts.snapshot ?? {
|
|
162
|
+
id: jobId,
|
|
163
|
+
state: "running",
|
|
164
|
+
current: "research",
|
|
165
|
+
currentStage: null,
|
|
166
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
167
|
+
tasks: { research: { state: "running" } },
|
|
168
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
169
|
+
};
|
|
170
|
+
await writeFile(join(jobDir, STATUS_FILENAME), JSON.stringify(snapshot, null, 2));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (opts.runnerPid !== undefined) {
|
|
174
|
+
await writeFile(join(jobDir, "runner.pid"), String(opts.runnerPid));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (opts.lease) {
|
|
178
|
+
const runningJobsDir = join(dataDir, "runtime", "running-jobs");
|
|
179
|
+
await mkdir(runningJobsDir, { recursive: true });
|
|
180
|
+
const lease: Record<string, unknown> = {
|
|
181
|
+
jobId,
|
|
182
|
+
pid: null,
|
|
183
|
+
acquiredAt: new Date(NOW_MS).toISOString(),
|
|
184
|
+
source: "orchestrator",
|
|
185
|
+
slotPath: join(runningJobsDir, `${jobId}.json`),
|
|
186
|
+
...opts.lease,
|
|
187
|
+
};
|
|
188
|
+
await writeFile(join(runningJobsDir, `${jobId}.json`), JSON.stringify(lease));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return { dataDir, jobDir };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function readSnapshot(jobDir: string): Promise<Record<string, unknown>> {
|
|
195
|
+
const result = await readJobStatusResult(jobDir);
|
|
196
|
+
if (!result.ok) throw new Error(`status not readable: ${result.reason}`);
|
|
197
|
+
return result.value as unknown as Record<string, unknown>;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
describe("reconcileInterruptedJob", () => {
|
|
201
|
+
let fixtures: Fixture[] = [];
|
|
202
|
+
|
|
203
|
+
afterEach(async () => {
|
|
204
|
+
await Promise.all(
|
|
205
|
+
fixtures.map((f) => rm(f.dataDir, { recursive: true, force: true }).catch(() => {})),
|
|
206
|
+
);
|
|
207
|
+
fixtures = [];
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
async function setup(opts: Parameters<typeof makeFixture>[0]): Promise<Fixture> {
|
|
211
|
+
const fixture = await makeFixture(opts);
|
|
212
|
+
fixtures.push(fixture);
|
|
213
|
+
return fixture;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
it("marks task and job failed with the interruption marker when pid is dead and no lease (AC-1, AC-2)", async () => {
|
|
217
|
+
const { dataDir, jobDir } = await setup({ runnerPid: DEAD_PID });
|
|
218
|
+
|
|
219
|
+
const result = await reconcileInterruptedJob({
|
|
220
|
+
dataDir,
|
|
221
|
+
jobId: "job-reconcile",
|
|
222
|
+
jobDir,
|
|
223
|
+
source: "child-exit",
|
|
224
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
225
|
+
code: 1,
|
|
226
|
+
signal: null,
|
|
227
|
+
now: () => NOW_MS,
|
|
228
|
+
isAlive: () => false,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(result).toEqual({ reconciled: true, taskId: "research", reason: "marked-failed" });
|
|
232
|
+
|
|
233
|
+
const snapshot = await readSnapshot(jobDir);
|
|
234
|
+
expect(snapshot["state"]).toBe("failed");
|
|
235
|
+
const tasks = snapshot["tasks"] as Record<string, Record<string, unknown>>;
|
|
236
|
+
expect(tasks["research"]?.["state"]).toBe("failed");
|
|
237
|
+
expect(tasks["research"]?.["error"]).toBe("runner exited without completing this task");
|
|
238
|
+
expect(tasks["research"]?.["endedAt"]).toBe(new Date(NOW_MS).toISOString());
|
|
239
|
+
expect(tasks["research"]?.["errorContext"]).toEqual({
|
|
240
|
+
kind: "runner-interrupted",
|
|
241
|
+
code: 1,
|
|
242
|
+
signal: null,
|
|
243
|
+
source: "child-exit",
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("is a no-op (live-pid) when runner.pid points at a live process (AC-6)", async () => {
|
|
248
|
+
const { dataDir, jobDir } = await setup({ runnerPid: process.pid });
|
|
249
|
+
|
|
250
|
+
const result = await reconcileInterruptedJob({
|
|
251
|
+
dataDir,
|
|
252
|
+
jobId: "job-reconcile",
|
|
253
|
+
jobDir,
|
|
254
|
+
source: "child-exit",
|
|
255
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
256
|
+
now: () => NOW_MS,
|
|
257
|
+
isAlive: isProcessAlive,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
expect(result).toEqual({ reconciled: false, reason: "live-pid" });
|
|
261
|
+
|
|
262
|
+
const snapshot = await readSnapshot(jobDir);
|
|
263
|
+
expect(snapshot["state"]).toBe("running");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("is a no-op (live-lease) when a fresh in-flight lease exists (pid null, acquiredAt within window) (AC-7)", async () => {
|
|
267
|
+
const { dataDir, jobDir } = await setup({
|
|
268
|
+
runnerPid: DEAD_PID,
|
|
269
|
+
lease: { pid: null, acquiredAt: new Date(NOW_MS).toISOString() },
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const result = await reconcileInterruptedJob({
|
|
273
|
+
dataDir,
|
|
274
|
+
jobId: "job-reconcile",
|
|
275
|
+
jobDir,
|
|
276
|
+
source: "child-exit",
|
|
277
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
278
|
+
now: () => NOW_MS,
|
|
279
|
+
isAlive: () => false,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
expect(result).toEqual({ reconciled: false, reason: "live-lease" });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("is a no-op (live-lease) when a lease pid is alive (AC-7)", async () => {
|
|
286
|
+
const { dataDir, jobDir } = await setup({
|
|
287
|
+
runnerPid: DEAD_PID,
|
|
288
|
+
lease: { pid: process.pid },
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
const result = await reconcileInterruptedJob({
|
|
292
|
+
dataDir,
|
|
293
|
+
jobId: "job-reconcile",
|
|
294
|
+
jobDir,
|
|
295
|
+
source: "child-exit",
|
|
296
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
297
|
+
now: () => NOW_MS,
|
|
298
|
+
isAlive: (pid) => pid === process.pid,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
expect(result).toEqual({ reconciled: false, reason: "live-lease" });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("is a no-op (not-running) for a waiting (gated) job (AC-8)", async () => {
|
|
305
|
+
const { dataDir, jobDir } = await setup({
|
|
306
|
+
snapshot: {
|
|
307
|
+
id: "job-reconcile",
|
|
308
|
+
state: "waiting",
|
|
309
|
+
current: "research",
|
|
310
|
+
currentStage: null,
|
|
311
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
312
|
+
tasks: { research: { state: "done" }, analysis: { state: "pending" } },
|
|
313
|
+
gate: { afterTask: "research", message: "approve", requestedAt: "2026-06-19T11:30:00.000Z" },
|
|
314
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const result = await reconcileInterruptedJob({
|
|
319
|
+
dataDir,
|
|
320
|
+
jobId: "job-reconcile",
|
|
321
|
+
jobDir,
|
|
322
|
+
source: "child-exit",
|
|
323
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
324
|
+
now: () => NOW_MS,
|
|
325
|
+
isAlive: () => false,
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
expect(result).toEqual({ reconciled: false, reason: "not-running" });
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("is a no-op (not-running) for a pending job (AC-8)", async () => {
|
|
332
|
+
const { dataDir, jobDir } = await setup({
|
|
333
|
+
snapshot: {
|
|
334
|
+
id: "job-reconcile",
|
|
335
|
+
state: "pending",
|
|
336
|
+
current: null,
|
|
337
|
+
currentStage: null,
|
|
338
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
339
|
+
tasks: { research: { state: "pending" } },
|
|
340
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const result = await reconcileInterruptedJob({
|
|
345
|
+
dataDir,
|
|
346
|
+
jobId: "job-reconcile",
|
|
347
|
+
jobDir,
|
|
348
|
+
source: "child-exit",
|
|
349
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
350
|
+
now: () => NOW_MS,
|
|
351
|
+
isAlive: () => false,
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(result).toEqual({ reconciled: false, reason: "not-running" });
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("is a no-op (not-running) for an already-failed job (AC-5)", async () => {
|
|
358
|
+
const { dataDir, jobDir } = await setup({
|
|
359
|
+
snapshot: {
|
|
360
|
+
id: "job-reconcile",
|
|
361
|
+
state: "failed",
|
|
362
|
+
current: "research",
|
|
363
|
+
currentStage: null,
|
|
364
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
365
|
+
tasks: { research: { state: "failed", error: "boom" } },
|
|
366
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
const result = await reconcileInterruptedJob({
|
|
371
|
+
dataDir,
|
|
372
|
+
jobId: "job-reconcile",
|
|
373
|
+
jobDir,
|
|
374
|
+
source: "child-exit",
|
|
375
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
376
|
+
now: () => NOW_MS,
|
|
377
|
+
isAlive: () => false,
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
expect(result).toEqual({ reconciled: false, reason: "not-running" });
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("is a no-op (not-running) for a done job (AC-5)", async () => {
|
|
384
|
+
const { dataDir, jobDir } = await setup({
|
|
385
|
+
snapshot: {
|
|
386
|
+
id: "job-reconcile",
|
|
387
|
+
state: "done",
|
|
388
|
+
current: null,
|
|
389
|
+
currentStage: null,
|
|
390
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
391
|
+
tasks: { research: { state: "done" } },
|
|
392
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const result = await reconcileInterruptedJob({
|
|
397
|
+
dataDir,
|
|
398
|
+
jobId: "job-reconcile",
|
|
399
|
+
jobDir,
|
|
400
|
+
source: "child-exit",
|
|
401
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
402
|
+
now: () => NOW_MS,
|
|
403
|
+
isAlive: () => false,
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
expect(result).toEqual({ reconciled: false, reason: "not-running" });
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("is a no-op (fresh-status) on the boot-sweep path when status is within the grace window (AC-9)", async () => {
|
|
410
|
+
const { dataDir, jobDir } = await setup({
|
|
411
|
+
snapshot: {
|
|
412
|
+
id: "job-reconcile",
|
|
413
|
+
state: "running",
|
|
414
|
+
current: "research",
|
|
415
|
+
currentStage: null,
|
|
416
|
+
lastUpdated: new Date(NOW_MS).toISOString(),
|
|
417
|
+
tasks: { research: { state: "running" } },
|
|
418
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
419
|
+
},
|
|
420
|
+
runnerPid: DEAD_PID,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const result = await reconcileInterruptedJob({
|
|
424
|
+
dataDir,
|
|
425
|
+
jobId: "job-reconcile",
|
|
426
|
+
jobDir,
|
|
427
|
+
source: "boot-sweep",
|
|
428
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
429
|
+
requireStaleStatus: true,
|
|
430
|
+
staleAfterMs: 30_000,
|
|
431
|
+
now: () => NOW_MS,
|
|
432
|
+
isAlive: () => false,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(result).toEqual({ reconciled: false, reason: "fresh-status" });
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("marks a stale job-level running state failed when no running task remains", async () => {
|
|
439
|
+
const { dataDir, jobDir } = await setup({
|
|
440
|
+
snapshot: {
|
|
441
|
+
id: "job-reconcile",
|
|
442
|
+
state: "running",
|
|
443
|
+
current: "research",
|
|
444
|
+
currentStage: null,
|
|
445
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
446
|
+
tasks: { research: { state: "done" } },
|
|
447
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
448
|
+
},
|
|
449
|
+
runnerPid: DEAD_PID,
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const result = await reconcileInterruptedJob({
|
|
453
|
+
dataDir,
|
|
454
|
+
jobId: "job-reconcile",
|
|
455
|
+
jobDir,
|
|
456
|
+
source: "boot-sweep",
|
|
457
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
458
|
+
requireStaleStatus: true,
|
|
459
|
+
staleAfterMs: 30_000,
|
|
460
|
+
now: () => NOW_MS,
|
|
461
|
+
isAlive: () => false,
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
expect(result).toEqual({ reconciled: true, reason: "marked-failed" });
|
|
465
|
+
|
|
466
|
+
const snapshot = await readSnapshot(jobDir);
|
|
467
|
+
expect(snapshot["state"]).toBe("failed");
|
|
468
|
+
expect(snapshot["current"]).toBeNull();
|
|
469
|
+
expect(snapshot["currentStage"]).toBeNull();
|
|
470
|
+
expect(snapshot["error"]).toEqual({
|
|
471
|
+
name: "RunnerInterrupted",
|
|
472
|
+
message: "runner exited while the job was still marked running",
|
|
473
|
+
});
|
|
474
|
+
expect(snapshot["errorContext"]).toEqual({
|
|
475
|
+
kind: "runner-interrupted",
|
|
476
|
+
code: undefined,
|
|
477
|
+
signal: undefined,
|
|
478
|
+
source: "boot-sweep",
|
|
479
|
+
});
|
|
480
|
+
const tasks = snapshot["tasks"] as Record<string, Record<string, unknown>>;
|
|
481
|
+
expect(tasks["research"]?.["state"]).toBe("done");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("leaves a fresh job-level running state alone when no running task remains", async () => {
|
|
485
|
+
const { dataDir, jobDir } = await setup({
|
|
486
|
+
snapshot: {
|
|
487
|
+
id: "job-reconcile",
|
|
488
|
+
state: "running",
|
|
489
|
+
current: "research",
|
|
490
|
+
currentStage: null,
|
|
491
|
+
lastUpdated: new Date(NOW_MS).toISOString(),
|
|
492
|
+
tasks: { research: { state: "done" } },
|
|
493
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
494
|
+
},
|
|
495
|
+
runnerPid: DEAD_PID,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
const result = await reconcileInterruptedJob({
|
|
499
|
+
dataDir,
|
|
500
|
+
jobId: "job-reconcile",
|
|
501
|
+
jobDir,
|
|
502
|
+
source: "boot-sweep",
|
|
503
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
504
|
+
requireStaleStatus: true,
|
|
505
|
+
staleAfterMs: 30_000,
|
|
506
|
+
now: () => NOW_MS,
|
|
507
|
+
isAlive: () => false,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
expect(result).toEqual({ reconciled: false, reason: "fresh-status" });
|
|
511
|
+
|
|
512
|
+
const snapshot = await readSnapshot(jobDir);
|
|
513
|
+
expect(snapshot["state"]).toBe("running");
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("is a no-op (unreadable) and writes nothing when status is missing (AC-12)", async () => {
|
|
517
|
+
const { dataDir, jobDir } = await setup({ snapshot: null, runnerPid: DEAD_PID });
|
|
518
|
+
|
|
519
|
+
const result = await reconcileInterruptedJob({
|
|
520
|
+
dataDir,
|
|
521
|
+
jobId: "job-reconcile",
|
|
522
|
+
jobDir,
|
|
523
|
+
source: "child-exit",
|
|
524
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
525
|
+
now: () => NOW_MS,
|
|
526
|
+
isAlive: () => false,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
expect(result).toEqual({ reconciled: false, reason: "unreadable" });
|
|
530
|
+
|
|
531
|
+
const after = await readJobStatusResult(jobDir);
|
|
532
|
+
expect(after).toEqual({ ok: false, reason: "missing" });
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("is a no-op (unreadable) and leaves a corrupt status file untouched (AC-12)", async () => {
|
|
536
|
+
const { dataDir, jobDir } = await setup({ snapshot: null, runnerPid: DEAD_PID });
|
|
537
|
+
const corruptPath = join(jobDir, STATUS_FILENAME);
|
|
538
|
+
const corruptContent = "{";
|
|
539
|
+
await writeFile(corruptPath, corruptContent);
|
|
540
|
+
|
|
541
|
+
const result = await reconcileInterruptedJob({
|
|
542
|
+
dataDir,
|
|
543
|
+
jobId: "job-reconcile",
|
|
544
|
+
jobDir,
|
|
545
|
+
source: "child-exit",
|
|
546
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
547
|
+
now: () => NOW_MS,
|
|
548
|
+
isAlive: () => false,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
expect(result).toEqual({ reconciled: false, reason: "unreadable" });
|
|
552
|
+
|
|
553
|
+
const after = await Bun.file(corruptPath).text();
|
|
554
|
+
expect(after).toBe(corruptContent);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("is idempotent: a second call after a successful reconcile is a no-op and does not change the file (AC-13)", async () => {
|
|
558
|
+
const { dataDir, jobDir } = await setup({ runnerPid: DEAD_PID });
|
|
559
|
+
|
|
560
|
+
const first = await reconcileInterruptedJob({
|
|
561
|
+
dataDir,
|
|
562
|
+
jobId: "job-reconcile",
|
|
563
|
+
jobDir,
|
|
564
|
+
source: "child-exit",
|
|
565
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
566
|
+
code: 1,
|
|
567
|
+
signal: null,
|
|
568
|
+
now: () => NOW_MS,
|
|
569
|
+
isAlive: () => false,
|
|
570
|
+
});
|
|
571
|
+
expect(first).toEqual({ reconciled: true, taskId: "research", reason: "marked-failed" });
|
|
572
|
+
|
|
573
|
+
const fileAfterFirst = await Bun.file(join(jobDir, STATUS_FILENAME)).text();
|
|
574
|
+
|
|
575
|
+
const second = await reconcileInterruptedJob({
|
|
576
|
+
dataDir,
|
|
577
|
+
jobId: "job-reconcile",
|
|
578
|
+
jobDir,
|
|
579
|
+
source: "child-exit",
|
|
580
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
581
|
+
now: () => NOW_MS,
|
|
582
|
+
isAlive: () => false,
|
|
583
|
+
});
|
|
584
|
+
expect(second).toEqual({ reconciled: false, reason: "not-running" });
|
|
585
|
+
|
|
586
|
+
const fileAfterSecond = await Bun.file(join(jobDir, STATUS_FILENAME)).text();
|
|
587
|
+
expect(fileAfterSecond).toBe(fileAfterFirst);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("does not clobber a fresh competing lease (forced interleave) (AC-14)", async () => {
|
|
591
|
+
const { dataDir, jobDir } = await setup({
|
|
592
|
+
runnerPid: DEAD_PID,
|
|
593
|
+
lease: { pid: null, acquiredAt: new Date(NOW_MS).toISOString() },
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const result = await reconcileInterruptedJob({
|
|
597
|
+
dataDir,
|
|
598
|
+
jobId: "job-reconcile",
|
|
599
|
+
jobDir,
|
|
600
|
+
source: "child-exit",
|
|
601
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
602
|
+
now: () => NOW_MS,
|
|
603
|
+
isAlive: () => false,
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
expect(result).toEqual({ reconciled: false, reason: "live-lease" });
|
|
607
|
+
|
|
608
|
+
const snapshot = await readSnapshot(jobDir);
|
|
609
|
+
expect(snapshot["state"]).toBe("running");
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("emits a structured log event with the required fields on success (AC-15)", async () => {
|
|
613
|
+
const { dataDir, jobDir } = await setup({ runnerPid: DEAD_PID });
|
|
614
|
+
|
|
615
|
+
const spy = spyOn(console, "log").mockImplementation(() => {});
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const result = await reconcileInterruptedJob({
|
|
619
|
+
dataDir,
|
|
620
|
+
jobId: "job-reconcile",
|
|
621
|
+
jobDir,
|
|
622
|
+
source: "child-exit",
|
|
623
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
624
|
+
code: 137,
|
|
625
|
+
signal: "SIGKILL",
|
|
626
|
+
now: () => NOW_MS,
|
|
627
|
+
isAlive: () => false,
|
|
628
|
+
});
|
|
629
|
+
expect(result).toEqual({ reconciled: true, taskId: "research", reason: "marked-failed" });
|
|
630
|
+
|
|
631
|
+
const calls = spy.mock.calls as unknown[][];
|
|
632
|
+
const eventCall = calls.find(
|
|
633
|
+
(call) => typeof call[1] === "string" && call[1] === "reconciled interrupted job",
|
|
634
|
+
);
|
|
635
|
+
expect(eventCall).toBeDefined();
|
|
636
|
+
const prefix = String(eventCall![0]);
|
|
637
|
+
expect(prefix).toContain("runner-liveness");
|
|
638
|
+
const payload = JSON.parse(String(eventCall![2])) as Record<string, unknown>;
|
|
639
|
+
expect(payload).toEqual({
|
|
640
|
+
jobId: "job-reconcile",
|
|
641
|
+
taskId: "research",
|
|
642
|
+
pid: DEAD_PID,
|
|
643
|
+
code: 137,
|
|
644
|
+
signal: "SIGKILL",
|
|
645
|
+
path: "child-exit",
|
|
646
|
+
});
|
|
647
|
+
} finally {
|
|
648
|
+
spy.mockRestore();
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
describe("reconcileOrphanedCurrentJobs", () => {
|
|
654
|
+
let dataDirs: string[] = [];
|
|
655
|
+
|
|
656
|
+
afterEach(async () => {
|
|
657
|
+
await Promise.all(
|
|
658
|
+
dataDirs.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => {})),
|
|
659
|
+
);
|
|
660
|
+
dataDirs = [];
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
async function makeDataDir(): Promise<string> {
|
|
664
|
+
const dataDir = await mkdtemp(join(tmpdir(), "pop-sweep-"));
|
|
665
|
+
dataDirs.push(dataDir);
|
|
666
|
+
return dataDir;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async function writeJob(
|
|
670
|
+
dataDir: string,
|
|
671
|
+
jobId: string,
|
|
672
|
+
snapshot: Record<string, unknown>,
|
|
673
|
+
runnerPid?: number,
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
const jobDir = join(dataDir, "current", jobId);
|
|
676
|
+
await mkdir(jobDir, { recursive: true });
|
|
677
|
+
await writeFile(join(jobDir, STATUS_FILENAME), JSON.stringify(snapshot, null, 2));
|
|
678
|
+
if (runnerPid !== undefined) {
|
|
679
|
+
await writeFile(join(jobDir, "runner.pid"), String(runnerPid));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function runningSnapshot(jobId: string, lastUpdated: string): Record<string, unknown> {
|
|
684
|
+
return {
|
|
685
|
+
id: jobId,
|
|
686
|
+
state: "running",
|
|
687
|
+
current: "research",
|
|
688
|
+
currentStage: null,
|
|
689
|
+
lastUpdated,
|
|
690
|
+
tasks: { research: { state: "running" } },
|
|
691
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
it("reconciles a stale current/ job with a dead pid and returns its jobId (AC-4)", async () => {
|
|
696
|
+
const dataDir = await makeDataDir();
|
|
697
|
+
const jobId = "job-stale";
|
|
698
|
+
await writeJob(
|
|
699
|
+
dataDir,
|
|
700
|
+
jobId,
|
|
701
|
+
runningSnapshot(jobId, "2026-06-19T11:00:00.000Z"),
|
|
702
|
+
DEAD_PID,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
706
|
+
dataDir,
|
|
707
|
+
currentDir: join(dataDir, "current"),
|
|
708
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
709
|
+
staleAfterMs: 30_000,
|
|
710
|
+
now: () => NOW_MS,
|
|
711
|
+
isAlive: () => false,
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
expect(result).toEqual({ scanned: 1, reconciled: [jobId] });
|
|
715
|
+
|
|
716
|
+
const snapshot = await readSnapshot(join(dataDir, "current", jobId));
|
|
717
|
+
expect(snapshot["state"]).toBe("failed");
|
|
718
|
+
const tasks = snapshot["tasks"] as Record<string, Record<string, unknown>>;
|
|
719
|
+
expect(tasks["research"]?.["state"]).toBe("failed");
|
|
720
|
+
expect(tasks["research"]?.["errorContext"]).toEqual({
|
|
721
|
+
kind: "runner-interrupted",
|
|
722
|
+
code: undefined,
|
|
723
|
+
signal: undefined,
|
|
724
|
+
source: "boot-sweep",
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("leaves alone a job whose runner.pid points at a live process (AC-11)", async () => {
|
|
729
|
+
const dataDir = await makeDataDir();
|
|
730
|
+
const jobId = "job-live";
|
|
731
|
+
await writeJob(
|
|
732
|
+
dataDir,
|
|
733
|
+
jobId,
|
|
734
|
+
runningSnapshot(jobId, "2026-06-19T11:00:00.000Z"),
|
|
735
|
+
process.pid,
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
739
|
+
dataDir,
|
|
740
|
+
currentDir: join(dataDir, "current"),
|
|
741
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
742
|
+
staleAfterMs: 30_000,
|
|
743
|
+
now: () => NOW_MS,
|
|
744
|
+
isAlive: isProcessAlive,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
expect(result).toEqual({ scanned: 1, reconciled: [] });
|
|
748
|
+
|
|
749
|
+
const snapshot = await readSnapshot(join(dataDir, "current", jobId));
|
|
750
|
+
expect(snapshot["state"]).toBe("running");
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("leaves alone a job whose status is fresh within the grace window (AC-11)", async () => {
|
|
754
|
+
const dataDir = await makeDataDir();
|
|
755
|
+
const jobId = "job-fresh";
|
|
756
|
+
await writeJob(
|
|
757
|
+
dataDir,
|
|
758
|
+
jobId,
|
|
759
|
+
runningSnapshot(jobId, new Date(NOW_MS).toISOString()),
|
|
760
|
+
DEAD_PID,
|
|
761
|
+
);
|
|
762
|
+
|
|
763
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
764
|
+
dataDir,
|
|
765
|
+
currentDir: join(dataDir, "current"),
|
|
766
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
767
|
+
staleAfterMs: 30_000,
|
|
768
|
+
now: () => NOW_MS,
|
|
769
|
+
isAlive: () => false,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(result).toEqual({ scanned: 1, reconciled: [] });
|
|
773
|
+
|
|
774
|
+
const snapshot = await readSnapshot(join(dataDir, "current", jobId));
|
|
775
|
+
expect(snapshot["state"]).toBe("running");
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("leaves alone a job in a terminal state (AC-11)", async () => {
|
|
779
|
+
const dataDir = await makeDataDir();
|
|
780
|
+
const jobId = "job-done";
|
|
781
|
+
await writeJob(
|
|
782
|
+
dataDir,
|
|
783
|
+
jobId,
|
|
784
|
+
{
|
|
785
|
+
id: jobId,
|
|
786
|
+
state: "done",
|
|
787
|
+
current: null,
|
|
788
|
+
currentStage: null,
|
|
789
|
+
lastUpdated: "2026-06-19T11:00:00.000Z",
|
|
790
|
+
tasks: { research: { state: "done" } },
|
|
791
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
792
|
+
},
|
|
793
|
+
DEAD_PID,
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
797
|
+
dataDir,
|
|
798
|
+
currentDir: join(dataDir, "current"),
|
|
799
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
800
|
+
staleAfterMs: 30_000,
|
|
801
|
+
now: () => NOW_MS,
|
|
802
|
+
isAlive: () => false,
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
expect(result).toEqual({ scanned: 1, reconciled: [] });
|
|
806
|
+
|
|
807
|
+
const snapshot = await readSnapshot(join(dataDir, "current", jobId));
|
|
808
|
+
expect(snapshot["state"]).toBe("done");
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
it("returns scanned: 0 when current/ does not exist (AC-16)", async () => {
|
|
812
|
+
const dataDir = await makeDataDir();
|
|
813
|
+
|
|
814
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
815
|
+
dataDir,
|
|
816
|
+
currentDir: join(dataDir, "current"),
|
|
817
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
818
|
+
staleAfterMs: 30_000,
|
|
819
|
+
now: () => NOW_MS,
|
|
820
|
+
isAlive: () => false,
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
expect(result).toEqual({ scanned: 0, reconciled: [] });
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("returns scanned: 0 for an empty current/ directory", async () => {
|
|
827
|
+
const dataDir = await makeDataDir();
|
|
828
|
+
await mkdir(join(dataDir, "current"), { recursive: true });
|
|
829
|
+
const log = spyOn(console, "log").mockImplementation(() => {});
|
|
830
|
+
|
|
831
|
+
try {
|
|
832
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
833
|
+
dataDir,
|
|
834
|
+
currentDir: join(dataDir, "current"),
|
|
835
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
836
|
+
staleAfterMs: 30_000,
|
|
837
|
+
now: () => NOW_MS,
|
|
838
|
+
isAlive: () => false,
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
expect(result).toEqual({ scanned: 0, reconciled: [] });
|
|
842
|
+
expect(log).not.toHaveBeenCalled();
|
|
843
|
+
} finally {
|
|
844
|
+
log.mockRestore();
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it("ignores non-directory entries in current/ when reporting scanned jobs", async () => {
|
|
849
|
+
const dataDir = await makeDataDir();
|
|
850
|
+
const currentDir = join(dataDir, "current");
|
|
851
|
+
await mkdir(currentDir, { recursive: true });
|
|
852
|
+
await writeFile(join(currentDir, ".DS_Store"), "not a job");
|
|
853
|
+
|
|
854
|
+
const result = await reconcileOrphanedCurrentJobs({
|
|
855
|
+
dataDir,
|
|
856
|
+
currentDir,
|
|
857
|
+
lockTimeoutMs: LOCK_TIMEOUT_MS,
|
|
858
|
+
staleAfterMs: 30_000,
|
|
859
|
+
now: () => NOW_MS,
|
|
860
|
+
isAlive: () => false,
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
expect(result).toEqual({ scanned: 0, reconciled: [] });
|
|
864
|
+
});
|
|
865
|
+
});
|