agent-dealer 1.2.8 → 1.2.10

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 (60) hide show
  1. package/bundle/server/dist/adapters/github.js +93 -4
  2. package/bundle/server/dist/adapters/github.test.js +96 -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 +341 -18
  8. package/bundle/server/dist/coordinator/commands.test.js +47 -0
  9. package/bundle/server/dist/coordinator/developer-effect.js +237 -3
  10. package/bundle/server/dist/coordinator/developer-effect.test.js +460 -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/merge-conflict-sync.js +100 -12
  14. package/bundle/server/dist/coordinator/merge-conflict-sync.test.js +190 -0
  15. package/bundle/server/dist/coordinator/muse-spawn.js +22 -4
  16. package/bundle/server/dist/coordinator/muse-spawn.test.js +166 -10
  17. package/bundle/server/dist/coordinator/operator-criteria.js +168 -0
  18. package/bundle/server/dist/coordinator/operator-criteria.test.js +78 -0
  19. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +347 -0
  20. package/bundle/server/dist/coordinator/playbook-feedback.js +3 -0
  21. package/bundle/server/dist/coordinator/projection.js +10 -4
  22. package/bundle/server/dist/coordinator/projection.test.js +13 -0
  23. package/bundle/server/dist/coordinator/prompts.js +134 -1
  24. package/bundle/server/dist/coordinator/prompts.test.js +148 -0
  25. package/bundle/server/dist/coordinator/recovery.js +57 -3
  26. package/bundle/server/dist/coordinator/recovery.test.js +67 -8
  27. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +1 -0
  28. package/bundle/server/dist/coordinator/reviewer-effect.js +6 -0
  29. package/bundle/server/dist/coordinator/routing.js +55 -6
  30. package/bundle/server/dist/coordinator/routing.test.js +62 -15
  31. package/bundle/server/dist/coordinator/session-timeouts.js +22 -0
  32. package/bundle/server/dist/coordinator/usage-cap-defer.js +61 -0
  33. package/bundle/server/dist/db/ci-attempts-migration.test.js +70 -0
  34. package/bundle/server/dist/db/index.js +13 -0
  35. package/bundle/server/dist/db/schema.sql +2 -0
  36. package/bundle/server/dist/repository/issues.js +39 -3
  37. package/bundle/server/dist/repository/work-items.js +7 -0
  38. package/bundle/server/dist/runners/muse-code-args.js +25 -0
  39. package/bundle/server/dist/runners/muse-code-args.test.js +30 -0
  40. package/bundle/server/dist/runners/muse-code-jsonl.js +61 -0
  41. package/bundle/server/dist/runners/muse-code.test.js +13 -1
  42. package/bundle/server/dist/runners/spawn-cli.js +93 -20
  43. package/bundle/server/dist/runners/spawn-cli.test.js +38 -0
  44. package/bundle/server/package.json +2 -2
  45. package/bundle/server/static-ui/assets/index-CU6fNntg.js +64 -0
  46. package/bundle/server/static-ui/index.html +1 -1
  47. package/bundle/shared/dist/human-actions.d.ts +4 -4
  48. package/bundle/shared/dist/human-actions.js +8 -0
  49. package/bundle/shared/dist/issues.d.ts +30 -0
  50. package/bundle/shared/dist/issues.js +14 -0
  51. package/bundle/shared/dist/issues.test.js +2 -0
  52. package/bundle/shared/dist/workflow.d.ts +4 -4
  53. package/bundle/shared/dist/workflow.js +6 -0
  54. package/bundle/shared/dist/workflow.test.js +1 -0
  55. package/bundle/shared/package.json +1 -1
  56. package/dist/action.js +4 -1
  57. package/dist/action.test.js +42 -0
  58. package/dist/index.js +1 -1
  59. package/package.json +1 -1
  60. package/bundle/server/static-ui/assets/index-B6SVCzMR.js +0 -64
@@ -9,7 +9,7 @@
9
9
  import fs from "node:fs";
10
10
  import { canTransitionIssue, tryCompileContract } from "@agent-dealer/shared";
11
11
  import { getDb } from "../db/index.js";
12
- import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, resetIssueInfraAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
12
+ import { getIssue, incrementIssueRound, incrementIssueInfraAttempts, incrementIssueCiAttempts, resetIssueInfraAttempts, resetIssueCiAttempts, grantReviewRetry, transitionIssue, } from "../repository/issues.js";
13
13
  import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowEventsForIssue, startWorkflowInstance, WorkflowAlreadyActiveError, } from "../repository/workflow-events.js";
14
14
  import { createHumanAction, findOpenHumanAction, getHumanAction, listHumanActionsForIssue, reopenHumanAction, resolveHumanAction, updateOpenHumanAction, } from "../repository/human-actions.js";
15
15
  import { pushLeaseToSha, readRemoteTip } from "../adapters/git-worktree.js";
@@ -20,7 +20,7 @@ import { getAgent } from "../repository/agents.js";
20
20
  import { githubIssuesSync, invalidateMuseHealthCache } from "../adapters/agent-health.js";
21
21
  import { recordMuseCapabilityOverride } from "../adapters/muse-capability.js";
22
22
  import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
23
- import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
23
+ import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, getWorkItemByIdempotencyKey, listWorkItemsForIssue, } from "../repository/work-items.js";
24
24
  import { completeSession, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
25
25
  import { killRunProcess } from "../runners/spawn-cli.js";
26
26
  import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
@@ -31,9 +31,11 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
31
31
  import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
32
32
  import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
33
33
  import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
34
+ import { queueConflictRepairRound } from "./merge-conflict-sync.js";
35
+ import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
34
36
  import { externalMergeStateForIssue } from "./external-merge.js";
35
37
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
36
- import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
38
+ import { capEscalationEvents, checksWaitDeferralStartedAt, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForChecksPending, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, formatChecksWaitEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
37
39
  import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
38
40
  import { fetchResumeLiveHeadSha } from "./resume-live-head.js";
39
41
  import { getWorkflow } from "./workflows/registry.js";
@@ -136,7 +138,11 @@ function freezeTaskSnapshot(issue) {
136
138
  * never changes the eventual session. The worker loop consumes this off the payload and
137
139
  * only falls back to a live resolve for a legacy item queued before snapshots were carried.
138
140
  */
139
- function queuedProfileSnapshot(issue, kind) {
141
+ /**
142
+ * NOT-333: shared with recovery's stranded-reviewer re-enqueue so a recovered round
143
+ * carries the same frozen profile as a normally routed one.
144
+ */
145
+ export function queuedProfileSnapshot(issue, kind) {
140
146
  const agentId = kind === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
141
147
  const agent = agentId ? getAgent(agentId) : null;
142
148
  return agent ? serializeProfileSnapshot(buildProfileSnapshot(agent, kind)) : null;
@@ -327,6 +333,18 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
327
333
  if (outcome.kind === "base_fetch_failed") {
328
334
  return applyBaseFetchDeferralCompletion(workItemId, leaseToken, outcome);
329
335
  }
336
+ // NOT-311: CI still queued/running (or not yet registered) at the poll timeout —
337
+ // the session ended cleanly, so the item waits on backoff and re-polls instead of
338
+ // spending an infra attempt. Past the wait ceiling this escalates to a human.
339
+ if (outcome.kind === "checks_pending") {
340
+ return applyChecksPendingCompletion(workItemId, leaseToken, outcome);
341
+ }
342
+ // NOT-354: the PR conflicts with its base, so CI will never start. The effect
343
+ // already ran the base sync; this re-polls the synced head, queues the one
344
+ // conflict-repair round, or escalates once — never a "Waiting for CI" deferral.
345
+ if (outcome.kind === "checks_conflicting") {
346
+ return applyChecksConflictCompletion(workItemId, leaseToken, outcome);
347
+ }
330
348
  const routed = getDb().transaction(() => {
331
349
  const before = getWorkItem(workItemId);
332
350
  if (!before)
@@ -415,6 +433,208 @@ function applyDeckOutageCompletion(workItemId, leaseToken, outage) {
415
433
  function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
416
434
  return applyDeferralCompletion(workItemId, leaseToken, failure, (item, issue, instance) => deferLeasedWorkItemForBaseFetch(item, leaseToken, failure, issue, instance));
417
435
  }
436
+ /**
437
+ * NOT-311: CI still pending at the poll timeout — defer with backoff, no attempt
438
+ * spent. Unlike a deck outage the wait has a ceiling, so the escalation arm raises a
439
+ * human action naming the stuck PR/SHA once CI has waited too long.
440
+ */
441
+ function applyChecksPendingCompletion(workItemId, leaseToken, pending) {
442
+ return applyDeferralCompletion(workItemId, leaseToken, pending, (item, issue, instance) => deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance), (issue, instance, item) => routeChecksWaitEscalation(issue, instance, item, pending));
443
+ }
444
+ /**
445
+ * NOT-354: act on the base sync a conflicting PR's CI wait ran. A clean sync is a
446
+ * normal CI wait on the pushed head, anchored at the original wait start (the
447
+ * deferral keeps any persisted `checksWaitStartedAt`), so the ceiling is never
448
+ * reset. A textual conflict queues the one NOT-310 conflict-repair round while
449
+ * finishing this item; anything else escalates once.
450
+ */
451
+ function applyChecksConflictCompletion(workItemId, leaseToken, outcome) {
452
+ const { sync } = outcome;
453
+ if (sync.result === "synced") {
454
+ const headSha = sync.headSha ?? outcome.headSha;
455
+ return applyChecksPendingCompletion(workItemId, leaseToken, {
456
+ kind: "checks_pending",
457
+ reason: `Synced ${outcome.baseBranch} into PR #${outcome.prNumber}; waiting for CI on ${headSha.slice(0, 12)} - no attempt spent`,
458
+ branch: outcome.branch,
459
+ prNumber: outcome.prNumber,
460
+ headSha,
461
+ waitStartedAt: outcome.waitStartedAt,
462
+ });
463
+ }
464
+ return getDb().transaction(() => {
465
+ const before = getWorkItem(workItemId);
466
+ if (!before)
467
+ return { applied: false, reason: "not_found" };
468
+ if (before.status === "done" || before.status === "dead" || before.status === "cancelled") {
469
+ return { applied: false, reason: "already_terminal" };
470
+ }
471
+ const issue = getIssue(before.issueId);
472
+ if (!issue)
473
+ return { applied: false, reason: "not_found" };
474
+ const instance = getActiveWorkflowInstance(before.issueId);
475
+ if (!instance || instance.id !== before.workflowInstanceId) {
476
+ return { applied: false, reason: "no_active_instance" };
477
+ }
478
+ if (sync.result === "repair") {
479
+ const queued = queueConflictRepairRound({
480
+ issueId: issue.id,
481
+ instanceId: instance.id,
482
+ baseBranch: outcome.baseBranch,
483
+ branch: outcome.branch,
484
+ files: sync.files,
485
+ entry: "checks_wait",
486
+ finish: { workItemId, leaseToken, result: outcome },
487
+ });
488
+ if (queued) {
489
+ const session = before.workerSessionId ? getWorkerSession(before.workerSessionId) : null;
490
+ appendWorkflowEvent({
491
+ issueId: issue.id,
492
+ workflowInstanceId: instance.id,
493
+ workerSessionId: before.workerSessionId,
494
+ type: "worker.completed",
495
+ actorType: "developer",
496
+ stage: getIssue(issue.id).status,
497
+ round: issue.currentRound,
498
+ payload: {
499
+ ...workerSessionPayload({
500
+ runtime: session?.runtime,
501
+ model: session?.model,
502
+ sessionId: before.workerSessionId ?? "",
503
+ worktreePath: session?.worktreePath,
504
+ }),
505
+ outcome: outcome.kind,
506
+ ...(isPublishOnlyItem(before) ? { publishOnly: true } : {}),
507
+ },
508
+ });
509
+ return {
510
+ applied: true,
511
+ issueStatus: getIssue(issue.id).status,
512
+ nextWorkItemId: queued.id,
513
+ humanActionId: null,
514
+ instanceCompleted: false,
515
+ };
516
+ }
517
+ // Lease lost, or the issue left its developer stage — a lost lease applies
518
+ // nothing; otherwise fall through to the single escalation.
519
+ if (!getWorkItem(workItemId) || getWorkItem(workItemId).leaseToken !== leaseToken) {
520
+ return { applied: false, reason: "lease_lost" };
521
+ }
522
+ }
523
+ const item = finishWorkItem(workItemId, leaseToken, { status: "done", result: outcome });
524
+ if (!item)
525
+ return { applied: false, reason: "lease_lost" };
526
+ const reason = sync.result === "escalate"
527
+ ? sync.reason
528
+ : `PR #${outcome.prNumber} conflicts with ${outcome.baseBranch}; the conflict-repair round could not be queued.`;
529
+ const evidence = sync.result === "escalate" ? sync.evidence : { checksConflict: true };
530
+ return routeChecksConflictEscalation(issue, instance, item, outcome, reason, evidence);
531
+ })();
532
+ }
533
+ /** NOT-354: the one escalation for a conflicting PR — same shape as the CI-wait
534
+ * ceiling escalation, naming the PR, base, and (when known) the conflicting files. */
535
+ function routeChecksConflictEscalation(issue, instance, item, outcome, reason, evidence) {
536
+ const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
537
+ const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
538
+ for (const type of capEscalationEvents()) {
539
+ ev.emit(type, {
540
+ actorType: "developer",
541
+ payload: {
542
+ ...workerSessionPayload({
543
+ runtime: session?.runtime ?? null,
544
+ model: session?.model ?? null,
545
+ sessionId: item.workerSessionId ?? "",
546
+ worktreePath: session?.worktreePath,
547
+ }),
548
+ outcome: outcome.kind,
549
+ reason,
550
+ branch: outcome.branch,
551
+ baseBranch: outcome.baseBranch,
552
+ prNumber: outcome.prNumber,
553
+ headSha: outcome.headSha,
554
+ },
555
+ });
556
+ }
557
+ applyProjectionTransition(issue, { issueStatus: "needs_human", currentOwner: "human", currentIntent: reason, events: capEscalationEvents() }, {});
558
+ const action = createHumanAction({
559
+ issueId: issue.id,
560
+ workflowInstanceId: instance.id,
561
+ actionType: "policy_escalation",
562
+ reason,
563
+ question: questionFor("policy_escalation", reason),
564
+ evidence: {
565
+ ...evidence,
566
+ checksConflict: {
567
+ branch: outcome.branch,
568
+ baseBranch: outcome.baseBranch,
569
+ prNumber: outcome.prNumber,
570
+ headSha: outcome.headSha,
571
+ },
572
+ },
573
+ responseOptions: responseOptionsFor("policy_escalation"),
574
+ });
575
+ ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
576
+ return {
577
+ applied: true,
578
+ issueStatus: "needs_human",
579
+ nextWorkItemId: null,
580
+ humanActionId: action.id,
581
+ instanceCompleted: false,
582
+ };
583
+ }
584
+ /**
585
+ * NOT-311: the CI wait passed its ceiling — hand to a human with the PR and head SHA
586
+ * CI never proved, without spending infra attempts. Mirrors the usage-cap ceiling
587
+ * escalation (worker.failed + needs_human + policy_escalation).
588
+ */
589
+ export function routeChecksWaitEscalation(issue, instance, item, pending) {
590
+ const payload = parseWorkItemPayload(item.payloadJson);
591
+ const firstDeferredAt = checksWaitDeferralStartedAt(payload) ?? pending.waitStartedAt;
592
+ const reason = formatChecksWaitEscalationReason(pending, firstDeferredAt);
593
+ const role = item.kind === "developer" ? "developer" : "reviewer";
594
+ const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
595
+ for (const type of capEscalationEvents()) {
596
+ const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
597
+ ev.emit(type, {
598
+ actorType: role,
599
+ payload: {
600
+ ...workerSessionPayload({
601
+ runtime: session?.runtime ?? null,
602
+ model: session?.model ?? null,
603
+ sessionId: item.workerSessionId ?? "",
604
+ worktreePath: session?.worktreePath,
605
+ }),
606
+ outcome: "checks_pending",
607
+ reason: pending.reason,
608
+ branch: pending.branch,
609
+ prNumber: pending.prNumber,
610
+ headSha: pending.headSha,
611
+ },
612
+ });
613
+ }
614
+ applyProjectionTransition(issue, {
615
+ issueStatus: "needs_human",
616
+ currentOwner: "human",
617
+ currentIntent: reason,
618
+ events: capEscalationEvents(),
619
+ }, {});
620
+ const action = createHumanAction({
621
+ issueId: issue.id,
622
+ workflowInstanceId: instance.id,
623
+ actionType: "policy_escalation",
624
+ reason,
625
+ question: questionFor("policy_escalation", reason),
626
+ evidence: { checksPending: { ...pending, firstDeferredAt } },
627
+ responseOptions: responseOptionsFor("policy_escalation"),
628
+ });
629
+ ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
630
+ return {
631
+ applied: true,
632
+ issueStatus: "needs_human",
633
+ nextWorkItemId: null,
634
+ humanActionId: action.id,
635
+ instanceCompleted: false,
636
+ };
637
+ }
418
638
  /**
419
639
  * Deferral ceiling exceeded — escalate with cap evidence without spending infra attempts.
420
640
  * Used when applyCompletion or the worker loop cannot defer any longer.
@@ -525,6 +745,8 @@ function applyDeveloper(issue, instance, item, outcome) {
525
745
  maxReviewRounds: issue.maxReviewRounds,
526
746
  infraAttempts: issue.infraAttempts,
527
747
  maxInfraAttempts: issue.maxInfraAttempts,
748
+ ciAttempts: issue.ciAttempts,
749
+ maxCiAttempts: issue.maxCiAttempts,
528
750
  });
529
751
  const { projection, effect, advance } = projectDeveloperRoute(route, issue.status, issue.currentRound);
530
752
  const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
@@ -607,6 +829,9 @@ function applyDeveloper(issue, instance, item, outcome) {
607
829
  incrementIssueRound(issue.id);
608
830
  else if (advance === "infra")
609
831
  incrementIssueInfraAttempts(issue.id);
832
+ // NOT-313: a CI-repair retry spends ci_attempts, never infra_attempts.
833
+ else if (advance === "ci")
834
+ incrementIssueCiAttempts(issue.id);
610
835
  const issueNow = getIssue(issue.id);
611
836
  return applyEffect(issue, instance, effect, route, issueNow, ev, item.id);
612
837
  }
@@ -740,9 +965,10 @@ function findNonConvergence(issue, instance, result) {
740
965
  });
741
966
  return detectNonConvergence(history, issue.currentRound, floorRound);
742
967
  }
743
- /** Every route that spends an infra attempt (projection.ts's advance === "infra") repeats
744
- * the same round/head, so its enqueue needs a suffix the plain round/head key doesn't
745
- * provide to avoid colliding with the previous (terminal) attempt's key.
968
+ /** Every route that spends an infra or CI attempt (projection.ts's advance === "infra"
969
+ * or "ci") repeats the same round/head, so its enqueue needs a suffix the plain
970
+ * round/head key doesn't provide to avoid colliding with the previous (terminal)
971
+ * attempt's key.
746
972
  * retry_reviewer_at_new_head belongs here too — a head that cycles A→B→A would otherwise
747
973
  * re-derive attempt A's ORIGINAL key (same round, same head) and collide with that
748
974
  * now-terminal work item, leaving the issue "reviewing" with no pending item. */
@@ -773,6 +999,21 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
773
999
  const attemptSuffix = isInfraRetry(route) ? `:retry-of:${causativeItemId}` : "";
774
1000
  if (route.next === "retry_developer_with_findings")
775
1001
  ev.emit("repair.started");
1002
+ let idempotencyKey = `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`;
1003
+ // NOT-333: a developer round that pushes nothing (e.g. a resume round that only
1004
+ // updates the PR description) re-derives the exact key of the already-terminal
1005
+ // reviewer item it follows — same instance, same round, same head — so the enqueue
1006
+ // dedupes against that done row and strands the issue in `reviewing` with no pending
1007
+ // work. A terminal duplicate gets the same `:retry-of:<causative work item id>`
1008
+ // shape the infra-retry path uses, so a new row is created; a still-active
1009
+ // (pending/leased) duplicate keeps deduping.
1010
+ if (kind === "reviewer" && attemptSuffix === "") {
1011
+ const existing = getWorkItemByIdempotencyKey(idempotencyKey);
1012
+ if (existing &&
1013
+ (existing.status === "done" || existing.status === "dead" || existing.status === "cancelled")) {
1014
+ idempotencyKey += `:retry-of:${causativeItemId}`;
1015
+ }
1016
+ }
776
1017
  const next = enqueueWorkItem({
777
1018
  issueId: issue.id,
778
1019
  workflowInstanceId: instance.id,
@@ -785,7 +1026,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
785
1026
  ...(effect.branch ? { branch: effect.branch } : {}),
786
1027
  profileSnapshot: queuedProfileSnapshot(issue, kind),
787
1028
  },
788
- idempotencyKey: `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`,
1029
+ idempotencyKey,
789
1030
  });
790
1031
  return { ...base, nextWorkItemId: next.id };
791
1032
  }
@@ -996,6 +1237,12 @@ function questionFor(actionType, reason, resumeAsReviewer = false, mergeFailure
996
1237
  // directly with its own question text (NOT-308). Case exists only so this
997
1238
  // function stays total over HumanActionType.
998
1239
  throw new Error("muse_capability is not raised through applyEffect");
1240
+ case "operator_verification":
1241
+ // Never raised through applyEffect — auto-merge.ts's operator gate creates
1242
+ // this action directly with a question listing each criterion and its
1243
+ // command (NOT-314). Case exists only so this function stays total over
1244
+ // HumanActionType.
1245
+ throw new Error("operator_verification is not raised through applyEffect");
999
1246
  }
1000
1247
  }
1001
1248
  /** Exported for db/migrate-to-issues.ts, which must populate the same response options on
@@ -1042,6 +1289,11 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false, opts =
1042
1289
  // admits on that Muse version (missing) or dismisses (unverified). Never raised
1043
1290
  // through applyEffect; case exists only so this function stays total.
1044
1291
  return [{ choice: "acknowledge", label: "Acknowledge" }];
1292
+ case "operator_verification":
1293
+ // Raised by auto-merge.ts's operator gate with these exact options (the
1294
+ // shared constant keeps the two from drifting); listed here so the table
1295
+ // stays total over HumanActionType.
1296
+ return [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS];
1045
1297
  }
1046
1298
  }
1047
1299
  function parseContinuationPreview(json) {
@@ -1080,6 +1332,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1080
1332
  const normalizedNote = normalizeResolutionNote(opts?.note);
1081
1333
  if (!normalizedNote.ok)
1082
1334
  return { ok: false, code: 400, error: normalizedNote.error };
1335
+ // NOT-314: every operator_verification choice requires a non-empty note — the
1336
+ // recorded result (verified), the waiver reason (waive), or what to fix
1337
+ // (repair). parseHumanResolution below also rejects a missing note; this
1338
+ // names the requirement instead of misreporting a valid choice as invalid.
1339
+ if (action.actionType === "operator_verification" && !normalizedNote.note) {
1340
+ return {
1341
+ ok: false,
1342
+ code: 400,
1343
+ error: 'A non-empty note is required to resolve operator_verification (verified: the result, e.g. pasted output; waive: why it is waived; repair: what the next round must fix)',
1344
+ };
1345
+ }
1083
1346
  // NOT-308: a Muse capability escalation resolves without touching the workflow — it
1084
1347
  // has no round to spend and no merge to park, and parseHumanResolution (below)
1085
1348
  // deliberately rejects it since there is no workflow outcome to map. Acknowledging a
@@ -1140,13 +1403,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1140
1403
  return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
1141
1404
  }
1142
1405
  // NOT-272: the typed resolution carries the note only for product_scope_decision
1143
- // (parseHumanResolution drops it for every other action type).
1406
+ // (parseHumanResolution drops it for every other action type — except
1407
+ // operator_verification, whose note is required and always carried).
1144
1408
  const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
1145
1409
  ? resolution.note
1146
1410
  : undefined;
1147
- const storedResolution = scopeDecisionNote
1148
- ? { choice, note: scopeDecisionNote }
1149
- : { choice };
1411
+ // NOT-314: the repair instruction rides the next developer round's payload;
1412
+ // the verified/waived note is stored on the artifact / waiver event below.
1413
+ const operatorNote = resolution.actionType === "operator_verification" && "note" in resolution
1414
+ ? resolution.note
1415
+ : undefined;
1416
+ const storedResolution = scopeDecisionNote ?? operatorNote ? { choice, note: scopeDecisionNote ?? operatorNote } : { choice };
1150
1417
  // NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
1151
1418
  // retry_merge/repair/close only (resume would re-run development on approved work);
1152
1419
  // every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
@@ -1191,12 +1458,56 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1191
1458
  // Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
1192
1459
  // NOT-194: a merge-failure retry_merge parks the same way — the old action is resolved
1193
1460
  // first, so a second failure escalates exactly one fresh action with the new reason.
1194
- if (instance &&
1195
- ((resolution.actionType === "final_review" &&
1196
- (resolution.choice === "merge" || resolution.choice === "complete")) ||
1197
- (resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge"))) {
1461
+ // NOT-314: an operator_verification verified/waive parks the same way — the recorded
1462
+ // result satisfies the gate, so finalizeAutoMerge merges on the very next pass.
1463
+ // (repair takes the generic repair-round path below, never this park.)
1464
+ const parksForMerge = (resolution.actionType === "final_review" &&
1465
+ (resolution.choice === "merge" || resolution.choice === "complete")) ||
1466
+ (resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge") ||
1467
+ (resolution.actionType === "operator_verification" &&
1468
+ (resolution.choice === "verified" || resolution.choice === "waive"));
1469
+ if (instance && parksForMerge) {
1198
1470
  return getDb().transaction(() => {
1199
- resolveHumanAction(actionId, resolvedBy, { choice });
1471
+ resolveHumanAction(actionId, resolvedBy, storedResolution);
1472
+ // NOT-314: durably record what the merge is authorized by, in the same txn as
1473
+ // the park — `verified` stores the result as an issue artifact (kind
1474
+ // operator_verification, author human) pinned to this head; `waive`
1475
+ // appends an operator_verification.waived workflow event. A later finalize
1476
+ // re-checks the record against the then-current head, so new commits
1477
+ // re-block the gate.
1478
+ if (resolution.actionType === "operator_verification" && operatorNote) {
1479
+ if (resolution.choice === "verified") {
1480
+ createIssueArtifact({
1481
+ issueId: issue.id,
1482
+ kind: OPERATOR_VERIFICATION_ARTIFACT_KIND,
1483
+ author: "human",
1484
+ content: {
1485
+ headSha: issue.headSha,
1486
+ note: operatorNote,
1487
+ verifiedBy: resolvedBy,
1488
+ verifiedAt: new Date().toISOString(),
1489
+ },
1490
+ });
1491
+ }
1492
+ else {
1493
+ appendWorkflowEvent({
1494
+ issueId: issue.id,
1495
+ workflowInstanceId: instance.id,
1496
+ type: "operator_verification.waived",
1497
+ actorType: "human",
1498
+ actorRef: resolvedBy,
1499
+ stage: issue.status,
1500
+ round: issue.currentRound,
1501
+ payload: {
1502
+ actionType: "operator_verification",
1503
+ choice: "waive",
1504
+ headSha: issue.headSha,
1505
+ waivedBy: resolvedBy,
1506
+ note: operatorNote,
1507
+ },
1508
+ });
1509
+ }
1510
+ }
1200
1511
  appendWorkflowEvent({
1201
1512
  issueId: issue.id,
1202
1513
  workflowInstanceId: instance.id,
@@ -1205,7 +1516,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1205
1516
  actorRef: resolvedBy,
1206
1517
  stage: resolution.actionType === "final_review" ? "final_review" : issue.status,
1207
1518
  round: issue.currentRound,
1208
- payload: { actionType: action.actionType, choice: resolution.choice, pendingMerge: true },
1519
+ payload: {
1520
+ actionType: action.actionType,
1521
+ choice: resolution.choice,
1522
+ pendingMerge: true,
1523
+ ...(operatorNote ? { note: operatorNote } : {}),
1524
+ },
1209
1525
  });
1210
1526
  transitionIssue(issue.id, "final_review", {
1211
1527
  currentOwner: "system",
@@ -1411,7 +1727,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1411
1727
  incrementIssueRound(issue.id);
1412
1728
  break;
1413
1729
  case "infra":
1730
+ // NOT-313: a resume past an infra OR CI-checks escalation grants a fresh
1731
+ // budget of both kinds — the CI budget is not a review-round spend either.
1414
1732
  resetIssueInfraAttempts(issue.id);
1733
+ resetIssueCiAttempts(issue.id);
1415
1734
  if (advancesRound)
1416
1735
  incrementIssueRound(issue.id);
1417
1736
  break;
@@ -1455,6 +1774,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1455
1774
  // NOT-272: a product_scope_decision note rides only this next round's
1456
1775
  // payload — later rounds never see it.
1457
1776
  ...(scopeDecisionNote ? { scopeDecisionNote } : {}),
1777
+ // NOT-314: same one-shot ride for an operator_verification repair
1778
+ // note — only the repair choice reaches this path (verified/waive
1779
+ // park for merge above), and only this round reads it.
1780
+ ...(operatorNote ? { operatorRepairNote: operatorNote } : {}),
1458
1781
  },
1459
1782
  idempotencyKey: resumeKey,
1460
1783
  });
@@ -635,6 +635,53 @@ test("fail → retry → exhaust → resume → fail again does not collide with
635
635
  assert.equal(pending.length, 1, "a fresh item must be enqueued — the old (round, infraAttempts)-keyed row must not be silently reused");
636
636
  assert.notEqual(pending[0].id, firstRetryItem.id, "must be a NEW work item, not the pre-escalation retry's now-terminal row");
637
637
  });
638
+ test("NOT-333: a resume round that pushes nothing still enqueues a fresh reviewer instead of deduping against the terminal one", async () => {
639
+ // The NOT-200 shape: reviewer round 1 at head H escalates with a product scope
640
+ // question, the human resumes, and the developer round changes nothing — so its
641
+ // handoff re-derives the exact idempotency key of the already-done reviewer item.
642
+ const issueId = newIssue();
643
+ startWorkflow(issueId);
644
+ await complete(issueId, cleanHandoff);
645
+ const firstReviewer = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer");
646
+ await complete(issueId, {
647
+ kind: "verdict",
648
+ result: { ...okReview("escalated"), productScopeQuestion: "Should deleted users retain sessions?" },
649
+ });
650
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
651
+ const resolved = resolveHumanActionAndAdvance(action.id, "yusuke", "resume");
652
+ assert.equal(resolved.ok, true);
653
+ assert.equal(getIssue(issueId).status, "developing");
654
+ // The resume round pushes nothing: the handoff carries the very same head SHA.
655
+ const { result } = await complete(issueId, cleanHandoff);
656
+ assert.equal(result.applied, true);
657
+ assert.equal(getIssue(issueId).status, "reviewing");
658
+ assert.equal(getWorkItem(firstReviewer.id).status, "done");
659
+ const reviewers = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer");
660
+ const pending = reviewers.filter((i) => i.status === "pending");
661
+ assert.equal(pending.length, 1, "a fresh reviewer round must be queued, not dropped via a key collision");
662
+ assert.notEqual(pending[0].id, firstReviewer.id, "must be a NEW work item, not the escalated round's now-terminal row");
663
+ assert.match(pending[0].idempotencyKey, /:retry-of:/, "the terminal duplicate is disambiguated like an infra retry");
664
+ assert.equal(JSON.parse(pending[0].payloadJson).inputSha, cleanHandoff.headSha);
665
+ });
666
+ test("NOT-333: a duplicate developer completion while the reviewer is still pending or leased enqueues no second reviewer", async () => {
667
+ const issueId = newIssue();
668
+ startWorkflow(issueId);
669
+ const item = claim(issueId);
670
+ const first = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
671
+ assert.equal(first.applied, true);
672
+ // A retried callback redelivers the same completion while the reviewer is pending.
673
+ const second = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
674
+ assert.equal(second.applied, false);
675
+ // Same while the reviewer is leased — still exactly one active reviewer row.
676
+ const reviewer = claim(issueId);
677
+ assert.equal(reviewer.kind, "reviewer");
678
+ const third = await applyCompletion(item.id, item.leaseToken, cleanHandoff);
679
+ assert.equal(third.applied, false);
680
+ const active = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && (i.status === "pending" || i.status === "leased"));
681
+ assert.equal(active.length, 1);
682
+ assert.equal(active[0].id, reviewer.id);
683
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 1);
684
+ });
638
685
  test("resolving product_scope_decision:resume after a reviewer's escalated+question resumes as the developer", async () => {
639
686
  const issueId = newIssue();
640
687
  startWorkflow(issueId);