@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/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
- async function collectResult(
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
- ): Promise<{ sessionId?: string; result?: string }> {
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
- const conversation = query({ prompt, options });
183
+ try {
184
+ const conversation = query({ prompt, options });
65
185
 
66
- for await (const message of conversation) {
67
- if (message.type === "result") {
68
- result = message.result;
69
- sessionId = message.session_id;
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
- logMsg(`Starting: ${config.prompt.slice(0, 60)}...`);
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
- const options: ClaudeCodeOptions = {
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
- collectResult(config.prompt, options),
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
- `Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (attempt ${attempt + 1}/${retryCount})...`
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
- `Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)`
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
- logMsg("Running verification...");
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
- collectResult(verifyPrompt, verifyOptions),
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
- const issueWords = ["issue", "error", "fail", "incorrect", "missing"];
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
- issueWords.some((word) =>
380
+ unfixableWords.some((word) =>
154
381
  verifyResult.result!.toLowerCase().includes(word)
155
382
  )
156
383
  ) {
157
- logMsg("Verification found potential issues");
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: `Verification issues: ${verifyResult.result}`,
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(`Completed in ${duration.toFixed(1)}s`);
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
- `Retryable error: ${error.message}, retrying in ${delay}s (attempt ${attempt + 1}/${retryCount})...`
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(`Failed: ${error.message}`);
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
- startIndex?: number;
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
- for (let i = 0; i < configs.length; i++) {
253
- if (i < startIndex) continue;
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
- options.log?.(`\n[${i + 1}/${configs.length}] Running job...`);
544
+ // Load existing state or start fresh
545
+ const state: RunState = loadState(stateFile) ?? {
546
+ completed: {},
547
+ timestamp: new Date().toISOString(),
548
+ };
256
549
 
257
- const result = await runJob(configs[i], options.log);
258
- results.push(result);
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
- // Save state after each job
261
- const state: RunState = {
262
- completed_indices: Array.from({ length: results.length }, (_, i) => i),
263
- results,
264
- timestamp: new Date().toISOString(),
265
- total_jobs: configs.length,
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
- if (i < configs.length - 1) {
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