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
@@ -248,7 +248,238 @@ test("NOT-278: a non-Muse developer prompt has the deck bootstrap but no cron pr
248
248
  assert.match(prompt, /bind_workspace/);
249
249
  assert.doesNotMatch(prompt, /cron_create/);
250
250
  });
251
+ // NOT-303: a Muse developer prompt carries the screenshot-path preflight verdict.
252
+ // Default (no headless shell installed): unusable, naming the Chrome.app abort and
253
+ // requiring an explicit `Visual QA: not run` conclusion line — never sandbox
254
+ // widening. Env is scrubbed so the test is hermetic.
255
+ test("NOT-303: museDeveloper prompt carries the visual-QA preflight (unusable by default)", () => {
256
+ const saved = process.env.MUSE_HEADLESS_SHELL_BIN;
257
+ delete process.env.MUSE_HEADLESS_SHELL_BIN;
258
+ try {
259
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt", museDeveloper: true });
260
+ assert.match(prompt, /## Visual QA/);
261
+ assert.match(prompt, /Google Chrome\.app/);
262
+ assert.match(prompt, /RegisterApplication/);
263
+ assert.match(prompt, /do not spend steps probing/i);
264
+ assert.match(prompt, /Visual QA: not run/);
265
+ assert.doesNotMatch(prompt, /--disable-sandbox/);
266
+ assert.doesNotMatch(prompt, /--yolo/);
267
+ }
268
+ finally {
269
+ if (saved !== undefined)
270
+ process.env.MUSE_HEADLESS_SHELL_BIN = saved;
271
+ }
272
+ });
273
+ test("NOT-303: museDeveloper prompt names the pre-installed headless shell when injected", () => {
274
+ const prompt = buildDeveloperPrompt({
275
+ taskSnapshot,
276
+ round: 1,
277
+ deckId: "deck-1",
278
+ worktreePath: "/wt",
279
+ museDeveloper: true,
280
+ museVisualQa: {
281
+ usable: true,
282
+ binary: "/opt/headless/chrome-headless-shell",
283
+ args: ["--screenshot=<png>", "--window-size=1280,800"],
284
+ reason: "pre-installed headless shell",
285
+ },
286
+ });
287
+ assert.match(prompt, /\/opt\/headless\/chrome-headless-shell/);
288
+ assert.match(prompt, /--screenshot=<png>/);
289
+ assert.match(prompt, /Google Chrome\.app/);
290
+ });
291
+ test("NOT-303: a non-Muse developer prompt has no visual-QA section", () => {
292
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: "deck-1", worktreePath: "/wt" });
293
+ assert.doesNotMatch(prompt, /## Visual QA/);
294
+ });
295
+ test("NOT-303: reviewer prompt states a missing screenshot is never a pass", () => {
296
+ const prompt = buildReviewerPrompt(reviewerBase);
297
+ assert.match(prompt, /missing screenshot/i);
298
+ assert.match(prompt, /visual QA not run/);
299
+ assert.match(prompt, /do not read the absence as a pass/);
300
+ });
251
301
  test("a deckless developer prompt still fails closed", () => {
252
302
  const prompt = buildDeveloperPrompt({ taskSnapshot, round: 1, deckId: null });
253
303
  assert.match(prompt, /misconfigured: Agent Deck is required but missing/);
254
304
  });
305
+ // NOT-310: the conflict-repair directive renders the base, the branch, and the
306
+ // conflicting files first — the developer merges the base before any other work.
307
+ test("NOT-310: conflict repair directive renders base, branch, and files before the task", () => {
308
+ const prompt = buildDeveloperPrompt({
309
+ taskSnapshot,
310
+ round: 3,
311
+ conflictRepair: { baseBranch: "main", branch: "issue-9", files: ["a.ts", "b.ts"] },
312
+ });
313
+ assert.match(prompt, /## Merge conflict with main/);
314
+ assert.ok(prompt.includes("`issue-9`"), "branch must be named");
315
+ assert.ok(prompt.includes("`a.ts`") && prompt.includes("`b.ts`"), "files must be named");
316
+ assert.ok(prompt.includes("git fetch origin main"), "must fetch the base");
317
+ assert.ok(prompt.includes("git merge origin/main"), "must merge the base");
318
+ assert.match(prompt, /never force-push/i);
319
+ assert.match(prompt, /never rebase/i);
320
+ assert.ok(prompt.indexOf("## Merge conflict") < prompt.indexOf("## Task"), "conflict section must precede the task");
321
+ });
322
+ test("NOT-310: unknown conflicting files render the discover-from-merge instruction", () => {
323
+ const prompt = buildDeveloperPrompt({
324
+ taskSnapshot,
325
+ round: 2,
326
+ conflictRepair: { baseBranch: "main", branch: "issue-9", files: [] },
327
+ });
328
+ assert.match(prompt, /## Merge conflict with main/);
329
+ assert.match(prompt, /conflicting files are unknown/);
330
+ });
331
+ test("NOT-310: no conflict directive produces byte-for-byte the same prompt as before", () => {
332
+ const base = { taskSnapshot, round: 2 };
333
+ const without = buildDeveloperPrompt(base);
334
+ assert.doesNotMatch(without, /## Merge conflict/);
335
+ assert.equal(buildDeveloperPrompt({ ...base, conflictRepair: undefined }), without);
336
+ assert.equal(buildDeveloperPrompt({ ...base, conflictRepair: { baseBranch: "", branch: "", files: [] } }), without);
337
+ });
338
+ // NOT-314: the developer prompt lists operator criteria separately — the worker
339
+ // ships the probe + doc and never attempts the criterion itself.
340
+ test("NOT-314: developer prompt lists operator criteria with the do-not-attempt instruction", () => {
341
+ const prompt = buildDeveloperPrompt({
342
+ taskSnapshot,
343
+ round: 1,
344
+ operatorCriteria: [
345
+ {
346
+ text: "Operator can sign in with SSO and see the org dashboard [operator]",
347
+ commands: ["`npm run probe:sso -- --env staging`"],
348
+ },
349
+ ],
350
+ });
351
+ assert.match(prompt, /## Operator verification required/);
352
+ assert.match(prompt, /Do not attempt these; ship the ready-to-run probe and a doc, put the exact command in the PR body/);
353
+ assert.match(prompt, /sign in with SSO/);
354
+ assert.match(prompt, /npm run probe:sso -- --env staging/);
355
+ });
356
+ test("NOT-314: no operator criteria produces byte-for-byte the same developer prompt as before", () => {
357
+ const base = { taskSnapshot, round: 2 };
358
+ const without = buildDeveloperPrompt(base);
359
+ assert.doesNotMatch(without, /## Operator verification/);
360
+ assert.equal(buildDeveloperPrompt({ ...base, operatorCriteria: undefined }), without);
361
+ assert.equal(buildDeveloperPrompt({ ...base, operatorCriteria: [] }), without);
362
+ });
363
+ test("NOT-314: operator repair note renders verbatim under its own heading before the task", () => {
364
+ const note = "The probe 404s — its path moved; fix the script and the doc.";
365
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round: 2, operatorRepairNote: note });
366
+ assert.match(prompt, /## Operator verification feedback/);
367
+ assert.ok(prompt.includes(note), "note text must appear verbatim");
368
+ assert.ok(prompt.indexOf("## Operator verification feedback") < prompt.indexOf("## Task"), "repair feedback must precede the task");
369
+ const without = buildDeveloperPrompt({ taskSnapshot, round: 2 });
370
+ assert.doesNotMatch(without, /## Operator verification feedback/);
371
+ assert.equal(buildDeveloperPrompt({ taskSnapshot, round: 2, operatorRepairNote: " " }), without);
372
+ });
373
+ // NOT-314: the reviewer must not flag missing operator evidence — Dealer gates
374
+ // the merge — but the probe and doc must exist.
375
+ test("NOT-314: reviewer prompt states missing operator evidence is not a defect, probe and doc must exist", () => {
376
+ const prompt = buildReviewerPrompt({
377
+ ...reviewerBase,
378
+ operatorCriteria: [
379
+ {
380
+ text: "Operator can complete a paid checkout [operator]",
381
+ commands: ["`npm run probe:checkout -- --env staging`"],
382
+ },
383
+ ],
384
+ });
385
+ assert.match(prompt, /## Operator-gated criteria/);
386
+ assert.match(prompt, /Missing operator evidence is NOT a defect/);
387
+ assert.match(prompt, /never raise a blocking finding/);
388
+ assert.match(prompt, /probe and doc/);
389
+ assert.match(prompt, /A missing probe or doc IS a blocking finding/);
390
+ assert.match(prompt, /paid checkout/);
391
+ assert.match(prompt, /npm run probe:checkout/);
392
+ });
393
+ test("NOT-314: no operator criteria produces byte-for-byte the same reviewer prompt as before", () => {
394
+ const without = buildReviewerPrompt(reviewerBase);
395
+ assert.doesNotMatch(without, /Operator-gated criteria/);
396
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, operatorCriteria: undefined }), without);
397
+ assert.equal(buildReviewerPrompt({ ...reviewerBase, operatorCriteria: [] }), without);
398
+ });
399
+ // NOT-315: dependencies arrive installed (coordinator-side `npm ci`); the sandbox has
400
+ // no network, so the worker must never try to install them itself.
401
+ test("NOT-315: developer prompt tells the worker dependencies are installed and not to run npm install", () => {
402
+ for (const round of [1, 2]) {
403
+ const prompt = buildDeveloperPrompt({ taskSnapshot, round });
404
+ assert.match(prompt, /Dependencies are installed; do not run npm install \(no network\)\./);
405
+ }
406
+ const retry = buildDeveloperPrompt({ taskSnapshot, round: 1, retryReason: "Developer session failed or crashed." });
407
+ assert.match(retry, /Dependencies are installed; do not run npm install \(no network\)\./);
408
+ });
409
+ // NOT-316: a Muse developer learns the sandbox limits from its prompt — the
410
+ // section is rendered from MUSE_SANDBOX_CAPABILITIES, not hand-written prose.
411
+ // Other runtimes are unchanged.
412
+ test("NOT-316: museDeveloper prompt contains the Environment limits section; other runtimes do not", async () => {
413
+ const { MUSE_SANDBOX_CAPABILITIES } = await import("../runners/muse-code-args.js");
414
+ const muse = buildDeveloperPrompt({ taskSnapshot, round: 1, museDeveloper: true });
415
+ assert.match(muse, /## Environment limits/);
416
+ assert.ok(muse.includes(`sandbox network: ${MUSE_SANDBOX_CAPABILITIES.sandboxNetwork}`), "section header names the sandbox-network value from the constant");
417
+ assert.ok(muse.includes(`Network: ${MUSE_SANDBOX_CAPABILITIES.network}`), "network limit comes from the constant");
418
+ assert.match(muse, /listen.*EPERM/i);
419
+ assert.match(muse, /Browser: no/);
420
+ assert.match(muse, /Credentials\/Keychain: no/);
421
+ assert.match(muse, /After one failed attempt.*stop and hand off; do not work around the sandbox\./);
422
+ const other = buildDeveloperPrompt({ taskSnapshot, round: 1 });
423
+ assert.doesNotMatch(other, /## Environment limits/);
424
+ assert.doesNotMatch(other, /do not work around the sandbox/);
425
+ });
426
+ // NOT-316: tag semantics appear only when the AC text carries the tags, per
427
+ // tag, and only for Muse developers — an untagged ticket's prompt carries no
428
+ // tag section at all.
429
+ test("NOT-316: developer tag semantics render per tag only for a Muse developer with tagged ACs", () => {
430
+ const taggedBoth = {
431
+ ...taskSnapshot,
432
+ acceptanceCriteria: "- [ ] [agent] Widget renders in the preview.\n- [ ] [ci] Extend the `verify` workflow for widgets.",
433
+ };
434
+ const museBoth = buildDeveloperPrompt({ taskSnapshot: taggedBoth, round: 1, museDeveloper: true });
435
+ assert.match(museBoth, /## Acceptance-criterion tags/);
436
+ assert.match(museBoth, /`\[agent\]`: verify yourself/);
437
+ assert.match(museBoth, /`\[ci\]`: implement or extend the named CI job\/test and push/);
438
+ assert.match(museBoth, /CI is the evidence, do not reproduce it locally/);
439
+ const agentOnly = buildDeveloperPrompt({
440
+ taskSnapshot: { ...taskSnapshot, acceptanceCriteria: "- [ ] [agent] Widget renders." },
441
+ round: 1,
442
+ museDeveloper: true,
443
+ });
444
+ assert.match(agentOnly, /`\[agent\]`: verify yourself/);
445
+ assert.doesNotMatch(agentOnly, /`\[ci\]`: implement or extend/);
446
+ const ciOnly = buildDeveloperPrompt({
447
+ taskSnapshot: { ...taskSnapshot, acceptanceCriteria: "- [ ] [ci] Extend the `verify` workflow." },
448
+ round: 1,
449
+ museDeveloper: true,
450
+ });
451
+ assert.match(ciOnly, /`\[ci\]`: implement or extend the named CI job\/test and push/);
452
+ assert.doesNotMatch(ciOnly, /`\[agent\]`: verify yourself/);
453
+ // Untagged Muse prompt: no tag section — snapshot-style byte check that the
454
+ // tag feature adds nothing beyond the section itself.
455
+ const untaggedMuse = buildDeveloperPrompt({ taskSnapshot, round: 1, museDeveloper: true });
456
+ assert.doesNotMatch(untaggedMuse, /## Acceptance-criterion tags/);
457
+ assert.doesNotMatch(untaggedMuse, /`\[agent\]`: verify yourself/);
458
+ assert.doesNotMatch(untaggedMuse, /`\[ci\]`: implement or extend/);
459
+ assert.equal(buildDeveloperPrompt({ taskSnapshot, round: 1, museDeveloper: true }), untaggedMuse);
460
+ // Non-Muse prompts never carry tag semantics, even with tagged ACs.
461
+ const otherTagged = buildDeveloperPrompt({ taskSnapshot: taggedBoth, round: 1 });
462
+ assert.doesNotMatch(otherTagged, /## Acceptance-criterion tags/);
463
+ assert.doesNotMatch(otherTagged, /## Environment limits/);
464
+ });
465
+ // NOT-316: the reviewer judges [ci] by the PR check status and never flags a
466
+ // missing [operator] result as a defect.
467
+ test("NOT-316: reviewer prompt judges [ci] by check status and never defects [operator]", () => {
468
+ const tagged = buildReviewerPrompt({
469
+ ...reviewerBase,
470
+ taskSnapshot: {
471
+ ...taskSnapshot,
472
+ acceptanceCriteria: "- [ ] [ci] The `verify` workflow covers widgets.\n- [ ] [operator] Operator completes a paid checkout.",
473
+ },
474
+ });
475
+ assert.match(tagged, /## Criterion tags/);
476
+ assert.match(tagged, /Judge `\[ci\]` criteria by the PR check status in the evidence/);
477
+ assert.match(tagged, /not by local runs/);
478
+ assert.match(tagged, /Do not mark an `\[operator\]` criterion as a defect/);
479
+ assert.match(tagged, /gated by Dealer/);
480
+ });
481
+ test("NOT-316: untagged reviewer prompt carries no tag-judging section", () => {
482
+ const without = buildReviewerPrompt(reviewerBase);
483
+ assert.doesNotMatch(without, /## Criterion tags/);
484
+ assert.equal(buildReviewerPrompt(reviewerBase), without);
485
+ });
@@ -26,23 +26,72 @@
26
26
  // effect; and one item that fails to route never rolls back the others.
27
27
  import { canTransitionIssue } from "@agent-dealer/shared";
28
28
  import { getDb } from "../db/index.js";
29
- import { getIssue, incrementIssueInfraAttempts, transitionIssue } from "../repository/issues.js";
29
+ import { getIssue, incrementIssueInfraAttempts, listIssues, transitionIssue } from "../repository/issues.js";
30
30
  import { appendWorkflowEvent, getActiveWorkflowInstance } from "../repository/workflow-events.js";
31
31
  import { completeSession, getWorkerSession } from "../repository/worker-sessions.js";
32
32
  import { runtimeAvailability } from "../repository/runtime-availability.js";
33
- import { deferWorkItem, finishWorkItem, listExpiredLeases, refreshHeartbeat, requeueWorkItem, } from "../repository/work-items.js";
33
+ import { deferWorkItem, enqueueWorkItem, finishWorkItem, listExpiredLeases, listWorkItemsForIssue, refreshHeartbeat, requeueWorkItem, } from "../repository/work-items.js";
34
34
  import { infraAttemptsRemain } from "./routing.js";
35
35
  import { activeClockJumpGrace } from "./clock-jump.js";
36
36
  import { inspectWorkerProcess, terminateWorkerProcess } from "./process-liveness.js";
37
37
  import { maxAliveHoldMsFor } from "./session-timeouts.js";
38
38
  import { emitHostSuspended } from "./agent-boundaries.js";
39
- import { routeAppliedOutcome } from "./commands.js";
39
+ import { queuedProfileSnapshot, routeAppliedOutcome } from "./commands.js";
40
40
  import { recoverStrandedAutoMerges } from "./auto-merge.js";
41
41
  import { workerSessionPayload } from "./session-progress.js";
42
42
  import { PRESUMED_DEAD_REASON, presumedDeadReclaimReason } from "./failure-reason.js";
43
43
  import { recordCausesForWorkerFailedEvent } from "./failure-cause.js";
44
44
  import { baseRefCandidates, developerBranchName, hasPublishableWork, inspectBranchProgress, } from "./branch-progress.js";
45
45
  const num = (name, dflt) => Number(process.env[name] ?? dflt);
46
+ /**
47
+ * NOT-333: an issue parked in `reviewing` with an open workflow instance but no
48
+ * pending/leased work item is stranded — nothing will ever claim it (the NOT-200
49
+ * shape: a resume round that pushed nothing deduped its reviewer enqueue against the
50
+ * already-terminal reviewer row). Re-enqueue one reviewer round at the current head.
51
+ *
52
+ * Bounded by construction: the fresh pending row makes the very next pass a no-op, so
53
+ * this fires at most once per stranding. Item history is required — reaching
54
+ * `reviewing` always enqueues a reviewer, so a bare row with no work items at all has
55
+ * nothing to resume and is left alone.
56
+ */
57
+ export function recoverStrandedReviewing() {
58
+ const recovered = [];
59
+ for (const issue of listIssues("reviewing")) {
60
+ const instance = getActiveWorkflowInstance(issue.id);
61
+ if (!instance)
62
+ continue;
63
+ const items = listWorkItemsForIssue(issue.id);
64
+ if (items.length === 0)
65
+ continue;
66
+ if (items.some((w) => w.status === "pending" || w.status === "leased"))
67
+ continue;
68
+ const headSha = issue.headSha ?? null;
69
+ const baseKey = `${instance.id}:reviewer:${issue.currentRound}${headSha ? `:${headSha}` : ""}:stranded-recovery`;
70
+ const taken = new Set(items.map((w) => w.idempotencyKey));
71
+ let key = baseKey;
72
+ for (let n = 2; taken.has(key); n++)
73
+ key = `${baseKey}:${n}`;
74
+ console.warn("[coordinator] issue stranded in reviewing with no active work — re-enqueueing reviewer", {
75
+ issueId: issue.id,
76
+ workflowInstanceId: instance.id,
77
+ round: issue.currentRound,
78
+ headSha,
79
+ });
80
+ const next = enqueueWorkItem({
81
+ issueId: issue.id,
82
+ workflowInstanceId: instance.id,
83
+ kind: "reviewer",
84
+ round: issue.currentRound,
85
+ payload: {
86
+ ...(headSha ? { inputSha: headSha } : {}),
87
+ profileSnapshot: queuedProfileSnapshot(issue, "reviewer"),
88
+ },
89
+ idempotencyKey: key,
90
+ });
91
+ recovered.push(next.id);
92
+ }
93
+ return recovered;
94
+ }
46
95
  /** Fail a worker_session still `running` for an item whose worker is gone. */
47
96
  function failOrphanSession(workerSessionId) {
48
97
  if (!workerSessionId)
@@ -489,6 +538,10 @@ export async function recoverCoordinator(opts) {
489
538
  console.warn(`[coordinator] ${republished.length} reclaim(s) routed to republish — branch already had commits, no new agent session`, { workItemIds: republished });
490
539
  }
491
540
  const stranded = await recoverStrandedAutoMerges();
541
+ // NOT-333: re-enqueue a reviewer for issues stranded in `reviewing` with no active
542
+ // work (runs every tick alongside the lease reclaim above — the fresh pending row
543
+ // makes the next pass a no-op, so this recovers exactly once per stranding).
544
+ const strandedReviewingRecovered = recoverStrandedReviewing();
492
545
  return {
493
546
  reclaimed,
494
547
  republished,
@@ -499,5 +552,6 @@ export async function recoverCoordinator(opts) {
499
552
  unverifiedOrphans,
500
553
  heldAcrossClockJump,
501
554
  autoMergesFinalized: stranded.finalized,
555
+ strandedReviewingRecovered,
502
556
  };
503
557
  }
@@ -9,10 +9,11 @@ process.env.MAX_COORDINATOR_CONCURRENCY = "2";
9
9
  process.env.COORDINATOR_FAIL_BACKOFF_MS = "0";
10
10
  const { migrate, getDb } = await import("../db/index.js");
11
11
  const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
12
- const { createIssue, getIssue } = await import("../repository/issues.js");
12
+ const { createIssue, getIssue, transitionIssue } = await import("../repository/issues.js");
13
+ const { getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
13
14
  const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
14
15
  const { listWorkerSessionsForIssue, createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
15
- const { getWorkItem, listWorkItemsForIssue, claimWorkItem, bindWorkItemSession, finishWorkItem, refreshHeartbeat } = await import("../repository/work-items.js");
16
+ const { getWorkItem, listWorkItemsForIssue, claimWorkItem, bindWorkItemSession, enqueueWorkItem, finishWorkItem, refreshHeartbeat } = await import("../repository/work-items.js");
16
17
  const { startWorkflow } = await import("./commands.js");
17
18
  const { recoverCoordinator } = await import("./recovery.js");
18
19
  const FUTURE = () => Date.now() + 3_600_000; // a clock well past any test lease
@@ -126,7 +127,7 @@ test("recovery ignores a lease that has not expired", async () => {
126
127
  const issueId = newIssue();
127
128
  startWorkflow(issueId);
128
129
  claimWorkItem("healthy", { leaseMs: 600_000 });
129
- assert.deepEqual(await recoverCoordinator({ now: Date.now() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [] });
130
+ assert.deepEqual(await recoverCoordinator({ now: Date.now() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [], strandedReviewingRecovered: [] });
130
131
  });
131
132
  test("recovery leaves a lease alone when a heartbeat renewed it after the snapshot", async () => {
132
133
  // Regression for the reviewer's "recovery can reclaim a freshly renewed lease".
@@ -138,7 +139,7 @@ test("recovery leaves a lease alone when a heartbeat renewed it after the snapsh
138
139
  // …but before its CAS runs, the worker heartbeats and renews the lease:
139
140
  refreshHeartbeat(devItem.id, claimed.leaseToken, { leaseMs: 600_000 });
140
141
  const res = await recoverCoordinator({ now: Date.now() + 1_000 });
141
- assert.deepEqual(res, { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [] });
142
+ assert.deepEqual(res, { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [], strandedReviewingRecovered: [] });
142
143
  assert.equal(getWorkItem(devItem.id).status, "leased");
143
144
  });
144
145
  test("an expired lease past the infra-attempt limit is dead-lettered AND routed in one step", async () => {
@@ -162,7 +163,7 @@ test("an expired lease past the infra-attempt limit is dead-lettered AND routed
162
163
  const last = JSON.parse(failed[failed.length - 1].payloadJson);
163
164
  assert.match(last.reason ?? "", /presumed dead/);
164
165
  // A second recovery pass is a no-op — the item is already dead, nothing to reclaim.
165
- assert.deepEqual(await recoverCoordinator({ now: FUTURE() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [] });
166
+ assert.deepEqual(await recoverCoordinator({ now: FUTURE() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [], strandedReviewingRecovered: [] });
166
167
  assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.status === "open").length, 1, "no duplicate human action from a second recovery");
167
168
  });
168
169
  test("recovery loses its CAS to a worker that completed concurrently", async () => {
@@ -172,7 +173,7 @@ test("recovery loses its CAS to a worker that completed concurrently", async ()
172
173
  const claimed = claimWorkItem("o", { leaseMs: 1 });
173
174
  // Worker finishes just before recovery's transaction runs.
174
175
  finishWorkItem(devItem.id, claimed.leaseToken, { status: "done", result: { kind: "no_pr" } });
175
- assert.deepEqual(await recoverCoordinator({ now: FUTURE() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [] });
176
+ assert.deepEqual(await recoverCoordinator({ now: FUTURE() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [], strandedReviewingRecovered: [] });
176
177
  assert.equal(getWorkItem(devItem.id).status, "done");
177
178
  });
178
179
  test("an expired lease on a usage-capped runtime is deferred, not dead-lettered (NOT-111 recovery gap)", async () => {
@@ -210,7 +211,8 @@ test("an expired lease on a usage-capped runtime is deferred, not dead-lettered
210
211
  heldAliveExpired: [],
211
212
  unverifiedOrphans: [],
212
213
  heldAcrossClockJump: [],
213
- autoMergesFinalized: []
214
+ autoMergesFinalized: [],
215
+ strandedReviewingRecovered: []
214
216
  });
215
217
  assert.equal(getWorkItem(devItem.id).status, "pending");
216
218
  assert.equal(getWorkItem(devItem.id).attemptCount, 0, "claim's attempt bump is reverted, like a live cap deferral");
@@ -223,5 +225,62 @@ test("an expired lease on a usage-capped runtime is deferred, not dead-lettered
223
225
  }
224
226
  });
225
227
  test("recoverCoordinator is a no-op on a clean queue", async () => {
226
- assert.deepEqual(await recoverCoordinator({ now: FUTURE() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [] });
228
+ assert.deepEqual(await recoverCoordinator({ now: FUTURE() }), { reclaimed: [], republished: [], deadLettered: [], deferredForCap: [], heldAlive: [], heldAliveExpired: [], unverifiedOrphans: [], heldAcrossClockJump: [], autoMergesFinalized: [], strandedReviewingRecovered: [] });
229
+ });
230
+ /** Settles an issue's developer round so the test can park the issue in `reviewing`. */
231
+ function settleDeveloper(issueId) {
232
+ const dev = claimWorkItem(`seed-${issueId}`, { leaseMs: 600_000 });
233
+ assert.equal(dev.issueId, issueId);
234
+ assert.equal(finishWorkItem(dev.id, dev.leaseToken, { status: "done", result: { kind: "no_pr" } }).status, "done");
235
+ }
236
+ test("NOT-333: recovery re-enqueues exactly once for an issue stranded in reviewing, and is a no-op otherwise", async () => {
237
+ // The NOT-200 stranding: reviewing, open instance, head pinned, but every work
238
+ // item terminal — the resume round's reviewer enqueue deduped against the done row.
239
+ const strandedId = newIssue();
240
+ startWorkflow(strandedId);
241
+ settleDeveloper(strandedId);
242
+ transitionIssue(strandedId, "reviewing", { headSha: "abc123" });
243
+ const instance = getActiveWorkflowInstance(strandedId);
244
+ const oldReviewer = enqueueWorkItem({
245
+ issueId: strandedId,
246
+ workflowInstanceId: instance.id,
247
+ kind: "reviewer",
248
+ round: 1,
249
+ payload: { inputSha: "abc123" },
250
+ idempotencyKey: `${instance.id}:reviewer:1:abc123`,
251
+ });
252
+ const leasedOld = claimWorkItem(`seed-rev-${strandedId}`, { leaseMs: 600_000 });
253
+ assert.equal(leasedOld.id, oldReviewer.id);
254
+ assert.equal(finishWorkItem(oldReviewer.id, leasedOld.leaseToken, { status: "done", result: { kind: "verdict" } }).status, "done");
255
+ // A healthy issue: reviewing WITH a pending reviewer must be left alone.
256
+ const healthyId = newIssue();
257
+ startWorkflow(healthyId);
258
+ settleDeveloper(healthyId);
259
+ transitionIssue(healthyId, "reviewing", { headSha: "def456" });
260
+ const healthyInstance = getActiveWorkflowInstance(healthyId);
261
+ const healthyReviewer = enqueueWorkItem({
262
+ issueId: healthyId,
263
+ workflowInstanceId: healthyInstance.id,
264
+ kind: "reviewer",
265
+ round: 1,
266
+ payload: { inputSha: "def456" },
267
+ idempotencyKey: `${healthyInstance.id}:reviewer:1:def456`,
268
+ });
269
+ // First tick recovers the stranded issue exactly once.
270
+ const first = await recoverCoordinator({ now: Date.now() });
271
+ assert.equal(first.strandedReviewingRecovered.length, 1);
272
+ const recovered = getWorkItem(first.strandedReviewingRecovered[0]);
273
+ assert.equal(recovered.issueId, strandedId);
274
+ assert.equal(recovered.kind, "reviewer");
275
+ assert.equal(recovered.status, "pending");
276
+ assert.notEqual(recovered.id, oldReviewer.id);
277
+ assert.equal(JSON.parse(recovered.payloadJson).inputSha, "abc123");
278
+ // ...and touches nothing else.
279
+ assert.equal(listWorkItemsForIssue(healthyId).length, 2);
280
+ assert.equal(getWorkItem(healthyReviewer.id).status, "pending");
281
+ // Second tick is a no-op — the fresh pending row bounds the guard, no loop.
282
+ const second = await recoverCoordinator({ now: Date.now() });
283
+ assert.deepEqual(second.strandedReviewingRecovered, []);
284
+ assert.equal(listWorkItemsForIssue(strandedId).filter((i) => i.kind === "reviewer").length, 2);
285
+ assert.equal(listWorkItemsForIssue(strandedId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
227
286
  });