@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.
- package/docs/pop-task-guide.md +1 -0
- package/package.json +1 -2
- package/src/core/__tests__/agent-step.test.ts +322 -3
- package/src/core/__tests__/task-runner.test.ts +57 -1
- package/src/core/agent-step.ts +231 -14
- package/src/core/agent-types.ts +3 -0
- package/src/core/file-io.ts +8 -74
- package/src/core/pipeline-runner.ts +54 -3
- package/src/core/status-writer.ts +44 -26
- package/src/core/task-runner.ts +27 -3
- package/src/harness/__tests__/discovery.test.ts +60 -8
- package/src/harness/discovery.ts +16 -6
- package/src/ui/client/hooks/useJobListWithUpdates.ts +16 -1
- package/src/ui/dist/assets/{index-CbS3OsW7.js → index--RH3sAt3.js} +14 -1
- package/src/ui/dist/assets/{index-CbS3OsW7.js.map → index--RH3sAt3.js.map} +1 -1
- package/src/ui/dist/assets/style-CSSKuMOe.css +2 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/embedded-assets.js +6 -6
- package/src/ui/server/__tests__/gate-endpoints.test.ts +90 -0
- package/src/ui/server/__tests__/job-control-endpoints.test.ts +188 -0
- package/src/ui/server/__tests__/path-containment.test.ts +54 -0
- package/src/ui/server/__tests__/status-corruption.test.ts +55 -0
- package/src/ui/server/config-bridge.ts +1 -0
- package/src/ui/server/endpoints/__tests__/upload-endpoints.test.ts +77 -0
- package/src/ui/server/endpoints/gate-endpoints.ts +17 -1
- package/src/ui/server/endpoints/job-control-endpoints.ts +36 -2
- package/src/ui/server/endpoints/upload-endpoints.ts +13 -3
- package/src/ui/server/utils/http-utils.ts +6 -1
- package/src/ui/server/utils/path-containment.ts +18 -0
- package/src/ui/server/utils/status-corruption.ts +27 -0
- package/src/ui/dist/assets/style-BUFg3Sth.css +0 -2
package/src/core/agent-step.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
|
141
|
-
|
|
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 {
|
package/src/core/agent-types.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/file-io.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { mkdir, rename, appendFile } from "node:fs/promises";
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/core/task-runner.ts
CHANGED
|
@@ -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
|
|
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;
|