@yail259/overnight 0.2.0 → 1.0.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 +71 -225
- package/bin/overnight.js +2 -0
- package/dist/cli.js +103923 -23493
- package/package.json +27 -6
- package/bun.lock +0 -63
- package/src/cli.ts +0 -538
- package/src/notify.ts +0 -50
- package/src/report.ts +0 -115
- package/src/runner.ts +0 -660
- package/src/security.ts +0 -162
- package/src/types.ts +0 -81
- package/tsconfig.json +0 -15
package/src/runner.ts
DELETED
|
@@ -1,660 +0,0 @@
|
|
|
1
|
-
import { query, type Options as ClaudeCodeOptions } from "@anthropic-ai/claude-agent-sdk";
|
|
2
|
-
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
|
|
3
|
-
import { execSync } from "child_process";
|
|
4
|
-
import { createHash } from "crypto";
|
|
5
|
-
import {
|
|
6
|
-
type JobConfig,
|
|
7
|
-
type JobResult,
|
|
8
|
-
type RunState,
|
|
9
|
-
DEFAULT_TOOLS,
|
|
10
|
-
DEFAULT_TIMEOUT,
|
|
11
|
-
DEFAULT_RETRY_COUNT,
|
|
12
|
-
DEFAULT_RETRY_DELAY,
|
|
13
|
-
DEFAULT_VERIFY_PROMPT,
|
|
14
|
-
DEFAULT_STATE_FILE,
|
|
15
|
-
DEFAULT_MAX_TURNS,
|
|
16
|
-
} from "./types.js";
|
|
17
|
-
import { createSecurityHooks } from "./security.js";
|
|
18
|
-
|
|
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
|
-
}
|
|
109
|
-
|
|
110
|
-
function isRetryableError(error: Error): boolean {
|
|
111
|
-
const errorStr = error.message.toLowerCase();
|
|
112
|
-
const retryablePatterns = [
|
|
113
|
-
"api",
|
|
114
|
-
"timeout",
|
|
115
|
-
"connection",
|
|
116
|
-
"network",
|
|
117
|
-
"rate limit",
|
|
118
|
-
"503",
|
|
119
|
-
"502",
|
|
120
|
-
"500",
|
|
121
|
-
"unavailable",
|
|
122
|
-
"overloaded",
|
|
123
|
-
];
|
|
124
|
-
return retryablePatterns.some((pattern) => errorStr.includes(pattern));
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function sleep(ms: number): Promise<void> {
|
|
128
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
async function runWithTimeout<T>(
|
|
132
|
-
promise: Promise<T>,
|
|
133
|
-
timeoutMs: number
|
|
134
|
-
): Promise<T> {
|
|
135
|
-
let timeoutId: Timer;
|
|
136
|
-
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
137
|
-
timeoutId = setTimeout(() => reject(new Error("TIMEOUT")), timeoutMs);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
const result = await Promise.race([promise, timeoutPromise]);
|
|
142
|
-
clearTimeout(timeoutId!);
|
|
143
|
-
return result;
|
|
144
|
-
} catch (e) {
|
|
145
|
-
clearTimeout(timeoutId!);
|
|
146
|
-
throw e;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
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(
|
|
174
|
-
prompt: string,
|
|
175
|
-
options: ClaudeCodeOptions,
|
|
176
|
-
progress: ProgressDisplay,
|
|
177
|
-
onSessionId?: (sessionId: string) => void
|
|
178
|
-
): Promise<{ sessionId?: string; result?: string; error?: string }> {
|
|
179
|
-
let sessionId: string | undefined;
|
|
180
|
-
let result: string | undefined;
|
|
181
|
-
let lastError: string | undefined;
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
const conversation = query({ prompt, options });
|
|
185
|
-
|
|
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
|
-
}
|
|
219
|
-
}
|
|
220
|
-
} catch (e) {
|
|
221
|
-
lastError = (e as Error).message;
|
|
222
|
-
throw e;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return { sessionId, result, error: lastError };
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export async function runJob(
|
|
229
|
-
config: JobConfig,
|
|
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
|
-
}
|
|
235
|
-
): Promise<JobResult> {
|
|
236
|
-
const startTime = Date.now();
|
|
237
|
-
const tools = config.allowed_tools ?? DEFAULT_TOOLS;
|
|
238
|
-
const timeout = (config.timeout_seconds ?? DEFAULT_TIMEOUT) * 1000;
|
|
239
|
-
const retryCount = config.retry_count ?? DEFAULT_RETRY_COUNT;
|
|
240
|
-
const retryDelay = config.retry_delay ?? DEFAULT_RETRY_DELAY;
|
|
241
|
-
const verifyPrompt = config.verify_prompt ?? DEFAULT_VERIFY_PROMPT;
|
|
242
|
-
let retriesUsed = 0;
|
|
243
|
-
let resumeSessionId = options?.resumeSessionId;
|
|
244
|
-
|
|
245
|
-
const logMsg = (msg: string) => log?.(msg);
|
|
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
|
-
}
|
|
276
|
-
|
|
277
|
-
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
278
|
-
try {
|
|
279
|
-
// Build security hooks if security config provided
|
|
280
|
-
const securityHooks = config.security ? createSecurityHooks(config.security) : undefined;
|
|
281
|
-
|
|
282
|
-
const sdkOptions: ClaudeCodeOptions = {
|
|
283
|
-
allowedTools: tools,
|
|
284
|
-
permissionMode: "acceptEdits",
|
|
285
|
-
...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
|
|
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 }),
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
let sessionId: string | undefined;
|
|
293
|
-
let result: string | undefined;
|
|
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
|
-
|
|
303
|
-
try {
|
|
304
|
-
const collected = await runWithTimeout(
|
|
305
|
-
collectResultWithProgress(prompt, sdkOptions, progress, (id) => {
|
|
306
|
-
sessionId = id;
|
|
307
|
-
options?.onSessionId?.(id);
|
|
308
|
-
}),
|
|
309
|
-
timeout
|
|
310
|
-
);
|
|
311
|
-
sessionId = collected.sessionId;
|
|
312
|
-
result = collected.result;
|
|
313
|
-
progress.stop();
|
|
314
|
-
} catch (e) {
|
|
315
|
-
progress.stop();
|
|
316
|
-
if ((e as Error).message === "TIMEOUT") {
|
|
317
|
-
if (attempt < retryCount) {
|
|
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
|
-
}
|
|
323
|
-
const delay = retryDelay * Math.pow(2, attempt);
|
|
324
|
-
logMsg(
|
|
325
|
-
`\x1b[33m⚠ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
|
|
326
|
-
);
|
|
327
|
-
await sleep(delay * 1000);
|
|
328
|
-
continue;
|
|
329
|
-
}
|
|
330
|
-
logMsg(
|
|
331
|
-
`\x1b[31m✗ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)\x1b[0m`
|
|
332
|
-
);
|
|
333
|
-
return {
|
|
334
|
-
task: config.prompt,
|
|
335
|
-
status: "timeout",
|
|
336
|
-
error: `Timed out after ${config.timeout_seconds ?? DEFAULT_TIMEOUT} seconds`,
|
|
337
|
-
duration_seconds: (Date.now() - startTime) / 1000,
|
|
338
|
-
verified: false,
|
|
339
|
-
retries: retriesUsed,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
throw e;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
// Verification pass if enabled — verify and fix issues
|
|
346
|
-
if (config.verify !== false && sessionId) {
|
|
347
|
-
progress.start("Verifying");
|
|
348
|
-
|
|
349
|
-
const verifyOptions: ClaudeCodeOptions = {
|
|
350
|
-
allowedTools: tools,
|
|
351
|
-
resume: sessionId,
|
|
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 }),
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
const fixPrompt = verifyPrompt +
|
|
359
|
-
" If you find any issues, fix them now. Only report issues you cannot fix.";
|
|
360
|
-
|
|
361
|
-
try {
|
|
362
|
-
const verifyResult = await runWithTimeout(
|
|
363
|
-
collectResultWithProgress(fixPrompt, verifyOptions, progress, (id) => {
|
|
364
|
-
sessionId = id;
|
|
365
|
-
options?.onSessionId?.(id);
|
|
366
|
-
}),
|
|
367
|
-
timeout / 2
|
|
368
|
-
);
|
|
369
|
-
progress.stop();
|
|
370
|
-
|
|
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"];
|
|
378
|
-
if (
|
|
379
|
-
verifyResult.result &&
|
|
380
|
-
unfixableWords.some((word) =>
|
|
381
|
-
verifyResult.result!.toLowerCase().includes(word)
|
|
382
|
-
)
|
|
383
|
-
) {
|
|
384
|
-
logMsg(`\x1b[33m⚠ Verification found unfixable issues\x1b[0m`);
|
|
385
|
-
return {
|
|
386
|
-
task: config.prompt,
|
|
387
|
-
status: "verification_failed",
|
|
388
|
-
result,
|
|
389
|
-
error: `Unfixable issues: ${verifyResult.result}`,
|
|
390
|
-
duration_seconds: (Date.now() - startTime) / 1000,
|
|
391
|
-
verified: false,
|
|
392
|
-
retries: retriesUsed,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
} catch (e) {
|
|
396
|
-
progress.stop();
|
|
397
|
-
if ((e as Error).message === "TIMEOUT") {
|
|
398
|
-
logMsg("\x1b[33m⚠ Verification timed out - continuing anyway\x1b[0m");
|
|
399
|
-
} else {
|
|
400
|
-
throw e;
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
406
|
-
logMsg(`\x1b[32m✓ Completed in ${duration.toFixed(1)}s\x1b[0m`);
|
|
407
|
-
|
|
408
|
-
return {
|
|
409
|
-
task: config.prompt,
|
|
410
|
-
status: "success",
|
|
411
|
-
result,
|
|
412
|
-
duration_seconds: duration,
|
|
413
|
-
verified: config.verify !== false,
|
|
414
|
-
retries: retriesUsed,
|
|
415
|
-
};
|
|
416
|
-
} catch (e) {
|
|
417
|
-
progress.stop();
|
|
418
|
-
const error = e as Error;
|
|
419
|
-
if (isRetryableError(error) && attempt < retryCount) {
|
|
420
|
-
retriesUsed = attempt + 1;
|
|
421
|
-
// Preserve session for resumption on retry
|
|
422
|
-
if (sessionId) {
|
|
423
|
-
resumeSessionId = sessionId;
|
|
424
|
-
}
|
|
425
|
-
const delay = retryDelay * Math.pow(2, attempt);
|
|
426
|
-
logMsg(
|
|
427
|
-
`\x1b[33m⚠ ${error.message}, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
|
|
428
|
-
);
|
|
429
|
-
await sleep(delay * 1000);
|
|
430
|
-
continue;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const duration = (Date.now() - startTime) / 1000;
|
|
434
|
-
logMsg(`\x1b[31m✗ Failed: ${error.message}\x1b[0m`);
|
|
435
|
-
return {
|
|
436
|
-
task: config.prompt,
|
|
437
|
-
status: "failed",
|
|
438
|
-
error: error.message,
|
|
439
|
-
duration_seconds: duration,
|
|
440
|
-
verified: false,
|
|
441
|
-
retries: retriesUsed,
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// Should not reach here
|
|
447
|
-
return {
|
|
448
|
-
task: config.prompt,
|
|
449
|
-
status: "failed",
|
|
450
|
-
error: "Exhausted all retries",
|
|
451
|
-
duration_seconds: (Date.now() - startTime) / 1000,
|
|
452
|
-
verified: false,
|
|
453
|
-
retries: retriesUsed,
|
|
454
|
-
};
|
|
455
|
-
}
|
|
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
|
-
|
|
514
|
-
export function saveState(state: RunState, stateFile: string): void {
|
|
515
|
-
writeFileSync(stateFile, JSON.stringify(state, null, 2));
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
export function loadState(stateFile: string): RunState | null {
|
|
519
|
-
if (!existsSync(stateFile)) return null;
|
|
520
|
-
return JSON.parse(readFileSync(stateFile, "utf-8"));
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
export function clearState(stateFile: string): void {
|
|
524
|
-
if (existsSync(stateFile)) unlinkSync(stateFile);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
export async function runJobsWithState(
|
|
528
|
-
configs: JobConfig[],
|
|
529
|
-
options: {
|
|
530
|
-
stateFile?: string;
|
|
531
|
-
log?: LogCallback;
|
|
532
|
-
reloadConfigs?: () => JobConfig[]; // Called between jobs to pick up new tasks
|
|
533
|
-
} = {}
|
|
534
|
-
): Promise<JobResult[]> {
|
|
535
|
-
const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
|
|
536
|
-
|
|
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
|
-
}
|
|
543
|
-
|
|
544
|
-
// Load existing state or start fresh
|
|
545
|
-
const state: RunState = loadState(stateFile) ?? {
|
|
546
|
-
completed: {},
|
|
547
|
-
timestamp: new Date().toISOString(),
|
|
548
|
-
};
|
|
549
|
-
|
|
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
|
-
}
|
|
583
|
-
|
|
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() };
|
|
607
|
-
saveState(state, stateFile);
|
|
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
|
-
|
|
639
|
-
// Brief pause between jobs
|
|
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) {
|
|
643
|
-
await sleep(1000);
|
|
644
|
-
}
|
|
645
|
-
}
|
|
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
|
-
|
|
652
|
-
// Clean up state file on completion
|
|
653
|
-
clearState(stateFile);
|
|
654
|
-
|
|
655
|
-
return results;
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
export function resultsToJson(results: JobResult[]): string {
|
|
659
|
-
return JSON.stringify(results, null, 2);
|
|
660
|
-
}
|