@ryanfw/prompt-orchestration-pipeline 1.2.6 → 1.2.7
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/package.json +1 -1
- package/src/config/__tests__/models.test.ts +21 -11
- package/src/config/models.ts +123 -74
- package/src/core/__tests__/lifecycle-policy.test.ts +78 -0
- package/src/core/__tests__/pipeline-runner.test.ts +361 -0
- package/src/core/__tests__/task-runner.test.ts +3 -3
- package/src/core/pipeline-runner.ts +28 -5
- package/src/core/task-runner.ts +2 -5
- package/src/providers/__tests__/base.test.ts +2 -2
- package/src/providers/__tests__/openai.test.ts +1 -1
- package/src/ui/dist/assets/{index-CeAgP91B.js → index-SKy2shWc.js} +100 -58
- package/src/ui/dist/assets/{index-CeAgP91B.js.map → index-SKy2shWc.js.map} +1 -1
- package/src/ui/dist/index.html +1 -1
- package/src/ui/embedded-assets.js +6 -6
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { describe, test, expect, mock, spyOn, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtemp, writeFile, mkdir, readFile, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
// ─── Mocks: NON-target externals only ─────────────────────────────────────────
|
|
7
|
+
// We leave status-writer and lifecycle-policy REAL so the stale-snapshot bug
|
|
8
|
+
// manifests end-to-end. runPipeline and symlink helpers are replaced with
|
|
9
|
+
// deterministic no-ops; config/validation/module-loader are replaced so we
|
|
10
|
+
// don't need a real pipelines directory on disk.
|
|
11
|
+
|
|
12
|
+
mock.module("../config", () => ({
|
|
13
|
+
getPipelineConfig: mock((_slug: string) => ({
|
|
14
|
+
pipelineJsonPath: "/mock/pipeline.json",
|
|
15
|
+
tasksDir: "/mock/tasks",
|
|
16
|
+
})),
|
|
17
|
+
getConfig: mock(() => ({})),
|
|
18
|
+
loadConfig: mock(async () => ({})),
|
|
19
|
+
resetConfig: mock(() => {}),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module("../validation", () => ({
|
|
23
|
+
validatePipelineOrThrow: mock((_pipeline: unknown, _pathHint?: string) => {}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
const mockLoadFreshModule = mock(async (_path: string) => ({
|
|
27
|
+
default: {} as Record<string, string>,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
mock.module("../module-loader", () => ({
|
|
31
|
+
loadFreshModule: mockLoadFreshModule,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const mockRunPipeline = mock(async (_modulePath: string, _ctx: unknown) => ({
|
|
35
|
+
ok: true as const,
|
|
36
|
+
logs: [{ stage: "generate", ok: true as const, ms: 10 }],
|
|
37
|
+
context: {} as Record<string, unknown>,
|
|
38
|
+
llmMetrics: [],
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
mock.module("../task-runner", () => ({
|
|
42
|
+
runPipeline: mockRunPipeline,
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
const mockEnsureTaskSymlinkBridge = mock(
|
|
46
|
+
async (_workDir: string, _taskName: string, _registryDir: string, modulePath: string) => ({
|
|
47
|
+
relocatedEntryPath: modulePath,
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
mock.module("../symlink-bridge", () => ({
|
|
52
|
+
ensureTaskSymlinkBridge: mockEnsureTaskSymlinkBridge,
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const mockValidateTaskSymlinks = mock(async () => true);
|
|
56
|
+
const mockRepairTaskSymlinks = mock(async () => {});
|
|
57
|
+
const mockCleanupTaskSymlinks = mock(async () => {});
|
|
58
|
+
|
|
59
|
+
mock.module("../symlink-utils", () => ({
|
|
60
|
+
validateTaskSymlinks: mockValidateTaskSymlinks,
|
|
61
|
+
repairTaskSymlinks: mockRepairTaskSymlinks,
|
|
62
|
+
cleanupTaskSymlinks: mockCleanupTaskSymlinks,
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
// Silence the job-level logger so test output stays readable.
|
|
66
|
+
const quietLogger = {
|
|
67
|
+
debug: mock(() => {}),
|
|
68
|
+
log: mock(() => {}),
|
|
69
|
+
warn: mock(() => {}),
|
|
70
|
+
error: mock(() => {}),
|
|
71
|
+
group: mock(() => {}),
|
|
72
|
+
groupEnd: mock(() => {}),
|
|
73
|
+
sse: mock(() => {}),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
mock.module("../logger", () => ({
|
|
77
|
+
createJobLogger: mock(() => quietLogger),
|
|
78
|
+
createLogger: mock(() => quietLogger),
|
|
79
|
+
createTaskLogger: mock(() => quietLogger),
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// ─── Import the module under test (after all mock.module calls) ───────────────
|
|
83
|
+
|
|
84
|
+
import { runPipelineJob } from "../pipeline-runner";
|
|
85
|
+
|
|
86
|
+
// ─── Test fixtures and helpers ────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
const PO_ENV_KEYS = [
|
|
89
|
+
"PO_ROOT",
|
|
90
|
+
"PO_DATA_DIR",
|
|
91
|
+
"PO_CURRENT_DIR",
|
|
92
|
+
"PO_COMPLETE_DIR",
|
|
93
|
+
"PO_PIPELINE_PATH",
|
|
94
|
+
"PO_PIPELINE_SLUG",
|
|
95
|
+
"PO_TASK_REGISTRY",
|
|
96
|
+
"PO_START_FROM_TASK",
|
|
97
|
+
"PO_RUN_SINGLE_TASK",
|
|
98
|
+
] as const;
|
|
99
|
+
|
|
100
|
+
interface MultiTaskFixture {
|
|
101
|
+
tmpDir: string;
|
|
102
|
+
jobId: string;
|
|
103
|
+
jobDir: string;
|
|
104
|
+
completeDir: string;
|
|
105
|
+
statusPath: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function setupMultiTaskFixture(taskNames: string[]): Promise<MultiTaskFixture> {
|
|
109
|
+
const tmpDir = await mkdtemp(join(tmpdir(), "pipeline-runner-regression-"));
|
|
110
|
+
const jobId = "job-regression";
|
|
111
|
+
const currentDir = join(tmpDir, "current");
|
|
112
|
+
const completeDir = join(tmpDir, "complete");
|
|
113
|
+
const jobDir = join(currentDir, jobId);
|
|
114
|
+
const pipelineDir = join(tmpDir, "pipeline");
|
|
115
|
+
|
|
116
|
+
await mkdir(jobDir, { recursive: true });
|
|
117
|
+
await mkdir(completeDir, { recursive: true });
|
|
118
|
+
await mkdir(pipelineDir, { recursive: true });
|
|
119
|
+
|
|
120
|
+
await writeFile(join(jobDir, "seed.json"), JSON.stringify({ pipeline: "test-pipeline" }));
|
|
121
|
+
|
|
122
|
+
const tasks: Record<string, { state: string }> = {};
|
|
123
|
+
for (const name of taskNames) {
|
|
124
|
+
tasks[name] = { state: "pending" };
|
|
125
|
+
}
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(jobDir, "tasks-status.json"),
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
id: jobId,
|
|
130
|
+
state: "pending",
|
|
131
|
+
current: null,
|
|
132
|
+
currentStage: null,
|
|
133
|
+
lastUpdated: new Date().toISOString(),
|
|
134
|
+
tasks,
|
|
135
|
+
files: { artifacts: [], logs: [], tmp: [] },
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
await writeFile(join(pipelineDir, "pipeline.json"), JSON.stringify({ tasks: taskNames }));
|
|
140
|
+
|
|
141
|
+
process.env["PO_ROOT"] = tmpDir;
|
|
142
|
+
process.env["PO_DATA_DIR"] = ".";
|
|
143
|
+
process.env["PO_CURRENT_DIR"] = currentDir;
|
|
144
|
+
process.env["PO_COMPLETE_DIR"] = completeDir;
|
|
145
|
+
process.env["PO_PIPELINE_PATH"] = join(pipelineDir, "pipeline.json");
|
|
146
|
+
process.env["PO_TASK_REGISTRY"] = join(pipelineDir, "tasks", "index.js");
|
|
147
|
+
|
|
148
|
+
mockLoadFreshModule.mockImplementation(async (_path: string) => ({
|
|
149
|
+
default: Object.fromEntries(taskNames.map((n) => [n, `./${n}.js`])),
|
|
150
|
+
}));
|
|
151
|
+
|
|
152
|
+
return { tmpDir, jobId, jobDir, completeDir, statusPath: join(completeDir, jobId, "tasks-status.json") };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe("runPipelineJob — multi-task success regression", () => {
|
|
156
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
157
|
+
const cleanupDirs: string[] = [];
|
|
158
|
+
|
|
159
|
+
beforeEach(() => {
|
|
160
|
+
for (const key of PO_ENV_KEYS) {
|
|
161
|
+
savedEnv[key] = process.env[key];
|
|
162
|
+
delete process.env[key];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
mockRunPipeline.mockClear();
|
|
166
|
+
mockRunPipeline.mockImplementation(async (_modulePath: string, _ctx: unknown) => ({
|
|
167
|
+
ok: true as const,
|
|
168
|
+
logs: [{ stage: "generate", ok: true as const, ms: 10 }],
|
|
169
|
+
context: {} as Record<string, unknown>,
|
|
170
|
+
llmMetrics: [],
|
|
171
|
+
}));
|
|
172
|
+
mockEnsureTaskSymlinkBridge.mockClear();
|
|
173
|
+
mockValidateTaskSymlinks.mockClear();
|
|
174
|
+
mockRepairTaskSymlinks.mockClear();
|
|
175
|
+
mockCleanupTaskSymlinks.mockClear();
|
|
176
|
+
mockLoadFreshModule.mockClear();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterEach(async () => {
|
|
180
|
+
for (const key of PO_ENV_KEYS) {
|
|
181
|
+
if (savedEnv[key] === undefined) {
|
|
182
|
+
delete process.env[key];
|
|
183
|
+
} else {
|
|
184
|
+
process.env[key] = savedEnv[key];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
process.exitCode = 0;
|
|
188
|
+
|
|
189
|
+
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("two-task successful run: both tasks end in state 'done'", async () => {
|
|
193
|
+
const fixture = await setupMultiTaskFixture(["task-a", "task-b"]);
|
|
194
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
195
|
+
|
|
196
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
197
|
+
throw new Error(`process.exit called with ${String(code)}`);
|
|
198
|
+
}) as typeof process.exit);
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
await runPipelineJob(fixture.jobId);
|
|
202
|
+
} finally {
|
|
203
|
+
exitSpy.mockRestore();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const statusText = await readFile(fixture.statusPath, "utf-8");
|
|
207
|
+
const status = JSON.parse(statusText) as {
|
|
208
|
+
tasks: Record<string, { state?: string }>;
|
|
209
|
+
};
|
|
210
|
+
expect(status.tasks["task-a"]?.state).toBe("done");
|
|
211
|
+
expect(status.tasks["task-b"]?.state).toBe("done");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("three-task successful run: all three tasks end in state 'done'", async () => {
|
|
215
|
+
const fixture = await setupMultiTaskFixture(["task-a", "task-b", "task-c"]);
|
|
216
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
217
|
+
|
|
218
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
219
|
+
throw new Error(`process.exit called with ${String(code)}`);
|
|
220
|
+
}) as typeof process.exit);
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await runPipelineJob(fixture.jobId);
|
|
224
|
+
} finally {
|
|
225
|
+
exitSpy.mockRestore();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const statusText = await readFile(fixture.statusPath, "utf-8");
|
|
229
|
+
const status = JSON.parse(statusText) as {
|
|
230
|
+
tasks: Record<string, { state?: string }>;
|
|
231
|
+
};
|
|
232
|
+
expect(status.tasks["task-a"]?.state).toBe("done");
|
|
233
|
+
expect(status.tasks["task-b"]?.state).toBe("done");
|
|
234
|
+
expect(status.tasks["task-c"]?.state).toBe("done");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("multi-task success does not throw a lifecycle-policy block error", async () => {
|
|
238
|
+
const fixture = await setupMultiTaskFixture(["task-a", "task-b", "task-c"]);
|
|
239
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
240
|
+
|
|
241
|
+
const exitCalls: Array<number | undefined> = [];
|
|
242
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
243
|
+
exitCalls.push(code);
|
|
244
|
+
throw new Error(`process.exit called with ${String(code)}`);
|
|
245
|
+
}) as typeof process.exit);
|
|
246
|
+
|
|
247
|
+
const consoleErrorMessages: unknown[][] = [];
|
|
248
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
249
|
+
consoleErrorMessages.push(args);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
await runPipelineJob(fixture.jobId);
|
|
254
|
+
} catch {
|
|
255
|
+
// If the bug throws, runPipelineJob's outer catch will call process.exit,
|
|
256
|
+
// which our spy converts to a throw. Swallow here so we can inspect the
|
|
257
|
+
// captured console.error output below.
|
|
258
|
+
} finally {
|
|
259
|
+
exitSpy.mockRestore();
|
|
260
|
+
consoleErrorSpy.mockRestore();
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const anyLifecycleBlock = consoleErrorMessages.some((args) =>
|
|
264
|
+
args.some((a) => {
|
|
265
|
+
if (a instanceof Error) return /Lifecycle policy blocked task start/.test(a.message);
|
|
266
|
+
if (typeof a === "string") return /Lifecycle policy blocked task start/.test(a);
|
|
267
|
+
return false;
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
expect(anyLifecycleBlock).toBe(false);
|
|
271
|
+
expect(exitCalls).toEqual([]);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
describe("runPipelineJob — outer-catch failure surfacing", () => {
|
|
276
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
277
|
+
const cleanupDirs: string[] = [];
|
|
278
|
+
|
|
279
|
+
beforeEach(() => {
|
|
280
|
+
for (const key of Object.keys(savedEnv)) delete savedEnv[key];
|
|
281
|
+
for (const key of PO_ENV_KEYS) {
|
|
282
|
+
savedEnv[key] = process.env[key];
|
|
283
|
+
delete process.env[key];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
mockRunPipeline.mockClear();
|
|
287
|
+
mockEnsureTaskSymlinkBridge.mockClear();
|
|
288
|
+
mockValidateTaskSymlinks.mockClear();
|
|
289
|
+
mockRepairTaskSymlinks.mockClear();
|
|
290
|
+
mockCleanupTaskSymlinks.mockClear();
|
|
291
|
+
mockLoadFreshModule.mockClear();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
afterEach(async () => {
|
|
295
|
+
for (const key of PO_ENV_KEYS) {
|
|
296
|
+
if (savedEnv[key] === undefined) {
|
|
297
|
+
delete process.env[key];
|
|
298
|
+
} else {
|
|
299
|
+
process.env[key] = savedEnv[key];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
process.exitCode = 0;
|
|
303
|
+
|
|
304
|
+
await Promise.all(cleanupDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("unhandled error: sets exitCode=1, writes orchestrator failure log, stderr includes message", async () => {
|
|
308
|
+
const fixture = await setupMultiTaskFixture(["task-a"]);
|
|
309
|
+
cleanupDirs.push(fixture.tmpDir);
|
|
310
|
+
|
|
311
|
+
const injectedMessage = "injected-outer-catch-failure";
|
|
312
|
+
mockLoadFreshModule.mockImplementation(async (_path: string) => {
|
|
313
|
+
throw new Error(injectedMessage);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const exitCalls: Array<number | undefined> = [];
|
|
317
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((code?: number) => {
|
|
318
|
+
exitCalls.push(code);
|
|
319
|
+
throw new Error(`__test_exit__:${String(code)}`);
|
|
320
|
+
}) as typeof process.exit);
|
|
321
|
+
|
|
322
|
+
const fakeTimer = { unref: () => fakeTimer, ref: () => fakeTimer };
|
|
323
|
+
const setTimeoutSpy = spyOn(globalThis, "setTimeout").mockImplementation(
|
|
324
|
+
((() => fakeTimer as unknown as ReturnType<typeof setTimeout>) as unknown) as typeof setTimeout,
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
const consoleErrorMessages: unknown[][] = [];
|
|
328
|
+
const consoleErrorSpy = spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
329
|
+
consoleErrorMessages.push(args);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
try {
|
|
333
|
+
await runPipelineJob(fixture.jobId);
|
|
334
|
+
} catch (e) {
|
|
335
|
+
if (!(e instanceof Error) || !/^__test_exit__:/.test(e.message)) throw e;
|
|
336
|
+
} finally {
|
|
337
|
+
exitSpy.mockRestore();
|
|
338
|
+
setTimeoutSpy.mockRestore();
|
|
339
|
+
consoleErrorSpy.mockRestore();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
expect(process.exitCode).toBe(1);
|
|
343
|
+
expect(exitCalls).toContain(1);
|
|
344
|
+
|
|
345
|
+
const failurePath = join(
|
|
346
|
+
fixture.jobDir,
|
|
347
|
+
"files",
|
|
348
|
+
"logs",
|
|
349
|
+
"orchestrator-runPipelineJob-failure-details.json",
|
|
350
|
+
);
|
|
351
|
+
const failureText = await readFile(failurePath, "utf-8");
|
|
352
|
+
const failure = JSON.parse(failureText) as { message?: unknown };
|
|
353
|
+
expect(typeof failure.message).toBe("string");
|
|
354
|
+
expect(failure.message).toContain(injectedMessage);
|
|
355
|
+
|
|
356
|
+
const stderrContainsMessage = consoleErrorMessages.some((args) =>
|
|
357
|
+
args.some((a) => typeof a === "string" && a.includes(injectedMessage)),
|
|
358
|
+
);
|
|
359
|
+
expect(stderrContainsMessage).toBe(true);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -60,7 +60,7 @@ describe("runPipeline log tracking", () => {
|
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
describe("task-runner does not write job-level status fields", () => {
|
|
63
|
-
it("does not set snapshot.state, snapshot.current,
|
|
63
|
+
it("does not set snapshot.state, snapshot.current, or snapshot.currentStage on success", async () => {
|
|
64
64
|
const root = await makeTempRoot();
|
|
65
65
|
const workDir = path.join(root, "job-1");
|
|
66
66
|
await mkdir(workDir, { recursive: true });
|
|
@@ -91,11 +91,11 @@ describe("task-runner does not write job-level status fields", () => {
|
|
|
91
91
|
|
|
92
92
|
const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
|
|
93
93
|
|
|
94
|
-
// Job-level fields must remain untouched by task-runner
|
|
94
|
+
// Job-level lifecycle fields must remain untouched by task-runner.
|
|
95
95
|
expect(status.state).toBe("pending");
|
|
96
96
|
expect(status.current).toBeNull();
|
|
97
97
|
expect(status.currentStage).toBeNull();
|
|
98
|
-
expect(status.progress).
|
|
98
|
+
expect(status.progress).toBe(100);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
it("does not set snapshot.state on task failure", async () => {
|
|
@@ -268,9 +268,6 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
268
268
|
const pipeline = await loadPipeline(config.pipelineJsonPath);
|
|
269
269
|
const taskRegistry = await loadTaskRegistry(config.taskRegistryPath);
|
|
270
270
|
|
|
271
|
-
const statusText = await Bun.file(config.statusPath).text();
|
|
272
|
-
const status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
|
|
273
|
-
|
|
274
271
|
const { startFromTask, runSingleTask } = config;
|
|
275
272
|
|
|
276
273
|
// ─── Validate startFromTask / runSingleTask config ───────────────────────
|
|
@@ -294,6 +291,9 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
294
291
|
for (const task of pipeline.tasks) {
|
|
295
292
|
const taskName = getTaskName(task);
|
|
296
293
|
|
|
294
|
+
const statusText = await Bun.file(config.statusPath).text();
|
|
295
|
+
const status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
|
|
296
|
+
|
|
297
297
|
// Skip tasks before startFromTask
|
|
298
298
|
if (!reachedStartFrom) {
|
|
299
299
|
if (taskName === startFromTask) {
|
|
@@ -485,9 +485,32 @@ export async function runPipelineJob(jobId: string): Promise<void> {
|
|
|
485
485
|
await completeJob(config, finalStatus, pipelineArtifacts);
|
|
486
486
|
}
|
|
487
487
|
} catch (err) {
|
|
488
|
-
|
|
488
|
+
const normalized = normalizeError(err);
|
|
489
|
+
console.error(normalized.message);
|
|
489
490
|
if (workDir !== undefined) {
|
|
490
|
-
|
|
491
|
+
try {
|
|
492
|
+
const failureIO = createTaskFileIO({
|
|
493
|
+
workDir,
|
|
494
|
+
taskName: "orchestrator",
|
|
495
|
+
trackTaskFiles: false,
|
|
496
|
+
getStage: () => "runPipelineJob",
|
|
497
|
+
statusPath: join(workDir, "tasks-status.json"),
|
|
498
|
+
});
|
|
499
|
+
const failureLogName = generateLogName(
|
|
500
|
+
"orchestrator",
|
|
501
|
+
"runPipelineJob",
|
|
502
|
+
LogEvent.FAILURE_DETAILS,
|
|
503
|
+
LogFileExtension.JSON,
|
|
504
|
+
);
|
|
505
|
+
await failureIO.writeLog(failureLogName, JSON.stringify(normalized, null, 2));
|
|
506
|
+
} catch {
|
|
507
|
+
// Do not mask the original failure if log-write fails
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
await cleanupPidFile(workDir);
|
|
511
|
+
} catch {
|
|
512
|
+
// Do not mask the original failure if PID cleanup fails
|
|
513
|
+
}
|
|
491
514
|
}
|
|
492
515
|
process.exitCode = 1;
|
|
493
516
|
setTimeout(() => process.exit(1), 5000).unref();
|
package/src/core/task-runner.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { mkdir } from "node:fs/promises";
|
|
5
5
|
import { dirname, isAbsolute, join } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
-
import { KNOWN_STAGES } from "./progress";
|
|
7
|
+
import { KNOWN_STAGES, computeDeterministicProgress } from "./progress";
|
|
8
8
|
import type { StageName } from "./progress";
|
|
9
9
|
import { createTaskFileIO, generateLogName, trackFile } from "./file-io";
|
|
10
10
|
import type { TaskFileIO } from "./file-io";
|
|
@@ -795,17 +795,14 @@ export async function runPipeline(
|
|
|
795
795
|
|
|
796
796
|
// Write done status (best-effort)
|
|
797
797
|
try {
|
|
798
|
-
const lastStage = KNOWN_STAGES[KNOWN_STAGES.length - 1]
|
|
798
|
+
const lastStage = KNOWN_STAGES[KNOWN_STAGES.length - 1]!;
|
|
799
799
|
const doneProgress = computeDeterministicProgress(
|
|
800
800
|
pipelineTasks ?? [taskName],
|
|
801
801
|
taskName,
|
|
802
802
|
lastStage,
|
|
803
803
|
);
|
|
804
804
|
await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
|
|
805
|
-
snapshot.state = TaskState.DONE;
|
|
806
805
|
snapshot.progress = doneProgress;
|
|
807
|
-
snapshot.current = null;
|
|
808
|
-
snapshot.currentStage = null;
|
|
809
806
|
if (!snapshot.tasks[taskName]) snapshot.tasks[taskName] = {};
|
|
810
807
|
snapshot.tasks[taskName]!.state = TaskState.DONE;
|
|
811
808
|
snapshot.tasks[taskName]!.currentStage = null;
|
|
@@ -165,8 +165,8 @@ describe("isRetryableError", () => {
|
|
|
165
165
|
});
|
|
166
166
|
|
|
167
167
|
describe("DEFAULT_REQUEST_TIMEOUT_MS", () => {
|
|
168
|
-
it("is
|
|
169
|
-
expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(
|
|
168
|
+
it("is 3 600 000 ms", () => {
|
|
169
|
+
expect(DEFAULT_REQUEST_TIMEOUT_MS).toBe(3_600_000);
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
172
|
|