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
@@ -7,9 +7,9 @@
7
7
  // duplicate delivery is a no-op. The effect *work* itself runs elsewhere, through a leased
8
8
  // worker whose structured result comes back into applyCompletion.
9
9
  import fs from "node:fs";
10
- import { canTransitionIssue } from "@agent-dealer/shared";
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";
@@ -17,9 +17,10 @@ import { ensureIssueRepoCheckout } from "../adapters/managed-repo.js";
17
17
  import { reconcileFinding, resolveFindingsAbsentFromRound } from "../repository/findings.js";
18
18
  import { normalizeReviewerResult } from "./reviewer-result.js";
19
19
  import { getAgent } from "../repository/agents.js";
20
- import { githubIssuesSync } from "../adapters/agent-health.js";
20
+ import { githubIssuesSync, invalidateMuseHealthCache } from "../adapters/agent-health.js";
21
+ import { recordMuseCapabilityOverride } from "../adapters/muse-capability.js";
21
22
  import { createIssueArtifact, latestIssueArtifact } from "../repository/artifacts.js";
22
- import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, listWorkItemsForIssue, } from "../repository/work-items.js";
23
+ import { cancelWorkItem, enqueueWorkItem, finishWorkItem, getWorkItem, getWorkItemByIdempotencyKey, listWorkItemsForIssue, } from "../repository/work-items.js";
23
24
  import { completeSession, getWorkerSession, listWorkerSessionsForIssue } from "../repository/worker-sessions.js";
24
25
  import { killRunProcess } from "../runners/spawn-cli.js";
25
26
  import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
@@ -30,9 +31,10 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
30
31
  import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
31
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";
32
33
  import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
34
+ import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
33
35
  import { externalMergeStateForIssue } from "./external-merge.js";
34
36
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
35
- import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
37
+ import { capEscalationEvents, checksWaitDeferralStartedAt, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForChecksPending, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, formatChecksWaitEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
36
38
  import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
37
39
  import { fetchResumeLiveHeadSha } from "./resume-live-head.js";
38
40
  import { getWorkflow } from "./workflows/registry.js";
@@ -53,7 +55,13 @@ export function getTaskSnapshot(issue) {
53
55
  const artifact = latestIssueArtifact(issue.id, TASK_SNAPSHOT_ARTIFACT_KIND);
54
56
  if (artifact?.contentJson) {
55
57
  try {
56
- return JSON.parse(artifact.contentJson);
58
+ const parsed = JSON.parse(artifact.contentJson);
59
+ // Snapshots frozen before NOT-306 carry no contract — derive it from the
60
+ // frozen source description so old workflows still render what they ran.
61
+ if (parsed.executionContract === undefined) {
62
+ return { ...parsed, executionContract: tryCompileContract(parsed.description) };
63
+ }
64
+ return parsed;
57
65
  }
58
66
  catch {
59
67
  // fall through to the live-field fallback below
@@ -66,6 +74,7 @@ export function getTaskSnapshot(issue) {
66
74
  repo: issue.repo,
67
75
  baseBranch: issue.baseBranch,
68
76
  workflowVersion: WORKFLOW_VERSION,
77
+ executionContract: tryCompileContract(issue.description),
69
78
  };
70
79
  }
71
80
  /**
@@ -83,9 +92,15 @@ export function canEditParkedIssue(issue) {
83
92
  return false;
84
93
  return !listWorkItemsForIssue(issue.id).some((w) => w.status === "pending" || w.status === "leased");
85
94
  }
86
- /** Re-freezes the snapshot when the operator edited title/description/criteria; returns the changed fields (empty = no-op). */
95
+ /**
96
+ * Re-freezes the snapshot when the operator edited title/description/criteria
97
+ * (or the contract compiled from the description); returns the changed fields
98
+ * (empty = no-op). Source and compiled contract always re-freeze atomically —
99
+ * one artifact write carries both, so they can never diverge.
100
+ */
87
101
  function refreshTaskSnapshotIfEdited(issue) {
88
102
  const frozen = getTaskSnapshot(issue);
103
+ const liveContract = tryCompileContract(issue.description);
89
104
  const changed = [];
90
105
  if (frozen.title !== issue.title)
91
106
  changed.push("title");
@@ -93,6 +108,9 @@ function refreshTaskSnapshotIfEdited(issue) {
93
108
  changed.push("description");
94
109
  if (frozen.acceptanceCriteria !== (issue.acceptanceCriteria ?? ""))
95
110
  changed.push("acceptanceCriteria");
111
+ if (JSON.stringify(frozen.executionContract ?? null) !== JSON.stringify(liveContract ?? null)) {
112
+ changed.push("executionContract");
113
+ }
96
114
  if (changed.length > 0)
97
115
  freezeTaskSnapshot(issue);
98
116
  return changed;
@@ -109,6 +127,7 @@ function freezeTaskSnapshot(issue) {
109
127
  repo: issue.repo,
110
128
  baseBranch: issue.baseBranch,
111
129
  workflowVersion: WORKFLOW_VERSION,
130
+ executionContract: tryCompileContract(issue.description),
112
131
  },
113
132
  });
114
133
  }
@@ -118,7 +137,11 @@ function freezeTaskSnapshot(issue) {
118
137
  * never changes the eventual session. The worker loop consumes this off the payload and
119
138
  * only falls back to a live resolve for a legacy item queued before snapshots were carried.
120
139
  */
121
- function queuedProfileSnapshot(issue, kind) {
140
+ /**
141
+ * NOT-333: shared with recovery's stranded-reviewer re-enqueue so a recovered round
142
+ * carries the same frozen profile as a normally routed one.
143
+ */
144
+ export function queuedProfileSnapshot(issue, kind) {
122
145
  const agentId = kind === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
123
146
  const agent = agentId ? getAgent(agentId) : null;
124
147
  return agent ? serializeProfileSnapshot(buildProfileSnapshot(agent, kind)) : null;
@@ -309,6 +332,12 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
309
332
  if (outcome.kind === "base_fetch_failed") {
310
333
  return applyBaseFetchDeferralCompletion(workItemId, leaseToken, outcome);
311
334
  }
335
+ // NOT-311: CI still queued/running (or not yet registered) at the poll timeout —
336
+ // the session ended cleanly, so the item waits on backoff and re-polls instead of
337
+ // spending an infra attempt. Past the wait ceiling this escalates to a human.
338
+ if (outcome.kind === "checks_pending") {
339
+ return applyChecksPendingCompletion(workItemId, leaseToken, outcome);
340
+ }
312
341
  const routed = getDb().transaction(() => {
313
342
  const before = getWorkItem(workItemId);
314
343
  if (!before)
@@ -397,6 +426,68 @@ function applyDeckOutageCompletion(workItemId, leaseToken, outage) {
397
426
  function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
398
427
  return applyDeferralCompletion(workItemId, leaseToken, failure, (item, issue, instance) => deferLeasedWorkItemForBaseFetch(item, leaseToken, failure, issue, instance));
399
428
  }
429
+ /**
430
+ * NOT-311: CI still pending at the poll timeout — defer with backoff, no attempt
431
+ * spent. Unlike a deck outage the wait has a ceiling, so the escalation arm raises a
432
+ * human action naming the stuck PR/SHA once CI has waited too long.
433
+ */
434
+ function applyChecksPendingCompletion(workItemId, leaseToken, pending) {
435
+ return applyDeferralCompletion(workItemId, leaseToken, pending, (item, issue, instance) => deferLeasedWorkItemForChecksPending(item, leaseToken, pending, issue, instance), (issue, instance, item) => routeChecksWaitEscalation(issue, instance, item, pending));
436
+ }
437
+ /**
438
+ * NOT-311: the CI wait passed its ceiling — hand to a human with the PR and head SHA
439
+ * CI never proved, without spending infra attempts. Mirrors the usage-cap ceiling
440
+ * escalation (worker.failed + needs_human + policy_escalation).
441
+ */
442
+ export function routeChecksWaitEscalation(issue, instance, item, pending) {
443
+ const payload = parseWorkItemPayload(item.payloadJson);
444
+ const firstDeferredAt = checksWaitDeferralStartedAt(payload) ?? pending.waitStartedAt;
445
+ const reason = formatChecksWaitEscalationReason(pending, firstDeferredAt);
446
+ const role = item.kind === "developer" ? "developer" : "reviewer";
447
+ const ev = eventEmitter(issue, instance, item.workerSessionId, "needs_human", issue.currentRound);
448
+ for (const type of capEscalationEvents()) {
449
+ const session = item.workerSessionId ? getWorkerSession(item.workerSessionId) : null;
450
+ ev.emit(type, {
451
+ actorType: role,
452
+ payload: {
453
+ ...workerSessionPayload({
454
+ runtime: session?.runtime ?? null,
455
+ model: session?.model ?? null,
456
+ sessionId: item.workerSessionId ?? "",
457
+ worktreePath: session?.worktreePath,
458
+ }),
459
+ outcome: "checks_pending",
460
+ reason: pending.reason,
461
+ branch: pending.branch,
462
+ prNumber: pending.prNumber,
463
+ headSha: pending.headSha,
464
+ },
465
+ });
466
+ }
467
+ applyProjectionTransition(issue, {
468
+ issueStatus: "needs_human",
469
+ currentOwner: "human",
470
+ currentIntent: reason,
471
+ events: capEscalationEvents(),
472
+ }, {});
473
+ const action = createHumanAction({
474
+ issueId: issue.id,
475
+ workflowInstanceId: instance.id,
476
+ actionType: "policy_escalation",
477
+ reason,
478
+ question: questionFor("policy_escalation", reason),
479
+ evidence: { checksPending: { ...pending, firstDeferredAt } },
480
+ responseOptions: responseOptionsFor("policy_escalation"),
481
+ });
482
+ ev.emit("human_action.requested", { payload: { actionType: "policy_escalation", actionId: action.id } });
483
+ return {
484
+ applied: true,
485
+ issueStatus: "needs_human",
486
+ nextWorkItemId: null,
487
+ humanActionId: action.id,
488
+ instanceCompleted: false,
489
+ };
490
+ }
400
491
  /**
401
492
  * Deferral ceiling exceeded — escalate with cap evidence without spending infra attempts.
402
493
  * Used when applyCompletion or the worker loop cannot defer any longer.
@@ -507,6 +598,8 @@ function applyDeveloper(issue, instance, item, outcome) {
507
598
  maxReviewRounds: issue.maxReviewRounds,
508
599
  infraAttempts: issue.infraAttempts,
509
600
  maxInfraAttempts: issue.maxInfraAttempts,
601
+ ciAttempts: issue.ciAttempts,
602
+ maxCiAttempts: issue.maxCiAttempts,
510
603
  });
511
604
  const { projection, effect, advance } = projectDeveloperRoute(route, issue.status, issue.currentRound);
512
605
  const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
@@ -589,6 +682,9 @@ function applyDeveloper(issue, instance, item, outcome) {
589
682
  incrementIssueRound(issue.id);
590
683
  else if (advance === "infra")
591
684
  incrementIssueInfraAttempts(issue.id);
685
+ // NOT-313: a CI-repair retry spends ci_attempts, never infra_attempts.
686
+ else if (advance === "ci")
687
+ incrementIssueCiAttempts(issue.id);
592
688
  const issueNow = getIssue(issue.id);
593
689
  return applyEffect(issue, instance, effect, route, issueNow, ev, item.id);
594
690
  }
@@ -722,9 +818,10 @@ function findNonConvergence(issue, instance, result) {
722
818
  });
723
819
  return detectNonConvergence(history, issue.currentRound, floorRound);
724
820
  }
725
- /** Every route that spends an infra attempt (projection.ts's advance === "infra") repeats
726
- * the same round/head, so its enqueue needs a suffix the plain round/head key doesn't
727
- * provide to avoid colliding with the previous (terminal) attempt's key.
821
+ /** Every route that spends an infra or CI attempt (projection.ts's advance === "infra"
822
+ * or "ci") repeats the same round/head, so its enqueue needs a suffix the plain
823
+ * round/head key doesn't provide to avoid colliding with the previous (terminal)
824
+ * attempt's key.
728
825
  * retry_reviewer_at_new_head belongs here too — a head that cycles A→B→A would otherwise
729
826
  * re-derive attempt A's ORIGINAL key (same round, same head) and collide with that
730
827
  * now-terminal work item, leaving the issue "reviewing" with no pending item. */
@@ -755,6 +852,21 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
755
852
  const attemptSuffix = isInfraRetry(route) ? `:retry-of:${causativeItemId}` : "";
756
853
  if (route.next === "retry_developer_with_findings")
757
854
  ev.emit("repair.started");
855
+ let idempotencyKey = `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`;
856
+ // NOT-333: a developer round that pushes nothing (e.g. a resume round that only
857
+ // updates the PR description) re-derives the exact key of the already-terminal
858
+ // reviewer item it follows — same instance, same round, same head — so the enqueue
859
+ // dedupes against that done row and strands the issue in `reviewing` with no pending
860
+ // work. A terminal duplicate gets the same `:retry-of:<causative work item id>`
861
+ // shape the infra-retry path uses, so a new row is created; a still-active
862
+ // (pending/leased) duplicate keeps deduping.
863
+ if (kind === "reviewer" && attemptSuffix === "") {
864
+ const existing = getWorkItemByIdempotencyKey(idempotencyKey);
865
+ if (existing &&
866
+ (existing.status === "done" || existing.status === "dead" || existing.status === "cancelled")) {
867
+ idempotencyKey += `:retry-of:${causativeItemId}`;
868
+ }
869
+ }
758
870
  const next = enqueueWorkItem({
759
871
  issueId: issue.id,
760
872
  workflowInstanceId: instance.id,
@@ -767,7 +879,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
767
879
  ...(effect.branch ? { branch: effect.branch } : {}),
768
880
  profileSnapshot: queuedProfileSnapshot(issue, kind),
769
881
  },
770
- idempotencyKey: `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`,
882
+ idempotencyKey,
771
883
  });
772
884
  return { ...base, nextWorkItemId: next.id };
773
885
  }
@@ -973,6 +1085,17 @@ function questionFor(actionType, reason, resumeAsReviewer = false, mergeFailure
973
1085
  // Run-scoped action type directly with its own question text (NOT-95). Case exists
974
1086
  // only so this function stays total over HumanActionType.
975
1087
  throw new Error("outbound_delivery_interaction_required is not raised through applyEffect");
1088
+ case "muse_capability":
1089
+ // Never raised through applyEffect — muse-capability.ts creates this action
1090
+ // directly with its own question text (NOT-308). Case exists only so this
1091
+ // function stays total over HumanActionType.
1092
+ throw new Error("muse_capability is not raised through applyEffect");
1093
+ case "operator_verification":
1094
+ // Never raised through applyEffect — auto-merge.ts's operator gate creates
1095
+ // this action directly with a question listing each criterion and its
1096
+ // command (NOT-314). Case exists only so this function stays total over
1097
+ // HumanActionType.
1098
+ throw new Error("operator_verification is not raised through applyEffect");
976
1099
  }
977
1100
  }
978
1101
  /** Exported for db/migrate-to-issues.ts, which must populate the same response options on
@@ -1014,6 +1137,16 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false, opts =
1014
1137
  case "outbound_delivery_interaction_required":
1015
1138
  // Never actually raised through applyEffect — see questionFor's identical case.
1016
1139
  throw new Error("outbound_delivery_interaction_required is not raised through applyEffect");
1140
+ case "muse_capability":
1141
+ // NOT-308: created with its own stored options (muse-capability.ts) — acknowledge
1142
+ // admits on that Muse version (missing) or dismisses (unverified). Never raised
1143
+ // through applyEffect; case exists only so this function stays total.
1144
+ return [{ choice: "acknowledge", label: "Acknowledge" }];
1145
+ case "operator_verification":
1146
+ // Raised by auto-merge.ts's operator gate with these exact options (the
1147
+ // shared constant keeps the two from drifting); listed here so the table
1148
+ // stays total over HumanActionType.
1149
+ return [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS];
1017
1150
  }
1018
1151
  }
1019
1152
  function parseContinuationPreview(json) {
@@ -1052,18 +1185,88 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1052
1185
  const normalizedNote = normalizeResolutionNote(opts?.note);
1053
1186
  if (!normalizedNote.ok)
1054
1187
  return { ok: false, code: 400, error: normalizedNote.error };
1188
+ // NOT-314: every operator_verification choice requires a non-empty note — the
1189
+ // recorded result (verified), the waiver reason (waive), or what to fix
1190
+ // (repair). parseHumanResolution below also rejects a missing note; this
1191
+ // names the requirement instead of misreporting a valid choice as invalid.
1192
+ if (action.actionType === "operator_verification" && !normalizedNote.note) {
1193
+ return {
1194
+ ok: false,
1195
+ code: 400,
1196
+ 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)',
1197
+ };
1198
+ }
1199
+ // NOT-308: a Muse capability escalation resolves without touching the workflow — it
1200
+ // has no round to spend and no merge to park, and parseHumanResolution (below)
1201
+ // deliberately rejects it since there is no workflow outcome to map. Acknowledging a
1202
+ // `missing` verdict records a per-version override so the admission gate stops
1203
+ // blocking that version; acknowledging `unverified` just dismisses. Works with or
1204
+ // without an active instance (a blocked admission never has one).
1205
+ if (action.actionType === "muse_capability") {
1206
+ if (choice !== "acknowledge") {
1207
+ return { ok: false, code: 400, error: `Invalid choice "${choice}" for muse_capability` };
1208
+ }
1209
+ if (!action.issueId)
1210
+ return { ok: false, code: 500, error: "Human action has no issue" };
1211
+ const museIssue = getIssue(action.issueId);
1212
+ if (!museIssue)
1213
+ return { ok: false, code: 404, error: "Issue not found" };
1214
+ const museInstance = getActiveWorkflowInstance(action.issueId);
1215
+ let evidence = null;
1216
+ try {
1217
+ evidence = action.evidenceJson
1218
+ ? JSON.parse(action.evidenceJson)
1219
+ : null;
1220
+ }
1221
+ catch {
1222
+ evidence = null;
1223
+ }
1224
+ // File first, then the row: if the resolve below fails the action stays open and
1225
+ // the operator retries idempotently; the reverse order could leave a resolved
1226
+ // action whose version still blocks with no way to re-acknowledge.
1227
+ if (evidence?.kind === "missing" && typeof evidence.version === "string" && evidence.version) {
1228
+ recordMuseCapabilityOverride(evidence.version);
1229
+ }
1230
+ // The lifted block must be visible on the next health read, not after the cache TTL.
1231
+ invalidateMuseHealthCache();
1232
+ return getDb().transaction(() => {
1233
+ resolveHumanAction(actionId, resolvedBy, { choice });
1234
+ appendWorkflowEvent({
1235
+ issueId: museIssue.id,
1236
+ ...(museInstance ? { workflowInstanceId: museInstance.id } : {}),
1237
+ type: "human_action.resolved",
1238
+ actorType: "human",
1239
+ actorRef: resolvedBy,
1240
+ stage: museIssue.status,
1241
+ round: museIssue.currentRound,
1242
+ payload: { actionType: action.actionType, choice },
1243
+ });
1244
+ return {
1245
+ ok: true,
1246
+ issueStatus: museIssue.status,
1247
+ nextWorkItemId: null,
1248
+ instanceCompleted: false,
1249
+ restarted: false,
1250
+ triggerReflect: false,
1251
+ };
1252
+ })();
1253
+ }
1055
1254
  const resolution = parseHumanResolution(action.actionType, choice, normalizedNote.note);
1056
1255
  if (!resolution) {
1057
1256
  return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
1058
1257
  }
1059
1258
  // NOT-272: the typed resolution carries the note only for product_scope_decision
1060
- // (parseHumanResolution drops it for every other action type).
1259
+ // (parseHumanResolution drops it for every other action type — except
1260
+ // operator_verification, whose note is required and always carried).
1061
1261
  const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
1062
1262
  ? resolution.note
1063
1263
  : undefined;
1064
- const storedResolution = scopeDecisionNote
1065
- ? { choice, note: scopeDecisionNote }
1066
- : { choice };
1264
+ // NOT-314: the repair instruction rides the next developer round's payload;
1265
+ // the verified/waived note is stored on the artifact / waiver event below.
1266
+ const operatorNote = resolution.actionType === "operator_verification" && "note" in resolution
1267
+ ? resolution.note
1268
+ : undefined;
1269
+ const storedResolution = scopeDecisionNote ?? operatorNote ? { choice, note: scopeDecisionNote ?? operatorNote } : { choice };
1067
1270
  // NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
1068
1271
  // retry_merge/repair/close only (resume would re-run development on approved work);
1069
1272
  // every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
@@ -1108,12 +1311,56 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1108
1311
  // Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
1109
1312
  // NOT-194: a merge-failure retry_merge parks the same way — the old action is resolved
1110
1313
  // first, so a second failure escalates exactly one fresh action with the new reason.
1111
- if (instance &&
1112
- ((resolution.actionType === "final_review" &&
1113
- (resolution.choice === "merge" || resolution.choice === "complete")) ||
1114
- (resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge"))) {
1314
+ // NOT-314: an operator_verification verified/waive parks the same way — the recorded
1315
+ // result satisfies the gate, so finalizeAutoMerge merges on the very next pass.
1316
+ // (repair takes the generic repair-round path below, never this park.)
1317
+ const parksForMerge = (resolution.actionType === "final_review" &&
1318
+ (resolution.choice === "merge" || resolution.choice === "complete")) ||
1319
+ (resolution.actionType === "policy_escalation" && resolution.choice === "retry_merge") ||
1320
+ (resolution.actionType === "operator_verification" &&
1321
+ (resolution.choice === "verified" || resolution.choice === "waive"));
1322
+ if (instance && parksForMerge) {
1115
1323
  return getDb().transaction(() => {
1116
- resolveHumanAction(actionId, resolvedBy, { choice });
1324
+ resolveHumanAction(actionId, resolvedBy, storedResolution);
1325
+ // NOT-314: durably record what the merge is authorized by, in the same txn as
1326
+ // the park — `verified` stores the result as an issue artifact (kind
1327
+ // operator_verification, author human) pinned to this head; `waive`
1328
+ // appends an operator_verification.waived workflow event. A later finalize
1329
+ // re-checks the record against the then-current head, so new commits
1330
+ // re-block the gate.
1331
+ if (resolution.actionType === "operator_verification" && operatorNote) {
1332
+ if (resolution.choice === "verified") {
1333
+ createIssueArtifact({
1334
+ issueId: issue.id,
1335
+ kind: OPERATOR_VERIFICATION_ARTIFACT_KIND,
1336
+ author: "human",
1337
+ content: {
1338
+ headSha: issue.headSha,
1339
+ note: operatorNote,
1340
+ verifiedBy: resolvedBy,
1341
+ verifiedAt: new Date().toISOString(),
1342
+ },
1343
+ });
1344
+ }
1345
+ else {
1346
+ appendWorkflowEvent({
1347
+ issueId: issue.id,
1348
+ workflowInstanceId: instance.id,
1349
+ type: "operator_verification.waived",
1350
+ actorType: "human",
1351
+ actorRef: resolvedBy,
1352
+ stage: issue.status,
1353
+ round: issue.currentRound,
1354
+ payload: {
1355
+ actionType: "operator_verification",
1356
+ choice: "waive",
1357
+ headSha: issue.headSha,
1358
+ waivedBy: resolvedBy,
1359
+ note: operatorNote,
1360
+ },
1361
+ });
1362
+ }
1363
+ }
1117
1364
  appendWorkflowEvent({
1118
1365
  issueId: issue.id,
1119
1366
  workflowInstanceId: instance.id,
@@ -1122,7 +1369,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1122
1369
  actorRef: resolvedBy,
1123
1370
  stage: resolution.actionType === "final_review" ? "final_review" : issue.status,
1124
1371
  round: issue.currentRound,
1125
- payload: { actionType: action.actionType, choice: resolution.choice, pendingMerge: true },
1372
+ payload: {
1373
+ actionType: action.actionType,
1374
+ choice: resolution.choice,
1375
+ pendingMerge: true,
1376
+ ...(operatorNote ? { note: operatorNote } : {}),
1377
+ },
1126
1378
  });
1127
1379
  transitionIssue(issue.id, "final_review", {
1128
1380
  currentOwner: "system",
@@ -1328,7 +1580,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1328
1580
  incrementIssueRound(issue.id);
1329
1581
  break;
1330
1582
  case "infra":
1583
+ // NOT-313: a resume past an infra OR CI-checks escalation grants a fresh
1584
+ // budget of both kinds — the CI budget is not a review-round spend either.
1331
1585
  resetIssueInfraAttempts(issue.id);
1586
+ resetIssueCiAttempts(issue.id);
1332
1587
  if (advancesRound)
1333
1588
  incrementIssueRound(issue.id);
1334
1589
  break;
@@ -1372,6 +1627,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1372
1627
  // NOT-272: a product_scope_decision note rides only this next round's
1373
1628
  // payload — later rounds never see it.
1374
1629
  ...(scopeDecisionNote ? { scopeDecisionNote } : {}),
1630
+ // NOT-314: same one-shot ride for an operator_verification repair
1631
+ // note — only the repair choice reaches this path (verified/waive
1632
+ // park for merge above), and only this round reads it.
1633
+ ...(operatorNote ? { operatorRepairNote: operatorNote } : {}),
1375
1634
  },
1376
1635
  idempotencyKey: resumeKey,
1377
1636
  });
@@ -1446,7 +1705,9 @@ export async function resolveHumanActionAndAdvanceAsync(actionId, resolvedBy, ch
1446
1705
  return {
1447
1706
  ok: true,
1448
1707
  issueStatus: merged.issueStatus,
1449
- nextWorkItemId: null,
1708
+ // NOT-310: a retry_merge that hit a textual conflict queues a repair round —
1709
+ // report it like any other queued round instead of dropping it.
1710
+ nextWorkItemId: merged.nextWorkItemId,
1450
1711
  instanceCompleted: merged.instanceCompleted,
1451
1712
  restarted: false,
1452
1713
  triggerReflect: merged.triggerReflect,
@@ -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);
@@ -31,15 +31,17 @@ const { deckOutageBackoffMs } = await import("./deck-outage-config.js");
31
31
  const UNREACHABLE = "Agent Deck is unreachable — fetch failed";
32
32
  before(() => migrate());
33
33
  beforeEach(() => {
34
+ // NOT-305: session-linked playbook_use_receipt artifacts reference worker_sessions,
35
+ // so child rows (artifacts, usage_events) go before their parents.
34
36
  getDb().exec(`
35
37
  DELETE FROM review_publications;
38
+ DELETE FROM artifacts;
36
39
  DELETE FROM work_items;
37
40
  DELETE FROM human_actions;
38
41
  DELETE FROM workflow_events;
39
42
  DELETE FROM findings;
40
- DELETE FROM worker_sessions;
41
- DELETE FROM artifacts;
42
43
  DELETE FROM usage_events;
44
+ DELETE FROM worker_sessions;
43
45
  DELETE FROM workflow_instances;
44
46
  DELETE FROM issues;
45
47
  DELETE FROM runtime_availability;