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