agent-dealer 1.2.7 → 1.2.9

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.
Files changed (112) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +16 -6
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +74 -0
  3. package/bundle/server/dist/adapters/agent-health.js +14 -3
  4. package/bundle/server/dist/adapters/github.js +84 -5
  5. package/bundle/server/dist/adapters/github.test.js +83 -1
  6. package/bundle/server/dist/adapters/muse-capability.js +443 -24
  7. package/bundle/server/dist/adapters/muse-capability.test.js +469 -25
  8. package/bundle/server/dist/adapters/muse-visual-qa.js +114 -0
  9. package/bundle/server/dist/adapters/muse-visual-qa.test.js +68 -0
  10. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  11. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  12. package/bundle/server/dist/capacity/muse-probe.js +56 -9
  13. package/bundle/server/dist/capacity/muse-probe.test.js +188 -1
  14. package/bundle/server/dist/coordinator/admission.js +14 -1
  15. package/bundle/server/dist/coordinator/admission.test.js +197 -4
  16. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +276 -0
  17. package/bundle/server/dist/coordinator/auto-merge.js +244 -4
  18. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  19. package/bundle/server/dist/coordinator/commands.js +284 -23
  20. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  21. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +4 -2
  22. package/bundle/server/dist/coordinator/developer-effect.js +224 -4
  23. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  24. package/bundle/server/dist/coordinator/execution-report.js +6 -0
  25. package/bundle/server/dist/coordinator/execution-report.test.js +7 -0
  26. package/bundle/server/dist/coordinator/failure-cause.js +64 -1
  27. package/bundle/server/dist/coordinator/failure-cause.test.js +92 -1
  28. package/bundle/server/dist/coordinator/failure-reason.js +7 -0
  29. package/bundle/server/dist/coordinator/failure-reason.test.js +14 -0
  30. package/bundle/server/dist/coordinator/human-resolution.js +36 -1
  31. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  32. package/bundle/server/dist/coordinator/merge-conflict-sync.js +647 -0
  33. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +121 -0
  34. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +9 -4
  35. package/bundle/server/dist/coordinator/muse-spawn.js +122 -29
  36. package/bundle/server/dist/coordinator/muse-spawn.test.js +210 -0
  37. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  38. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  39. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  40. package/bundle/server/dist/coordinator/playbook-feedback.js +693 -0
  41. package/bundle/server/dist/coordinator/playbook-feedback.test.js +702 -0
  42. package/bundle/server/dist/coordinator/projection.js +10 -4
  43. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  44. package/bundle/server/dist/coordinator/prompts-execution-contract.test.js +107 -0
  45. package/bundle/server/dist/coordinator/prompts.js +211 -2
  46. package/bundle/server/dist/coordinator/prompts.test.js +231 -0
  47. package/bundle/server/dist/coordinator/recovery.js +57 -3
  48. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  49. package/bundle/server/dist/coordinator/reflect-trigger.js +33 -169
  50. package/bundle/server/dist/coordinator/reflect-trigger.test.js +149 -200
  51. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  52. package/bundle/server/dist/coordinator/reviewer-effect.js +16 -1
  53. package/bundle/server/dist/coordinator/reviewer-result.js +6 -0
  54. package/bundle/server/dist/coordinator/routing.js +45 -6
  55. package/bundle/server/dist/coordinator/routing.test.js +76 -15
  56. package/bundle/server/dist/coordinator/session-timeouts.js +30 -0
  57. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  58. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +1 -1
  59. package/bundle/server/dist/coordinator/worker-loop.js +18 -4
  60. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  61. package/bundle/server/dist/db/index.js +18 -0
  62. package/bundle/server/dist/db/schema.sql +5 -0
  63. package/bundle/server/dist/docs-execution-analysis.test.js +1 -0
  64. package/bundle/server/dist/repository/artifacts-for-issue.js +3 -3
  65. package/bundle/server/dist/repository/human-actions.js +14 -0
  66. package/bundle/server/dist/repository/issues.js +67 -6
  67. package/bundle/server/dist/repository/work-items.js +7 -0
  68. package/bundle/server/dist/repository/worker-sessions.js +51 -2
  69. package/bundle/server/dist/routes/human-actions.js +10 -8
  70. package/bundle/server/dist/routes/issues-execution-contract.test.js +321 -0
  71. package/bundle/server/dist/routes/issues.js +19 -2
  72. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  73. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  74. package/bundle/server/dist/runners/muse-code-jsonl.js +106 -11
  75. package/bundle/server/dist/runners/muse-config-core.js +18 -1
  76. package/bundle/server/dist/runners/muse-config.test.js +37 -0
  77. package/bundle/server/dist/runners/muse-serve-session.js +5 -0
  78. package/bundle/server/dist/runners/spawn-cli.js +116 -0
  79. package/bundle/server/dist/runners/spawn-cli.test.js +124 -0
  80. package/bundle/server/package.json +2 -2
  81. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  82. package/bundle/server/static-ui/assets/{index-DyAJNyfV.css → index-K_YcYkQU.css} +1 -1
  83. package/bundle/server/static-ui/index.html +2 -2
  84. package/bundle/shared/dist/execution-analysis.d.ts +22 -22
  85. package/bundle/shared/dist/execution-contract.d.ts +117 -0
  86. package/bundle/shared/dist/execution-contract.js +307 -0
  87. package/bundle/shared/dist/execution-contract.test.d.ts +2 -0
  88. package/bundle/shared/dist/execution-contract.test.js +499 -0
  89. package/bundle/shared/dist/execution-report.d.ts +9 -9
  90. package/bundle/shared/dist/execution-report.js +2 -0
  91. package/bundle/shared/dist/failure-cause.d.ts +4 -4
  92. package/bundle/shared/dist/failure-cause.js +2 -0
  93. package/bundle/shared/dist/human-actions.d.ts +4 -4
  94. package/bundle/shared/dist/human-actions.js +17 -0
  95. package/bundle/shared/dist/index.d.ts +85 -84
  96. package/bundle/shared/dist/index.js +3 -0
  97. package/bundle/shared/dist/issues.d.ts +176 -16
  98. package/bundle/shared/dist/issues.js +22 -0
  99. package/bundle/shared/dist/issues.test.js +3 -0
  100. package/bundle/shared/dist/outbound-draft.d.ts +12 -12
  101. package/bundle/shared/dist/worker-sessions.d.ts +10 -0
  102. package/bundle/shared/dist/worker-sessions.js +8 -0
  103. package/bundle/shared/dist/worker-sessions.test.js +1 -0
  104. package/bundle/shared/dist/workflow.d.ts +4 -4
  105. package/bundle/shared/dist/workflow.js +16 -0
  106. package/bundle/shared/dist/workflow.test.js +3 -0
  107. package/bundle/shared/package.json +1 -1
  108. package/dist/action.js +4 -1
  109. package/dist/action.test.js +42 -0
  110. package/dist/index.js +1 -1
  111. package/package.json +1 -1
  112. package/bundle/server/static-ui/assets/index-yLyxRd-7.js +0 -64
@@ -151,7 +151,12 @@ function fakeGithub(opts = {}) {
151
151
  if (number == null && !branch) {
152
152
  throw new Error("fakeGithub.checksSnapshot requires an explicit number or branch — bare checks lookup is the NOT-82 bug");
153
153
  }
154
- return opts.checks ?? "success";
154
+ // NOT-311: a function lets one test flip CI mid-wait (pending → success) so the
155
+ // deferred re-poll can prove the wait closes without a fresh agent session.
156
+ return typeof opts.checks === "function" ? opts.checks() : (opts.checks ?? "success");
157
+ },
158
+ async hasPullRequestWorkflow() {
159
+ return opts.pullRequestWorkflow ?? null;
155
160
  },
156
161
  async publishReview() {
157
162
  throw new Error("publishReview is unused by the developer effect");
@@ -530,6 +535,8 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
530
535
  await pump(1);
531
536
  const issue = getIssue(issueId);
532
537
  assert.equal(issue.status, "developing");
538
+ assert.equal(issue.ciAttempts, 1, "a checks_failed retry spends the CI budget");
539
+ assert.equal(issue.infraAttempts, 0, "a checks_failed retry never touches the infra budget");
533
540
  assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
534
541
  const kinds = listArtifactsForIssue(issueId).map((a) => a.kind);
535
542
  const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
@@ -540,12 +547,159 @@ test("checks_failed: CI failure after a clean push/PR retries without a human ac
540
547
  assert.ok(kinds.includes("implementation_conclusion"), "conclusion must survive a post-session verification failure");
541
548
  assert.ok(kinds.includes("developer_transcript"), "raw trace must survive a post-session verification failure");
542
549
  });
543
- test("timed_out (checks poll): checks stay pending past the poll deadline", async () => {
550
+ test("NOT-311: checks_pending — checks still pending at the poll deadline defers, spending no attempt", async () => {
551
+ // Previously this returned `timed_out`, spending an infra attempt on a session that
552
+ // had already ended cleanly. Now the item waits on backoff and re-polls.
544
553
  const issueId = await makeIssue();
545
554
  registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
546
555
  startWorkflow(issueId);
547
556
  await pump(1);
557
+ const issue = getIssue(issueId);
558
+ assert.equal(issue.status, "developing");
559
+ assert.equal(issue.infraAttempts, 0, "a CI wait spends no infra attempt");
560
+ assert.equal(issue.currentRound, 1, "a CI wait must not spend a review round");
561
+ assert.match(issue.currentIntent ?? "", /Waiting for CI \(running\) - no attempt spent/);
562
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on unverified CI");
563
+ const items = listWorkItemsForIssue(issueId);
564
+ assert.equal(items.length, 1);
565
+ assert.equal(items[0].status, "pending", "the item waits, it is not finished or dead-lettered");
566
+ assert.equal(items[0].attemptCount, 0, "the claim-time attempt bump is reverted");
567
+ assert.ok(Date.parse(items[0].availableAt) > Date.parse(items[0].updatedAt), "the re-poll is gated behind a backoff");
568
+ const deferredPayload = JSON.parse(items[0].payloadJson ?? "{}");
569
+ assert.equal(deferredPayload.publishOnly, true, "the re-poll takes the no-agent publish path, not a fresh spawn");
570
+ assert.equal(deferredPayload.branch, issueBranchName(issueId));
571
+ assert.ok(deferredPayload.checksWaitStartedAt, "the wait anchors its ceiling at the first poll");
572
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 0);
573
+ const events = listWorkflowEventsForIssue(issueId);
574
+ const deferrals = events.filter((e) => e.type === "worker.deferred");
575
+ assert.ok(deferrals.length > 0);
576
+ assert.equal(JSON.parse(deferrals[0].payloadJson ?? "{}").outcome, "checks_pending");
577
+ assert.equal(events.some((e) => e.type === "worker.failed"), false, "a CI wait is not a failure");
578
+ const completed = events.find((e) => e.type === "checks.completed");
579
+ assert.ok(completed, "the wait still records a checks.completed milestone");
580
+ assert.equal(JSON.parse(completed.payloadJson ?? "{}").snapshot, "timeout");
581
+ });
582
+ test("NOT-311: the deferred CI wait re-polls through publish-only and hands off once CI passes", async () => {
583
+ // First run waits (pending), the deferred re-poll sees success and reaches review —
584
+ // all without spawning a second agent session and without spending infra attempts.
585
+ let ci = "pending";
586
+ let spawns = 0;
587
+ const countingSpawn = async (input) => {
588
+ spawns++;
589
+ return commitingSpawn(input);
590
+ };
591
+ // A short backoff (set before the first deferral computes its `until`) lets the
592
+ // second pump actually pick the waiting item back up.
593
+ const prevBase = process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
594
+ const prevMax = process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
595
+ process.env.DECK_OUTAGE_BACKOFF_BASE_MS = "10";
596
+ process.env.DECK_OUTAGE_BACKOFF_MAX_MS = "10";
597
+ try {
598
+ const issueId = await makeIssue();
599
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: countingSpawn, github: fakeGithub({ checks: () => ci }) }));
600
+ startWorkflow(issueId);
601
+ await pump(1);
602
+ assert.equal(getIssue(issueId).status, "developing");
603
+ assert.equal(getIssue(issueId).infraAttempts, 0);
604
+ const deferred = listWorkItemsForIssue(issueId)[0];
605
+ assert.equal(deferred.status, "pending");
606
+ // Fast-forward past the deferral backoff, then let CI pass.
607
+ await new Promise((resolve) => setTimeout(resolve, 60));
608
+ ci = "success";
609
+ await pump(1);
610
+ const issue = getIssue(issueId);
611
+ assert.equal(issue.status, "reviewing", "the re-poll hands off once CI passes");
612
+ assert.equal(issue.infraAttempts, 0, "waiting plus a passing re-poll still spends nothing");
613
+ assert.equal(spawns, 1, "the re-poll must not spawn a second agent session");
614
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
615
+ }
616
+ finally {
617
+ if (prevBase === undefined)
618
+ delete process.env.DECK_OUTAGE_BACKOFF_BASE_MS;
619
+ else
620
+ process.env.DECK_OUTAGE_BACKOFF_BASE_MS = prevBase;
621
+ if (prevMax === undefined)
622
+ delete process.env.DECK_OUTAGE_BACKOFF_MAX_MS;
623
+ else
624
+ process.env.DECK_OUTAGE_BACKOFF_MAX_MS = prevMax;
625
+ }
626
+ });
627
+ test("NOT-311: none with a pull_request workflow on the base waits (no review handoff)", async () => {
628
+ const issueId = await makeIssue();
629
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: true }) }));
630
+ startWorkflow(issueId);
631
+ await pump(1);
632
+ const issue = getIssue(issueId);
633
+ assert.equal(issue.status, "developing");
634
+ assert.equal(issue.infraAttempts, 0, "an unregistered-CI wait spends no infra attempt");
635
+ assert.match(issue.currentIntent ?? "", /Waiting for CI \(queued\) - no attempt spent/);
636
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0, "no review handoff on 'no checks yet'");
637
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
638
+ assert.ok(completed);
639
+ const payload = JSON.parse(completed.payloadJson ?? "{}");
640
+ assert.equal(payload.snapshot, "none");
641
+ assert.equal(payload.reason, "none_with_workflow");
642
+ });
643
+ test("NOT-311: none with no pull_request workflow on the base proceeds as today", async () => {
644
+ const issueId = await makeIssue();
645
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "none", pullRequestWorkflow: false }) }));
646
+ startWorkflow(issueId);
647
+ await pump(1);
648
+ const issue = getIssue(issueId);
649
+ assert.equal(issue.status, "reviewing", "no configured CI still hands off");
650
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
651
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
652
+ assert.ok(completed);
653
+ const payload = JSON.parse(completed.payloadJson ?? "{}");
654
+ assert.equal(payload.snapshot, "none");
655
+ assert.equal(payload.reason, "none_no_workflow");
656
+ });
657
+ test("NOT-311: none with an unknown workflow answer waits fail-closed", async () => {
658
+ // The adapter cannot read the base (or has no such method): 'no checks yet' must
659
+ // never read as a pass.
660
+ const issueId = await makeIssue();
661
+ const github = fakeGithub({ checks: "none", pullRequestWorkflow: null });
662
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
663
+ startWorkflow(issueId);
664
+ await pump(1);
548
665
  assert.equal(getIssue(issueId).status, "developing");
666
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 0);
667
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
668
+ assert.equal(JSON.parse(completed.payloadJson ?? "{}").reason, "none_with_workflow");
669
+ });
670
+ test("NOT-311: past CHECKS_WAIT_CEILING_MS the wait escalates naming the PR and head SHA", async () => {
671
+ // A zero ceiling makes the very first wait already past it — no clock to inject,
672
+ // no sleeping: the deferral reports escalated and a policy_escalation lands.
673
+ const prev = process.env.CHECKS_WAIT_CEILING_MS;
674
+ process.env.CHECKS_WAIT_CEILING_MS = "0";
675
+ try {
676
+ const issueId = await makeIssue();
677
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub({ checks: "pending" }) }));
678
+ startWorkflow(issueId);
679
+ await pump(1);
680
+ const issue = getIssue(issueId);
681
+ assert.equal(issue.status, "needs_human");
682
+ assert.equal(issue.infraAttempts, 0, "a ceiling escalation spends no infra attempt");
683
+ const actions = listHumanActionsForIssue(issueId).filter((a) => a.status === "open");
684
+ assert.equal(actions.length, 1);
685
+ assert.equal(actions[0].actionType, "policy_escalation");
686
+ // The wait never hands off, so the issue row carries no PR/SHA — the milestone
687
+ // and the human action do.
688
+ const completed = listWorkflowEventsForIssue(issueId).find((e) => e.type === "checks.completed");
689
+ assert.ok(completed);
690
+ const msPayload = JSON.parse(completed.payloadJson ?? "{}");
691
+ assert.equal(msPayload.reason, "pending_ceiling");
692
+ assert.ok(msPayload.prNumber, "the milestone names the PR");
693
+ assert.ok(msPayload.headSha, "the milestone names the head SHA");
694
+ assert.match(actions[0].reason, new RegExp(`CI did not complete on ${msPayload.headSha}`));
695
+ assert.match(actions[0].reason, new RegExp(`PR #${msPayload.prNumber}`));
696
+ }
697
+ finally {
698
+ if (prev === undefined)
699
+ delete process.env.CHECKS_WAIT_CEILING_MS;
700
+ else
701
+ process.env.CHECKS_WAIT_CEILING_MS = prev;
702
+ }
549
703
  });
550
704
  test("NOT-255: a non-draft PR with otherwise-verified identity hands off cleanly (just-failed retry_merge)", async () => {
551
705
  // A merge-failure escalation is reachable from `retry_merge`, which undrafts the PR
@@ -2079,7 +2233,9 @@ test("NOT-252: checks_failed enriches checks_evidence and the retry prompt with
2079
2233
  registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github }));
2080
2234
  startWorkflow(issueId);
2081
2235
  await pump(1);
2082
- assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the infra budget");
2236
+ assert.equal(getIssue(issueId).status, "developing", "enriched checks_failed still retries on the CI budget");
2237
+ assert.equal(getIssue(issueId).ciAttempts, 1, "the enriched retry spends ci_attempts");
2238
+ assert.equal(getIssue(issueId).infraAttempts, 0, "the enriched retry leaves infra_attempts alone");
2083
2239
  const evidence = listArtifactsForIssue(issueId).find((a) => a.kind === "checks_evidence");
2084
2240
  assert.ok(evidence, "enriched checks_failed persists checks_evidence");
2085
2241
  const content = JSON.parse(evidence.contentJson);
@@ -2138,3 +2294,132 @@ test("NOT-252: publish-only retry on checks_failed enriches evidence and the nex
2138
2294
  assert.match(prompts[1], /publish-checks/);
2139
2295
  assert.match(prompts[1], /is not in this registry/);
2140
2296
  });
2297
+ test("NOT-313: three CI repair attempts then a CI-specific escalation — infra_attempts stays 0", async () => {
2298
+ // Exit-predicate loop: the CI budget (default 3) buys three repair attempts;
2299
+ // the fourth consecutive checks_failed escalates with a CI-specific reason.
2300
+ const issueId = await makeIssue();
2301
+ const prompts = [];
2302
+ let call = 0;
2303
+ const evolvingSpawn = async (input) => {
2304
+ call++;
2305
+ prompts.push(input.prompt);
2306
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), `implemented attempt ${call}\n`);
2307
+ git(input.cwd, "add", ".");
2308
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", `implement ${call}`);
2309
+ return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
2310
+ };
2311
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: evolvingSpawn, github: fakeGithub({ checks: "failure" }) }));
2312
+ startWorkflow(issueId);
2313
+ await pump(1); // failure #1 → CI repair attempt 1
2314
+ assert.equal(getIssue(issueId).status, "developing");
2315
+ assert.equal(getIssue(issueId).ciAttempts, 1);
2316
+ assert.equal(getIssue(issueId).infraAttempts, 0);
2317
+ await pump(1); // failure #2 → CI repair attempt 2
2318
+ assert.equal(getIssue(issueId).status, "developing");
2319
+ assert.equal(getIssue(issueId).ciAttempts, 2);
2320
+ assert.equal(getIssue(issueId).infraAttempts, 0);
2321
+ assert.match(prompts[1], /CI repair attempt 1 of 3/);
2322
+ await pump(1); // failure #3 → CI repair attempt 3
2323
+ assert.equal(getIssue(issueId).status, "developing");
2324
+ assert.equal(getIssue(issueId).ciAttempts, 3);
2325
+ assert.equal(getIssue(issueId).infraAttempts, 0);
2326
+ assert.match(prompts[2], /CI repair attempt 2 of 3/);
2327
+ await pump(1); // failure #4 → budget spent → escalate
2328
+ const issue = getIssue(issueId);
2329
+ assert.equal(issue.status, "needs_human");
2330
+ assert.equal(issue.ciAttempts, 3, "the escalation itself spends no further CI attempt");
2331
+ assert.equal(issue.infraAttempts, 0, "four CI failures never touched the infra budget");
2332
+ assert.match(prompts[3], /CI repair attempt 3 of 3/);
2333
+ const action = listHumanActionsForIssue(issueId).find((a) => a.status === "open");
2334
+ assert.ok(action, "the exhausted CI budget raises a human action");
2335
+ assert.equal(action.actionType, "policy_escalation");
2336
+ assert.match(action.reason, /CI checks still failing after 3 repair attempts \(limit 3\)/);
2337
+ assert.doesNotMatch(action.reason, /infra-attempt limit reached/);
2338
+ });
2339
+ test("NOT-315: a failing dependency install is a worktree-setup failure with the stderr tail — the session is not spawned", async () => {
2340
+ // Hermetic repo: the shared fixture carries no package.json (so the step would skip
2341
+ // before the runner is ever invoked), so this test cuts its own repo + remote whose
2342
+ // main tip declares npm dependencies — the fresh worktree then really triggers the install.
2343
+ const npmRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-repo-"));
2344
+ const npmRemote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-deveff-npm-remote-"));
2345
+ try {
2346
+ git(npmRepo, "init", "-q", "-b", "main");
2347
+ git(npmRepo, "config", "user.email", "test@example.com");
2348
+ git(npmRepo, "config", "user.name", "Test");
2349
+ fs.writeFileSync(path.join(npmRepo, "README.md"), "hello\n");
2350
+ fs.writeFileSync(path.join(npmRepo, "package.json"), JSON.stringify({ name: "wt", version: "1.0.0" }));
2351
+ fs.writeFileSync(path.join(npmRepo, "package-lock.json"), JSON.stringify({ name: "wt", lockfileVersion: 3, packages: {} }));
2352
+ git(npmRepo, "add", ".");
2353
+ git(npmRepo, "commit", "-q", "-m", "init");
2354
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", npmRemote]);
2355
+ git(npmRepo, "remote", "add", "origin", npmRemote);
2356
+ git(npmRepo, "push", "-q", "origin", "main");
2357
+ const devAgent = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
2358
+ const revAgent = createAgent({ name: `rev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
2359
+ const issueId = createIssue({
2360
+ title: "Add widget",
2361
+ description: "Build the widget.",
2362
+ acceptanceCriteria: "Widget renders.",
2363
+ repo: npmRepo,
2364
+ baseBranch: "main",
2365
+ developerAgentId: devAgent.id,
2366
+ reviewerAgentId: revAgent.id,
2367
+ maxReviewRounds: 3,
2368
+ maxInfraAttempts: 3,
2369
+ source: "manual"
2370
+ }).id;
2371
+ let spawnCalls = 0;
2372
+ const neverSpawn = async () => {
2373
+ spawnCalls++;
2374
+ return { exitCode: 0, transcript: "", logPath: "/dev/null", timedOut: false };
2375
+ };
2376
+ let depsCalls = 0;
2377
+ const failingDeps = async () => {
2378
+ depsCalls++;
2379
+ const err = new Error("npm ci exited");
2380
+ err.stderr = "npm error code EAI_AGAIN\nnpm error registry unreachable (sandbox has no network)\n";
2381
+ throw err;
2382
+ };
2383
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: neverSpawn, github: fakeGithub(), depsRunner: failingDeps }));
2384
+ startWorkflow(issueId);
2385
+ await pump(1);
2386
+ assert.equal(depsCalls, 1, "the worktree declares npm deps, so the install must run exactly once");
2387
+ assert.equal(spawnCalls, 0, "the session must not spawn when dependency provisioning fails");
2388
+ const issue = getIssue(issueId);
2389
+ assert.equal(issue.status, "developing", "adapter_failure retries on the infra budget");
2390
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
2391
+ // adapter_failure never spawned, so the session row goes terminal (done) with the
2392
+ // setup failure recorded as its reason — like every other adapter_failure.
2393
+ assert.ok(dev.status === "done" || dev.status === "failed", `terminal session, got ${dev.status}`);
2394
+ const reason = JSON.parse(dev.errorJson ?? "{}").reason ?? dev.errorJson ?? "";
2395
+ assert.match(reason, /worktree setup failed/);
2396
+ assert.match(reason, /dependency install failed: /);
2397
+ assert.match(reason, /registry unreachable/);
2398
+ }
2399
+ finally {
2400
+ fs.rmSync(npmRepo, { recursive: true, force: true });
2401
+ fs.rmSync(npmRemote, { recursive: true, force: true });
2402
+ }
2403
+ });
2404
+ test("NOT-315: a skipped dependency install still emits worktree.deps_ready with ran/skipped and durationMs", async () => {
2405
+ // The fixture repo carries no package.json, so the step skips (no package.json) and
2406
+ // the fake runner is never called — the milestone must still record the skip.
2407
+ const issueId = await makeIssue();
2408
+ let depsCalls = 0;
2409
+ const countingDeps = async () => {
2410
+ depsCalls++;
2411
+ return { stdout: "", stderr: "" };
2412
+ };
2413
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github: fakeGithub(), depsRunner: countingDeps }));
2414
+ startWorkflow(issueId);
2415
+ await pump(1);
2416
+ assert.equal(getIssue(issueId).status, "reviewing", "a skipped install must not block handoff");
2417
+ assert.equal(depsCalls, 0, "no package.json means the runner is never invoked");
2418
+ const { listWorkflowEventsForIssue: listEvents } = await import("../repository/workflow-events.js");
2419
+ const depsEvent = listEvents(issueId).find((e) => e.type === "worktree.deps_ready");
2420
+ assert.ok(depsEvent, "expected a worktree.deps_ready milestone");
2421
+ const payload = JSON.parse(depsEvent.payloadJson);
2422
+ assert.equal(payload.ran, false);
2423
+ assert.match(String(payload.reason ?? ""), /no package\.json/);
2424
+ assert.ok(typeof payload.durationMs === "number" && payload.durationMs >= 0);
2425
+ });
@@ -151,6 +151,12 @@ export function classifyFailureReason(reason) {
151
151
  const text = (reason ?? "").trim();
152
152
  if (!text)
153
153
  return { code: "unknown", domain: "unknown", confidence: "low" };
154
+ // NOT-307: an idle-watchdog kill names itself ("made no progress … idle timeout").
155
+ // Checked before the timeout rules: the reason contains both "timeout" and "tool"
156
+ // ("last tool: …"), which would otherwise misread as tool_test_timeout.
157
+ if (/made no progress|killed on the idle timeout/i.test(text)) {
158
+ return { code: "muse_no_progress", domain: "infrastructure", confidence: "high" };
159
+ }
154
160
  // A bare timeout with no tool/test in flight is unknown by rule — do not guess.
155
161
  if (/timed? ?out|deadline/i.test(text) && !/tool|test|jest|vitest|pytest|subprocess|command/i.test(text)) {
156
162
  return { code: "unknown", domain: "unknown", confidence: "low" };
@@ -88,6 +88,13 @@ test("classifier keeps ambiguous timeouts unknown", () => {
88
88
  assert.equal(classifyFailureReason(null).code, "unknown");
89
89
  assert.equal(classifyFailureReason("").code, "unknown");
90
90
  });
91
+ test("NOT-307: classifier reads an idle-watchdog kill as muse_no_progress", () => {
92
+ const reason = "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.";
93
+ const got = classifyFailureReason(reason);
94
+ assert.equal(got.code, "muse_no_progress");
95
+ assert.equal(got.domain, "infrastructure");
96
+ assert.equal(got.confidence, "high");
97
+ });
91
98
  test("classifier anchors keep author/release/unrelated spawn out of buckets", () => {
92
99
  // `auth` must not match "author"; `lease` must not match "release"/"please".
93
100
  assert.equal(classifyFailureReason("the author released a fix").code, "unknown");
@@ -329,6 +329,27 @@ export function classifyAttemptFailure(input) {
329
329
  rawReason: rawReason || "Agent Deck failure.",
330
330
  });
331
331
  }
332
+ // NOT-307: an idle-watchdog kill is a specific, high-confidence infrastructure
333
+ // cause — never `unknown`. It sorts after log evidence (an auth/429 already on
334
+ // record names an earlier, more specific root cause for the same stall) but
335
+ // before the outcome-level timeout guess, so a hung tool/test in flight does not
336
+ // demote the stall to `tool_test_timeout`. The raw reason carries the idle
337
+ // minutes and the last tool name, reusing the recorded reason when it already
338
+ // names them (reasonForSessionCrash writes them at kill time).
339
+ if (input.idleTimedOut) {
340
+ const minutes = typeof input.idleForMs === "number" && Number.isFinite(input.idleForMs) && input.idleForMs >= 0
341
+ ? Math.max(1, Math.round(input.idleForMs / 60_000))
342
+ : null;
343
+ const alreadySpecific = /made no progress/i.test(rawReason);
344
+ signals.push({
345
+ code: "muse_no_progress",
346
+ confidence: "high",
347
+ evidenceSource: "outcome_kind",
348
+ rawReason: alreadySpecific
349
+ ? rawReason
350
+ : `Muse developer session made no progress${minutes !== null ? ` for ${minutes} minute${minutes === 1 ? "" : "s"}` : ""} (last tool: ${input.lastToolName ?? "none"}).${rawReason ? ` ${rawReason}` : ""}`,
351
+ });
352
+ }
332
353
  const outcomeSignal = detectOutcomeSignal(outcomeKind, rawReason, {
333
354
  timedOut,
334
355
  toolInFlight,
@@ -413,6 +434,36 @@ function agentCompletedEvidence(workerSessionId) {
413
434
  }
414
435
  return out;
415
436
  }
437
+ /**
438
+ * NOT-307: idle-watchdog evidence read back out of `worker_sessions.metadata_json`
439
+ * (written by the developer effect for every Muse session). Silence duration is
440
+ * measured against `occurredAtMs` — the kill/completion time — from the recorded
441
+ * `lastActivityAt`. Anything unparseable reads as no idle evidence, never a throw.
442
+ */
443
+ export function museStallFromMetadata(metadataJson, occurredAtMs) {
444
+ const none = { idleTimedOut: false, idleForMs: null, lastToolName: null };
445
+ if (!metadataJson)
446
+ return none;
447
+ try {
448
+ const parsed = JSON.parse(metadataJson);
449
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
450
+ return none;
451
+ const rec = parsed;
452
+ if (rec.idleTimedOut !== true)
453
+ return none;
454
+ const lastToolName = typeof rec.lastToolName === "string" ? rec.lastToolName : null;
455
+ let idleForMs = null;
456
+ if (occurredAtMs !== null && Number.isFinite(occurredAtMs) && typeof rec.lastActivityAt === "string") {
457
+ const at = Date.parse(rec.lastActivityAt);
458
+ if (Number.isFinite(at) && occurredAtMs >= at)
459
+ idleForMs = occurredAtMs - at;
460
+ }
461
+ return { idleTimedOut: true, idleForMs, lastToolName };
462
+ }
463
+ catch {
464
+ return none;
465
+ }
466
+ }
416
467
  /**
417
468
  * Backfill-on-read for legacy rows: classify from existing session error/log
418
469
  * evidence without mutating any append-only event. Always quality "inferred" —
@@ -421,6 +472,9 @@ function agentCompletedEvidence(workerSessionId) {
421
472
  export function backfillCausesForSession(session, opts) {
422
473
  const agent = agentCompletedEvidence(session.id);
423
474
  const outcomeKind = opts?.outcomeKind ?? null;
475
+ const occurredAt = opts?.occurredAt ?? session.completedAt ?? session.updatedAt;
476
+ const occurredAtMs = occurredAt ? Date.parse(occurredAt) : NaN;
477
+ const stall = museStallFromMetadata(session.metadataJson ?? null, Number.isFinite(occurredAtMs) ? occurredAtMs : null);
424
478
  return classifyAttemptFailure({
425
479
  outcomeKind,
426
480
  sessionErrorJson: session.errorJson,
@@ -428,8 +482,11 @@ export function backfillCausesForSession(session, opts) {
428
482
  runtime: session.runtime,
429
483
  exitCode: session.exitCode ?? agent.exitCode,
430
484
  timedOut: outcomeKind === "timed_out" || agent.timedOut,
485
+ idleTimedOut: stall.idleTimedOut || undefined,
486
+ idleForMs: stall.idleForMs,
487
+ lastToolName: stall.lastToolName,
431
488
  hostSuspended: agent.hostSuspended,
432
- occurredAt: opts?.occurredAt ?? session.completedAt ?? session.updatedAt,
489
+ occurredAt,
433
490
  eventCursor: opts?.eventCursor ?? null,
434
491
  sessionId: session.id,
435
492
  eventId: opts?.eventId ?? null,
@@ -449,6 +506,9 @@ export function recordCausesForWorkerFailedEvent(opts) {
449
506
  const session = opts.event.workerSessionId ? getWorkerSession(opts.event.workerSessionId) : null;
450
507
  const agent = agentCompletedEvidence(opts.event.workerSessionId);
451
508
  const outcomeTimedOut = opts.outcomeKind === "timed_out";
509
+ const occurredAtMs = Date.parse(opts.event.ts);
510
+ // NOT-307: an idle kill's stall evidence rides on the session row's metadata.
511
+ const stall = museStallFromMetadata(session?.metadataJson ?? null, Number.isFinite(occurredAtMs) ? occurredAtMs : null);
452
512
  const causes = classifyAttemptFailure({
453
513
  outcomeKind: opts.outcomeKind,
454
514
  outcomeReason: opts.outcomeReason,
@@ -458,6 +518,9 @@ export function recordCausesForWorkerFailedEvent(opts) {
458
518
  runtime: session?.runtime ?? undefined,
459
519
  exitCode: session?.exitCode ?? agent.exitCode,
460
520
  timedOut: outcomeTimedOut || agent.timedOut,
521
+ idleTimedOut: stall.idleTimedOut || undefined,
522
+ idleForMs: stall.idleForMs,
523
+ lastToolName: stall.lastToolName,
461
524
  hostSuspended: agent.hostSuspended,
462
525
  recovery: opts.recovery,
463
526
  occurredAt: opts.event.ts,
@@ -8,7 +8,7 @@ import assert from "node:assert/strict";
8
8
  import fs from "node:fs";
9
9
  import os from "node:os";
10
10
  import path from "node:path";
11
- import { classifyAttemptFailure, orderAttemptCauses, } from "./failure-cause.js";
11
+ import { backfillCausesForSession, classifyAttemptFailure, museStallFromMetadata, orderAttemptCauses, } from "./failure-cause.js";
12
12
  import { PRESUMED_DEAD_REASON } from "./failure-reason.js";
13
13
  function writeLog(body) {
14
14
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-failure-cause-"));
@@ -274,6 +274,97 @@ test("reclaimed session with provider/auth log evidence keeps it primary", () =>
274
274
  assert.equal(reclaimedAuth[0].primary, true);
275
275
  assert.equal(reclaimedAuth.find((c) => c.code === "coordinator_crash")?.primary, false);
276
276
  });
277
+ // NOT-307: an idle-watchdog kill classifies as muse_no_progress (infrastructure,
278
+ // high confidence) instead of unknown — with the idle minutes and last tool name
279
+ // in the raw reason — even when a tool was in flight (a hung tool is still a stall).
280
+ test("idle-watchdog kill classifies as muse_no_progress, not unknown", () => {
281
+ const idle = primary(classifyAttemptFailure({
282
+ outcomeKind: "timed_out",
283
+ outcomeReason: "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.",
284
+ timedOut: true,
285
+ idleTimedOut: true,
286
+ idleForMs: 20 * 60_000,
287
+ lastToolName: "npm_test",
288
+ }));
289
+ assert.equal(idle.code, "muse_no_progress");
290
+ assert.equal(idle.domain, "infrastructure");
291
+ assert.equal(idle.confidence, "high");
292
+ assert.match(idle.rawReason, /20 minutes/);
293
+ assert.match(idle.rawReason, /npm_test/);
294
+ });
295
+ test("idle kill beats the tool_test_timeout guess and the unknown fallback", () => {
296
+ // A hung test in flight plus an idle kill: still the stall, not the tool timeout.
297
+ const hungTool = primary(classifyAttemptFailure({
298
+ outcomeKind: "timed_out",
299
+ outcomeReason: "vitest run timed out after 30000ms",
300
+ timedOut: true,
301
+ toolInFlight: true,
302
+ idleTimedOut: true,
303
+ idleForMs: 21 * 60_000,
304
+ lastToolName: "vitest",
305
+ }));
306
+ assert.equal(hungTool.code, "muse_no_progress");
307
+ // A bare timed_out with no idle evidence still classifies as before (unknown here).
308
+ const plain = primary(classifyAttemptFailure({ outcomeKind: "timed_out", timedOut: true }));
309
+ assert.equal(plain.code, "unknown");
310
+ });
311
+ test("idle kill composes the reason when the recorded text lacks minutes and tool", () => {
312
+ const composed = primary(classifyAttemptFailure({
313
+ outcomeKind: "timed_out",
314
+ outcomeReason: "Developer session timed out.",
315
+ timedOut: true,
316
+ idleTimedOut: true,
317
+ idleForMs: 22 * 60_000,
318
+ lastToolName: null,
319
+ }));
320
+ assert.equal(composed.code, "muse_no_progress");
321
+ assert.match(composed.rawReason, /22 minutes/);
322
+ assert.match(composed.rawReason, /last tool: none/);
323
+ assert.match(composed.rawReason, /Developer session timed out\./);
324
+ });
325
+ // NOT-307: stall evidence round-trips through worker_sessions.metadata_json —
326
+ // the record/backfill paths read the same five fields the developer effect wrote.
327
+ test("museStallFromMetadata reads idle evidence, and ignores anything else", () => {
328
+ const at = Date.parse("2026-10-01T19:40:00.000Z");
329
+ const stall = museStallFromMetadata(JSON.stringify({
330
+ lastActivityAt: "2026-10-01T19:20:00.000Z",
331
+ toolCallCount: 24,
332
+ lastToolName: "npm_test",
333
+ firstOutputMs: 900,
334
+ idleTimedOut: true,
335
+ }), at);
336
+ assert.equal(stall.idleTimedOut, true);
337
+ assert.equal(stall.idleForMs, 20 * 60_000);
338
+ assert.equal(stall.lastToolName, "npm_test");
339
+ assert.deepEqual(museStallFromMetadata(null, at).idleTimedOut, false);
340
+ assert.deepEqual(museStallFromMetadata("not-json{", at).idleTimedOut, false);
341
+ assert.deepEqual(museStallFromMetadata(JSON.stringify({ idleTimedOut: false }), at).idleTimedOut, false, "a clean session's metadata is not idle evidence");
342
+ assert.deepEqual(museStallFromMetadata(JSON.stringify({ idleTimedOut: true }), null).idleForMs, null, "no kill time means no measured silence");
343
+ });
344
+ test("backfill over an idle-kill row classifies muse_no_progress", () => {
345
+ const causes = backfillCausesForSession({
346
+ id: "s-idle-backfill",
347
+ errorJson: JSON.stringify({
348
+ reason: "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.",
349
+ }),
350
+ logPath: null,
351
+ runtime: "muse_code",
352
+ exitCode: 124,
353
+ completedAt: "2026-10-01T19:40:00.000Z",
354
+ updatedAt: "2026-10-01T19:40:00.000Z",
355
+ metadataJson: JSON.stringify({
356
+ lastActivityAt: "2026-10-01T19:20:00.000Z",
357
+ toolCallCount: 24,
358
+ lastToolName: "npm_test",
359
+ firstOutputMs: 900,
360
+ idleTimedOut: true,
361
+ }),
362
+ }, { outcomeKind: "timed_out" });
363
+ const found = primary(causes);
364
+ assert.equal(found.code, "muse_no_progress");
365
+ assert.equal(found.quality, "inferred");
366
+ assert.match(found.rawReason, /20 minutes/);
367
+ });
277
368
  test("usage-capped and tool-timeout evidence map to capacity/task", () => {
278
369
  const capped = primary(classifyAttemptFailure({ outcomeKind: "usage_capped", outcomeReason: "claude_code usage capped — plan limit rejected" }));
279
370
  assert.equal(capped.code, "provider_capacity_rate_limit");
@@ -184,6 +184,13 @@ export function reasonForSessionCrash(opts) {
184
184
  const classified = classifyRunnerLogFailure(opts.logPath, opts.runtime);
185
185
  if (classified)
186
186
  return classified;
187
+ if (opts.timedOut && opts.idle) {
188
+ const mins = typeof opts.idle.minutes === "number" && Number.isFinite(opts.idle.minutes)
189
+ ? ` for ${opts.idle.minutes} minute${opts.idle.minutes === 1 ? "" : "s"}`
190
+ : "";
191
+ const tool = opts.idle.lastToolName ? ` (last tool: ${opts.idle.lastToolName})` : " (no tool call seen)";
192
+ return `Developer session made no progress${mins}.${tool} Killed on the idle timeout.`;
193
+ }
187
194
  return opts.timedOut ? "Developer session timed out." : "Developer session failed or crashed.";
188
195
  }
189
196
  function outcomeExplicitReason(outcome) {
@@ -206,6 +206,20 @@ test("NOT-133: a worker.failed event for an auth death carries the auth reason",
206
206
  test("reasonForSessionCrash falls back when log is clean", () => {
207
207
  assert.equal(reasonForSessionCrash({ timedOut: false, logPath: writeLog('{"type":"result"}\n') }), "Developer session failed or crashed.");
208
208
  });
209
+ test("NOT-307: reasonForSessionCrash names the idle minutes and last tool on an idle kill", () => {
210
+ assert.equal(reasonForSessionCrash({
211
+ timedOut: true,
212
+ logPath: writeLog('{"type":"result"}\n'),
213
+ idle: { minutes: 20, lastToolName: "npm_test" },
214
+ }), "Developer session made no progress for 20 minutes. (last tool: npm_test) Killed on the idle timeout.");
215
+ assert.equal(reasonForSessionCrash({
216
+ timedOut: true,
217
+ logPath: writeLog('{"type":"result"}\n'),
218
+ idle: { minutes: null, lastToolName: null },
219
+ }), "Developer session made no progress. (no tool call seen) Killed on the idle timeout.");
220
+ // Wall-clock timeouts read exactly as before when no idle info is given.
221
+ assert.equal(reasonForSessionCrash({ timedOut: true, logPath: writeLog('{"type":"result"}\n') }), "Developer session timed out.");
222
+ });
209
223
  test("parseErrorJsonReason reads recovery presumed-dead shape", () => {
210
224
  assert.equal(parseErrorJsonReason(JSON.stringify({ reason: PRESUMED_DEAD_REASON })), PRESUMED_DEAD_REASON);
211
225
  });