@ryanfw/prompt-orchestration-pipeline 1.3.1 → 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.
Files changed (67) hide show
  1. package/docs/http-api.md +66 -0
  2. package/docs/pop-task-guide.md +1 -0
  3. package/package.json +1 -2
  4. package/src/api/__tests__/index.test.ts +311 -3
  5. package/src/api/index.ts +94 -45
  6. package/src/config/__tests__/statuses.test.ts +41 -0
  7. package/src/config/statuses.ts +20 -1
  8. package/src/core/__tests__/agent-step.test.ts +322 -3
  9. package/src/core/__tests__/config.test.ts +31 -1
  10. package/src/core/__tests__/job-concurrency.test.ts +60 -0
  11. package/src/core/__tests__/job-view.test.ts +859 -0
  12. package/src/core/__tests__/orchestrator.test.ts +242 -0
  13. package/src/core/__tests__/runner-liveness.test.ts +865 -0
  14. package/src/core/__tests__/single-derivation.test.ts +159 -0
  15. package/src/core/__tests__/task-runner.test.ts +57 -1
  16. package/src/core/agent-step.ts +231 -14
  17. package/src/core/agent-types.ts +3 -0
  18. package/src/core/config.ts +19 -0
  19. package/src/core/file-io.ts +8 -74
  20. package/src/core/job-concurrency.ts +6 -1
  21. package/src/core/job-view.ts +329 -0
  22. package/src/core/orchestrator.ts +78 -6
  23. package/src/core/pipeline-runner.ts +54 -3
  24. package/src/core/runner-liveness.ts +276 -0
  25. package/src/core/status-writer.ts +68 -31
  26. package/src/core/task-runner.ts +27 -3
  27. package/src/harness/__tests__/discovery.test.ts +60 -8
  28. package/src/harness/discovery.ts +16 -6
  29. package/src/ui/client/__tests__/job-adapter.test.ts +78 -0
  30. package/src/ui/client/adapters/job-adapter.ts +10 -0
  31. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  32. package/src/ui/client/types.ts +2 -0
  33. package/src/ui/components/JobTable.tsx +29 -12
  34. package/src/ui/components/__tests__/JobTable.test.tsx +54 -1
  35. package/src/ui/components/types.ts +2 -0
  36. package/src/ui/dist/assets/{index-CbS3OsW7.js → index-L6cvsCAx.js} +39 -13
  37. package/src/ui/dist/assets/{index-CbS3OsW7.js.map → index-L6cvsCAx.js.map} +1 -1
  38. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  39. package/src/ui/dist/index.html +2 -2
  40. package/src/ui/embedded-assets.js +6 -6
  41. package/src/ui/server/__tests__/config-bridge.test.ts +9 -1
  42. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  43. package/src/ui/server/__tests__/job-control-endpoints.test.ts +245 -1
  44. package/src/ui/server/__tests__/job-endpoints.test.ts +115 -1
  45. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  46. package/src/ui/server/__tests__/sse-enhancer.test.ts +31 -0
  47. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  48. package/src/ui/server/config-bridge.ts +4 -4
  49. package/src/ui/server/endpoints/__tests__/meta-endpoint.test.ts +1 -0
  50. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  51. package/src/ui/server/endpoints/gate-endpoints.ts +19 -7
  52. package/src/ui/server/endpoints/job-control-endpoints.ts +43 -70
  53. package/src/ui/server/endpoints/job-endpoints.ts +6 -0
  54. package/src/ui/server/endpoints/meta-endpoint.ts +1 -1
  55. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  56. package/src/ui/server/utils/http-utils.ts +6 -1
  57. package/src/ui/server/utils/path-containment.ts +18 -0
  58. package/src/ui/server/utils/status-corruption.ts +27 -0
  59. package/src/ui/state/__tests__/snapshot.test.ts +51 -0
  60. package/src/ui/state/__tests__/types.test.ts +98 -5
  61. package/src/ui/state/snapshot.ts +1 -1
  62. package/src/ui/state/transformers/__tests__/list-transformer.test.ts +43 -2
  63. package/src/ui/state/transformers/__tests__/status-transformer.test.ts +208 -22
  64. package/src/ui/state/transformers/list-transformer.ts +7 -3
  65. package/src/ui/state/transformers/status-transformer.ts +36 -170
  66. package/src/ui/state/types.ts +9 -47
  67. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -1,9 +1,10 @@
1
1
  import { mkdir, rename, appendFile } from "node:fs/promises";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, renameSync } from "node:fs";
2
+ import { existsSync, mkdirSync, writeFileSync, appendFileSync, renameSync } from "node:fs";
3
3
  import { dirname, join, basename } from "node:path";
4
4
  import { Database } from "bun:sqlite";
5
5
  import { LogFileExtension, isValidLogEvent, isValidLogFileExtension } from "../config/log-events";
6
- import { writeJobStatus } from "./status-writer";
6
+ import { writeJobStatus, flushJobStatus } from "./status-writer";
7
+ import { createJobLogger } from "./logger";
7
8
  import { executeBatch, validateBatchOptions } from "./batch-runner";
8
9
  import type { BatchOptions, BatchResult } from "./batch-runner";
9
10
 
@@ -169,34 +170,6 @@ export async function trackFile(
169
170
  });
170
171
  }
171
172
 
172
- /**
173
- * @internal Synchronous status writer for writeLogSync and getDB.
174
- * Reads tasks-status.json, applies updater, writes back as pretty-printed JSON.
175
- * Falls back to a minimal default snapshot on missing or invalid JSON.
176
- * No temp-file-rename, no async queue participation.
177
- */
178
- export function writeJobStatusSync(
179
- jobDir: string,
180
- updater: (snapshot: Record<string, unknown>) => void,
181
- ): void {
182
- const statusPath = join(jobDir, "tasks-status.json");
183
- let snapshot: Record<string, unknown>;
184
- try {
185
- const raw = readFileSync(statusPath, "utf-8");
186
- snapshot = JSON.parse(raw) as Record<string, unknown>;
187
- } catch {
188
- snapshot = {
189
- id: basename(jobDir),
190
- state: "pending",
191
- tasks: {},
192
- files: { artifacts: [], logs: [], tmp: [] },
193
- };
194
- }
195
- updater(snapshot);
196
- mkdirSync(jobDir, { recursive: true });
197
- writeFileSync(statusPath, JSON.stringify(snapshot, null, 2));
198
- }
199
-
200
173
  export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
201
174
  const { workDir, taskName, statusPath, getStage } = config;
202
175
  const trackTaskFilesEnabled = config.trackTaskFiles ?? true;
@@ -234,28 +207,8 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
234
207
  const db = new Database(dbPath);
235
208
  db.exec("PRAGMA journal_mode = WAL;");
236
209
 
237
- writeJobStatusSync(jobDir, (snapshot: Record<string, unknown>) => {
238
- const files = (snapshot["files"] ?? {}) as FilesRecord;
239
- snapshot["files"] = files;
240
- const artifactsList = (files.artifacts ?? []) as string[];
241
- files.artifacts = artifactsList;
242
- if (!artifactsList.includes("run.db")) {
243
- artifactsList.push("run.db");
244
- }
245
-
246
- if (trackTaskFilesEnabled) {
247
- const tasks = (snapshot["tasks"] ?? {}) as Record<string, TaskRecord>;
248
- snapshot["tasks"] = tasks;
249
- const task = (tasks[taskName] ?? {}) as TaskRecord;
250
- tasks[taskName] = task;
251
- const taskFiles = (task.files ?? {}) as FilesRecord;
252
- task.files = taskFiles;
253
- const taskArtifactsList = (taskFiles.artifacts ?? []) as string[];
254
- taskFiles.artifacts = taskArtifactsList;
255
- if (!taskArtifactsList.includes("run.db")) {
256
- taskArtifactsList.push("run.db");
257
- }
258
- }
210
+ void trackFile(jobDir, "artifacts", "run.db", taskName, trackTaskFilesEnabled).catch((err) => {
211
+ createJobLogger("file-io", basename(jobDir)).warn("getDB: manifest tracking failed", err);
259
212
  });
260
213
 
261
214
  return db;
@@ -314,28 +267,8 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
314
267
  renameSync(tmpPath, filePath);
315
268
  }
316
269
 
317
- writeJobStatusSync(jobDir, (snapshot: Record<string, unknown>) => {
318
- const files = (snapshot["files"] ?? {}) as FilesRecord;
319
- snapshot["files"] = files;
320
- const logsList = (files.logs ?? []) as string[];
321
- files.logs = logsList;
322
- if (!logsList.includes(name)) {
323
- logsList.push(name);
324
- }
325
-
326
- if (trackTaskFilesEnabled) {
327
- const tasks = (snapshot["tasks"] ?? {}) as Record<string, TaskRecord>;
328
- snapshot["tasks"] = tasks;
329
- const task = (tasks[taskName] ?? {}) as TaskRecord;
330
- tasks[taskName] = task;
331
- const taskFiles = (task.files ?? {}) as FilesRecord;
332
- task.files = taskFiles;
333
- const taskLogsList = (taskFiles.logs ?? []) as string[];
334
- taskFiles.logs = taskLogsList;
335
- if (!taskLogsList.includes(name)) {
336
- taskLogsList.push(name);
337
- }
338
- }
270
+ void trackFile(jobDir, "logs", name, taskName, trackTaskFilesEnabled).catch((err) => {
271
+ createJobLogger("file-io", basename(jobDir)).warn("writeLogSync: manifest tracking failed", err);
339
272
  });
340
273
  },
341
274
 
@@ -348,6 +281,7 @@ export function createTaskFileIO(config: TaskFileIOConfig): TaskFileIO {
348
281
  return await executeBatch(db, options);
349
282
  } finally {
350
283
  db.close();
284
+ await flushJobStatus(jobDir);
351
285
  }
352
286
  },
353
287
  };
@@ -252,7 +252,7 @@ function delay(ms: number): Promise<void> {
252
252
  }
253
253
 
254
254
  // mkdir with recursive:false is atomic — exactly one caller wins the create when racing.
255
- async function withRuntimeLock<T>(
255
+ export async function withRuntimeLock<T>(
256
256
  dataDir: string,
257
257
  lockTimeoutMs: number,
258
258
  fn: () => Promise<T>,
@@ -347,6 +347,11 @@ async function readLease(slotPath: string): Promise<JobSlotLease | null> {
347
347
  }
348
348
  }
349
349
 
350
+ export async function readJobSlotLease(dataDir: string, jobId: string): Promise<JobSlotLease | null> {
351
+ const { runningJobsDir } = getConcurrencyRuntimePaths(dataDir);
352
+ return readLease(join(runningJobsDir, `${jobId}.json`));
353
+ }
354
+
350
355
  async function writeLeaseAtomic(slotPath: string, lease: JobSlotLease): Promise<void> {
351
356
  const tmpPath = `${slotPath}.tmp.${randomBytes(8).toString("hex")}`;
352
357
  await writeFile(tmpPath, JSON.stringify(lease));
@@ -0,0 +1,329 @@
1
+ import {
2
+ JOB_STATUS_SYNONYMS,
3
+ VALID_JOB_STATUSES,
4
+ deriveJobStatusFromTasks,
5
+ jobStateToStatus,
6
+ normalizeJobStatus,
7
+ normalizeTaskState,
8
+ } from "../config/statuses.ts";
9
+ import type { JobStatusValue, TaskStateValue } from "../config/statuses.ts";
10
+ import type { GateInfo } from "./status-writer.ts";
11
+
12
+ export type RetryError = { message: string; name?: string; stack?: string } | string;
13
+
14
+ export interface JobViewError {
15
+ code: string;
16
+ message: string;
17
+ path?: string;
18
+ name?: string;
19
+ stack?: string;
20
+ }
21
+
22
+ export interface JobViewTask {
23
+ state: TaskStateValue;
24
+ name: string;
25
+ files: { artifacts: string[]; logs: string[]; tmp: string[] };
26
+ startedAt?: string | null;
27
+ endedAt?: string | null;
28
+ attempts?: number;
29
+ restartCount?: number;
30
+ retrying?: boolean;
31
+ nextRetryAt?: string;
32
+ lastRetryError?: RetryError | null;
33
+ executionTimeMs?: number;
34
+ refinementAttempts?: number;
35
+ stageLogPath?: string;
36
+ errorContext?: unknown;
37
+ currentStage?: string;
38
+ failedStage?: string;
39
+ artifacts?: unknown;
40
+ tokenUsage?: unknown[];
41
+ error?: { message: string; [key: string]: unknown } | null;
42
+ skipReason?: string;
43
+ skippedBy?: string;
44
+ controlApplied?: boolean;
45
+ }
46
+
47
+ export interface JobView {
48
+ id: string;
49
+ jobId: string;
50
+ name: string;
51
+ title: string;
52
+ status: JobStatusValue;
53
+ progress: number;
54
+ createdAt: string | null;
55
+ updatedAt: string | null;
56
+ lastUpdated?: string;
57
+ location: string | null;
58
+ tasks: Record<string, JobViewTask>;
59
+ files: Record<string, unknown>;
60
+ costs: Record<string, unknown>;
61
+ gate?: GateInfo | null;
62
+ pipeline?: string;
63
+ pipelineLabel?: string;
64
+ pipelineConfig?: Record<string, unknown>;
65
+ current?: unknown;
66
+ currentStage?: unknown;
67
+ warnings?: string[];
68
+ readable?: boolean;
69
+ error?: JobViewError;
70
+ errorContext?: unknown;
71
+ }
72
+
73
+ interface TaskFilesView {
74
+ artifacts: string[];
75
+ logs: string[];
76
+ tmp: string[];
77
+ }
78
+
79
+ const EMPTY_TASK_FILES: TaskFilesView = { artifacts: [], logs: [], tmp: [] };
80
+ const DEFAULT_JOB_ERROR_CODE = "JOB_ERROR";
81
+
82
+ const warnedFallbackKeys = new Set<string>();
83
+
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null && !Array.isArray(value);
86
+ }
87
+
88
+ function isRecognizedStatusValue(value: unknown): value is string {
89
+ if (typeof value !== "string") return false;
90
+ const normalized = value.toLowerCase().trim();
91
+ if (Object.prototype.hasOwnProperty.call(JOB_STATUS_SYNONYMS, normalized)) return true;
92
+ return VALID_JOB_STATUSES.has(normalized);
93
+ }
94
+
95
+ function toStringOrNull(value: unknown): string | null {
96
+ return typeof value === "string" ? value : null;
97
+ }
98
+
99
+ function toStringArray(value: unknown): string[] {
100
+ if (!Array.isArray(value)) return [];
101
+ return value.filter((entry): entry is string => typeof entry === "string");
102
+ }
103
+
104
+ function toFiniteNumber(value: unknown): number | undefined {
105
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
106
+ }
107
+
108
+ function toTaskFiles(value: unknown): TaskFilesView {
109
+ if (!isRecord(value)) return { ...EMPTY_TASK_FILES };
110
+ return {
111
+ artifacts: toStringArray(value["artifacts"]),
112
+ logs: toStringArray(value["logs"]),
113
+ tmp: toStringArray(value["tmp"]),
114
+ };
115
+ }
116
+
117
+ function toTaskError(value: unknown): JobViewTask["error"] {
118
+ if (value === null) return null;
119
+ if (typeof value === "string") return { message: value };
120
+ if (isRecord(value)) return value as { message: string; [key: string]: unknown };
121
+ return undefined;
122
+ }
123
+
124
+ function toGateInfo(value: unknown): GateInfo | null | undefined {
125
+ if (value === null) return null;
126
+ if (!isRecord(value)) return undefined;
127
+ const rawArtifacts = value["artifacts"];
128
+ const artifacts = Array.isArray(rawArtifacts)
129
+ ? rawArtifacts.filter((entry): entry is string => typeof entry === "string")
130
+ : undefined;
131
+ return {
132
+ afterTask: typeof value["afterTask"] === "string" ? value["afterTask"] : "",
133
+ message: typeof value["message"] === "string" ? value["message"] : "",
134
+ requestedAt: typeof value["requestedAt"] === "string" ? value["requestedAt"] : "",
135
+ ...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
136
+ };
137
+ }
138
+
139
+ function toJobViewTask(name: string, value: unknown): JobViewTask {
140
+ const task = isRecord(value) ? value : {};
141
+ return {
142
+ name,
143
+ state: normalizeTaskState(task["state"]),
144
+ files: toTaskFiles(task["files"]),
145
+ startedAt: toStringOrNull(task["startedAt"]),
146
+ endedAt: toStringOrNull(task["endedAt"]),
147
+ attempts: typeof task["attempts"] === "number" ? task["attempts"] : undefined,
148
+ restartCount: typeof task["restartCount"] === "number" ? task["restartCount"] : undefined,
149
+ retrying: typeof task["retrying"] === "boolean" ? task["retrying"] : undefined,
150
+ nextRetryAt: typeof task["nextRetryAt"] === "string" ? task["nextRetryAt"] : undefined,
151
+ lastRetryError: Object.hasOwn(task, "lastRetryError")
152
+ ? (task["lastRetryError"] as RetryError | null)
153
+ : undefined,
154
+ executionTimeMs: typeof task["executionTimeMs"] === "number"
155
+ ? task["executionTimeMs"]
156
+ : undefined,
157
+ refinementAttempts: typeof task["refinementAttempts"] === "number"
158
+ ? task["refinementAttempts"]
159
+ : undefined,
160
+ stageLogPath: typeof task["stageLogPath"] === "string" ? task["stageLogPath"] : undefined,
161
+ errorContext: task["errorContext"],
162
+ currentStage: typeof task["currentStage"] === "string" ? task["currentStage"] : undefined,
163
+ failedStage: typeof task["failedStage"] === "string" ? task["failedStage"] : undefined,
164
+ artifacts: task["artifacts"],
165
+ tokenUsage: Array.isArray(task["tokenUsage"]) ? task["tokenUsage"] : undefined,
166
+ error: toTaskError(task["error"]),
167
+ skipReason: typeof task["skipReason"] === "string" ? task["skipReason"] : undefined,
168
+ skippedBy: typeof task["skippedBy"] === "string" ? task["skippedBy"] : undefined,
169
+ controlApplied: typeof task["controlApplied"] === "boolean" ? task["controlApplied"] : undefined,
170
+ };
171
+ }
172
+
173
+ function toJobViewTasks(value: unknown): Record<string, JobViewTask> {
174
+ if (Array.isArray(value)) {
175
+ return Object.fromEntries(
176
+ value.map((entry, index) => {
177
+ const record = isRecord(entry) ? entry : {};
178
+ const explicitName = record["name"];
179
+ const name = typeof explicitName === "string" && explicitName.length > 0
180
+ ? explicitName
181
+ : `task-${index}`;
182
+ return [name, toJobViewTask(name, record)] as const;
183
+ }),
184
+ );
185
+ }
186
+ if (isRecord(value)) {
187
+ return Object.fromEntries(
188
+ Object.entries(value).map(([name, entry]) => [name, toJobViewTask(name, entry)] as const),
189
+ );
190
+ }
191
+ return {};
192
+ }
193
+
194
+ function getProgress(rawProgress: unknown, tasks: JobViewTask[]): number {
195
+ const explicitProgress = toFiniteNumber(rawProgress);
196
+ if (explicitProgress !== undefined) return explicitProgress;
197
+ if (tasks.length === 0) return 0;
198
+
199
+ const completed = tasks.filter((task) => task.state === "done" || task.state === "skipped").length;
200
+ return Math.floor((completed / tasks.length) * 100);
201
+ }
202
+
203
+ function getRawTaskEntries(value: unknown): unknown[] {
204
+ if (Array.isArray(value)) return value;
205
+ if (isRecord(value)) return Object.values(value);
206
+ return [];
207
+ }
208
+
209
+ function getCosts(record: Record<string, unknown>): Record<string, unknown> {
210
+ const explicitCosts = record["costs"];
211
+ if (isRecord(explicitCosts)) return explicitCosts;
212
+
213
+ let totalCost = 0;
214
+ let totalInputTokens = 0;
215
+ let totalOutputTokens = 0;
216
+
217
+ for (const value of getRawTaskEntries(record["tasks"])) {
218
+ if (!isRecord(value)) continue;
219
+ const usage = value["tokenUsage"];
220
+ if (!Array.isArray(usage)) continue;
221
+
222
+ for (const entry of usage) {
223
+ if (!Array.isArray(entry)) continue;
224
+ totalInputTokens += toFiniteNumber(entry[1]) ?? 0;
225
+ totalOutputTokens += toFiniteNumber(entry[2]) ?? 0;
226
+ totalCost += toFiniteNumber(entry[3]) ?? 0;
227
+ }
228
+ }
229
+
230
+ const totalTokens = totalInputTokens + totalOutputTokens;
231
+ if (totalTokens === 0 && totalCost === 0) return {};
232
+ return { totalCost, totalTokens, totalInputTokens, totalOutputTokens };
233
+ }
234
+
235
+ function resolveStateStatus(
236
+ fromState: JobStatusValue,
237
+ tasks: JobViewTask[],
238
+ location: string,
239
+ ): JobStatusValue {
240
+ if (fromState !== "complete" || location === "complete" || tasks.length === 0) {
241
+ return fromState;
242
+ }
243
+
244
+ const taskStatus = deriveJobStatusFromTasks(tasks);
245
+ return taskStatus === "complete" ? fromState : taskStatus;
246
+ }
247
+
248
+ function toTitle(record: Record<string, unknown>, fallback: string): string {
249
+ const candidate = record["title"] ?? record["name"];
250
+ return typeof candidate === "string" && candidate.length > 0 ? candidate : fallback;
251
+ }
252
+
253
+ function toErrorView(value: unknown): JobViewError | undefined {
254
+ if (!isRecord(value)) return undefined;
255
+ if (typeof value["message"] !== "string") return undefined;
256
+ const code = typeof value["code"] === "string"
257
+ ? value["code"]
258
+ : typeof value["name"] === "string"
259
+ ? value["name"]
260
+ : DEFAULT_JOB_ERROR_CODE;
261
+ const result: JobViewError = { code, message: value["message"] };
262
+ if (typeof value["path"] === "string") result.path = value["path"];
263
+ if (typeof value["name"] === "string") result.name = value["name"];
264
+ if (typeof value["stack"] === "string") result.stack = value["stack"];
265
+ return result;
266
+ }
267
+
268
+ export function normalizeJobView(raw: unknown, jobId: string, location: string): JobView {
269
+ const record = isRecord(raw) ? raw : {};
270
+
271
+ const tasks = toJobViewTasks(record["tasks"]);
272
+ const taskList = Object.values(tasks);
273
+
274
+ const fromState = jobStateToStatus(record["state"]);
275
+ let status: JobStatusValue;
276
+ if (fromState !== null) {
277
+ status = resolveStateStatus(fromState, taskList, location);
278
+ } else if (isRecognizedStatusValue(record["status"])) {
279
+ status = normalizeJobStatus(record["status"]);
280
+ } else {
281
+ const warnKey = `${location}:${jobId}`;
282
+ if (!warnedFallbackKeys.has(warnKey)) {
283
+ warnedFallbackKeys.add(warnKey);
284
+ console.warn(
285
+ `normalizeJobView: no valid state or status for ${warnKey}; deriving job status from tasks.`,
286
+ );
287
+ }
288
+ status = deriveJobStatusFromTasks(taskList);
289
+ }
290
+
291
+ const title = toTitle(record, jobId);
292
+ const lastUpdated = toStringOrNull(record["lastUpdated"]);
293
+
294
+ const result: JobView = {
295
+ id: jobId,
296
+ jobId,
297
+ name: title,
298
+ title,
299
+ status,
300
+ progress: getProgress(record["progress"], taskList),
301
+ createdAt: toStringOrNull(record["createdAt"]),
302
+ updatedAt: toStringOrNull(record["updatedAt"]) ?? lastUpdated,
303
+ location,
304
+ tasks,
305
+ files: isRecord(record["files"]) ? record["files"] : {},
306
+ costs: getCosts(record),
307
+ };
308
+
309
+ if (lastUpdated !== null) result.lastUpdated = lastUpdated;
310
+ if ("gate" in record) result.gate = toGateInfo(record["gate"]);
311
+ if (typeof record["pipeline"] === "string") result.pipeline = record["pipeline"];
312
+ if (typeof record["pipelineLabel"] === "string") result.pipelineLabel = record["pipelineLabel"];
313
+ if (isRecord(record["pipelineConfig"])) result.pipelineConfig = record["pipelineConfig"];
314
+ if ("current" in record) result.current = record["current"];
315
+ if ("currentStage" in record) result.currentStage = record["currentStage"];
316
+ if (Array.isArray(record["warnings"])) {
317
+ result.warnings = record["warnings"].filter((entry): entry is string => typeof entry === "string");
318
+ }
319
+ if (typeof record["readable"] === "boolean") result.readable = record["readable"];
320
+ const errorView = toErrorView(record["error"]);
321
+ if (errorView) result.error = errorView;
322
+ if ("errorContext" in record) result.errorContext = record["errorContext"];
323
+
324
+ return result;
325
+ }
326
+
327
+ export function __resetWarnedFallbackKeys(): void {
328
+ warnedFallbackKeys.clear();
329
+ }
@@ -98,6 +98,20 @@ interface SpawnedRunner {
98
98
  kill?: () => void;
99
99
  }
100
100
 
101
+ function describeCleanupError(err: unknown): unknown {
102
+ if (!(err instanceof AggregateError)) return err;
103
+ return {
104
+ name: err.name,
105
+ message: err.message,
106
+ errors: err.errors.map((cause) => {
107
+ if (cause instanceof Error) {
108
+ return { name: cause.name, message: cause.message, stack: cause.stack };
109
+ }
110
+ return { message: String(cause) };
111
+ }),
112
+ };
113
+ }
114
+
101
115
  /** Seed filename regex. Captures jobId from {jobId}-seed.json. */
102
116
  export const SEED_PATTERN = /^([A-Za-z0-9-_]+)-seed\.json$/;
103
117
 
@@ -120,6 +134,7 @@ import {
120
134
  tryAcquireJobSlot,
121
135
  updateJobSlotPid,
122
136
  } from "./job-concurrency";
137
+ import { reconcileInterruptedJob, reconcileOrphanedCurrentJobs } from "./runner-liveness";
123
138
 
124
139
  /**
125
140
  * Normalize any path that may already include `pipeline-data` (or subdirs
@@ -184,7 +199,7 @@ export async function spawnRunner(
184
199
  running: Map<string, ChildHandle>,
185
200
  logger: ReturnType<typeof createLogger>,
186
201
  spawnFn: SpawnFn,
187
- onExit?: (jobId: string) => void | Promise<void>,
202
+ onExit?: (jobId: string, result: ChildExitResult) => void | Promise<void>,
188
203
  ): Promise<void> {
189
204
  if (!seed.pipeline) {
190
205
  throw new Error(`seed.pipeline is required for job ${jobId}`);
@@ -236,9 +251,9 @@ export async function spawnRunner(
236
251
  });
237
252
  if (!onExit) return;
238
253
  try {
239
- await onExit(jobId);
254
+ await onExit(jobId, result);
240
255
  } catch (err) {
241
- logger.error(`child exit cleanup failed for job ${jobId}`, err);
256
+ logger.error(`child exit cleanup failed for job ${jobId}`, describeCleanupError(err));
242
257
  }
243
258
  })
244
259
  .catch((err) => {
@@ -251,11 +266,46 @@ export interface HandleChildExitOptions {
251
266
  jobId: string;
252
267
  triggerDrain: () => void;
253
268
  lockTimeoutMs?: number;
269
+ code?: number | null;
270
+ signal?: string | null;
271
+ releaseSlot?: (dataDir: string, jobId: string, lockTimeoutMs?: number) => Promise<void>;
272
+ reconcileJob?: typeof reconcileInterruptedJob;
254
273
  }
255
274
 
256
275
  export async function handleChildExit(opts: HandleChildExitOptions): Promise<void> {
276
+ const lockTimeoutMs = opts.lockTimeoutMs ?? getOrchestratorConfig().lockFileTimeout;
277
+ const releaseSlot = opts.releaseSlot ?? releaseJobSlot;
278
+ const reconcileJob = opts.reconcileJob ?? reconcileInterruptedJob;
257
279
  try {
258
- await releaseJobSlot(opts.dataDir, opts.jobId, opts.lockTimeoutMs);
280
+ let releaseError: unknown;
281
+ let reconcileError: unknown;
282
+
283
+ try {
284
+ await releaseSlot(opts.dataDir, opts.jobId, lockTimeoutMs);
285
+ } catch (err) {
286
+ releaseError = err;
287
+ }
288
+
289
+ const jobDir = join(opts.dataDir, "current", opts.jobId);
290
+ try {
291
+ await reconcileJob({
292
+ dataDir: opts.dataDir,
293
+ jobId: opts.jobId,
294
+ jobDir,
295
+ source: "child-exit",
296
+ code: opts.code,
297
+ signal: opts.signal,
298
+ lockTimeoutMs,
299
+ });
300
+ } catch (err) {
301
+ reconcileError = err;
302
+ }
303
+
304
+ if (releaseError && reconcileError) {
305
+ throw new AggregateError([releaseError, reconcileError], "child exit cleanup failed");
306
+ }
307
+ if (releaseError) throw releaseError;
308
+ if (reconcileError) throw reconcileError;
259
309
  } finally {
260
310
  opts.triggerDrain();
261
311
  }
@@ -685,6 +735,21 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
685
735
  .then(() => mkdir(dirs.rejected, { recursive: true }))
686
736
  .then(() => mkdir(dirs.staging, { recursive: true }))
687
737
  .then(() => harnessPreflight(logger))
738
+ .then(async () => {
739
+ try {
740
+ const summary = await reconcileOrphanedCurrentJobs({
741
+ dataDir: dirs.dataDir,
742
+ currentDir: dirs.current,
743
+ lockTimeoutMs,
744
+ staleAfterMs: getOrchestratorConfig().staleRunningGraceMs,
745
+ });
746
+ logger.log("boot reconciliation complete", summary);
747
+ } catch (err) {
748
+ logger.warn(
749
+ `boot reconciliation failed; continuing: ${err instanceof Error ? err.message : String(err)}`,
750
+ );
751
+ }
752
+ })
688
753
  .then(() => new Promise<OrchestratorHandle>((resolve, reject) => {
689
754
  const running = new Map<string, ChildHandle>();
690
755
  const spawnFn = opts.spawn ?? createDefaultSpawn();
@@ -721,9 +786,16 @@ export function startOrchestrator(opts: OrchestratorOptions): Promise<Orchestrat
721
786
  })();
722
787
  };
723
788
 
724
- const onChildExit = (jobId: string): Promise<void> => {
789
+ const onChildExit = (jobId: string, result: ChildExitResult): Promise<void> => {
725
790
  if (stopped) return Promise.resolve();
726
- return handleChildExit({ dataDir: dirs.dataDir, jobId, triggerDrain, lockTimeoutMs });
791
+ return handleChildExit({
792
+ dataDir: dirs.dataDir,
793
+ jobId,
794
+ triggerDrain,
795
+ lockTimeoutMs,
796
+ code: result.code,
797
+ signal: result.signal,
798
+ });
727
799
  };
728
800
 
729
801
  const spawnRunnerForJob = async (jobId: string, seed: SeedData): Promise<SpawnedRunner> => {
@@ -5,7 +5,7 @@ import { unlinkSync } from "node:fs";
5
5
  import { getConfig, getPipelineConfig } from "./config";
6
6
  import { validatePipelineOrThrow } from "./validation";
7
7
  import { loadFreshModule } from "./module-loader";
8
- import { atomicWrite, writeJobStatus, type GateInfo } from "./status-writer";
8
+ import { atomicWrite, writeJobStatus, flushJobStatus, StatusCorruptError, type GateInfo } from "./status-writer";
9
9
  import { decideTransition } from "./lifecycle-policy";
10
10
  import { runPipeline } from "./task-runner";
11
11
  import type { AuditLogEntry, PipelineResult } from "./task-runner";
@@ -672,8 +672,14 @@ export async function runPipelineJob(jobId: string): Promise<void> {
672
672
  while (true) {
673
673
  const pipeline = await loadPipeline(config.pipelineJsonPath);
674
674
  const pipelineTasks = normalizePipelineTasks(pipeline);
675
- const statusText = await Bun.file(config.statusPath).text();
676
- const status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
675
+ let status: { tasks: Record<string, { state?: string }> };
676
+ try {
677
+ const statusText = await Bun.file(config.statusPath).text();
678
+ status = JSON.parse(statusText) as { tasks: Record<string, { state?: string }> };
679
+ } catch (parseErr: unknown) {
680
+ if (parseErr instanceof SyntaxError) throw new StatusCorruptError(config.statusPath, { cause: parseErr });
681
+ throw parseErr;
682
+ }
677
683
 
678
684
  let selectedEntry: PipelineTaskEntry | null = null;
679
685
  let selectedTaskState: TaskStateValue = TaskState.PENDING;
@@ -1092,6 +1098,49 @@ export async function runPipelineJob(jobId: string): Promise<void> {
1092
1098
  }
1093
1099
  await releaseJobSlotBestEffort(dataDir, jobId);
1094
1100
  } catch (err) {
1101
+ if (err instanceof StatusCorruptError) {
1102
+ console.error(`Status corruption detected for job "${jobId}": ${err.statusPath} — refusing to overwrite`);
1103
+ if (workDir !== undefined) {
1104
+ try {
1105
+ const failureIO = createTaskFileIO({
1106
+ workDir,
1107
+ taskName: "orchestrator",
1108
+ trackTaskFiles: false,
1109
+ getStage: () => "runPipelineJob",
1110
+ statusPath: join(workDir, "tasks-status.json"),
1111
+ });
1112
+ const failureLogName = generateLogName(
1113
+ "orchestrator",
1114
+ "runPipelineJob",
1115
+ LogEvent.FAILURE_DETAILS,
1116
+ LogFileExtension.JSON,
1117
+ );
1118
+ await failureIO.writeLog(
1119
+ failureLogName,
1120
+ JSON.stringify({
1121
+ type: "status_corrupt",
1122
+ jobId,
1123
+ statusPath: err.statusPath,
1124
+ message: err.message,
1125
+ }, null, 2),
1126
+ );
1127
+ } catch {
1128
+ // Do not mask the original error if log-write fails
1129
+ }
1130
+ try {
1131
+ await cleanupPidFile(workDir);
1132
+ } catch {
1133
+ // Do not mask the original failure if PID cleanup fails
1134
+ }
1135
+ }
1136
+ if (dataDir !== undefined) {
1137
+ await releaseJobSlotBestEffort(dataDir, jobId);
1138
+ }
1139
+ process.exitCode = 1;
1140
+ setTimeout(() => process.exit(1), 5000).unref();
1141
+ process.exit(1);
1142
+ return;
1143
+ }
1095
1144
  const normalized = normalizeError(err);
1096
1145
  console.error(normalized.message);
1097
1146
  if (workDir !== undefined) {
@@ -1171,6 +1220,8 @@ export async function completeJob(
1171
1220
  // Create complete/ directory if needed
1172
1221
  await mkdir(config.completeDir, { recursive: true });
1173
1222
 
1223
+ await flushJobStatus(config.workDir);
1224
+
1174
1225
  // Move job directory from current/ to complete/
1175
1226
  await rename(config.workDir, destDir);
1176
1227