@ryanfw/prompt-orchestration-pipeline 1.3.0 → 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 (50) hide show
  1. package/README.md +1 -0
  2. package/docs/pop-task-guide.md +45 -0
  3. package/package.json +3 -3
  4. package/src/core/__tests__/agent-step.test.ts +402 -35
  5. package/src/core/__tests__/task-runner.test.ts +104 -0
  6. package/src/core/agent-step.ts +295 -41
  7. package/src/core/agent-types.ts +61 -0
  8. package/src/core/file-io.ts +8 -74
  9. package/src/core/orchestrator.ts +2 -1
  10. package/src/core/pipeline-definition.ts +1 -1
  11. package/src/core/pipeline-runner.ts +54 -3
  12. package/src/core/status-writer.ts +44 -26
  13. package/src/core/task-runner.ts +44 -1
  14. package/src/core/validation.ts +1 -1
  15. package/src/harness/__tests__/discovery.test.ts +235 -0
  16. package/src/harness/discovery.ts +109 -0
  17. package/src/harness/index.ts +22 -0
  18. package/src/harness/mcp-io-server.ts +1 -1
  19. package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
  20. package/src/ui/dist/assets/{index-D7hzshSS.js → index--RH3sAt3.js} +129 -1
  21. package/src/ui/dist/assets/index--RH3sAt3.js.map +1 -0
  22. package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
  23. package/src/ui/dist/index.html +2 -2
  24. package/src/ui/embedded-assets.js +6 -6
  25. package/src/ui/pages/Code.tsx +135 -0
  26. package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
  27. package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
  28. package/src/ui/server/__tests__/path-containment.test.ts +54 -0
  29. package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
  30. package/src/ui/server/config-bridge.ts +1 -0
  31. package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
  32. package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
  33. package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
  34. package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
  35. package/src/ui/server/utils/http-utils.ts +6 -1
  36. package/src/ui/server/utils/path-containment.ts +18 -0
  37. package/src/ui/server/utils/status-corruption.ts +27 -0
  38. package/src/harness/__tests__/descriptors.test.ts +0 -378
  39. package/src/harness/__tests__/executor.test.ts +0 -193
  40. package/src/harness/__tests__/resolve.test.ts +0 -200
  41. package/src/harness/__tests__/types.test.ts +0 -297
  42. package/src/harness/descriptors/claude.ts +0 -132
  43. package/src/harness/descriptors/codex.ts +0 -126
  44. package/src/harness/descriptors/index.ts +0 -10
  45. package/src/harness/descriptors/opencode.ts +0 -147
  46. package/src/harness/executor.ts +0 -128
  47. package/src/harness/resolve.ts +0 -176
  48. package/src/harness/types.ts +0 -100
  49. package/src/ui/dist/assets/index-D7hzshSS.js.map +0 -1
  50. package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
@@ -211,3 +211,107 @@ describe("task-runner writes correct task-level state transitions", () => {
211
211
  expect(task!.error).toBe("stage exploded");
212
212
  });
213
213
  });
214
+
215
+ describe("runPipeline runAgent injection", () => {
216
+ it("passes runAgent to stages and delegates to the supplied override", async () => {
217
+ const root = await makeTempRoot();
218
+ const workDir = path.join(root, "job-agent");
219
+ await mkdir(workDir, { recursive: true });
220
+ await writeFile(
221
+ path.join(workDir, "tasks-status.json"),
222
+ JSON.stringify({ id: "job-agent", tasks: {} }),
223
+ );
224
+
225
+ const modulePath = path.join(root, "agent-task.mjs");
226
+ await writeFile(
227
+ modulePath,
228
+ [
229
+ "export const ingestion = async ({ runAgent, flags }) => {",
230
+ " const r = await runAgent({ harness: 'claude', prompt: 'hello agent' });",
231
+ " return { output: r, flags };",
232
+ "};",
233
+ ].join("\n"),
234
+ );
235
+
236
+ const calls: unknown[] = [];
237
+ const result = await runPipeline(modulePath, {
238
+ workDir,
239
+ taskName: "agent-task",
240
+ statusPath: path.join(workDir, "tasks-status.json"),
241
+ jobId: "job-agent",
242
+ envLoaded: true,
243
+ seed: { data: {} },
244
+ pipelineTasks: ["agent-task"],
245
+ llm: {} as never,
246
+ runAgent: async (options) => {
247
+ calls.push(options);
248
+ return {
249
+ ok: true,
250
+ finalMessage: "did it",
251
+ artifactsWritten: ["x.md"],
252
+ usage: { inputTokens: 12, outputTokens: 7, totalTokens: 19 },
253
+ costUsd: 0.03,
254
+ };
255
+ },
256
+ });
257
+
258
+ expect(result.ok).toBe(true);
259
+ expect(calls).toEqual([{ harness: "claude", prompt: "hello agent" }]);
260
+ if (result.ok) {
261
+ expect(result.context.data["ingestion"]).toMatchObject({
262
+ ok: true,
263
+ finalMessage: "did it",
264
+ });
265
+ }
266
+
267
+ const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
268
+ expect(status.tasks["agent-task"]?.tokenUsage).toEqual([
269
+ ["claude:default", 12, 7, 0.03],
270
+ ]);
271
+ });
272
+
273
+ it("records runAgent cost when normalized usage is absent", async () => {
274
+ const root = await makeTempRoot();
275
+ const workDir = path.join(root, "job-agent-cost");
276
+ await mkdir(workDir, { recursive: true });
277
+ await writeFile(
278
+ path.join(workDir, "tasks-status.json"),
279
+ JSON.stringify({ id: "job-agent-cost", tasks: {} }),
280
+ );
281
+
282
+ const modulePath = path.join(root, "agent-cost-task.mjs");
283
+ await writeFile(
284
+ modulePath,
285
+ [
286
+ "export const ingestion = async ({ runAgent, flags }) => {",
287
+ " const r = await runAgent({ harness: 'opencode', model: 'gpt-4o', prompt: 'hello agent' });",
288
+ " return { output: r, flags };",
289
+ "};",
290
+ ].join("\n"),
291
+ );
292
+
293
+ const result = await runPipeline(modulePath, {
294
+ workDir,
295
+ taskName: "agent-cost-task",
296
+ statusPath: path.join(workDir, "tasks-status.json"),
297
+ jobId: "job-agent-cost",
298
+ envLoaded: true,
299
+ seed: { data: {} },
300
+ pipelineTasks: ["agent-cost-task"],
301
+ llm: {} as never,
302
+ runAgent: async () => ({
303
+ ok: true,
304
+ finalMessage: "did it",
305
+ artifactsWritten: [],
306
+ costUsd: 0.12,
307
+ }),
308
+ });
309
+
310
+ expect(result.ok).toBe(true);
311
+
312
+ const status = JSON.parse(await readFile(path.join(workDir, "tasks-status.json"), "utf8")) as StatusSnapshot;
313
+ expect(status.tasks["agent-cost-task"]?.tokenUsage).toEqual([
314
+ ["opencode:gpt-4o", 0, 0, 0.12],
315
+ ]);
316
+ });
317
+ });
@@ -1,18 +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
- import { runHarnessTask } from "../harness/executor.ts";
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";
11
+ import type { AgentEntryConfig, AgentStepResult } from "./agent-types.ts";
9
12
  import type {
10
- AgentEntryConfig,
11
- AgentStepResult,
13
+ RunOptions,
14
+ HarnessRun,
12
15
  HarnessEvent,
13
- } from "../harness/types.ts";
16
+ HarnessName,
17
+ McpServerConfig,
18
+ RunResult,
19
+ } from "../harness/index.ts";
14
20
  import type { TaskFileIO } from "./file-io.ts";
15
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
+
16
31
  function gitSync(args: string[], cwd: string): { exitCode: number; stdout: string; stderr: string } {
17
32
  const result = Bun.spawnSync(["git", ...args], {
18
33
  cwd,
@@ -65,78 +80,284 @@ async function captureDiff(io: TaskFileIO, cwd: string): Promise<boolean> {
65
80
  }
66
81
  }
67
82
 
68
- export async function runAgentStep(
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
+
277
+ /** Dependency seam shared by the agent runners (injected in tests). */
278
+ interface AgentRunDeps {
279
+ run?: typeof run;
280
+ startMcpIoServer?: typeof startMcpIoServer;
281
+ }
282
+
283
+ /**
284
+ * Runs a single CLI-agent harness step against an existing task `io`.
285
+ *
286
+ * This is the shared core behind both pipeline `agent:` entries (via
287
+ * {@link runAgentStep}) and the `runAgent()` helper injected into standard
288
+ * JavaScript task stages (via the task runner). The caller owns the `io`, so
289
+ * the agent reads and writes the same task artifacts the surrounding task sees.
290
+ */
291
+ export async function executeAgent(
69
292
  args: {
293
+ io: TaskFileIO;
70
294
  entry: AgentEntryConfig & { name: string };
71
- workDir: string;
72
- statusPath: string;
73
- jobId: string | undefined;
74
- getStage: () => string;
75
- },
76
- deps?: {
77
- runHarnessTask?: typeof runHarnessTask;
78
- startMcpIoServer?: typeof startMcpIoServer;
79
- createTaskFileIO?: typeof createTaskFileIO;
80
295
  },
296
+ deps?: AgentRunDeps,
81
297
  ): Promise<AgentStepResult> {
82
- const _runHarnessTask = deps?.runHarnessTask ?? runHarnessTask;
298
+ const _run = deps?.run ?? run;
83
299
  const _startMcpIoServer = deps?.startMcpIoServer ?? startMcpIoServer;
84
- const _createTaskFileIO = deps?.createTaskFileIO ?? createTaskFileIO;
85
-
86
- const io = _createTaskFileIO({
87
- workDir: args.workDir,
88
- taskName: args.entry.name,
89
- getStage: args.getStage,
90
- statusPath: args.statusPath,
91
- });
300
+ const { io, entry } = args;
301
+ const observedEvents: HarnessEvent[] = [];
302
+ let completedResult: RunResult | undefined;
92
303
 
93
- const cwd = args.entry.cwd ?? io.getTaskDir();
304
+ const cwd = entry.cwd ?? io.getTaskDir();
94
305
  // The harness spawns with this cwd before any artifact is written, so the task
95
306
  // dir may not exist yet — posix_spawn ENOENTs on a missing working directory.
96
307
  await mkdir(cwd, { recursive: true });
97
308
 
98
309
  let prompt: string;
99
- if (args.entry.prompt !== undefined) {
100
- prompt = args.entry.prompt;
101
- } else if (args.entry.promptFrom !== undefined) {
102
- prompt = await io.readArtifact(args.entry.promptFrom);
310
+ if (entry.prompt !== undefined) {
311
+ prompt = entry.prompt;
312
+ } else if (entry.promptFrom !== undefined) {
313
+ prompt = await io.readArtifact(entry.promptFrom);
103
314
  } else {
104
315
  throw new Error(
105
- `Agent entry "${args.entry.name}" must specify either "prompt" or "promptFrom"`,
316
+ `Agent entry "${entry.name}" must specify either "prompt" or "promptFrom"`,
106
317
  );
107
318
  }
108
319
 
109
320
  let mcpHandle: McpIoServerHandle | undefined;
110
- if (args.entry.io !== false) {
321
+ if (entry.io !== false) {
111
322
  mcpHandle = await _startMcpIoServer(io);
112
323
  }
113
324
 
114
325
  try {
115
326
  const logName = generateLogName(
116
- args.entry.name,
327
+ entry.name,
117
328
  "agent",
118
329
  LogEvent.DEBUG,
119
330
  LogFileExtension.TEXT,
120
331
  );
121
-
122
- const result = await _runHarnessTask({
123
- harness: args.entry.harness,
332
+ const compatibility = await prepareHarnessCompatibility(entry.harness, mcpHandle);
333
+ const options: RunOptions = {
334
+ harness: entry.harness,
124
335
  prompt,
125
336
  cwd,
126
- model: args.entry.model,
127
- mcp: mcpHandle?.connection,
128
- timeoutMs: args.entry.timeoutMs,
337
+ model: entry.model,
338
+ ...(compatibility.mcpServers ? { mcpServers: compatibility.mcpServers } : {}),
339
+ timeoutMs: entry.timeoutMs,
340
+ ...(entry.idleTimeoutMs !== undefined ? { idleTimeoutMs: entry.idleTimeoutMs } : {}),
341
+ permissionMode: "bypass",
129
342
  onEvent: (event: HarnessEvent) => {
343
+ observedEvents.push(event);
130
344
  void io.writeLog(logName, JSON.stringify(event.raw) + "\n", {
131
345
  mode: "append",
132
346
  });
133
347
  },
348
+ };
349
+
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;
134
355
  });
135
356
 
136
357
  await io.writeArtifact("agent-result.md", result.finalMessage);
137
358
 
138
359
  let patchWritten = false;
139
- if (args.entry.captureDiff) {
360
+ if (entry.captureDiff) {
140
361
  patchWritten = await captureDiff(io, cwd);
141
362
  }
142
363
 
@@ -150,7 +371,7 @@ export async function runAgentStep(
150
371
  finalMessage: result.finalMessage,
151
372
  artifactsWritten: allArtifacts,
152
373
  usage: result.usage,
153
- costUsd: result.costUsd,
374
+ costUsd: result.costUsd ?? extractCostUsd(observedEvents),
154
375
  sessionId: result.sessionId,
155
376
  };
156
377
  } catch (err) {
@@ -163,6 +384,9 @@ export async function runAgentStep(
163
384
  ok: false,
164
385
  finalMessage: "",
165
386
  artifactsWritten: allArtifacts,
387
+ usage: completedResult?.usage,
388
+ costUsd: completedResult?.costUsd ?? extractCostUsd(observedEvents),
389
+ sessionId: completedResult?.sessionId,
166
390
  error: err instanceof Error ? err.message : String(err),
167
391
  };
168
392
  } finally {
@@ -171,3 +395,33 @@ export async function runAgentStep(
171
395
  }
172
396
  }
173
397
  }
398
+
399
+ /**
400
+ * Runs a CLI-agent harness step for a pipeline `agent:` entry.
401
+ *
402
+ * Creates a task-scoped `io` from the run paths, then delegates to
403
+ * {@link executeAgent}. Used by the pipeline runner for `agent` entries.
404
+ */
405
+ export async function runAgentStep(
406
+ args: {
407
+ entry: AgentEntryConfig & { name: string };
408
+ workDir: string;
409
+ statusPath: string;
410
+ jobId: string | undefined;
411
+ getStage: () => string;
412
+ },
413
+ deps?: AgentRunDeps & {
414
+ createTaskFileIO?: typeof createTaskFileIO;
415
+ },
416
+ ): Promise<AgentStepResult> {
417
+ const _createTaskFileIO = deps?.createTaskFileIO ?? createTaskFileIO;
418
+
419
+ const io = _createTaskFileIO({
420
+ workDir: args.workDir,
421
+ taskName: args.entry.name,
422
+ getStage: args.getStage,
423
+ statusPath: args.statusPath,
424
+ });
425
+
426
+ return executeAgent({ io, entry: args.entry }, deps);
427
+ }
@@ -0,0 +1,61 @@
1
+ import type { HarnessName, Usage } from "../harness/index.ts";
2
+
3
+ export interface McpServerConnection {
4
+ url: string;
5
+ token: string;
6
+ }
7
+
8
+ export interface AgentEntryConfig {
9
+ harness: HarnessName;
10
+ model?: string;
11
+ prompt?: string;
12
+ promptFrom?: string;
13
+ cwd?: string;
14
+ io?: boolean;
15
+ timeoutMs?: number;
16
+ idleTimeoutMs?: number;
17
+ captureDiff?: boolean;
18
+ }
19
+
20
+ export interface AgentStepResult {
21
+ ok: boolean;
22
+ finalMessage: string;
23
+ artifactsWritten: string[];
24
+ usage?: Usage;
25
+ costUsd?: number;
26
+ sessionId?: string;
27
+ error?: string;
28
+ }
29
+
30
+ /**
31
+ * Options for the `runAgent()` helper injected into standard JavaScript task
32
+ * stages. Mirrors {@link AgentEntryConfig} but requires an inline `prompt`
33
+ * (a task builds the prompt programmatically rather than reading it from an
34
+ * artifact via `promptFrom`).
35
+ */
36
+ export interface TaskAgentOptions {
37
+ /** Which CLI agent to run: `"claude" | "codex" | "opencode"`. */
38
+ harness: HarnessName;
39
+ /** The instruction handed to the agent. */
40
+ prompt: string;
41
+ /** Optional model id passed through to the harness verbatim. */
42
+ model?: string;
43
+ /** Working directory for the agent. Defaults to the task directory. */
44
+ cwd?: string;
45
+ /**
46
+ * Bridge POP file I/O into the agent (read_artifact/write_artifact tools).
47
+ * Defaults to `true` so the agent shares the task's artifacts.
48
+ */
49
+ io?: boolean;
50
+ /** Overall wall-clock cap in milliseconds. */
51
+ timeoutMs?: number;
52
+ /** No-output cap in milliseconds. Defaults to the adapter's idle timeout. */
53
+ idleTimeoutMs?: number;
54
+ /** Capture a git diff of the working tree as an `agent.patch` artifact. */
55
+ captureDiff?: boolean;
56
+ }
57
+
58
+ /** The `runAgent()` function injected into JavaScript task stages. */
59
+ export type TaskAgentRunner = (
60
+ options: TaskAgentOptions,
61
+ ) => Promise<AgentStepResult>;
@@ -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
  };