agent-dealer 1.2.8 → 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 (51) hide show
  1. package/bundle/server/dist/adapters/github.js +80 -4
  2. package/bundle/server/dist/adapters/github.test.js +83 -1
  3. package/bundle/server/dist/adapters/worktree-deps.js +96 -0
  4. package/bundle/server/dist/adapters/worktree-deps.test.js +144 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +205 -1
  6. package/bundle/server/dist/coordinator/checks-wait-config.js +19 -0
  7. package/bundle/server/dist/coordinator/commands.js +194 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +145 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +288 -3
  11. package/bundle/server/dist/coordinator/human-resolution.js +19 -0
  12. package/bundle/server/dist/coordinator/human-resolution.test.js +33 -0
  13. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  14. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  15. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  16. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  17. package/bundle/server/dist/coordinator/projection.js +10 -4
  18. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  19. package/bundle/server/dist/coordinator/prompts.js +134 -1
  20. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  21. package/bundle/server/dist/coordinator/recovery.js +57 -3
  22. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  23. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  24. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  25. package/bundle/server/dist/coordinator/routing.js +45 -6
  26. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  27. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  28. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  29. package/bundle/server/dist/db/index.js +13 -0
  30. package/bundle/server/dist/db/schema.sql +2 -0
  31. package/bundle/server/dist/repository/issues.js +39 -3
  32. package/bundle/server/dist/repository/work-items.js +7 -0
  33. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  34. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  35. package/bundle/server/package.json +2 -2
  36. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  37. package/bundle/server/static-ui/index.html +1 -1
  38. package/bundle/shared/dist/human-actions.d.ts +4 -4
  39. package/bundle/shared/dist/human-actions.js +8 -0
  40. package/bundle/shared/dist/issues.d.ts +30 -0
  41. package/bundle/shared/dist/issues.js +14 -0
  42. package/bundle/shared/dist/issues.test.js +2 -0
  43. package/bundle/shared/dist/workflow.d.ts +4 -4
  44. package/bundle/shared/dist/workflow.js +6 -0
  45. package/bundle/shared/dist/workflow.test.js +1 -0
  46. package/bundle/shared/package.json +1 -1
  47. package/dist/action.js +4 -1
  48. package/dist/action.test.js +42 -0
  49. package/dist/index.js +1 -1
  50. package/package.json +1 -1
  51. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -18,15 +18,18 @@ import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { parsePhaseBudget, parseProfileSnapshot, roleCeiling } from "@agent-dealer/shared";
20
20
  import { getTaskSnapshot } from "./commands.js";
21
+ import { extractOperatorCriteria } from "./operator-criteria.js";
21
22
  import { buildDeveloperPrompt } from "./prompts.js";
22
23
  import { guidanceForNextSession } from "./guidance.js";
23
24
  import { realDeveloperSpawn, developerSessionLogPath } from "./spawn.js";
25
+ import { ensureWorktreeDeps, worktreeDepsTimeoutMs, } from "../adapters/worktree-deps.js";
24
26
  import { DEFAULT_BASE_FETCH_TIMEOUT_MS, fastForwardLocalBranchToSha, resolveDeveloperWorktree, safeRemoveWorktree, isWorktreeClean, commitsAhead, pushBranch, mergeBase, branchExists, pushBranchRef, revParseHead, revParseRef, fetchRef, salvageDirtyWorktree, dirtyWorktreeRecoveryCommands, withRepoLock, leftoverFingerprint, } from "../adapters/git-worktree.js";
25
27
  import { recordIssueBaseSha } from "../repository/issues.js";
26
28
  import { ensureIssueRepoCheckout, roleWorktreePathForResolution, resolveCheckoutBaseBranch, } from "../adapters/managed-repo.js";
27
29
  import { baseRefCandidates, inspectBranchProgress } from "./branch-progress.js";
28
30
  import { prepareWorkerDeckConnection, releaseWorkerDeckConnection, verifyWorkerDeckConnection } from "../adapters/agent-deck-bind.js";
29
- import { realGithubAdapter, pollPrChecks } from "../adapters/github.js";
31
+ import { realGithubAdapter, pollPrChecksDetailed } from "../adapters/github.js";
32
+ import { checksWaitCeilingExceeded, checksWaitCeilingMs } from "./checks-wait-config.js";
30
33
  import { museCapabilitySafetyNetAfterSession } from "../adapters/muse-capability.js";
31
34
  import { getOrAssignSessionCorrelationId, getWorkerSession, mergeSessionMetadata, patchRunningSession, recordSessionProcess, setSessionInputSha } from "../repository/worker-sessions.js";
32
35
  import { museIdleMinutes, museStallMetadata } from "./muse-spawn.js";
@@ -117,10 +120,18 @@ export const developerEffectConfig = {
117
120
  get checksPollIntervalMs() {
118
121
  return num("CHECKS_POLL_INTERVAL_MS", 15_000);
119
122
  },
123
+ /** NOT-311: overall CI-wait ceiling measured from the first poll, across deferrals. */
124
+ get checksWaitCeilingMs() {
125
+ return checksWaitCeilingMs();
126
+ },
120
127
  /** NOT-197: bound for the pre-branch `git fetch origin <base>` on a fresh issue branch. */
121
128
  get baseFetchTimeoutMs() {
122
129
  return num("BASE_FETCH_TIMEOUT_MS", DEFAULT_BASE_FETCH_TIMEOUT_MS);
123
130
  },
131
+ /** NOT-315: bound for `npm ci` in the fresh worktree (coordinator side, with network). */
132
+ get worktreeDepsTimeoutMs() {
133
+ return worktreeDepsTimeoutMs();
134
+ },
124
135
  /** NOT-110: bounded window to let a lagging `gh pr view` catch up to a just-pushed HEAD. */
125
136
  get headReconcileTimeoutMs() {
126
137
  return num("HEAD_RECONCILE_TIMEOUT_MS", 30_000);
@@ -290,6 +301,69 @@ async function checksFailedOutcome(opts) {
290
301
  });
291
302
  return details ? { kind: "checks_failed", details } : { kind: "checks_failed" };
292
303
  }
304
+ /**
305
+ * NOT-311: the `checksWaitStartedAt` persisted by a prior `checks_pending` deferral,
306
+ * anchoring the overall wait ceiling across re-polls. Null on a first wait (or an
307
+ * unparseable payload) — the caller then anchors at its own poll start.
308
+ */
309
+ function checksWaitStartedAtFromPayload(payloadJson) {
310
+ if (!payloadJson)
311
+ return null;
312
+ try {
313
+ const v = JSON.parse(payloadJson).checksWaitStartedAt;
314
+ return typeof v === "string" && v ? v : null;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ /**
321
+ * NOT-311: decide what a finished checks poll means. Returns a `checks_pending`
322
+ * outcome when CI is still queued/running at the timeout (or reports no checks while
323
+ * the base branch has a `pull_request` workflow — "not yet registered", never "no
324
+ * CI"), and null when the caller stays on today's path (success, failure, an aborted
325
+ * wait, or `none` with no workflow configured). Also yields the `checks.completed`
326
+ * milestone reason for the wait cases.
327
+ *
328
+ * A missing/unknown workflow answer (no adapter method, unreadable base ref) waits
329
+ * fail-closed: sending an unverified PR to review is the outcome this exists to
330
+ * prevent, and the wait ceiling still bounds it.
331
+ */
332
+ async function resolveChecksWait(opts) {
333
+ const waitStartedAt = checksWaitStartedAtFromPayload(opts.payloadJson) ?? opts.pollStartedAt;
334
+ const base = {
335
+ kind: "checks_pending",
336
+ branch: opts.branch,
337
+ prNumber: opts.prNumber,
338
+ headSha: opts.headSha,
339
+ waitStartedAt,
340
+ };
341
+ const noChecks = opts.poll.result === "none" || (opts.poll.result === "timeout" && opts.poll.lastSnapshot === "none");
342
+ if (noChecks) {
343
+ let hasWorkflow = null;
344
+ try {
345
+ hasWorkflow = (await opts.github.hasPullRequestWorkflow?.({ cwd: opts.cwd, baseRef: opts.baseRef })) ?? null;
346
+ }
347
+ catch {
348
+ hasWorkflow = null;
349
+ }
350
+ if (hasWorkflow === false) {
351
+ return { pending: null, milestoneReason: "none_no_workflow" };
352
+ }
353
+ return {
354
+ pending: { ...base, reason: "Waiting for CI (queued) - no attempt spent" },
355
+ milestoneReason: "none_with_workflow",
356
+ };
357
+ }
358
+ if (opts.poll.result === "timeout" && opts.poll.lastSnapshot === "pending" && !opts.poll.aborted && !opts.signal?.aborted) {
359
+ const milestoneReason = checksWaitCeilingExceeded(waitStartedAt) ? "pending_ceiling" : undefined;
360
+ return {
361
+ pending: { ...base, reason: "Waiting for CI (running) - no attempt spent" },
362
+ ...(milestoneReason ? { milestoneReason } : {}),
363
+ };
364
+ }
365
+ return { pending: null };
366
+ }
293
367
  /**
294
368
  * Infra retry that skips the agent: publish whatever the branch already carries. Uses the
295
369
  * issue repo as `gh` cwd and `origin/<branch>` as the verified head (any worktree was
@@ -528,17 +602,35 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
528
602
  prNumber: prView.number,
529
603
  headSha: prView.headRefOid,
530
604
  });
531
- const checks = await pollPrChecks(deps.github, {
605
+ const pollStartedAt = new Date().toISOString();
606
+ const poll = await pollPrChecksDetailed(deps.github, {
532
607
  cwd,
533
608
  timeoutMs: developerEffectConfig.checksPollTimeoutMs,
534
609
  intervalMs: developerEffectConfig.checksPollIntervalMs,
535
610
  signal: ctx.signal,
536
611
  number: prView.number,
537
612
  });
613
+ // NOT-311: pending/queued CI at the timeout (or no checks yet with a
614
+ // `pull_request` workflow on the base) waits with backoff instead of spending
615
+ // an infra attempt — resolved here so the milestone carries the wait reason.
616
+ const wait = await resolveChecksWait({
617
+ github: deps.github,
618
+ cwd,
619
+ prNumber: prView.number,
620
+ baseRef: prView.baseRefName,
621
+ headSha: prView.headRefOid,
622
+ branch: branchName,
623
+ poll,
624
+ signal: ctx.signal,
625
+ payloadJson: workItem.payloadJson,
626
+ pollStartedAt,
627
+ });
628
+ const checks = poll.result;
538
629
  milestone("checks.completed", `Developer · checks ${checks}`, {
539
630
  snapshot: checks,
540
631
  prNumber: prView.number,
541
632
  headSha: prView.headRefOid,
633
+ ...(wait.milestoneReason ? { reason: wait.milestoneReason } : {}),
542
634
  });
543
635
  const postPollView = await deps.github.viewPr({ cwd, branch: branchName });
544
636
  if (!postPollView) {
@@ -575,6 +667,10 @@ async function runPublishOnlyHandoff(ctx, deps, branchName) {
575
667
  author: "system",
576
668
  content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
577
669
  });
670
+ // NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
671
+ // timing out the session. An aborted wait keeps today's `timed_out`.
672
+ if (wait.pending)
673
+ return wait.pending;
578
674
  if (checks === "timeout")
579
675
  return { kind: "timed_out" };
580
676
  await fetchRef(cwd, prView.baseRefName);
@@ -773,6 +869,18 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
773
869
  recordIssueBaseSha(issue.id, resolved.baseSha);
774
870
  issue.baseSha = resolved.baseSha;
775
871
  }
872
+ // NOT-315: provision dependencies here, outside the sandbox, with network — the
873
+ // builder sandbox has none, so an in-session install can never work. A throw
874
+ // lands in the worktree-setup adapter_failure below: visible and
875
+ // infra-retryable, never a silent hour-long stall. The session is not spawned.
876
+ const depsResult = await ensureWorktreeDeps(worktreePath, {
877
+ runner: deps.depsRunner,
878
+ timeoutMs: developerEffectConfig.worktreeDepsTimeoutMs,
879
+ });
880
+ milestone("worktree.deps_ready", `Developer · dependencies ready (round ${round})`, {
881
+ ...(depsResult.ran ? { ran: true } : { ran: false, reason: depsResult.reason }),
882
+ durationMs: depsResult.durationMs,
883
+ });
776
884
  }
777
885
  catch (err) {
778
886
  return { kind: "adapter_failure", reason: `worktree setup failed: ${String(err)}` };
@@ -886,6 +994,14 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
886
994
  const scopeDecisionNote = typeof payload.scopeDecisionNote === "string" && payload.scopeDecisionNote.trim()
887
995
  ? payload.scopeDecisionNote.trim()
888
996
  : undefined;
997
+ // NOT-314: same one-shot ride for an operator_verification repair note.
998
+ const operatorRepairNote = typeof payload.operatorRepairNote === "string" && payload.operatorRepairNote.trim()
999
+ ? payload.operatorRepairNote.trim()
1000
+ : undefined;
1001
+ // NOT-314: the frozen snapshot's operator criteria, parsed here so the
1002
+ // worker never re-derives them — the section renders only when non-empty.
1003
+ const operatorCriteria = extractOperatorCriteria(taskSnapshot.acceptanceCriteria);
1004
+ const hasOperatorCriteria = operatorCriteria.length > 0 ? operatorCriteria : undefined;
889
1005
  // NOT-310: the conflict directive rides only the work item queued by the merge
890
1006
  // path — exactly this round reads it; nothing is re-read from the DB. Shaped
891
1007
  // defensively: a malformed directive renders nothing rather than breaking the
@@ -938,6 +1054,8 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
938
1054
  guidance: guidance.length ? guidance : undefined,
939
1055
  scopeDecisionNote,
940
1056
  conflictRepair,
1057
+ operatorCriteria: hasOperatorCriteria,
1058
+ operatorRepairNote,
941
1059
  });
942
1060
  // NOT-83 review finding: the session is marked `running` (worker-loop.ts) before this
943
1061
  // handler ever runs, so an abort landing during worktree setup/deck-bind above already
@@ -1614,7 +1732,8 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1614
1732
  prNumber: prView.number,
1615
1733
  headSha: prView.headRefOid,
1616
1734
  });
1617
- const checks = await pollPrChecks(deps.github, {
1735
+ const pollStartedAt = new Date().toISOString();
1736
+ const poll = await pollPrChecksDetailed(deps.github, {
1618
1737
  cwd: worktreePath,
1619
1738
  timeoutMs: developerEffectConfig.checksPollTimeoutMs,
1620
1739
  intervalMs: developerEffectConfig.checksPollIntervalMs,
@@ -1624,10 +1743,27 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1624
1743
  // back to a bare `gh pr view` either).
1625
1744
  number: prView.number,
1626
1745
  });
1746
+ // NOT-311: pending/queued CI at the timeout (or no checks yet with a
1747
+ // `pull_request` workflow on the base) waits with backoff instead of spending
1748
+ // an infra attempt — resolved here so the milestone carries the wait reason.
1749
+ const wait = await resolveChecksWait({
1750
+ github: deps.github,
1751
+ cwd: worktreePath,
1752
+ prNumber: prView.number,
1753
+ baseRef: prView.baseRefName,
1754
+ headSha: prView.headRefOid,
1755
+ branch: branchName,
1756
+ poll,
1757
+ signal: ctx.signal,
1758
+ payloadJson: workItem.payloadJson,
1759
+ pollStartedAt,
1760
+ });
1761
+ const checks = poll.result;
1627
1762
  milestone("checks.completed", `Developer · checks ${checks}`, {
1628
1763
  snapshot: checks,
1629
1764
  prNumber: prView.number,
1630
1765
  headSha: prView.headRefOid,
1766
+ ...(wait.milestoneReason ? { reason: wait.milestoneReason } : {}),
1631
1767
  });
1632
1768
  // The poll can run for up to checksPollTimeoutMs (default 10 minutes) — re-fetch and
1633
1769
  // re-validate identity against the SAME localHead before trusting either the checks
@@ -1675,6 +1811,12 @@ export async function runDeveloperEffect(ctx, deps = defaultDeps) {
1675
1811
  author: "system",
1676
1812
  content: { snapshot: checks, prNumber: prView.number, headSha: prView.headRefOid },
1677
1813
  });
1814
+ // NOT-311: still-waiting CI defers with backoff (no attempt spent) instead of
1815
+ // timing out the session. An aborted wait keeps today's `timed_out`.
1816
+ if (wait.pending) {
1817
+ await bestEffortRemove(repoPath, worktreePath);
1818
+ return wait.pending;
1819
+ }
1678
1820
  if (checks === "timeout") {
1679
1821
  await bestEffortRemove(repoPath, worktreePath);
1680
1822
  return { kind: "timed_out" };
@@ -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
+ });
@@ -82,6 +82,7 @@ const VALID_CHOICES = {
82
82
  // below and the only legal resolver is commands.ts's dedicated muse_capability branch
83
83
  // (acknowledge records a per-version override; there is no workflow outcome to map).
84
84
  muse_capability: ["acknowledge"],
85
+ operator_verification: ["verified", "waive", "repair"],
85
86
  };
86
87
  /**
87
88
  * Validates a raw (actionType, choice) pair against that action type's allowed response
@@ -114,6 +115,16 @@ export function parseHumanResolution(actionType, choice, note) {
114
115
  if (actionType === "product_scope_decision" && note) {
115
116
  return { actionType, choice, note };
116
117
  }
118
+ // NOT-314: operator_verification always carries a required non-empty note (the
119
+ // recorded result, the waiver reason, or the repair instruction). A missing or
120
+ // blank note is a caller error — resolveHumanActionAndAdvance 400s, never stores.
121
+ if (actionType === "operator_verification") {
122
+ if (!VALID_CHOICES.operator_verification.includes(choice))
123
+ return null;
124
+ if (!note || !note.trim())
125
+ return null;
126
+ return { actionType, choice, note: note.trim() };
127
+ }
117
128
  return { actionType, choice };
118
129
  }
119
130
  /**
@@ -164,6 +175,14 @@ export function resolveHumanActionOutcome(resolution) {
164
175
  throw new Error(`Unrecognized policy_escalation choice: ${resolution.choice}`);
165
176
  case "product_scope_decision":
166
177
  return { issueStatus: "developing", startNewRound: true, roundKind: "none" };
178
+ case "operator_verification":
179
+ // NOT-314: `repair` is a genuine repair cycle (same shape as
180
+ // final_review:repair). `verified` / `waive` never reach here — the sync
181
+ // core parks them for undraft+merge before consulting this outcome map
182
+ // (same as policy_escalation:retry_merge).
183
+ if (resolution.choice === "repair")
184
+ return { issueStatus: "repairing", startNewRound: true, roundKind: "review" };
185
+ throw new Error(`Unrecognized operator_verification choice: ${resolution.choice}`);
167
186
  case "deck_interaction_required":
168
187
  // Same semantics as policy_escalation:resume — an authority/control-plane hiccup is
169
188
  // not a review-round spend; a fresh attempt mints its own new authority when it runs.
@@ -82,3 +82,36 @@ test("deck_interaction_required close ends the issue", () => {
82
82
  assert.equal(result.issueStatus, "closed");
83
83
  assert.equal(result.workflowOutcome, "closed");
84
84
  });
85
+ // NOT-314: operator_verification parses only with a non-empty note.
86
+ test("parseHumanResolution accepts operator_verification choices only with a note", () => {
87
+ assert.deepStrictEqual(parseHumanResolution("operator_verification", "verified", "SSO login OK"), {
88
+ actionType: "operator_verification",
89
+ choice: "verified",
90
+ note: "SSO login OK",
91
+ });
92
+ assert.deepStrictEqual(parseHumanResolution("operator_verification", "waive", " waiver reason "), {
93
+ actionType: "operator_verification",
94
+ choice: "waive",
95
+ note: "waiver reason",
96
+ });
97
+ assert.deepStrictEqual(parseHumanResolution("operator_verification", "repair", "fix the probe"), {
98
+ actionType: "operator_verification",
99
+ choice: "repair",
100
+ note: "fix the probe",
101
+ });
102
+ assert.equal(parseHumanResolution("operator_verification", "verified"), null);
103
+ assert.equal(parseHumanResolution("operator_verification", "verified", ""), null);
104
+ assert.equal(parseHumanResolution("operator_verification", "verified", " "), null);
105
+ assert.equal(parseHumanResolution("operator_verification", "merge", "a note"), null);
106
+ });
107
+ test("operator_verification repair queues another repair round; verified/waive never map here", () => {
108
+ const result = resolveHumanActionOutcome({
109
+ actionType: "operator_verification",
110
+ choice: "repair",
111
+ note: "fix the probe",
112
+ });
113
+ assert.equal(result.issueStatus, "repairing");
114
+ assert.equal(result.startNewRound, true);
115
+ assert.equal(result.roundKind, "review");
116
+ assert.throws(() => resolveHumanActionOutcome({ actionType: "operator_verification", choice: "verified", note: "ok" }), /Unrecognized operator_verification choice/);
117
+ });