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
@@ -27,14 +27,27 @@ import { classifyIssueRepo } from "../adapters/managed-repo.js";
27
27
  import { getDb } from "../db/index.js";
28
28
  import { getIssue, listIssues, transitionIssue } from "../repository/issues.js";
29
29
  import { appendWorkflowEvent, completeWorkflowInstance, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
30
- import { createHumanAction, findOpenHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
30
+ import { createHumanAction, findOpenHumanAction, findOpenHumanActionByRequestId, resolveHumanAction, } from "../repository/human-actions.js";
31
31
  import { MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS } from "./human-resolution.js";
32
32
  import { isMergeConflictFailure, runMergeConflictSync } from "./merge-conflict-sync.js";
33
+ import { OPERATOR_VERIFICATION_RESPONSE_OPTIONS, formatOperatorCriteria, getOperatorCriteriaForIssue, hasOperatorVerificationForHead, operatorVerificationRequestId, } from "./operator-criteria.js";
33
34
  const run = promisify(execFile);
34
35
  /** Bound each `gh` shell-out so a hang cannot freeze the coordinator process. */
35
36
  export const GH_MERGE_TIMEOUT_MS = 20_000;
36
37
  /** Must match projection.ts's auto_merge currentIntent — recovery keys off this string. */
37
38
  export const AUTO_MERGE_INTENT = "Auto-merging approved PR";
39
+ /**
40
+ * NOT-314: the issue intent while an `[operator]` gate holds the merge. Named
41
+ * verbatim by the ticket: the PR stays unmerged in `needs_human` under this intent
42
+ * until the human records a result or waives the criterion.
43
+ */
44
+ export const OPERATOR_VERIFICATION_INTENT = "Operator verification required before merge";
45
+ /**
46
+ * NOT-314 fail-closed: the stable request id for the no-head escalation, so
47
+ * repeat finalizes dedupe onto the one open `policy_escalation` instead of
48
+ * stacking a new one per tick.
49
+ */
50
+ export const OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID = "operator-verification-blocked:no-head";
38
51
  const ALREADY_MERGED = /already (been )?merged|pull request is not mergeable:.*merged/i;
39
52
  /**
40
53
  * Map issue.repo (portable identity or legacy local path) to a real filesystem cwd for
@@ -184,6 +197,15 @@ async function finalizeAutoMergeOnce(issueId) {
184
197
  if (issue.prNumber == null) {
185
198
  return escalateMergeFailure(issue, instance.id, "Reviewer approved but the issue has no PR number to merge.");
186
199
  }
200
+ // NOT-314 seam: this finalize is the single path from reviewer-approve to the
201
+ // `gh` merge — auto-merge-on routes here from applyCompletion, auto-merge-off
202
+ // arrives via final_review:merge / policy_escalation:retry_merge, and crash
203
+ // recovery re-enters through recoverStrandedAutoMerges. An `[operator]`
204
+ // criterion in the frozen snapshot blocks here (PR unmerged, needs_human)
205
+ // until a human records the result for the current head.
206
+ const operatorBlock = gateOperatorVerification(issue.id);
207
+ if (operatorBlock)
208
+ return operatorBlock;
187
209
  const resolvedCwd = resolveAutoMergeCwd(issue.repo);
188
210
  if (!resolvedCwd.ok) {
189
211
  return escalateMergeFailure(issue, instance.id, `Auto-merge failed: ${resolvedCwd.reason}`);
@@ -287,6 +309,188 @@ async function finalizeAutoMergeOnce(issueId) {
287
309
  };
288
310
  })();
289
311
  }
312
+ /**
313
+ * NOT-314: the `[operator]` merge gate. After reviewer approve and before the
314
+ * `gh` merge: when the frozen snapshot holds operator criteria and no result is
315
+ * recorded for the current head (a `verified` artifact or a
316
+ * `operator_verification.waived` event — both head-pinned), the PR stays
317
+ * unmerged and the issue parks in `needs_human` with an `operator_verification`
318
+ * action listing each criterion and its command.
319
+ *
320
+ * Returns null when the merge may proceed (no operator criteria, a recorded
321
+ * result for this head, or no workflow to gate). Idempotent: a second finalize
322
+ * for the same head reuses the open action instead of raising another.
323
+ *
324
+ * Fail-closed: when operator criteria exist, every non-proceed path returns a
325
+ * result (never null), so a missing head SHA or an unexpected status blocks the
326
+ * `gh` merge instead of letting it through.
327
+ */
328
+ export function gateOperatorVerification(issueId) {
329
+ return getDb().transaction(() => {
330
+ const current = getIssue(issueId);
331
+ if (!current)
332
+ return null;
333
+ const active = getActiveWorkflowInstance(issueId);
334
+ if (!active)
335
+ return null;
336
+ const criteria = getOperatorCriteriaForIssue(current);
337
+ if (criteria.length === 0)
338
+ return null;
339
+ const head = current.headSha;
340
+ if (head && hasOperatorVerificationForHead(issueId, head))
341
+ return null;
342
+ if (current.status === "done")
343
+ return null;
344
+ // Only the auto-merge park (or an already-gated issue) is gated — any other
345
+ // status is a racer or a non-parked issue; do not invent an action there,
346
+ // but do not let the merge through either — park the finalize as a no-op.
347
+ if (current.status !== "final_review" && current.status !== "needs_human") {
348
+ return {
349
+ applied: true,
350
+ issueStatus: current.status,
351
+ nextWorkItemId: null,
352
+ humanActionId: null,
353
+ instanceCompleted: false,
354
+ triggerReflect: false,
355
+ };
356
+ }
357
+ // No head SHA to pin the verification to (no handoff recorded one): the
358
+ // gate cannot name a head, so escalate instead of merging — retry_merge
359
+ // re-enters this same finalize once a head exists and gates normally.
360
+ // Repeat finalizes reuse the one open escalation (no duplicate action or
361
+ // event), and the transition is skipped when already parked
362
+ // (needs_human has no self-loop).
363
+ if (!head) {
364
+ const retry = findOpenHumanActionByRequestId(issueId, "policy_escalation", OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID);
365
+ if (retry) {
366
+ if (current.status !== "needs_human") {
367
+ transitionIssue(issueId, "needs_human", {
368
+ currentOwner: "human",
369
+ currentIntent: OPERATOR_VERIFICATION_INTENT,
370
+ });
371
+ }
372
+ return {
373
+ applied: true,
374
+ issueStatus: "needs_human",
375
+ nextWorkItemId: null,
376
+ humanActionId: retry.id,
377
+ instanceCompleted: false,
378
+ triggerReflect: false,
379
+ };
380
+ }
381
+ const reason = `Reviewer approved, but ${criteria.length} acceptance ` +
382
+ `${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
383
+ `to verify (tagged [operator] in the frozen task snapshot) and no head SHA is ` +
384
+ `recorded to pin the verification to. The PR stays unmerged.`;
385
+ if (current.status !== "needs_human") {
386
+ transitionIssue(issueId, "needs_human", {
387
+ currentOwner: "human",
388
+ currentIntent: reason,
389
+ });
390
+ }
391
+ const action = createHumanAction({
392
+ issueId,
393
+ workflowInstanceId: active.id,
394
+ actionType: "policy_escalation",
395
+ reason,
396
+ question: `${reason} Retry the merge once a head is recorded, queue another repair round, or close the issue?`,
397
+ evidence: { [MERGE_FAILURE_EVIDENCE_KEY]: true, operatorVerificationBlocked: true },
398
+ responseOptions: [...MERGE_FAILURE_RESPONSE_OPTIONS],
399
+ requestId: OPERATOR_VERIFICATION_NO_HEAD_REQUEST_ID,
400
+ });
401
+ appendWorkflowEvent({
402
+ issueId,
403
+ workflowInstanceId: active.id,
404
+ workerSessionId: null,
405
+ type: "human_action.requested",
406
+ actorType: "system",
407
+ stage: "needs_human",
408
+ round: current.currentRound,
409
+ payload: { actionType: "policy_escalation", actionId: action.id, operatorVerificationBlocked: true },
410
+ });
411
+ return {
412
+ applied: true,
413
+ issueStatus: "needs_human",
414
+ nextWorkItemId: null,
415
+ humanActionId: action.id,
416
+ instanceCompleted: false,
417
+ triggerReflect: false,
418
+ };
419
+ }
420
+ const existing = findOpenHumanAction(issueId, "operator_verification");
421
+ if (existing && operatorActionHeadSha(existing) === head) {
422
+ if (current.status !== "needs_human") {
423
+ transitionIssue(issueId, "needs_human", {
424
+ currentOwner: "human",
425
+ currentIntent: OPERATOR_VERIFICATION_INTENT,
426
+ });
427
+ }
428
+ return {
429
+ applied: true,
430
+ issueStatus: "needs_human",
431
+ nextWorkItemId: null,
432
+ humanActionId: existing.id,
433
+ instanceCompleted: false,
434
+ triggerReflect: false,
435
+ };
436
+ }
437
+ const shortHead = head.slice(0, 8);
438
+ const reason = `Reviewer approved ${shortHead}, but ${criteria.length} acceptance ` +
439
+ `${criteria.length === 1 ? "criterion requires" : "criteria require"} a human operator ` +
440
+ `to verify (tagged [operator] in the frozen task snapshot) and no result is recorded ` +
441
+ `for this head. The PR stays unmerged until the result is recorded:\n` +
442
+ formatOperatorCriteria(criteria).join("\n");
443
+ transitionIssue(issueId, "needs_human", {
444
+ currentOwner: "human",
445
+ currentIntent: OPERATOR_VERIFICATION_INTENT,
446
+ });
447
+ // Head-pinned request_id: a concurrent finalize for the same head dedupes
448
+ // onto this action through createHumanAction's open-request conflict path;
449
+ // new commits after the gate get a fresh action with their own commands.
450
+ const action = createHumanAction({
451
+ issueId,
452
+ workflowInstanceId: active.id,
453
+ actionType: "operator_verification",
454
+ reason,
455
+ question: `${reason}\n\nPaste the probe output to record verification, waive with a reason, ` +
456
+ `or send the work back for another repair round?`,
457
+ evidence: { operatorVerification: { criteria, headSha: head } },
458
+ responseOptions: [...OPERATOR_VERIFICATION_RESPONSE_OPTIONS],
459
+ requestId: operatorVerificationRequestId(head),
460
+ });
461
+ appendWorkflowEvent({
462
+ issueId,
463
+ workflowInstanceId: active.id,
464
+ workerSessionId: null,
465
+ type: "human_action.requested",
466
+ actorType: "system",
467
+ stage: "needs_human",
468
+ round: current.currentRound,
469
+ payload: { actionType: "operator_verification", actionId: action.id, headSha: head },
470
+ });
471
+ return {
472
+ applied: true,
473
+ issueStatus: "needs_human",
474
+ nextWorkItemId: null,
475
+ humanActionId: action.id,
476
+ instanceCompleted: false,
477
+ triggerReflect: false,
478
+ };
479
+ })();
480
+ }
481
+ /** The head SHA an `operator_verification` action gates (null when unreadable). */
482
+ function operatorActionHeadSha(action) {
483
+ if (!action.evidenceJson)
484
+ return null;
485
+ try {
486
+ const evidence = JSON.parse(action.evidenceJson);
487
+ const headSha = evidence.operatorVerification?.headSha;
488
+ return typeof headSha === "string" && headSha ? headSha : null;
489
+ }
490
+ catch {
491
+ return null;
492
+ }
493
+ }
290
494
  function escalateMergeFailure(issue, workflowInstanceId, reason, evidence = { [MERGE_FAILURE_EVIDENCE_KEY]: true }) {
291
495
  return getDb().transaction(() => {
292
496
  const current = getIssue(issue.id);
@@ -0,0 +1,19 @@
1
+ // packages/server/src/coordinator/checks-wait-config.ts
2
+ //
3
+ // NOT-311: how long one work item may wait on CI before the wait itself escalates.
4
+ // The per-poll bound (CHECKS_POLL_TIMEOUT_MS, default 10 min) only ends a single
5
+ // poll with a `checks_pending` deferral; this ceiling bounds the whole wait measured
6
+ // from the first poll, across deferrals. Past it the item stops re-queueing and a
7
+ // human is asked to look at the stuck CI run instead of waiting forever.
8
+ /** Overall CI-wait ceiling measured from the first checks poll (default 2 h). */
9
+ export function checksWaitCeilingMs() {
10
+ const parsed = Number(process.env.CHECKS_WAIT_CEILING_MS ?? 2 * 60 * 60_000);
11
+ return Number.isFinite(parsed) ? parsed : 2 * 60 * 60_000;
12
+ }
13
+ /** True once `nowMs` is at or past the ceiling after the first wait started. */
14
+ export function checksWaitCeilingExceeded(firstWaitStartedAt, nowMs = Date.now()) {
15
+ const start = Date.parse(firstWaitStartedAt);
16
+ if (!Number.isFinite(start))
17
+ return false;
18
+ return nowMs - start >= checksWaitCeilingMs();
19
+ }
@@ -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,10 @@ 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 { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
34
35
  import { externalMergeStateForIssue } from "./external-merge.js";
35
36
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
36
- 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";
37
38
  import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
38
39
  import { fetchResumeLiveHeadSha } from "./resume-live-head.js";
39
40
  import { getWorkflow } from "./workflows/registry.js";
@@ -136,7 +137,11 @@ function freezeTaskSnapshot(issue) {
136
137
  * never changes the eventual session. The worker loop consumes this off the payload and
137
138
  * only falls back to a live resolve for a legacy item queued before snapshots were carried.
138
139
  */
139
- 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) {
140
145
  const agentId = kind === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
141
146
  const agent = agentId ? getAgent(agentId) : null;
142
147
  return agent ? serializeProfileSnapshot(buildProfileSnapshot(agent, kind)) : null;
@@ -327,6 +332,12 @@ export async function applyCompletion(workItemId, leaseToken, outcome) {
327
332
  if (outcome.kind === "base_fetch_failed") {
328
333
  return applyBaseFetchDeferralCompletion(workItemId, leaseToken, outcome);
329
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
+ }
330
341
  const routed = getDb().transaction(() => {
331
342
  const before = getWorkItem(workItemId);
332
343
  if (!before)
@@ -415,6 +426,68 @@ function applyDeckOutageCompletion(workItemId, leaseToken, outage) {
415
426
  function applyBaseFetchDeferralCompletion(workItemId, leaseToken, failure) {
416
427
  return applyDeferralCompletion(workItemId, leaseToken, failure, (item, issue, instance) => deferLeasedWorkItemForBaseFetch(item, leaseToken, failure, issue, instance));
417
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
+ }
418
491
  /**
419
492
  * Deferral ceiling exceeded — escalate with cap evidence without spending infra attempts.
420
493
  * Used when applyCompletion or the worker loop cannot defer any longer.
@@ -525,6 +598,8 @@ function applyDeveloper(issue, instance, item, outcome) {
525
598
  maxReviewRounds: issue.maxReviewRounds,
526
599
  infraAttempts: issue.infraAttempts,
527
600
  maxInfraAttempts: issue.maxInfraAttempts,
601
+ ciAttempts: issue.ciAttempts,
602
+ maxCiAttempts: issue.maxCiAttempts,
528
603
  });
529
604
  const { projection, effect, advance } = projectDeveloperRoute(route, issue.status, issue.currentRound);
530
605
  const ev = eventEmitter(issue, instance, item.workerSessionId, projection.issueStatus, issue.currentRound);
@@ -607,6 +682,9 @@ function applyDeveloper(issue, instance, item, outcome) {
607
682
  incrementIssueRound(issue.id);
608
683
  else if (advance === "infra")
609
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);
610
688
  const issueNow = getIssue(issue.id);
611
689
  return applyEffect(issue, instance, effect, route, issueNow, ev, item.id);
612
690
  }
@@ -740,9 +818,10 @@ function findNonConvergence(issue, instance, result) {
740
818
  });
741
819
  return detectNonConvergence(history, issue.currentRound, floorRound);
742
820
  }
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.
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.
746
825
  * retry_reviewer_at_new_head belongs here too — a head that cycles A→B→A would otherwise
747
826
  * re-derive attempt A's ORIGINAL key (same round, same head) and collide with that
748
827
  * now-terminal work item, leaving the issue "reviewing" with no pending item. */
@@ -773,6 +852,21 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
773
852
  const attemptSuffix = isInfraRetry(route) ? `:retry-of:${causativeItemId}` : "";
774
853
  if (route.next === "retry_developer_with_findings")
775
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
+ }
776
870
  const next = enqueueWorkItem({
777
871
  issueId: issue.id,
778
872
  workflowInstanceId: instance.id,
@@ -785,7 +879,7 @@ function applyEffect(issue, instance, effect, route, issueNow, ev, causativeItem
785
879
  ...(effect.branch ? { branch: effect.branch } : {}),
786
880
  profileSnapshot: queuedProfileSnapshot(issue, kind),
787
881
  },
788
- idempotencyKey: `${instance.id}:${kind}:${issueNow.currentRound}${headSuffix}${attemptSuffix}`,
882
+ idempotencyKey,
789
883
  });
790
884
  return { ...base, nextWorkItemId: next.id };
791
885
  }
@@ -996,6 +1090,12 @@ function questionFor(actionType, reason, resumeAsReviewer = false, mergeFailure
996
1090
  // directly with its own question text (NOT-308). Case exists only so this
997
1091
  // function stays total over HumanActionType.
998
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");
999
1099
  }
1000
1100
  }
1001
1101
  /** Exported for db/migrate-to-issues.ts, which must populate the same response options on
@@ -1042,6 +1142,11 @@ export function responseOptionsFor(actionType, resumeAsReviewer = false, opts =
1042
1142
  // admits on that Muse version (missing) or dismisses (unverified). Never raised
1043
1143
  // through applyEffect; case exists only so this function stays total.
1044
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];
1045
1150
  }
1046
1151
  }
1047
1152
  function parseContinuationPreview(json) {
@@ -1080,6 +1185,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1080
1185
  const normalizedNote = normalizeResolutionNote(opts?.note);
1081
1186
  if (!normalizedNote.ok)
1082
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
+ }
1083
1199
  // NOT-308: a Muse capability escalation resolves without touching the workflow — it
1084
1200
  // has no round to spend and no merge to park, and parseHumanResolution (below)
1085
1201
  // deliberately rejects it since there is no workflow outcome to map. Acknowledging a
@@ -1140,13 +1256,17 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1140
1256
  return { ok: false, code: 400, error: `Invalid choice "${choice}" for ${action.actionType}` };
1141
1257
  }
1142
1258
  // NOT-272: the typed resolution carries the note only for product_scope_decision
1143
- // (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).
1144
1261
  const scopeDecisionNote = resolution.actionType === "product_scope_decision" && "note" in resolution
1145
1262
  ? resolution.note
1146
1263
  : undefined;
1147
- const storedResolution = scopeDecisionNote
1148
- ? { choice, note: scopeDecisionNote }
1149
- : { 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 };
1150
1270
  // NOT-194: narrow policy_escalation choices per action. A merge-failure action offers
1151
1271
  // retry_merge/repair/close only (resume would re-run development on approved work);
1152
1272
  // every other policy_escalation keeps resume/close only. Pre-NOT-194 open merge-failure
@@ -1191,12 +1311,56 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1191
1311
  // Park like auto-merge, then the async wrapper runs finalizeAutoMerge outside this txn.
1192
1312
  // NOT-194: a merge-failure retry_merge parks the same way — the old action is resolved
1193
1313
  // 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"))) {
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) {
1198
1323
  return getDb().transaction(() => {
1199
- 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
+ }
1200
1364
  appendWorkflowEvent({
1201
1365
  issueId: issue.id,
1202
1366
  workflowInstanceId: instance.id,
@@ -1205,7 +1369,12 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1205
1369
  actorRef: resolvedBy,
1206
1370
  stage: resolution.actionType === "final_review" ? "final_review" : issue.status,
1207
1371
  round: issue.currentRound,
1208
- 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
+ },
1209
1378
  });
1210
1379
  transitionIssue(issue.id, "final_review", {
1211
1380
  currentOwner: "system",
@@ -1411,7 +1580,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1411
1580
  incrementIssueRound(issue.id);
1412
1581
  break;
1413
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.
1414
1585
  resetIssueInfraAttempts(issue.id);
1586
+ resetIssueCiAttempts(issue.id);
1415
1587
  if (advancesRound)
1416
1588
  incrementIssueRound(issue.id);
1417
1589
  break;
@@ -1455,6 +1627,10 @@ export function resolveHumanActionAndAdvance(actionId, resolvedBy, choice, opts)
1455
1627
  // NOT-272: a product_scope_decision note rides only this next round's
1456
1628
  // payload — later rounds never see it.
1457
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 } : {}),
1458
1634
  },
1459
1635
  idempotencyKey: resumeKey,
1460
1636
  });
@@ -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);