@ryanfw/prompt-orchestration-pipeline 1.3.1 → 1.3.2

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 (31) hide show
  1. package/docs/pop-task-guide.md +1 -0
  2. package/package.json +1 -2
  3. package/src/core/__tests__/agent-step.test.ts +322 -3
  4. package/src/core/__tests__/task-runner.test.ts +57 -1
  5. package/src/core/agent-step.ts +231 -14
  6. package/src/core/agent-types.ts +3 -0
  7. package/src/core/file-io.ts +8 -74
  8. package/src/core/pipeline-runner.ts +54 -3
  9. package/src/core/status-writer.ts +44 -26
  10. package/src/core/task-runner.ts +27 -3
  11. package/src/harness/__tests__/discovery.test.ts +60 -8
  12. package/src/harness/discovery.ts +16 -6
  13. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  14. package/src/ui/dist/assets/{index-CbS3OsW7.js → index--RH3sAt3.js} +14 -1
  15. package/src/ui/dist/assets/{index-CbS3OsW7.js.map → index--RH3sAt3.js.map} +1 -1
  16. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  17. package/src/ui/dist/index.html +2 -2
  18. package/src/ui/embedded-assets.js +6 -6
  19. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  20. package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
  21. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  22. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  23. package/src/ui/server/config-bridge.ts +1 -0
  24. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  25. package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
  26. package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
  27. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  28. package/src/ui/server/utils/http-utils.ts +6 -1
  29. package/src/ui/server/utils/path-containment.ts +18 -0
  30. package/src/ui/server/utils/status-corruption.ts +27 -0
  31. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -1,15 +1,33 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync } from "node:fs";
3
- import { mkdir } from "node:fs/promises";
2
+ import { chmodSync, existsSync } from "node:fs";
3
+ import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { homedir, tmpdir } from "node:os";
5
+ import { join } from "node:path";
4
6
  import { createTaskFileIO, generateLogName } from "./file-io.ts";
5
7
  import { LogEvent, LogFileExtension } from "../config/log-events.ts";
6
8
  import { run } from "../harness/index.ts";
7
9
  import { startMcpIoServer } from "../harness/mcp-io-server.ts";
8
10
  import type { McpIoServerHandle } from "../harness/mcp-io-server.ts";
9
11
  import type { AgentEntryConfig, AgentStepResult } from "./agent-types.ts";
10
- import type { RunOptions, HarnessRun, HarnessEvent } from "../harness/index.ts";
12
+ import type {
13
+ RunOptions,
14
+ HarnessRun,
15
+ HarnessEvent,
16
+ HarnessName,
17
+ McpServerConfig,
18
+ RunResult,
19
+ } from "../harness/index.ts";
11
20
  import type { TaskFileIO } from "./file-io.ts";
12
21
 
22
+ type EnvSnapshot = Record<string, string | undefined>;
23
+ let compatibilityEnvQueue: Promise<void> = Promise.resolve();
24
+
25
+ interface HarnessCompatibility {
26
+ mcpServers?: McpServerConfig[];
27
+ env?: Record<string, string>;
28
+ cleanup?: () => Promise<void>;
29
+ }
30
+
13
31
  function gitSync(args: string[], cwd: string): { exitCode: number; stdout: string; stderr: string } {
14
32
  const result = Bun.spawnSync(["git", ...args], {
15
33
  cwd,
@@ -62,6 +80,200 @@ async function captureDiff(io: TaskFileIO, cwd: string): Promise<boolean> {
62
80
  }
63
81
  }
64
82
 
83
+ function asRecord(value: unknown): Record<string, unknown> | null {
84
+ return typeof value === "object" && value !== null ? value as Record<string, unknown> : null;
85
+ }
86
+
87
+ function expandHome(path: string): string {
88
+ return path.startsWith("~/") ? join(process.env.HOME ?? "", path.slice(2)) : path;
89
+ }
90
+
91
+ function findExecutable(name: string, candidateDirs: string[]): string | null {
92
+ const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean);
93
+ for (const dir of [...pathEntries, ...candidateDirs.map(expandHome)]) {
94
+ const candidate = join(dir, name);
95
+ if (existsSync(candidate)) return candidate;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function existingBinOverride(harness: HarnessName): string | undefined {
101
+ const upper = harness.toUpperCase();
102
+ return process.env[`LLM_ADAPTER_${upper}_BIN`] ?? process.env[`POP_${upper}_BIN`];
103
+ }
104
+
105
+ function snapshotEnv(keys: string[]): EnvSnapshot {
106
+ return Object.fromEntries(keys.map((key) => [key, process.env[key]]));
107
+ }
108
+
109
+ function applyEnv(values: Record<string, string>): EnvSnapshot {
110
+ const previous = snapshotEnv(Object.keys(values));
111
+ for (const [key, value] of Object.entries(values)) {
112
+ process.env[key] = value;
113
+ }
114
+ return previous;
115
+ }
116
+
117
+ function restoreEnv(previous: EnvSnapshot): void {
118
+ for (const [key, value] of Object.entries(previous)) {
119
+ if (value === undefined) {
120
+ delete process.env[key];
121
+ } else {
122
+ process.env[key] = value;
123
+ }
124
+ }
125
+ }
126
+
127
+ async function withCompatibilityEnv<T>(
128
+ compatibility: HarnessCompatibility,
129
+ runWithEnv: () => Promise<T>,
130
+ ): Promise<T> {
131
+ if (!compatibility.env) {
132
+ try {
133
+ return await runWithEnv();
134
+ } finally {
135
+ await compatibility.cleanup?.();
136
+ }
137
+ }
138
+
139
+ const previousTurn = compatibilityEnvQueue;
140
+ let releaseTurn!: () => void;
141
+ compatibilityEnvQueue = new Promise<void>((resolve) => {
142
+ releaseTurn = resolve;
143
+ });
144
+ await previousTurn;
145
+
146
+ const previousEnv = applyEnv(compatibility.env);
147
+ try {
148
+ return await runWithEnv();
149
+ } finally {
150
+ restoreEnv(previousEnv);
151
+ try {
152
+ await compatibility.cleanup?.();
153
+ } finally {
154
+ releaseTurn();
155
+ }
156
+ }
157
+ }
158
+
159
+ function defaultOpencodeConfigDir(): string {
160
+ return join(process.env.HOME ?? homedir(), ".config", "opencode");
161
+ }
162
+
163
+ function opencodeConfigDir(): string {
164
+ return process.env.OPENCODE_CONFIG_DIR ?? defaultOpencodeConfigDir();
165
+ }
166
+
167
+ async function readJsonObject(path: string): Promise<Record<string, unknown>> {
168
+ if (!existsSync(path)) return {};
169
+ const parsed = JSON.parse(await readFile(path, "utf8")) as unknown;
170
+ return asRecord(parsed) ?? {};
171
+ }
172
+
173
+ function codexConfigValue(key: string, value: string): string {
174
+ return `${key}=${JSON.stringify(value)}`;
175
+ }
176
+
177
+ async function prepareHarnessCompatibility(
178
+ harness: HarnessName,
179
+ mcpHandle: McpIoServerHandle | undefined,
180
+ ): Promise<HarnessCompatibility> {
181
+ if (!mcpHandle) return {};
182
+
183
+ const mcpServer: McpServerConfig = {
184
+ name: "popio",
185
+ url: mcpHandle.connection.url,
186
+ headers: { Authorization: `Bearer ${mcpHandle.connection.token}` },
187
+ };
188
+
189
+ if (harness === "claude") {
190
+ return { mcpServers: [mcpServer] };
191
+ }
192
+
193
+ if (harness === "opencode") {
194
+ const configDir = await mkdtemp(join(tmpdir(), "opencode-mcp-"));
195
+ const sourceConfigDir = opencodeConfigDir();
196
+ if (existsSync(sourceConfigDir)) {
197
+ await cp(sourceConfigDir, configDir, { recursive: true, force: true });
198
+ }
199
+ const configPath = join(configDir, "opencode.json");
200
+ const existingConfig = await readJsonObject(configPath);
201
+ const existingMcp = asRecord(existingConfig["mcp"]) ?? {};
202
+ await writeFile(
203
+ configPath,
204
+ JSON.stringify({
205
+ ...existingConfig,
206
+ mcp: {
207
+ ...existingMcp,
208
+ popio: {
209
+ type: "remote",
210
+ url: mcpHandle.connection.url,
211
+ headers: { Authorization: `Bearer ${mcpHandle.connection.token}` },
212
+ enabled: true,
213
+ },
214
+ },
215
+ }, null, 2),
216
+ );
217
+ return {
218
+ env: { OPENCODE_CONFIG_DIR: configDir },
219
+ cleanup: async () => {
220
+ await rm(configDir, { recursive: true, force: true });
221
+ },
222
+ };
223
+ }
224
+
225
+ if (harness === "codex") {
226
+ const wrapperDir = await mkdtemp(join(tmpdir(), "codex-mcp-"));
227
+ const wrapperPath = join(wrapperDir, "codex");
228
+ const realBin =
229
+ existingBinOverride("codex") ??
230
+ findExecutable("codex", ["/opt/homebrew/bin", "~/.local/bin"]) ??
231
+ "codex";
232
+ await writeFile(
233
+ wrapperPath,
234
+ [
235
+ "#!/bin/sh",
236
+ 'exec "$POP_REAL_CODEX_BIN" "$@" \\',
237
+ ' -c "$POP_MCP_URL_CONFIG" \\',
238
+ ' -c \'mcp_servers.popio.bearer_token_env_var="POP_MCP_TOKEN"\'',
239
+ "",
240
+ ].join("\n"),
241
+ );
242
+ chmodSync(wrapperPath, 0o700);
243
+ return {
244
+ env: {
245
+ LLM_ADAPTER_CODEX_BIN: wrapperPath,
246
+ POP_REAL_CODEX_BIN: realBin,
247
+ POP_MCP_URL_CONFIG: codexConfigValue("mcp_servers.popio.url", mcpHandle.connection.url),
248
+ POP_MCP_TOKEN: mcpHandle.connection.token,
249
+ },
250
+ cleanup: async () => {
251
+ await rm(wrapperDir, { recursive: true, force: true });
252
+ },
253
+ };
254
+ }
255
+
256
+ return {};
257
+ }
258
+
259
+ function costFromRaw(raw: unknown): number | undefined {
260
+ const record = asRecord(raw);
261
+ if (!record) return undefined;
262
+ if (typeof record["total_cost_usd"] === "number") return record["total_cost_usd"];
263
+ if (typeof record["costUsd"] === "number") return record["costUsd"];
264
+ const info = asRecord(record["info"]);
265
+ if (typeof info?.["costUsd"] === "number") return info["costUsd"];
266
+ return undefined;
267
+ }
268
+
269
+ function extractCostUsd(events: HarnessEvent[]): number | undefined {
270
+ for (let i = events.length - 1; i >= 0; i--) {
271
+ const cost = costFromRaw(events[i]!.raw);
272
+ if (cost !== undefined) return cost;
273
+ }
274
+ return undefined;
275
+ }
276
+
65
277
  /** Dependency seam shared by the agent runners (injected in tests). */
66
278
  interface AgentRunDeps {
67
279
  run?: typeof run;
@@ -86,6 +298,8 @@ export async function executeAgent(
86
298
  const _run = deps?.run ?? run;
87
299
  const _startMcpIoServer = deps?.startMcpIoServer ?? startMcpIoServer;
88
300
  const { io, entry } = args;
301
+ const observedEvents: HarnessEvent[] = [];
302
+ let completedResult: RunResult | undefined;
89
303
 
90
304
  const cwd = entry.cwd ?? io.getTaskDir();
91
305
  // The harness spawns with this cwd before any artifact is written, so the task
@@ -115,30 +329,30 @@ export async function executeAgent(
115
329
  LogEvent.DEBUG,
116
330
  LogFileExtension.TEXT,
117
331
  );
118
-
332
+ const compatibility = await prepareHarnessCompatibility(entry.harness, mcpHandle);
119
333
  const options: RunOptions = {
120
334
  harness: entry.harness,
121
335
  prompt,
122
336
  cwd,
123
337
  model: entry.model,
124
- mcpServers: mcpHandle
125
- ? [{
126
- name: "popio",
127
- url: mcpHandle.connection.url,
128
- headers: { Authorization: `Bearer ${mcpHandle.connection.token}` },
129
- }]
130
- : undefined,
338
+ ...(compatibility.mcpServers ? { mcpServers: compatibility.mcpServers } : {}),
131
339
  timeoutMs: entry.timeoutMs,
340
+ ...(entry.idleTimeoutMs !== undefined ? { idleTimeoutMs: entry.idleTimeoutMs } : {}),
132
341
  permissionMode: "bypass",
133
342
  onEvent: (event: HarnessEvent) => {
343
+ observedEvents.push(event);
134
344
  void io.writeLog(logName, JSON.stringify(event.raw) + "\n", {
135
345
  mode: "append",
136
346
  });
137
347
  },
138
348
  };
139
349
 
140
- const harnessRun: HarnessRun = _run(options);
141
- const result = await harnessRun.result;
350
+ const result = await withCompatibilityEnv(compatibility, async () => {
351
+ const harnessRun: HarnessRun = _run(options);
352
+ const harnessResult = await harnessRun.result;
353
+ completedResult = harnessResult;
354
+ return harnessResult;
355
+ });
142
356
 
143
357
  await io.writeArtifact("agent-result.md", result.finalMessage);
144
358
 
@@ -157,7 +371,7 @@ export async function executeAgent(
157
371
  finalMessage: result.finalMessage,
158
372
  artifactsWritten: allArtifacts,
159
373
  usage: result.usage,
160
- costUsd: result.costUsd,
374
+ costUsd: result.costUsd ?? extractCostUsd(observedEvents),
161
375
  sessionId: result.sessionId,
162
376
  };
163
377
  } catch (err) {
@@ -170,6 +384,9 @@ export async function executeAgent(
170
384
  ok: false,
171
385
  finalMessage: "",
172
386
  artifactsWritten: allArtifacts,
387
+ usage: completedResult?.usage,
388
+ costUsd: completedResult?.costUsd ?? extractCostUsd(observedEvents),
389
+ sessionId: completedResult?.sessionId,
173
390
  error: err instanceof Error ? err.message : String(err),
174
391
  };
175
392
  } finally {
@@ -13,6 +13,7 @@ export interface AgentEntryConfig {
13
13
  cwd?: string;
14
14
  io?: boolean;
15
15
  timeoutMs?: number;
16
+ idleTimeoutMs?: number;
16
17
  captureDiff?: boolean;
17
18
  }
18
19
 
@@ -48,6 +49,8 @@ export interface TaskAgentOptions {
48
49
  io?: boolean;
49
50
  /** Overall wall-clock cap in milliseconds. */
50
51
  timeoutMs?: number;
52
+ /** No-output cap in milliseconds. Defaults to the adapter's idle timeout. */
53
+ idleTimeoutMs?: number;
51
54
  /** Capture a git diff of the working tree as an `agent.patch` artifact. */
52
55
  captureDiff?: boolean;
53
56
  }
@@ -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
  };
@@ -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
 
@@ -1,5 +1,5 @@
1
- import { rename, unlink, mkdir } from "node:fs/promises";
2
- import { basename, join } from "node:path";
1
+ import { rename, unlink, mkdir, open } from "node:fs/promises";
2
+ import { basename, dirname, join } from "node:path";
3
3
  import { createJobLogger } from "./logger";
4
4
  import type { NormalizedError } from "./pipeline-runner";
5
5
 
@@ -71,6 +71,19 @@ export interface UploadArtifact {
71
71
  content: string;
72
72
  }
73
73
 
74
+ export class StatusCorruptError extends Error {
75
+ readonly statusPath: string;
76
+ constructor(statusPath: string, options?: { cause?: unknown }) {
77
+ super(
78
+ `tasks-status.json is corrupt (unparseable) at ${statusPath}; refusing to overwrite — ` +
79
+ `repair the file or delete it to let a fresh status be created`,
80
+ options,
81
+ );
82
+ this.name = "StatusCorruptError";
83
+ this.statusPath = statusPath;
84
+ }
85
+ }
86
+
74
87
  export const STATUS_FILENAME = "tasks-status.json";
75
88
 
76
89
  function clearResetTaskMetadata(task: TaskEntry): void {
@@ -150,13 +163,40 @@ export async function atomicWrite(filePath: string, content: string): Promise<vo
150
163
  const tmp = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
151
164
  try {
152
165
  await Bun.write(tmp, content);
166
+ const fh = await open(tmp, "r+");
167
+ try { await fh.sync(); } finally { await fh.close(); }
153
168
  await rename(tmp, filePath);
169
+ const dirFh = await open(dirname(filePath), "r");
170
+ try { await dirFh.sync(); } finally { await dirFh.close(); }
154
171
  } catch (err) {
155
172
  await unlink(tmp).catch(() => {});
156
173
  throw err;
157
174
  }
158
175
  }
159
176
 
177
+ async function readSnapshotForUpdate(statusPath: string, jobDir: string): Promise<unknown> {
178
+ try {
179
+ const text = await Bun.file(statusPath).text();
180
+ return JSON.parse(text);
181
+ } catch (err: unknown) {
182
+ const code = (err as NodeJS.ErrnoException).code;
183
+ if (code === "ENOENT") return createDefaultStatus(jobDir);
184
+ if (err instanceof SyntaxError) throw new StatusCorruptError(statusPath, { cause: err });
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ export async function flushJobStatus(jobDir: string): Promise<void> {
190
+ const pending = writeQueues.get(jobDir);
191
+ if (pending) {
192
+ try {
193
+ await pending;
194
+ } catch {
195
+ // drain only
196
+ }
197
+ }
198
+ }
199
+
160
200
  const writeQueues = new Map<string, Promise<StatusSnapshot>>();
161
201
 
162
202
  export async function readJobStatus(jobDir: string): Promise<StatusSnapshot | null> {
@@ -195,18 +235,7 @@ export function updateTaskStatus(jobDir: string, taskId: string, taskUpdateFn: T
195
235
  return next;
196
236
 
197
237
  async function runTaskWrite(): Promise<StatusSnapshot> {
198
- let raw: unknown;
199
- try {
200
- const text = await Bun.file(statusPath).text();
201
- raw = JSON.parse(text);
202
- } catch (err: unknown) {
203
- const code = (err as NodeJS.ErrnoException).code;
204
- if (code === "ENOENT" || err instanceof SyntaxError) {
205
- raw = createDefaultStatus(jobDir);
206
- } else {
207
- throw err;
208
- }
209
- }
238
+ const raw = await readSnapshotForUpdate(statusPath, jobDir);
210
239
 
211
240
  const snapshot = validateStatusSnapshot(raw, jobDir);
212
241
 
@@ -361,18 +390,7 @@ export function writeJobStatus(jobDir: string, updateFn: StatusUpdateFn): Promis
361
390
  return next;
362
391
 
363
392
  async function runWrite(): Promise<StatusSnapshot> {
364
- let raw: unknown;
365
- try {
366
- const text = await Bun.file(statusPath).text();
367
- raw = JSON.parse(text);
368
- } catch (err: unknown) {
369
- const code = (err as NodeJS.ErrnoException).code;
370
- if (code === "ENOENT" || err instanceof SyntaxError) {
371
- raw = createDefaultStatus(jobDir);
372
- } else {
373
- throw err;
374
- }
375
- }
393
+ const raw = await readSnapshotForUpdate(statusPath, jobDir);
376
394
 
377
395
  let snapshot = validateStatusSnapshot(raw, jobDir);
378
396
 
@@ -19,7 +19,7 @@ import { TaskState } from "../config/statuses";
19
19
  import { LogEvent, LogFileExtension } from "../config/log-events";
20
20
  // LLMClient: src/llm/index.ts exports HighLevelLLM (no LLMClient type exists there).
21
21
  import type { HighLevelLLM } from "../providers/types";
22
- import type { TaskAgentOptions, TaskAgentRunner } from "./agent-types";
22
+ import type { AgentStepResult, TaskAgentOptions, TaskAgentRunner } from "./agent-types";
23
23
 
24
24
  /** Opaque alias — the LLM client passed into execution contexts. */
25
25
  export type LLMClient = HighLevelLLM;
@@ -254,6 +254,21 @@ export function deriveModelKeyAndTokens(metric: Record<string, unknown>): TokenU
254
254
  return [modelKey, inputTokens, outputTokens, cost];
255
255
  }
256
256
 
257
+ function deriveAgentUsageTuple(options: TaskAgentOptions, result: AgentStepResult): TokenUsageTuple | null {
258
+ const costUsd =
259
+ typeof result.costUsd === "number" && Number.isFinite(result.costUsd)
260
+ ? result.costUsd
261
+ : undefined;
262
+ if (!result.usage && costUsd === undefined) return null;
263
+
264
+ return [
265
+ `${options.harness}:${options.model ?? "default"}`,
266
+ result.usage?.inputTokens ?? 0,
267
+ result.usage?.outputTokens ?? 0,
268
+ costUsd ?? 0,
269
+ ];
270
+ }
271
+
257
272
  // ─── Safe clone ─────────────────────────────────────────────────────────────
258
273
 
259
274
  function safeClone<T>(value: T): T {
@@ -525,8 +540,11 @@ export async function runPipeline(
525
540
  };
526
541
  llmMetrics.push(record);
527
542
 
528
- // Append token usage to status file, serialized via promise queue
529
543
  const tuple = deriveModelKeyAndTokens(metric);
544
+ enqueueTokenUsage(tuple);
545
+ };
546
+
547
+ const enqueueTokenUsage = (tuple: TokenUsageTuple) => {
530
548
  tokenWriteQueue = tokenWriteQueue.then(async () => {
531
549
  await writeJobStatus(jobDir, (snapshot: StatusSnapshot) => {
532
550
  const tasks = snapshot.tasks;
@@ -569,10 +587,16 @@ export async function runPipeline(
569
587
  // 9b. Build the CLI-agent harness runner injected into stages. It reuses the
570
588
  // task's own `io`, so an agent reads and writes the same artifacts the task
571
589
  // sees. A caller may override it (tests/custom hosts).
572
- const runAgent: TaskAgentRunner =
590
+ const runAgentCore: TaskAgentRunner =
573
591
  initialContext.runAgent ??
574
592
  ((options: TaskAgentOptions) =>
575
593
  executeAgent({ io, entry: { ...options, name: taskName } }));
594
+ const runAgent: TaskAgentRunner = async (options: TaskAgentOptions) => {
595
+ const result = await runAgentCore(options);
596
+ const tuple = deriveAgentUsageTuple(options, result);
597
+ if (tuple) enqueueTokenUsage(tuple);
598
+ return result;
599
+ };
576
600
 
577
601
  // 10. Build execution context
578
602
  const pipelineTasks = initialContext.pipelineTasks ?? initialContext.meta?.pipelineTasks;