nightralph 0.0.18 → 0.0.21

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.
@@ -38,10 +38,21 @@ import {
38
38
  import {
39
39
  createDisplay
40
40
  } from "./display.js";
41
+ import { runTestCmd } from "./testcmd.js";
41
42
  function formatErrorMessage(error) {
42
43
  return error instanceof Error ? error.message : String(error);
43
44
  }
44
45
  __name(formatErrorMessage, "formatErrorMessage");
46
+ async function removeWorktreeQuietly(worktreePath, d) {
47
+ try {
48
+ await removeWorktree(worktreePath, { force: true });
49
+ } catch (err) {
50
+ d.log(
51
+ ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
52
+ );
53
+ }
54
+ }
55
+ __name(removeWorktreeQuietly, "removeWorktreeQuietly");
45
56
  const TICKET_RE = /^(\d+)-(.+)\.md$/;
46
57
  function parseTicketFilename(filename) {
47
58
  const m = filename.match(TICKET_RE);
@@ -132,7 +143,39 @@ function markDone(ticket) {
132
143
  ticket.body = updated;
133
144
  }
134
145
  __name(markDone, "markDone");
135
- function renderPrompt(specBody, ticket) {
146
+ function stripSpecSection(specBody, heading) {
147
+ const out = [];
148
+ let skipping = false;
149
+ let skipLevel = 0;
150
+ let inFence = false;
151
+ for (const line of specBody.split("\n")) {
152
+ if (/^\s*```/.test(line)) {
153
+ inFence = !inFence;
154
+ }
155
+ if (!inFence) {
156
+ const m = /^(#{1,6})\s+(.*?)\s*$/.exec(line);
157
+ if (m) {
158
+ const level = m[1].length;
159
+ if (skipping && level <= skipLevel) {
160
+ skipping = false;
161
+ }
162
+ if (!skipping && m[2] === heading) {
163
+ skipping = true;
164
+ skipLevel = level;
165
+ continue;
166
+ }
167
+ }
168
+ }
169
+ if (!skipping) out.push(line);
170
+ }
171
+ return out.join("\n");
172
+ }
173
+ __name(stripSpecSection, "stripSpecSection");
174
+ function renderPrompt(specBody, ticket, opts = {}) {
175
+ const testRules = opts.testCmd ? [
176
+ "- Work test-first. For each checklist item, write a failing test at the seams named in the spec's Testing Decisions section, then write only enough code to make it pass.",
177
+ `- Run \`${opts.testCmd}\` before committing. Do not commit with failing tests. The orchestrator runs the same command after you exit and rejects the ticket if it fails.`
178
+ ] : [];
136
179
  return [
137
180
  "---- SPEC CONTEXT ----",
138
181
  specBody,
@@ -144,10 +187,12 @@ function renderPrompt(specBody, ticket) {
144
187
  "---- AGENT INSTRUCTIONS ----",
145
188
  "You are an autonomous coding agent. Implement the ticket.",
146
189
  "",
147
- "Time is limited. Follow these rules:",
148
- "- Spend at most 2 minutes reading existing code. Read only the files directly relevant to the ticket.",
190
+ "Follow these rules:",
191
+ "- Read the files directly relevant to the ticket before writing code.",
149
192
  "- Do NOT research external APIs or services via web search. Use the spec and ticket body as your sole reference for API shapes.",
150
- "- Start writing code as soon as you understand the immediate context. You can read more files as needed while implementing.",
193
+ ...testRules,
194
+ "- Do NOT read or modify files outside your current directory (the worktree).",
195
+ "- Do NOT run git push.",
151
196
  "- Commit your work when finished.",
152
197
  "- After completing each checklist item in the ticket, print a line: [x] <item text> (matching the checklist text exactly).",
153
198
  ""
@@ -173,7 +218,8 @@ function renderMergePrompt(specBody, ticket) {
173
218
  "2. Make sure the code compiles and tests pass.",
174
219
  "3. Commit the resolved files.",
175
220
  "",
176
- "Do NOT re-implement the ticket from scratch."
221
+ "Do NOT re-implement the ticket from scratch.",
222
+ "Do NOT run git push."
177
223
  ].join("\n");
178
224
  }
179
225
  __name(renderMergePrompt, "renderMergePrompt");
@@ -252,7 +298,11 @@ const PROVIDER_ARGS = {
252
298
  "stream-json",
253
299
  "--dangerously-skip-permissions"
254
300
  ],
255
- codex: ["exec", "--full-auto"],
301
+ // codex removed --full-auto; this is the counterpart
302
+ // of claude's --dangerously-skip-permissions, and the
303
+ // workspace-write sandbox would block commits because
304
+ // a worktree's .git link points outside the tree
305
+ codex: ["exec", "--dangerously-bypass-approvals-and-sandbox"],
256
306
  pi: [
257
307
  "-p",
258
308
  "--verbose",
@@ -267,6 +317,21 @@ function getProviderArgs(cmd) {
267
317
  return PROVIDER_ARGS[name] ?? [];
268
318
  }
269
319
  __name(getProviderArgs, "getProviderArgs");
320
+ const THINKING_LEVELS = [
321
+ "off",
322
+ "minimal",
323
+ "low",
324
+ "medium",
325
+ "high",
326
+ "xhigh",
327
+ "max"
328
+ ];
329
+ function escalateThinking(level) {
330
+ if (!level) return "high";
331
+ const i = THINKING_LEVELS.indexOf(level);
332
+ return THINKING_LEVELS[Math.min(i + 1, THINKING_LEVELS.length - 1)];
333
+ }
334
+ __name(escalateThinking, "escalateThinking");
270
335
  function formatStreamLine(line) {
271
336
  let obj;
272
337
  try {
@@ -318,7 +383,7 @@ function createPiStreamFormatter() {
318
383
  return [out];
319
384
  }
320
385
  __name(flushBuf, "flushBuf");
321
- function process(line) {
386
+ function process2(line) {
322
387
  let obj;
323
388
  try {
324
389
  obj = JSON.parse(line);
@@ -361,8 +426,8 @@ function createPiStreamFormatter() {
361
426
  }
362
427
  return [];
363
428
  }
364
- __name(process, "process");
365
- return { process, flush: flushBuf };
429
+ __name(process2, "process");
430
+ return { process: process2, flush: flushBuf };
366
431
  }
367
432
  __name(createPiStreamFormatter, "createPiStreamFormatter");
368
433
  function writePromptFile(prompt) {
@@ -379,6 +444,22 @@ function cleanupPromptFile(filePath) {
379
444
  }
380
445
  }
381
446
  __name(cleanupPromptFile, "cleanupPromptFile");
447
+ const CLOSE_GRACE_MS = 2e3;
448
+ function killProcessGroup(pid) {
449
+ if (pid === void 0) return;
450
+ try {
451
+ process.kill(-pid, "SIGKILL");
452
+ } catch {
453
+ }
454
+ }
455
+ __name(killProcessGroup, "killProcessGroup");
456
+ const runningAgents = /* @__PURE__ */ new Set();
457
+ function killRunningAgents() {
458
+ for (const pid of runningAgents) {
459
+ killProcessGroup(pid);
460
+ }
461
+ }
462
+ __name(killRunningAgents, "killRunningAgents");
382
463
  function spawnAgent(opts) {
383
464
  const args = [
384
465
  ...getProviderArgs(opts.agentCmd)
@@ -386,9 +467,15 @@ function spawnAgent(opts) {
386
467
  if (opts.model) {
387
468
  args.push("--model", opts.model);
388
469
  }
470
+ if (opts.maxTurns) {
471
+ args.push("--max-turns", String(opts.maxTurns));
472
+ }
389
473
  const providerName = basename(opts.agentCmd);
390
474
  const isClaude = providerName === "claude";
391
475
  const isPi = providerName === "pi";
476
+ if (isPi && opts.thinking) {
477
+ args.push("--thinking", opts.thinking);
478
+ }
392
479
  const prefix = opts.label ? ` [${opts.label}] ` : " ";
393
480
  let promptFile = null;
394
481
  if (isPi) {
@@ -401,8 +488,10 @@ function spawnAgent(opts) {
401
488
  );
402
489
  const proc = spawn(opts.agentCmd, args, {
403
490
  stdio: ["pipe", "pipe", "pipe"],
404
- cwd: opts.cwd
491
+ cwd: opts.cwd,
492
+ detached: true
405
493
  });
494
+ if (proc.pid !== void 0) runningAgents.add(proc.pid);
406
495
  proc.stdout.pipe(logStream);
407
496
  const piFormatter = isPi ? createPiStreamFormatter() : null;
408
497
  const completedItems = [];
@@ -456,21 +545,41 @@ function spawnAgent(opts) {
456
545
  console.error(output);
457
546
  }
458
547
  });
548
+ let timedOut = false;
459
549
  const timeout = setTimeout(() => {
460
- if (!proc.killed) {
461
- console.warn(
462
- `Agent did not exit in ${opts.timeout}s; killing.`
463
- );
464
- proc.kill("SIGKILL");
550
+ if (proc.killed) return;
551
+ timedOut = true;
552
+ const msg = `agent timed out after ${opts.timeout}s; killing it`;
553
+ logStream.write(`nightralph: ${msg}
554
+ `);
555
+ if (opts.onLine) {
556
+ opts.onLine(`${prefix}${msg}`);
557
+ } else {
558
+ console.warn(`${prefix}${msg}`);
465
559
  }
560
+ killProcessGroup(proc.pid);
466
561
  }, opts.timeout * 1e3);
562
+ proc.stdin.on("error", (err) => {
563
+ logStream.write(
564
+ `nightralph: stdin write failed: ${formatErrorMessage(err)}
565
+ `
566
+ );
567
+ });
467
568
  if (!promptFile) {
468
569
  proc.stdin.write(opts.prompt);
469
570
  }
470
571
  proc.stdin.end();
471
572
  return new Promise((resolve) => {
472
- proc.on("close", (code) => {
573
+ let settled = false;
574
+ let graceTimer;
575
+ function finish(exitCode) {
576
+ if (settled) return;
577
+ settled = true;
473
578
  clearTimeout(timeout);
579
+ if (graceTimer) clearTimeout(graceTimer);
580
+ if (proc.pid !== void 0) {
581
+ runningAgents.delete(proc.pid);
582
+ }
474
583
  if (piFormatter) {
475
584
  for (const line of piFormatter.flush()) {
476
585
  collectCompleted(line);
@@ -486,13 +595,27 @@ function spawnAgent(opts) {
486
595
  rlErr.close();
487
596
  logStream.end();
488
597
  if (promptFile) cleanupPromptFile(promptFile);
489
- resolve({
490
- exitCode: code ?? 1,
491
- completedItems
492
- });
598
+ resolve({ exitCode, completedItems, timedOut });
599
+ }
600
+ __name(finish, "finish");
601
+ proc.on("exit", (code) => {
602
+ graceTimer = setTimeout(() => {
603
+ proc.stdout.destroy();
604
+ proc.stderr.destroy();
605
+ finish(code ?? 1);
606
+ }, CLOSE_GRACE_MS);
607
+ });
608
+ proc.on("close", (code) => {
609
+ finish(code ?? 1);
493
610
  });
494
611
  proc.on("error", (err) => {
612
+ if (settled) return;
613
+ settled = true;
614
+ if (graceTimer) clearTimeout(graceTimer);
495
615
  clearTimeout(timeout);
616
+ if (proc.pid !== void 0) {
617
+ runningAgents.delete(proc.pid);
618
+ }
496
619
  rl.close();
497
620
  rlErr.close();
498
621
  logStream.end();
@@ -501,7 +624,11 @@ function spawnAgent(opts) {
501
624
  "Agent process error:",
502
625
  formatErrorMessage(err)
503
626
  );
504
- resolve({ exitCode: 1, completedItems });
627
+ resolve({
628
+ exitCode: 1,
629
+ completedItems,
630
+ timedOut
631
+ });
505
632
  });
506
633
  });
507
634
  }
@@ -564,18 +691,65 @@ Prompt for first ready ticket (${firstReady[0].filename}):
564
691
  `
565
692
  );
566
693
  console.log(
567
- renderPrompt(opts.specBody, firstReady[0])
694
+ renderPrompt(
695
+ opts.specBody,
696
+ firstReady[0],
697
+ { testCmd: opts.testCmd }
698
+ )
568
699
  );
569
700
  }
570
701
  const strategyDesc = opts.conflictStrategy === "respawn" ? "respawn (re-run agent on conflict)" : "stop (stop merging on conflict)";
571
702
  console.log(
572
703
  `
704
+ Test command: ${opts.testCmd ?? "(none)"}`
705
+ );
706
+ console.log(
707
+ `Thinking: ${opts.thinking ?? "(provider default)"}`
708
+ );
709
+ console.log(
710
+ `Retries: ${opts.retries ?? DEFAULT_RETRIES}`
711
+ );
712
+ console.log(
713
+ `Retry delay: ${opts.retryDelay ?? DEFAULT_RETRY_DELAY}s`
714
+ );
715
+ console.log(
716
+ `
573
717
  Merge strategy: ${strategyDesc}`
574
718
  );
575
719
  console.log(`Progress file: ${opts.progressPath}`);
576
720
  }
577
721
  __name(dryRunIsolated, "dryRunIsolated");
722
+ async function runTestGate(opts) {
723
+ const result = await runTestCmd({
724
+ cmd: opts.testCmd,
725
+ cwd: opts.worktreePath,
726
+ timeout: opts.timeout
727
+ });
728
+ const testLogPath = attemptLogPath(
729
+ opts.logsDir,
730
+ opts.ticket,
731
+ opts.attempt ?? 1,
732
+ ".test.log"
733
+ );
734
+ writeFileSync(testLogPath, result.output);
735
+ if (result.exitCode !== 0) {
736
+ opts.log(
737
+ ` ${opts.ticket.filename}: tests failed (exit ${result.exitCode}); see ${testLogPath}`
738
+ );
739
+ return false;
740
+ }
741
+ opts.log(` ${opts.ticket.filename}: tests passed`);
742
+ return true;
743
+ }
744
+ __name(runTestGate, "runTestGate");
578
745
  async function respawnAndRetryMerge(opts) {
746
+ const log = /* @__PURE__ */ __name((msg) => {
747
+ if (opts.onLine) {
748
+ opts.onLine(msg);
749
+ } else {
750
+ console.log(msg);
751
+ }
752
+ }, "log");
579
753
  await updateWorktreeFromBase(
580
754
  opts.worktreePath,
581
755
  opts.baseBranch
@@ -606,6 +780,7 @@ async function respawnAndRetryMerge(opts) {
606
780
  logPath,
607
781
  cwd: opts.worktreePath,
608
782
  label,
783
+ maxTurns: opts.maxTurns,
609
784
  onLine: opts.onLine
610
785
  });
611
786
  if (result.exitCode !== 0) {
@@ -617,6 +792,22 @@ async function respawnAndRetryMerge(opts) {
617
792
  }
618
793
  return false;
619
794
  }
795
+ if (opts.testCmd) {
796
+ const passed = await runTestGate({
797
+ testCmd: opts.testCmd,
798
+ ticket: opts.ticket,
799
+ worktreePath: opts.worktreePath,
800
+ timeout: opts.timeout,
801
+ logsDir: opts.logsDir,
802
+ log
803
+ });
804
+ if (!passed) {
805
+ log(
806
+ ` ${opts.ticket.filename}: tests failed after conflict respawn; not merging`
807
+ );
808
+ return false;
809
+ }
810
+ }
620
811
  const mergeResult = await mergeBranch(
621
812
  opts.repoRoot,
622
813
  opts.branch
@@ -640,6 +831,171 @@ async function respawnAndRetryMerge(opts) {
640
831
  return mergeResult.success;
641
832
  }
642
833
  __name(respawnAndRetryMerge, "respawnAndRetryMerge");
834
+ const DEFAULT_RETRIES = 2;
835
+ const DEFAULT_RETRY_DELAY = 30;
836
+ function retryDelayFor(base, attempt) {
837
+ return base * 2 ** (attempt - 2);
838
+ }
839
+ __name(retryDelayFor, "retryDelayFor");
840
+ function sleep(ms) {
841
+ return new Promise((resolve) => setTimeout(resolve, ms));
842
+ }
843
+ __name(sleep, "sleep");
844
+ function attemptLogPath(logsDir, ticket, attempt, ext) {
845
+ const stem = ticket.filename.replace(/\.md$/, "");
846
+ const suffix = attempt > 1 ? `.attempt${attempt}` : "";
847
+ return join(logsDir, `${stem}${suffix}${ext}`);
848
+ }
849
+ __name(attemptLogPath, "attemptLogPath");
850
+ async function runTicketAttempt(opts) {
851
+ const { ticket, d } = opts;
852
+ let worktree;
853
+ try {
854
+ worktree = await createWorktree({
855
+ repoRoot: opts.repoRoot,
856
+ repoName: opts.repoName,
857
+ baseBranch: opts.baseBranch,
858
+ ticket
859
+ });
860
+ } catch (error) {
861
+ return { kind: "worktree-error", error };
862
+ }
863
+ d.setTicketStatus(ticket.num, "in-progress");
864
+ const agentResult = await spawnAgent({
865
+ prompt: renderPrompt(
866
+ opts.specBody,
867
+ ticket,
868
+ { testCmd: opts.testCmd }
869
+ ),
870
+ agentCmd: opts.agentCmd,
871
+ model: opts.model,
872
+ timeout: opts.timeout,
873
+ logPath: attemptLogPath(
874
+ opts.logsDir,
875
+ ticket,
876
+ opts.attempt,
877
+ ".log"
878
+ ),
879
+ cwd: worktree.worktreePath,
880
+ label: ticket.filename.replace(/\.md$/, ""),
881
+ maxTurns: opts.maxTurns,
882
+ thinking: opts.thinking,
883
+ onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
884
+ });
885
+ if (agentResult.timedOut) {
886
+ d.log(
887
+ ` ${ticket.filename}: timed out; keeping the work it committed`
888
+ );
889
+ } else if (agentResult.exitCode !== 0) {
890
+ d.log(
891
+ ` ${ticket.filename}: agent exited ${agentResult.exitCode}`
892
+ );
893
+ await removeWorktreeQuietly(worktree.worktreePath, d);
894
+ return {
895
+ kind: "failed",
896
+ reason: "exit",
897
+ branchName: worktree.branchName,
898
+ exitCode: agentResult.exitCode,
899
+ completedItems: agentResult.completedItems
900
+ };
901
+ }
902
+ if (isDirty(worktree.worktreePath)) {
903
+ const committed = await commitDirty(
904
+ worktree.worktreePath,
905
+ `nightralph: auto-commit ${ticket.filename} remaining changes`
906
+ );
907
+ if (committed) {
908
+ d.log(
909
+ ` ${ticket.filename}: auto-committed remaining changes`
910
+ );
911
+ }
912
+ }
913
+ if (!hasNewCommits(
914
+ opts.repoRoot,
915
+ opts.baseBranch,
916
+ worktree.branchName
917
+ )) {
918
+ return { kind: "no-commits" };
919
+ }
920
+ if (opts.testCmd) {
921
+ const passed = await runTestGate({
922
+ testCmd: opts.testCmd,
923
+ ticket,
924
+ worktreePath: worktree.worktreePath,
925
+ timeout: opts.timeout,
926
+ logsDir: opts.logsDir,
927
+ attempt: opts.attempt,
928
+ log: /* @__PURE__ */ __name((msg) => d.log(msg), "log")
929
+ });
930
+ if (!passed) {
931
+ await removeWorktreeQuietly(
932
+ worktree.worktreePath,
933
+ d
934
+ );
935
+ return {
936
+ kind: "failed",
937
+ reason: "tests",
938
+ branchName: worktree.branchName,
939
+ exitCode: 1,
940
+ completedItems: agentResult.completedItems
941
+ };
942
+ }
943
+ }
944
+ return {
945
+ kind: "ready",
946
+ worktreePath: worktree.worktreePath,
947
+ branchName: worktree.branchName,
948
+ completedItems: agentResult.completedItems
949
+ };
950
+ }
951
+ __name(runTicketAttempt, "runTicketAttempt");
952
+ async function runTicketAttempts(opts) {
953
+ const { ticket, d } = opts;
954
+ const maxAttempts = opts.retries + 1;
955
+ const isPi = basename(opts.agentCmd) === "pi";
956
+ let thinking = opts.thinking;
957
+ let outcome = { kind: "no-commits" };
958
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
959
+ if (attempt === 1) {
960
+ d.log(` Starting ${ticket.filename}`);
961
+ } else {
962
+ thinking = escalateThinking(thinking);
963
+ const delay = retryDelayFor(opts.retryDelay, attempt);
964
+ d.log(
965
+ ` ${ticket.filename}: retrying (attempt ${attempt}/${maxAttempts}` + (isPi ? `, thinking ${thinking}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
966
+ );
967
+ if (delay > 0) await sleep(delay * 1e3);
968
+ }
969
+ try {
970
+ outcome = await runTicketAttempt({
971
+ ...opts,
972
+ attempt,
973
+ thinking
974
+ });
975
+ } catch (error) {
976
+ d.log(
977
+ ` ${ticket.filename}: attempt ${attempt} threw: ${formatErrorMessage(error)}`
978
+ );
979
+ return { kind: "error", error };
980
+ }
981
+ if (outcome.kind === "ready" || outcome.kind === "worktree-error") {
982
+ return outcome;
983
+ }
984
+ }
985
+ return outcome;
986
+ }
987
+ __name(runTicketAttempts, "runTicketAttempts");
988
+ async function saveProgress(repoRoot, progressPath, progress, message, d) {
989
+ writeProgress(progressPath, progress);
990
+ try {
991
+ await commitProgress(repoRoot, progressPath, message);
992
+ } catch (err) {
993
+ d.log(
994
+ " Failed to commit progress: " + formatErrorMessage(err)
995
+ );
996
+ }
997
+ }
998
+ __name(saveProgress, "saveProgress");
643
999
  function runProgress(tickets) {
644
1000
  return {
645
1001
  completed: tickets.filter((t) => t.status === "done").length,
@@ -647,15 +1003,21 @@ function runProgress(tickets) {
647
1003
  };
648
1004
  }
649
1005
  __name(runProgress, "runProgress");
650
- async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, display) {
1006
+ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, display, maxTurns, thinking, retries, retryDelay) {
651
1007
  mkdirSync(logsDir, { recursive: true });
652
1008
  const d = display ?? createDisplay();
1009
+ const maxAttempts = (retries ?? DEFAULT_RETRIES) + 1;
1010
+ const delayBase = retryDelay ?? 0;
1011
+ const isPi = basename(agentCmd) === "pi";
653
1012
  let totalWaves = 0;
654
1013
  for (const _ of simulateWaves(tickets)) totalWaves++;
655
1014
  let totalCompleted = 0;
656
1015
  let waveNum = 0;
1016
+ const failedNums = /* @__PURE__ */ new Set();
657
1017
  while (true) {
658
- const ready = findReadyTickets(tickets);
1018
+ const ready = findReadyTickets(tickets).filter(
1019
+ (t) => !failedNums.has(t.num)
1020
+ );
659
1021
  if (ready.length === 0) break;
660
1022
  waveNum++;
661
1023
  const readyNums = new Set(ready.map((t) => t.num));
@@ -678,25 +1040,48 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
678
1040
  const results = await Promise.allSettled(
679
1041
  ready.map(async (t) => {
680
1042
  const prompt = renderPrompt(specBody, t);
681
- const logPath = join(
682
- logsDir,
683
- t.filename.replace(/\.md$/, ".log")
684
- );
685
1043
  const label = t.filename.replace(
686
1044
  /\.md$/,
687
1045
  ""
688
1046
  );
689
1047
  d.setTicketStatus(t.num, "in-progress");
690
1048
  d.log(` Starting ${t.filename}`);
691
- const result = await spawnAgent({
1049
+ let level = thinking;
1050
+ let result = await spawnAgent({
692
1051
  prompt,
693
1052
  agentCmd,
694
1053
  model,
695
1054
  timeout,
696
- logPath,
1055
+ logPath: attemptLogPath(logsDir, t, 1, ".log"),
697
1056
  label,
1057
+ maxTurns,
1058
+ thinking: level,
698
1059
  onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
699
1060
  });
1061
+ for (let attempt = 2; result.exitCode !== 0 && !result.timedOut && attempt <= maxAttempts; attempt++) {
1062
+ level = escalateThinking(level);
1063
+ const delay = retryDelayFor(delayBase, attempt);
1064
+ d.log(
1065
+ ` ${t.filename}: agent exited ${result.exitCode}; retrying (attempt ${attempt}/${maxAttempts}` + (isPi ? `, thinking ${level}` : "") + (delay > 0 ? `, waiting ${delay}s` : "") + ")"
1066
+ );
1067
+ if (delay > 0) await sleep(delay * 1e3);
1068
+ result = await spawnAgent({
1069
+ prompt,
1070
+ agentCmd,
1071
+ model,
1072
+ timeout,
1073
+ logPath: attemptLogPath(
1074
+ logsDir,
1075
+ t,
1076
+ attempt,
1077
+ ".log"
1078
+ ),
1079
+ label,
1080
+ maxTurns,
1081
+ thinking: level,
1082
+ onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
1083
+ });
1084
+ }
700
1085
  return { ticket: t, result };
701
1086
  })
702
1087
  );
@@ -705,6 +1090,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
705
1090
  if (r.status === "rejected") continue;
706
1091
  const { ticket, result } = r.value;
707
1092
  if (result.exitCode !== 0) {
1093
+ failedNums.add(ticket.num);
708
1094
  d.setTicketStatus(ticket.num, "failed");
709
1095
  d.log(
710
1096
  ` ${ticket.filename}: agent exited ${result.exitCode}`
@@ -732,7 +1118,7 @@ async function runWaves(tickets, specBody, agentCmd, model, timeout, logsDir, di
732
1118
  );
733
1119
  d.log(
734
1120
  `
735
- Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
1121
+ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "") + (failedNums.size > 0 ? `, ${failedNums.size} failed` : "")
736
1122
  );
737
1123
  d.cleanup();
738
1124
  return { allDone: remaining.length === 0 };
@@ -741,6 +1127,8 @@ __name(runWaves, "runWaves");
741
1127
  async function runWavesIsolated(opts) {
742
1128
  mkdirSync(opts.logsDir, { recursive: true });
743
1129
  const d = opts.display ?? createDisplay();
1130
+ const retries = opts.retries ?? DEFAULT_RETRIES;
1131
+ const retryDelay = opts.retryDelay ?? 0;
744
1132
  let totalWaves = 0;
745
1133
  for (const _ of simulateWaves(opts.tickets)) totalWaves++;
746
1134
  const repoRoot = getRepoRoot();
@@ -759,16 +1147,23 @@ async function runWavesIsolated(opts) {
759
1147
  generateProgress(opts.featureName, opts.tickets),
760
1148
  readProgress(progressPath)
761
1149
  );
762
- writeProgress(progressPath, progress);
763
- await commitProgress(
1150
+ await saveProgress(
764
1151
  repoRoot,
765
1152
  progressPath,
766
- `nightralph: start ${opts.featureName}`
1153
+ progress,
1154
+ `nightralph: start ${opts.featureName}`,
1155
+ d
767
1156
  );
1157
+ if (!opts.testCmd) {
1158
+ d.log(" Test gate disabled: no test command");
1159
+ }
768
1160
  let totalCompleted = 0;
769
1161
  let waveNum = 0;
1162
+ const failedNums = /* @__PURE__ */ new Set();
770
1163
  while (true) {
771
- const ready = findReadyTickets(opts.tickets);
1164
+ const ready = findReadyTickets(opts.tickets).filter(
1165
+ (t) => !failedNums.has(t.num)
1166
+ );
772
1167
  if (ready.length === 0) break;
773
1168
  waveNum++;
774
1169
  const readyNums = new Set(ready.map((t) => t.num));
@@ -788,125 +1183,89 @@ async function runWavesIsolated(opts) {
788
1183
  filename: t.filename
789
1184
  }))
790
1185
  );
791
- const worktreeSettled = await Promise.allSettled(
792
- ready.map((t) => createWorktree({
1186
+ const outcomes = await Promise.all(
1187
+ ready.map((t) => runTicketAttempts({
1188
+ ticket: t,
793
1189
  repoRoot,
794
1190
  repoName,
795
1191
  baseBranch,
796
- ticket: t
1192
+ specBody: opts.specBody,
1193
+ agentCmd: opts.agentCmd,
1194
+ model: opts.model,
1195
+ timeout: opts.timeout,
1196
+ logsDir: opts.logsDir,
1197
+ testCmd: opts.testCmd,
1198
+ maxTurns: opts.maxTurns,
1199
+ thinking: opts.thinking,
1200
+ retries,
1201
+ retryDelay,
1202
+ d
797
1203
  }))
798
1204
  );
799
- const activeTickets = [];
800
- const worktrees = [];
801
- for (let i = 0; i < worktreeSettled.length; i++) {
802
- const settled = worktreeSettled[i];
1205
+ let waveCompleted = 0;
1206
+ const successfulBranches = [];
1207
+ const ticketsByNum = /* @__PURE__ */ new Map();
1208
+ const worktreePathsByNum = /* @__PURE__ */ new Map();
1209
+ for (let i = 0; i < outcomes.length; i++) {
1210
+ const outcome = outcomes[i];
803
1211
  const ticket = ready[i];
804
- if (settled.status === "rejected") {
1212
+ if (outcome.kind === "worktree-error") {
1213
+ failedNums.add(ticket.num);
805
1214
  d.setTicketStatus(ticket.num, "failed");
806
1215
  d.log(
807
- ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(settled.reason)}`
1216
+ ` ${ticket.filename}: failed to create worktree: ${formatErrorMessage(outcome.error)}`
808
1217
  );
809
1218
  continue;
810
1219
  }
811
- activeTickets.push(ticket);
812
- worktrees.push(settled.value);
813
- }
814
- const results = await Promise.allSettled(
815
- activeTickets.map((t, i) => {
816
- const prompt = renderPrompt(opts.specBody, t);
817
- const logPath = join(
818
- opts.logsDir,
819
- t.filename.replace(/\.md$/, ".log")
820
- );
821
- const label = t.filename.replace(
822
- /\.md$/,
823
- ""
1220
+ if (outcome.kind === "error") {
1221
+ failedNums.add(ticket.num);
1222
+ d.setTicketStatus(ticket.num, "failed");
1223
+ d.log(
1224
+ ` ${ticket.filename}: attempt error: ` + formatErrorMessage(outcome.error)
824
1225
  );
825
- d.setTicketStatus(t.num, "in-progress");
826
- d.log(` Starting ${t.filename}`);
827
- return spawnAgent({
828
- prompt,
829
- agentCmd: opts.agentCmd,
830
- model: opts.model,
831
- timeout: opts.timeout,
832
- logPath,
833
- cwd: worktrees[i].worktreePath,
834
- label,
835
- onLine: /* @__PURE__ */ __name((line) => d.log(line), "onLine")
836
- });
837
- })
838
- );
839
- let waveCompleted = 0;
840
- const successfulBranches = [];
841
- const ticketsByNum = /* @__PURE__ */ new Map();
842
- const worktreePathsByNum = /* @__PURE__ */ new Map();
843
- for (let i = 0; i < results.length; i++) {
844
- const r = results[i];
845
- if (r.status === "rejected") continue;
846
- const agentResult = r.value;
847
- const ticket = activeTickets[i];
848
- const worktree = worktrees[i];
849
- if (agentResult.exitCode !== 0) {
1226
+ continue;
1227
+ }
1228
+ if (outcome.kind === "no-commits") {
1229
+ failedNums.add(ticket.num);
850
1230
  d.setTicketStatus(ticket.num, "failed");
851
1231
  d.log(
852
- ` ${ticket.filename}: agent exited ${agentResult.exitCode}`
1232
+ ` ${ticket.filename}: agent exited 0 but made no changes; leaving ticket ready-for-agent`
853
1233
  );
854
- checkItems(ticket, agentResult.completedItems);
1234
+ continue;
1235
+ }
1236
+ if (outcome.kind === "failed") {
1237
+ failedNums.add(ticket.num);
1238
+ d.setTicketStatus(ticket.num, "failed");
1239
+ if (outcome.reason === "exit") {
1240
+ checkItems(ticket, outcome.completedItems);
1241
+ }
855
1242
  checkTicket(progress, ticket.num, {
856
- branch: worktree.branchName,
857
- exitCode: agentResult.exitCode,
1243
+ branch: outcome.branchName,
1244
+ exitCode: outcome.exitCode,
858
1245
  done: false
859
1246
  });
860
- writeProgress(progressPath, progress);
861
- await commitProgress(
1247
+ await saveProgress(
862
1248
  repoRoot,
863
1249
  progressPath,
864
- `nightralph: ${ticket.filename} failed`
1250
+ progress,
1251
+ `nightralph: ${ticket.filename} ` + (outcome.reason === "tests" ? "tests failed" : "failed"),
1252
+ d
865
1253
  );
866
- try {
867
- await removeWorktree(worktree.worktreePath);
868
- } catch (err) {
869
- d.log(
870
- ` Failed to remove worktree ${worktree.worktreePath}: ` + formatErrorMessage(err)
871
- );
872
- }
873
1254
  continue;
874
1255
  }
875
- if (isDirty(worktree.worktreePath)) {
876
- const committed = await commitDirty(
877
- worktree.worktreePath,
878
- `nightralph: auto-commit ${ticket.filename} remaining changes`
879
- );
880
- if (committed) {
881
- d.log(
882
- ` ${ticket.filename}: auto-committed remaining changes`
883
- );
884
- }
885
- }
886
- if (!hasNewCommits(
887
- repoRoot,
888
- baseBranch,
889
- worktree.branchName
890
- )) {
891
- d.setTicketStatus(ticket.num, "failed");
892
- d.log(
893
- ` ${ticket.filename}: agent exited 0 but made no changes; leaving ticket ready-for-agent`
894
- );
895
- continue;
896
- }
897
- checkItems(ticket, agentResult.completedItems);
1256
+ checkItems(ticket, outcome.completedItems);
898
1257
  d.log(
899
1258
  ` ${ticket.filename}: agent finished, awaiting merge`
900
1259
  );
901
1260
  ticketsByNum.set(ticket.num, ticket);
902
1261
  worktreePathsByNum.set(
903
1262
  ticket.num,
904
- worktree.worktreePath
1263
+ outcome.worktreePath
905
1264
  );
906
1265
  successfulBranches.push({
907
- branch: worktree.branchName,
1266
+ branch: outcome.branchName,
908
1267
  ticketNum: ticket.num,
909
- worktreePath: worktree.worktreePath
1268
+ worktreePath: outcome.worktreePath
910
1269
  });
911
1270
  }
912
1271
  if (successfulBranches.length > 0) {
@@ -933,23 +1292,21 @@ async function runWavesIsolated(opts) {
933
1292
  branch: branchResult.branch,
934
1293
  exitCode: 0
935
1294
  });
936
- writeProgress(progressPath, progress);
937
- await commitProgress(
1295
+ await saveProgress(
938
1296
  repoRoot,
939
1297
  progressPath,
940
- `nightralph: ${ticket.filename} done`
1298
+ progress,
1299
+ `nightralph: ${ticket.filename} done`,
1300
+ d
941
1301
  );
942
1302
  const worktreePath = worktreePathsByNum.get(
943
1303
  ticket.num
944
1304
  );
945
1305
  if (worktreePath) {
946
- try {
947
- await removeWorktree(worktreePath);
948
- } catch (err) {
949
- d.log(
950
- ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
951
- );
952
- }
1306
+ await removeWorktreeQuietly(
1307
+ worktreePath,
1308
+ d
1309
+ );
953
1310
  }
954
1311
  } else {
955
1312
  const worktreePath = worktreePathsByNum.get(
@@ -966,6 +1323,8 @@ async function runWavesIsolated(opts) {
966
1323
  model: opts.model,
967
1324
  timeout: opts.timeout,
968
1325
  logsDir: opts.logsDir,
1326
+ testCmd: opts.testCmd,
1327
+ maxTurns: opts.maxTurns,
969
1328
  onLine: /* @__PURE__ */ __name((msg) => d.log(msg), "onLine")
970
1329
  }) : false;
971
1330
  if (recovered) {
@@ -980,15 +1339,19 @@ async function runWavesIsolated(opts) {
980
1339
  branch: branchResult.branch,
981
1340
  exitCode: 0
982
1341
  });
983
- writeProgress(progressPath, progress);
984
- await commitProgress(
1342
+ await saveProgress(
985
1343
  repoRoot,
986
1344
  progressPath,
987
- `nightralph: ${ticket.filename} done (respawn)`
1345
+ progress,
1346
+ `nightralph: ${ticket.filename} done (respawn)`,
1347
+ d
988
1348
  );
989
1349
  if (worktreePath) {
990
1350
  try {
991
- await removeWorktree(worktreePath);
1351
+ await removeWorktree(
1352
+ worktreePath,
1353
+ { force: true }
1354
+ );
992
1355
  } catch (err) {
993
1356
  d.log(
994
1357
  ` Failed to remove worktree ${worktreePath}: ` + formatErrorMessage(err)
@@ -1009,11 +1372,12 @@ async function runWavesIsolated(opts) {
1009
1372
  exitCode: 0,
1010
1373
  done: false
1011
1374
  });
1012
- writeProgress(progressPath, progress);
1013
- await commitProgress(
1375
+ await saveProgress(
1014
1376
  repoRoot,
1015
1377
  progressPath,
1016
- `nightralph: ${ticket.filename} merge failed`
1378
+ progress,
1379
+ `nightralph: ${ticket.filename} merge failed`,
1380
+ d
1017
1381
  );
1018
1382
  }
1019
1383
  }
@@ -1027,7 +1391,10 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1027
1391
  const ticket = ticketsByNum.get(sb.ticketNum);
1028
1392
  if (ticket && ticket.status === "done") continue;
1029
1393
  try {
1030
- await removeWorktree(sb.worktreePath);
1394
+ await removeWorktree(
1395
+ sb.worktreePath,
1396
+ { force: true }
1397
+ );
1031
1398
  } catch (err) {
1032
1399
  d.log(
1033
1400
  ` Failed to remove worktree ${sb.worktreePath}: ` + formatErrorMessage(err)
@@ -1049,7 +1416,7 @@ Merge stopped at ${mergeResult.stoppedAt.branch}`
1049
1416
  );
1050
1417
  d.log(
1051
1418
  `
1052
- Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "")
1419
+ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.length} remaining` : "") + (failedNums.size > 0 ? `, ${failedNums.size} failed` : "")
1053
1420
  );
1054
1421
  d.cleanup();
1055
1422
  return { allDone: remaining.length === 0 };
@@ -1057,25 +1424,33 @@ Finished: ${totalCompleted} completed` + (remaining.length > 0 ? `, ${remaining.
1057
1424
  __name(runWavesIsolated, "runWavesIsolated");
1058
1425
  export {
1059
1426
  COMPLETED_RE,
1427
+ DEFAULT_RETRIES,
1428
+ DEFAULT_RETRY_DELAY,
1429
+ THINKING_LEVELS,
1060
1430
  TICKET_RE,
1061
1431
  TOOL_USE_RE,
1062
1432
  checkItems,
1063
1433
  createPiStreamFormatter,
1064
1434
  dryRun,
1065
1435
  dryRunIsolated,
1436
+ escalateThinking,
1066
1437
  findReadyTickets,
1067
1438
  formatErrorMessage,
1068
1439
  formatStreamLine,
1069
1440
  getProviderArgs,
1441
+ killProcessGroup,
1442
+ killRunningAgents,
1070
1443
  markDone,
1071
1444
  parseBlockers,
1072
1445
  parseStatus,
1073
1446
  parseTicketFilename,
1074
1447
  renderMergePrompt,
1075
1448
  renderPrompt,
1449
+ retryDelayFor,
1076
1450
  runWaves,
1077
1451
  runWavesIsolated,
1078
1452
  scanTickets,
1079
- spawnAgent
1453
+ spawnAgent,
1454
+ stripSpecSection
1080
1455
  };
1081
1456
  //# sourceMappingURL=orchestrator.js.map