@yail259/overnight 0.1.0 → 0.2.0
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/README.md +73 -16
- package/dist/cli.js +599 -118
- package/package.json +1 -1
- package/src/cli.ts +123 -19
- package/src/runner.ts +422 -45
- package/src/security.ts +162 -0
- package/src/types.ts +37 -4
package/src/runner.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { query, type Options as ClaudeCodeOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
2
2
|
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
|
|
3
|
+
import { execSync } from "child_process";
|
|
4
|
+
import { createHash } from "crypto";
|
|
3
5
|
import {
|
|
4
6
|
type JobConfig,
|
|
5
7
|
type JobResult,
|
|
@@ -10,9 +12,100 @@ import {
|
|
|
10
12
|
DEFAULT_RETRY_DELAY,
|
|
11
13
|
DEFAULT_VERIFY_PROMPT,
|
|
12
14
|
DEFAULT_STATE_FILE,
|
|
15
|
+
DEFAULT_MAX_TURNS,
|
|
13
16
|
} from "./types.js";
|
|
17
|
+
import { createSecurityHooks } from "./security.js";
|
|
14
18
|
|
|
15
19
|
type LogCallback = (msg: string) => void;
|
|
20
|
+
type ProgressCallback = (activity: string) => void;
|
|
21
|
+
|
|
22
|
+
// Progress display
|
|
23
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
24
|
+
|
|
25
|
+
class ProgressDisplay {
|
|
26
|
+
private interval: ReturnType<typeof setInterval> | null = null;
|
|
27
|
+
private frame = 0;
|
|
28
|
+
private startTime = Date.now();
|
|
29
|
+
private currentActivity = "Working";
|
|
30
|
+
private lastToolUse = "";
|
|
31
|
+
|
|
32
|
+
start(activity: string): void {
|
|
33
|
+
this.currentActivity = activity;
|
|
34
|
+
this.startTime = Date.now();
|
|
35
|
+
this.frame = 0;
|
|
36
|
+
|
|
37
|
+
if (this.interval) return;
|
|
38
|
+
|
|
39
|
+
this.interval = setInterval(() => {
|
|
40
|
+
const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
|
|
41
|
+
const toolInfo = this.lastToolUse ? ` → ${this.lastToolUse}` : "";
|
|
42
|
+
process.stdout.write(
|
|
43
|
+
`\r\x1b[K${SPINNER_FRAMES[this.frame]} ${this.currentActivity} (${elapsed}s)${toolInfo}`
|
|
44
|
+
);
|
|
45
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
46
|
+
}, 100);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
updateActivity(activity: string): void {
|
|
50
|
+
this.currentActivity = activity;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
updateTool(toolName: string, detail?: string): void {
|
|
54
|
+
this.lastToolUse = detail ? `${toolName}: ${detail}` : toolName;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
stop(finalMessage?: string): void {
|
|
58
|
+
if (this.interval) {
|
|
59
|
+
clearInterval(this.interval);
|
|
60
|
+
this.interval = null;
|
|
61
|
+
}
|
|
62
|
+
process.stdout.write("\r\x1b[K"); // Clear line
|
|
63
|
+
if (finalMessage) {
|
|
64
|
+
console.log(finalMessage);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getElapsed(): number {
|
|
69
|
+
return (Date.now() - this.startTime) / 1000;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Cache the claude executable path
|
|
74
|
+
let claudeExecutablePath: string | undefined;
|
|
75
|
+
|
|
76
|
+
function findClaudeExecutable(): string | undefined {
|
|
77
|
+
if (claudeExecutablePath !== undefined) return claudeExecutablePath;
|
|
78
|
+
|
|
79
|
+
// Check environment variable first
|
|
80
|
+
if (process.env.CLAUDE_CODE_PATH) {
|
|
81
|
+
claudeExecutablePath = process.env.CLAUDE_CODE_PATH;
|
|
82
|
+
return claudeExecutablePath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Try to find claude using which/where
|
|
86
|
+
try {
|
|
87
|
+
const cmd = process.platform === "win32" ? "where claude" : "which claude";
|
|
88
|
+
claudeExecutablePath = execSync(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
|
|
89
|
+
return claudeExecutablePath;
|
|
90
|
+
} catch {
|
|
91
|
+
// Fall back to common locations
|
|
92
|
+
const commonPaths = [
|
|
93
|
+
"/usr/local/bin/claude",
|
|
94
|
+
"/opt/homebrew/bin/claude",
|
|
95
|
+
`${process.env.HOME}/.local/bin/claude`,
|
|
96
|
+
`${process.env.HOME}/.nvm/versions/node/v22.12.0/bin/claude`,
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
for (const p of commonPaths) {
|
|
100
|
+
if (existsSync(p)) {
|
|
101
|
+
claudeExecutablePath = p;
|
|
102
|
+
return claudeExecutablePath;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
16
109
|
|
|
17
110
|
function isRetryableError(error: Error): boolean {
|
|
18
111
|
const errorStr = error.message.toLowerCase();
|
|
@@ -54,28 +147,91 @@ async function runWithTimeout<T>(
|
|
|
54
147
|
}
|
|
55
148
|
}
|
|
56
149
|
|
|
57
|
-
|
|
150
|
+
// Extract useful info from tool input for display
|
|
151
|
+
function getToolDetail(toolName: string, toolInput: Record<string, unknown>): string {
|
|
152
|
+
switch (toolName) {
|
|
153
|
+
case "Read":
|
|
154
|
+
case "Write":
|
|
155
|
+
case "Edit":
|
|
156
|
+
const filePath = toolInput.file_path as string;
|
|
157
|
+
if (filePath) {
|
|
158
|
+
// Show just filename, not full path
|
|
159
|
+
return filePath.split("/").pop() || filePath;
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
case "Glob":
|
|
163
|
+
return (toolInput.pattern as string) || "";
|
|
164
|
+
case "Grep":
|
|
165
|
+
return (toolInput.pattern as string)?.slice(0, 20) || "";
|
|
166
|
+
case "Bash":
|
|
167
|
+
const cmd = (toolInput.command as string) || "";
|
|
168
|
+
return cmd.slice(0, 30) + (cmd.length > 30 ? "..." : "");
|
|
169
|
+
}
|
|
170
|
+
return "";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function collectResultWithProgress(
|
|
58
174
|
prompt: string,
|
|
59
|
-
options: ClaudeCodeOptions
|
|
60
|
-
|
|
175
|
+
options: ClaudeCodeOptions,
|
|
176
|
+
progress: ProgressDisplay,
|
|
177
|
+
onSessionId?: (sessionId: string) => void
|
|
178
|
+
): Promise<{ sessionId?: string; result?: string; error?: string }> {
|
|
61
179
|
let sessionId: string | undefined;
|
|
62
180
|
let result: string | undefined;
|
|
181
|
+
let lastError: string | undefined;
|
|
63
182
|
|
|
64
|
-
|
|
183
|
+
try {
|
|
184
|
+
const conversation = query({ prompt, options });
|
|
65
185
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
186
|
+
for await (const message of conversation) {
|
|
187
|
+
// Debug logging
|
|
188
|
+
if (process.env.OVERNIGHT_DEBUG) {
|
|
189
|
+
console.error(`\n[DEBUG] message.type=${message.type}, keys=${Object.keys(message).join(",")}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Handle different message types
|
|
193
|
+
if (message.type === "result") {
|
|
194
|
+
result = message.result;
|
|
195
|
+
sessionId = message.session_id;
|
|
196
|
+
} else if (message.type === "assistant" && "message" in message) {
|
|
197
|
+
// Assistant message with tool use - SDK nests content in message.message
|
|
198
|
+
const assistantMsg = message.message as { content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }> };
|
|
199
|
+
if (assistantMsg.content) {
|
|
200
|
+
for (const block of assistantMsg.content) {
|
|
201
|
+
if (process.env.OVERNIGHT_DEBUG) {
|
|
202
|
+
console.error(`[DEBUG] content block: type=${block.type}, name=${block.name}`);
|
|
203
|
+
}
|
|
204
|
+
if (block.type === "tool_use" && block.name) {
|
|
205
|
+
const detail = block.input ? getToolDetail(block.name, block.input) : "";
|
|
206
|
+
progress.updateTool(block.name, detail);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
} else if (message.type === "system" && "subtype" in message) {
|
|
211
|
+
// System messages
|
|
212
|
+
if (message.subtype === "init") {
|
|
213
|
+
sessionId = message.session_id;
|
|
214
|
+
if (sessionId && onSessionId) {
|
|
215
|
+
onSessionId(sessionId);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
70
219
|
}
|
|
220
|
+
} catch (e) {
|
|
221
|
+
lastError = (e as Error).message;
|
|
222
|
+
throw e;
|
|
71
223
|
}
|
|
72
224
|
|
|
73
|
-
return { sessionId, result };
|
|
225
|
+
return { sessionId, result, error: lastError };
|
|
74
226
|
}
|
|
75
227
|
|
|
76
228
|
export async function runJob(
|
|
77
229
|
config: JobConfig,
|
|
78
|
-
log?: LogCallback
|
|
230
|
+
log?: LogCallback,
|
|
231
|
+
options?: {
|
|
232
|
+
resumeSessionId?: string; // Resume from a previous session
|
|
233
|
+
onSessionId?: (id: string) => void; // Called when session ID is available
|
|
234
|
+
}
|
|
79
235
|
): Promise<JobResult> {
|
|
80
236
|
const startTime = Date.now();
|
|
81
237
|
const tools = config.allowed_tools ?? DEFAULT_TOOLS;
|
|
@@ -84,41 +240,95 @@ export async function runJob(
|
|
|
84
240
|
const retryDelay = config.retry_delay ?? DEFAULT_RETRY_DELAY;
|
|
85
241
|
const verifyPrompt = config.verify_prompt ?? DEFAULT_VERIFY_PROMPT;
|
|
86
242
|
let retriesUsed = 0;
|
|
243
|
+
let resumeSessionId = options?.resumeSessionId;
|
|
87
244
|
|
|
88
245
|
const logMsg = (msg: string) => log?.(msg);
|
|
89
|
-
|
|
246
|
+
const progress = new ProgressDisplay();
|
|
247
|
+
|
|
248
|
+
// Find claude executable once at start
|
|
249
|
+
const claudePath = findClaudeExecutable();
|
|
250
|
+
if (!claudePath) {
|
|
251
|
+
logMsg("\x1b[31m✗ Error: Could not find 'claude' CLI.\x1b[0m");
|
|
252
|
+
logMsg("\x1b[33m Install it with:\x1b[0m");
|
|
253
|
+
logMsg(" curl -fsSL https://claude.ai/install.sh | bash");
|
|
254
|
+
logMsg("\x1b[33m Or set CLAUDE_CODE_PATH environment variable.\x1b[0m");
|
|
255
|
+
return {
|
|
256
|
+
task: config.prompt,
|
|
257
|
+
status: "failed",
|
|
258
|
+
error: "Claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash",
|
|
259
|
+
duration_seconds: 0,
|
|
260
|
+
verified: false,
|
|
261
|
+
retries: 0,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (process.env.OVERNIGHT_DEBUG) {
|
|
266
|
+
logMsg(`\x1b[2mDebug: Claude path = ${claudePath}\x1b[0m`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Show task being started
|
|
270
|
+
const taskPreview = config.prompt.slice(0, 60) + (config.prompt.length > 60 ? "..." : "");
|
|
271
|
+
if (resumeSessionId) {
|
|
272
|
+
logMsg(`\x1b[36m▶\x1b[0m Resuming: ${taskPreview}`);
|
|
273
|
+
} else {
|
|
274
|
+
logMsg(`\x1b[36m▶\x1b[0m ${taskPreview}`);
|
|
275
|
+
}
|
|
90
276
|
|
|
91
277
|
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
92
278
|
try {
|
|
93
|
-
|
|
279
|
+
// Build security hooks if security config provided
|
|
280
|
+
const securityHooks = config.security ? createSecurityHooks(config.security) : undefined;
|
|
281
|
+
|
|
282
|
+
const sdkOptions: ClaudeCodeOptions = {
|
|
94
283
|
allowedTools: tools,
|
|
95
284
|
permissionMode: "acceptEdits",
|
|
285
|
+
...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
|
|
96
286
|
...(config.working_dir && { cwd: config.working_dir }),
|
|
287
|
+
...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
|
|
288
|
+
...(securityHooks && { hooks: securityHooks }),
|
|
289
|
+
...(resumeSessionId && { resume: resumeSessionId }),
|
|
97
290
|
};
|
|
98
291
|
|
|
99
292
|
let sessionId: string | undefined;
|
|
100
293
|
let result: string | undefined;
|
|
101
294
|
|
|
295
|
+
// Prompt: if resuming, ask to continue; otherwise use original prompt
|
|
296
|
+
const prompt = resumeSessionId
|
|
297
|
+
? "Continue where you left off. Complete the original task."
|
|
298
|
+
: config.prompt;
|
|
299
|
+
|
|
300
|
+
// Start progress display
|
|
301
|
+
progress.start(resumeSessionId ? "Resuming" : "Working");
|
|
302
|
+
|
|
102
303
|
try {
|
|
103
304
|
const collected = await runWithTimeout(
|
|
104
|
-
|
|
305
|
+
collectResultWithProgress(prompt, sdkOptions, progress, (id) => {
|
|
306
|
+
sessionId = id;
|
|
307
|
+
options?.onSessionId?.(id);
|
|
308
|
+
}),
|
|
105
309
|
timeout
|
|
106
310
|
);
|
|
107
311
|
sessionId = collected.sessionId;
|
|
108
312
|
result = collected.result;
|
|
313
|
+
progress.stop();
|
|
109
314
|
} catch (e) {
|
|
315
|
+
progress.stop();
|
|
110
316
|
if ((e as Error).message === "TIMEOUT") {
|
|
111
317
|
if (attempt < retryCount) {
|
|
112
318
|
retriesUsed = attempt + 1;
|
|
319
|
+
// On timeout, if we have a session ID, use it for the retry
|
|
320
|
+
if (sessionId) {
|
|
321
|
+
resumeSessionId = sessionId;
|
|
322
|
+
}
|
|
113
323
|
const delay = retryDelay * Math.pow(2, attempt);
|
|
114
324
|
logMsg(
|
|
115
|
-
|
|
325
|
+
`\x1b[33m⚠ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
|
|
116
326
|
);
|
|
117
327
|
await sleep(delay * 1000);
|
|
118
328
|
continue;
|
|
119
329
|
}
|
|
120
330
|
logMsg(
|
|
121
|
-
|
|
331
|
+
`\x1b[31m✗ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)\x1b[0m`
|
|
122
332
|
);
|
|
123
333
|
return {
|
|
124
334
|
task: config.prompt,
|
|
@@ -132,42 +342,60 @@ export async function runJob(
|
|
|
132
342
|
throw e;
|
|
133
343
|
}
|
|
134
344
|
|
|
135
|
-
// Verification pass if enabled
|
|
345
|
+
// Verification pass if enabled — verify and fix issues
|
|
136
346
|
if (config.verify !== false && sessionId) {
|
|
137
|
-
|
|
347
|
+
progress.start("Verifying");
|
|
138
348
|
|
|
139
349
|
const verifyOptions: ClaudeCodeOptions = {
|
|
350
|
+
allowedTools: tools,
|
|
140
351
|
resume: sessionId,
|
|
141
352
|
permissionMode: "acceptEdits",
|
|
353
|
+
...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
|
|
354
|
+
...(config.working_dir && { cwd: config.working_dir }),
|
|
355
|
+
...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
|
|
142
356
|
};
|
|
143
357
|
|
|
358
|
+
const fixPrompt = verifyPrompt +
|
|
359
|
+
" If you find any issues, fix them now. Only report issues you cannot fix.";
|
|
360
|
+
|
|
144
361
|
try {
|
|
145
362
|
const verifyResult = await runWithTimeout(
|
|
146
|
-
|
|
363
|
+
collectResultWithProgress(fixPrompt, verifyOptions, progress, (id) => {
|
|
364
|
+
sessionId = id;
|
|
365
|
+
options?.onSessionId?.(id);
|
|
366
|
+
}),
|
|
147
367
|
timeout / 2
|
|
148
368
|
);
|
|
369
|
+
progress.stop();
|
|
149
370
|
|
|
150
|
-
|
|
371
|
+
// Update result with verification output
|
|
372
|
+
if (verifyResult.result) {
|
|
373
|
+
result = verifyResult.result;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Only mark as failed if there are issues that couldn't be fixed
|
|
377
|
+
const unfixableWords = ["cannot fix", "unable to", "blocked by", "requires manual"];
|
|
151
378
|
if (
|
|
152
379
|
verifyResult.result &&
|
|
153
|
-
|
|
380
|
+
unfixableWords.some((word) =>
|
|
154
381
|
verifyResult.result!.toLowerCase().includes(word)
|
|
155
382
|
)
|
|
156
383
|
) {
|
|
157
|
-
logMsg(
|
|
384
|
+
logMsg(`\x1b[33m⚠ Verification found unfixable issues\x1b[0m`);
|
|
158
385
|
return {
|
|
159
386
|
task: config.prompt,
|
|
160
387
|
status: "verification_failed",
|
|
161
388
|
result,
|
|
162
|
-
error: `
|
|
389
|
+
error: `Unfixable issues: ${verifyResult.result}`,
|
|
163
390
|
duration_seconds: (Date.now() - startTime) / 1000,
|
|
164
391
|
verified: false,
|
|
165
392
|
retries: retriesUsed,
|
|
166
393
|
};
|
|
167
394
|
}
|
|
168
395
|
} catch (e) {
|
|
396
|
+
progress.stop();
|
|
169
397
|
if ((e as Error).message === "TIMEOUT") {
|
|
170
|
-
logMsg("Verification timed out - continuing anyway");
|
|
398
|
+
logMsg("\x1b[33m⚠ Verification timed out - continuing anyway\x1b[0m");
|
|
171
399
|
} else {
|
|
172
400
|
throw e;
|
|
173
401
|
}
|
|
@@ -175,7 +403,7 @@ export async function runJob(
|
|
|
175
403
|
}
|
|
176
404
|
|
|
177
405
|
const duration = (Date.now() - startTime) / 1000;
|
|
178
|
-
logMsg(
|
|
406
|
+
logMsg(`\x1b[32m✓ Completed in ${duration.toFixed(1)}s\x1b[0m`);
|
|
179
407
|
|
|
180
408
|
return {
|
|
181
409
|
task: config.prompt,
|
|
@@ -186,19 +414,24 @@ export async function runJob(
|
|
|
186
414
|
retries: retriesUsed,
|
|
187
415
|
};
|
|
188
416
|
} catch (e) {
|
|
417
|
+
progress.stop();
|
|
189
418
|
const error = e as Error;
|
|
190
419
|
if (isRetryableError(error) && attempt < retryCount) {
|
|
191
420
|
retriesUsed = attempt + 1;
|
|
421
|
+
// Preserve session for resumption on retry
|
|
422
|
+
if (sessionId) {
|
|
423
|
+
resumeSessionId = sessionId;
|
|
424
|
+
}
|
|
192
425
|
const delay = retryDelay * Math.pow(2, attempt);
|
|
193
426
|
logMsg(
|
|
194
|
-
|
|
427
|
+
`\x1b[33m⚠ ${error.message}, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
|
|
195
428
|
);
|
|
196
429
|
await sleep(delay * 1000);
|
|
197
430
|
continue;
|
|
198
431
|
}
|
|
199
432
|
|
|
200
433
|
const duration = (Date.now() - startTime) / 1000;
|
|
201
|
-
logMsg(
|
|
434
|
+
logMsg(`\x1b[31m✗ Failed: ${error.message}\x1b[0m`);
|
|
202
435
|
return {
|
|
203
436
|
task: config.prompt,
|
|
204
437
|
status: "failed",
|
|
@@ -221,6 +454,63 @@ export async function runJob(
|
|
|
221
454
|
};
|
|
222
455
|
}
|
|
223
456
|
|
|
457
|
+
export function taskKey(config: JobConfig): string {
|
|
458
|
+
if (config.id) return config.id;
|
|
459
|
+
return createHash("sha256").update(config.prompt).digest("hex").slice(0, 12);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** @deprecated Use taskKey(config) instead — kept for CLI backward compat */
|
|
463
|
+
export function taskHash(prompt: string): string {
|
|
464
|
+
return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function validateDag(configs: JobConfig[]): string | null {
|
|
468
|
+
const ids = new Set(configs.map(c => c.id).filter(Boolean));
|
|
469
|
+
// Check all depends_on references exist
|
|
470
|
+
for (const c of configs) {
|
|
471
|
+
for (const dep of c.depends_on ?? []) {
|
|
472
|
+
if (!ids.has(dep)) {
|
|
473
|
+
return `Task "${c.id ?? c.prompt.slice(0, 40)}" depends on unknown id "${dep}"`;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Check for cycles via DFS
|
|
478
|
+
const visited = new Set<string>();
|
|
479
|
+
const inStack = new Set<string>();
|
|
480
|
+
const idToConfig = new Map(configs.filter(c => c.id).map(c => [c.id!, c]));
|
|
481
|
+
|
|
482
|
+
function hasCycle(id: string): boolean {
|
|
483
|
+
if (inStack.has(id)) return true;
|
|
484
|
+
if (visited.has(id)) return false;
|
|
485
|
+
visited.add(id);
|
|
486
|
+
inStack.add(id);
|
|
487
|
+
const config = idToConfig.get(id);
|
|
488
|
+
for (const dep of config?.depends_on ?? []) {
|
|
489
|
+
if (hasCycle(dep)) return true;
|
|
490
|
+
}
|
|
491
|
+
inStack.delete(id);
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
for (const id of ids) {
|
|
496
|
+
if (hasCycle(id)) return `Dependency cycle detected involving "${id}"`;
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function depsReady(
|
|
502
|
+
config: JobConfig,
|
|
503
|
+
completed: Record<string, JobResult>,
|
|
504
|
+
): "ready" | "waiting" | "blocked" {
|
|
505
|
+
if (!config.depends_on || config.depends_on.length === 0) return "ready";
|
|
506
|
+
for (const dep of config.depends_on) {
|
|
507
|
+
const result = completed[dep];
|
|
508
|
+
if (!result) return "waiting";
|
|
509
|
+
if (result.status !== "success") return "blocked";
|
|
510
|
+
}
|
|
511
|
+
return "ready";
|
|
512
|
+
}
|
|
513
|
+
|
|
224
514
|
export function saveState(state: RunState, stateFile: string): void {
|
|
225
515
|
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
226
516
|
}
|
|
@@ -239,39 +529,126 @@ export async function runJobsWithState(
|
|
|
239
529
|
options: {
|
|
240
530
|
stateFile?: string;
|
|
241
531
|
log?: LogCallback;
|
|
242
|
-
|
|
243
|
-
priorResults?: JobResult[];
|
|
532
|
+
reloadConfigs?: () => JobConfig[]; // Called between jobs to pick up new tasks
|
|
244
533
|
} = {}
|
|
245
534
|
): Promise<JobResult[]> {
|
|
246
535
|
const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
|
|
247
|
-
const results: JobResult[] = options.priorResults
|
|
248
|
-
? [...options.priorResults]
|
|
249
|
-
: [];
|
|
250
|
-
const startIndex = options.startIndex ?? 0;
|
|
251
536
|
|
|
252
|
-
|
|
253
|
-
|
|
537
|
+
// Validate DAG if any tasks have dependencies
|
|
538
|
+
const dagError = validateDag(configs);
|
|
539
|
+
if (dagError) {
|
|
540
|
+
options.log?.(`\x1b[31m✗ DAG error: ${dagError}\x1b[0m`);
|
|
541
|
+
return [];
|
|
542
|
+
}
|
|
254
543
|
|
|
255
|
-
|
|
544
|
+
// Load existing state or start fresh
|
|
545
|
+
const state: RunState = loadState(stateFile) ?? {
|
|
546
|
+
completed: {},
|
|
547
|
+
timestamp: new Date().toISOString(),
|
|
548
|
+
};
|
|
256
549
|
|
|
257
|
-
|
|
258
|
-
|
|
550
|
+
let currentConfigs = configs;
|
|
551
|
+
|
|
552
|
+
while (true) {
|
|
553
|
+
// Find tasks not yet completed
|
|
554
|
+
const notDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
|
|
555
|
+
if (notDone.length === 0) break;
|
|
556
|
+
|
|
557
|
+
// Among not-done tasks, find those whose dependencies are satisfied
|
|
558
|
+
const ready = notDone.filter(c => depsReady(c, state.completed) === "ready");
|
|
559
|
+
|
|
560
|
+
// Find tasks blocked by failed dependencies
|
|
561
|
+
const blocked = notDone.filter(c => depsReady(c, state.completed) === "blocked");
|
|
562
|
+
|
|
563
|
+
// Mark blocked tasks as failed without running them
|
|
564
|
+
for (const bc of blocked) {
|
|
565
|
+
const key = taskKey(bc);
|
|
566
|
+
if (key in state.completed) continue; // already recorded
|
|
567
|
+
const failedDeps = (bc.depends_on ?? []).filter(
|
|
568
|
+
dep => state.completed[dep] && state.completed[dep].status !== "success"
|
|
569
|
+
);
|
|
570
|
+
const label = bc.id ?? bc.prompt.slice(0, 40);
|
|
571
|
+
options.log?.(`\n\x1b[31m✗ Skipping "${label}" — dependency failed: ${failedDeps.join(", ")}\x1b[0m`);
|
|
572
|
+
state.completed[key] = {
|
|
573
|
+
task: bc.prompt,
|
|
574
|
+
status: "failed",
|
|
575
|
+
error: `Blocked by failed dependencies: ${failedDeps.join(", ")}`,
|
|
576
|
+
duration_seconds: 0,
|
|
577
|
+
verified: false,
|
|
578
|
+
retries: 0,
|
|
579
|
+
};
|
|
580
|
+
state.timestamp = new Date().toISOString();
|
|
581
|
+
saveState(state, stateFile);
|
|
582
|
+
}
|
|
259
583
|
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
584
|
+
// If nothing is ready and nothing is blocked, everything remaining is waiting
|
|
585
|
+
// on something that will never complete — break to avoid infinite loop
|
|
586
|
+
if (ready.length === 0) break;
|
|
587
|
+
|
|
588
|
+
const config = ready[0];
|
|
589
|
+
const key = taskKey(config);
|
|
590
|
+
|
|
591
|
+
const totalNotDone = notDone.length - blocked.length;
|
|
592
|
+
const totalDone = Object.keys(state.completed).length;
|
|
593
|
+
const label = config.id ? `${config.id}` : "";
|
|
594
|
+
options.log?.(`\n\x1b[1m[${totalDone + 1}/${totalDone + totalNotDone}]${label ? ` ${label}` : ""}\x1b[0m`);
|
|
595
|
+
|
|
596
|
+
// Check if this task was previously in-progress (crashed mid-task)
|
|
597
|
+
const resumeSessionId = (state.inProgress?.hash === key)
|
|
598
|
+
? state.inProgress.sessionId
|
|
599
|
+
: undefined;
|
|
600
|
+
|
|
601
|
+
if (resumeSessionId) {
|
|
602
|
+
options.log?.(`\x1b[2mResuming session ${resumeSessionId.slice(0, 8)}...\x1b[0m`);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Mark task as in-progress before starting
|
|
606
|
+
state.inProgress = { hash: key, prompt: config.prompt, startedAt: new Date().toISOString() };
|
|
267
607
|
saveState(state, stateFile);
|
|
268
608
|
|
|
609
|
+
const result = await runJob(config, options.log, {
|
|
610
|
+
resumeSessionId,
|
|
611
|
+
onSessionId: (id) => {
|
|
612
|
+
// Checkpoint the session ID so we can resume on crash
|
|
613
|
+
state.inProgress = { hash: key, prompt: config.prompt, sessionId: id, startedAt: state.inProgress!.startedAt };
|
|
614
|
+
saveState(state, stateFile);
|
|
615
|
+
},
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
// Task done — save result and clear in-progress
|
|
619
|
+
state.completed[key] = result;
|
|
620
|
+
state.inProgress = undefined;
|
|
621
|
+
state.timestamp = new Date().toISOString();
|
|
622
|
+
saveState(state, stateFile);
|
|
623
|
+
|
|
624
|
+
// Re-read YAML to pick up new tasks added while running
|
|
625
|
+
if (options.reloadConfigs) {
|
|
626
|
+
try {
|
|
627
|
+
currentConfigs = options.reloadConfigs();
|
|
628
|
+
// Re-validate DAG with new configs
|
|
629
|
+
const newDagError = validateDag(currentConfigs);
|
|
630
|
+
if (newDagError) {
|
|
631
|
+
options.log?.(`\x1b[33m⚠ DAG error in updated YAML, ignoring reload: ${newDagError}\x1b[0m`);
|
|
632
|
+
currentConfigs = configs; // revert to original
|
|
633
|
+
}
|
|
634
|
+
} catch {
|
|
635
|
+
// If reload fails (e.g. YAML syntax error mid-edit), keep current list
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
269
639
|
// Brief pause between jobs
|
|
270
|
-
|
|
640
|
+
const nextNotDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
|
|
641
|
+
const nextReady = nextNotDone.filter(c => depsReady(c, state.completed) === "ready");
|
|
642
|
+
if (nextReady.length > 0) {
|
|
271
643
|
await sleep(1000);
|
|
272
644
|
}
|
|
273
645
|
}
|
|
274
646
|
|
|
647
|
+
// Collect results in original order
|
|
648
|
+
const results = currentConfigs
|
|
649
|
+
.map(c => state.completed[taskKey(c)])
|
|
650
|
+
.filter((r): r is JobResult => r !== undefined);
|
|
651
|
+
|
|
275
652
|
// Clean up state file on completion
|
|
276
653
|
clearState(stateFile);
|
|
277
654
|
|