agent-dealer 0.2.0 → 1.0.0

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 (276) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +443 -0
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +454 -0
  3. package/bundle/server/dist/adapters/agent-deck.js +38 -50
  4. package/bundle/server/dist/adapters/agent-deck.test.js +63 -0
  5. package/bundle/server/dist/adapters/agent-health.js +230 -33
  6. package/bundle/server/dist/adapters/agent-health.test.js +237 -0
  7. package/bundle/server/dist/adapters/authority-lifecycle.js +289 -0
  8. package/bundle/server/dist/adapters/codex-scoped-config.js +103 -0
  9. package/bundle/server/dist/adapters/execution-authority.js +103 -0
  10. package/bundle/server/dist/adapters/execution-authority.test.js +166 -0
  11. package/bundle/server/dist/adapters/external.js +0 -1
  12. package/bundle/server/dist/adapters/git-worktree.js +514 -0
  13. package/bundle/server/dist/adapters/git-worktree.test.js +502 -0
  14. package/bundle/server/dist/adapters/github.js +170 -0
  15. package/bundle/server/dist/adapters/github.test.js +205 -0
  16. package/bundle/server/dist/adapters/linear-inbox.js +199 -30
  17. package/bundle/server/dist/adapters/linear-inbox.test.js +35 -0
  18. package/bundle/server/dist/adapters/linear-sync.js +3 -101
  19. package/bundle/server/dist/adapters/managed-repo.js +168 -0
  20. package/bundle/server/dist/adapters/managed-repo.test.js +63 -0
  21. package/bundle/server/dist/adapters/outbound-delivery.js +83 -0
  22. package/bundle/server/dist/adapters/outbound-delivery.test.js +53 -0
  23. package/bundle/server/dist/adapters/reflect-authority.js +43 -0
  24. package/bundle/server/dist/cli-env.js +26 -0
  25. package/bundle/server/dist/cli-env.test.js +14 -1
  26. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  27. package/bundle/server/dist/coordinator/admission.js +330 -0
  28. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  29. package/bundle/server/dist/coordinator/args.js +128 -0
  30. package/bundle/server/dist/coordinator/args.test.js +132 -0
  31. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  32. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +363 -0
  33. package/bundle/server/dist/coordinator/auto-merge.js +286 -0
  34. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +14 -0
  35. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  36. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  37. package/bundle/server/dist/coordinator/commands.js +1088 -0
  38. package/bundle/server/dist/coordinator/commands.test.js +554 -0
  39. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  40. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  41. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  42. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  43. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  44. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  45. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  46. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  47. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  48. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  49. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  50. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  51. package/bundle/server/dist/coordinator/guidance.js +33 -0
  52. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  53. package/bundle/server/dist/coordinator/human-resolution.js +88 -0
  54. package/bundle/server/dist/coordinator/human-resolution.test.js +72 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  56. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  57. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  58. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  59. package/bundle/server/dist/coordinator/metrics.js +35 -0
  60. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  61. package/bundle/server/dist/coordinator/permissions.js +127 -0
  62. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  63. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  64. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  66. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  67. package/bundle/server/dist/coordinator/projection.js +147 -0
  68. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  69. package/bundle/server/dist/coordinator/prompts.js +164 -0
  70. package/bundle/server/dist/coordinator/prompts.test.js +177 -0
  71. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  72. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  73. package/bundle/server/dist/coordinator/recovery.js +466 -0
  74. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  76. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  77. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  78. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.js +507 -0
  80. package/bundle/server/dist/coordinator/reviewer-effect.test.js +612 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.js +38 -0
  82. package/bundle/server/dist/coordinator/reviewer-result.test.js +45 -0
  83. package/bundle/server/dist/coordinator/routing.js +160 -0
  84. package/bundle/server/dist/coordinator/routing.test.js +336 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  87. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  88. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  89. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  90. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  91. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  92. package/bundle/server/dist/coordinator/spawn.js +49 -0
  93. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  94. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  95. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  96. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  97. package/bundle/server/dist/coordinator/usage.js +40 -0
  98. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  100. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  101. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  102. package/bundle/server/dist/coordinator/worker-loop.test.js +327 -0
  103. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  105. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  106. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  107. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  108. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  109. package/bundle/server/dist/db/index.js +359 -7
  110. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  111. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  112. package/bundle/server/dist/db/migrate-to-issues.test.js +842 -0
  113. package/bundle/server/dist/db/schema.sql +311 -1
  114. package/bundle/server/dist/db/schema.test.js +66 -0
  115. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  116. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +338 -0
  117. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  118. package/bundle/server/dist/index.js +58 -5
  119. package/bundle/server/dist/paths.js +40 -4
  120. package/bundle/server/dist/paths.test.js +42 -0
  121. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  122. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  123. package/bundle/server/dist/pid-marker.js +141 -0
  124. package/bundle/server/dist/pid-marker.test.js +208 -0
  125. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  126. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  127. package/bundle/server/dist/queue/result-qa.js +1 -1
  128. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  129. package/bundle/server/dist/repository/agents.js +55 -18
  130. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  131. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  132. package/bundle/server/dist/repository/artifacts.js +47 -0
  133. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  134. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  135. package/bundle/server/dist/repository/findings.js +70 -0
  136. package/bundle/server/dist/repository/findings.test.js +68 -0
  137. package/bundle/server/dist/repository/human-actions.js +142 -0
  138. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  139. package/bundle/server/dist/repository/intake-settings.js +5 -59
  140. package/bundle/server/dist/repository/issues.js +305 -0
  141. package/bundle/server/dist/repository/issues.test.js +69 -0
  142. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  143. package/bundle/server/dist/repository/queue-entries.js +162 -0
  144. package/bundle/server/dist/repository/review-publications.js +153 -0
  145. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  146. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  147. package/bundle/server/dist/repository/runs.js +4 -211
  148. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  149. package/bundle/server/dist/repository/usage-events.js +60 -0
  150. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  151. package/bundle/server/dist/repository/work-items.js +305 -0
  152. package/bundle/server/dist/repository/work-items.test.js +142 -0
  153. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  154. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  155. package/bundle/server/dist/repository/workflow-events.js +193 -0
  156. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  157. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  158. package/bundle/server/dist/routes/human-actions.js +68 -0
  159. package/bundle/server/dist/routes/human-actions.test.js +284 -0
  160. package/bundle/server/dist/routes/index.js +29 -496
  161. package/bundle/server/dist/routes/issues.js +240 -0
  162. package/bundle/server/dist/routes/issues.test.js +451 -0
  163. package/bundle/server/dist/routes/queue.js +41 -0
  164. package/bundle/server/dist/routes/queue.test.js +78 -0
  165. package/bundle/server/dist/runners/claude-args.js +18 -30
  166. package/bundle/server/dist/runners/claude.js +19 -163
  167. package/bundle/server/dist/runners/claude.test.js +15 -70
  168. package/bundle/server/dist/runners/codex-args.js +59 -0
  169. package/bundle/server/dist/runners/codex-args.test.js +72 -0
  170. package/bundle/server/dist/runners/codex-jsonl.js +150 -0
  171. package/bundle/server/dist/runners/codex-jsonl.test.js +36 -0
  172. package/bundle/server/dist/runners/codex-resume.test.js +19 -0
  173. package/bundle/server/dist/runners/codex.js +46 -0
  174. package/bundle/server/dist/runners/models-codex.test.js +14 -0
  175. package/bundle/server/dist/runners/models.js +53 -1
  176. package/bundle/server/dist/runners/persist.js +4 -1
  177. package/bundle/server/dist/runners/process-registry.js +11 -0
  178. package/bundle/server/dist/runners/prompts.js +13 -280
  179. package/bundle/server/dist/runners/prompts.test.js +22 -153
  180. package/bundle/server/dist/runners/qa.js +17 -1
  181. package/bundle/server/dist/runners/reflect.js +4 -16
  182. package/bundle/server/dist/runners/spawn-cli.js +197 -0
  183. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  184. package/bundle/server/dist/runners/stream-json.js +0 -141
  185. package/bundle/server/dist/runners/usage-cap.js +242 -0
  186. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  187. package/bundle/server/dist/server-liveness.js +37 -0
  188. package/bundle/server/dist/server-liveness.test.js +82 -0
  189. package/bundle/server/dist/static-ui.test.js +44 -0
  190. package/bundle/server/package.json +4 -2
  191. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  192. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  193. package/bundle/server/static-ui/index.html +2 -2
  194. package/bundle/shared/dist/agents.d.ts +262 -133
  195. package/bundle/shared/dist/agents.js +85 -19
  196. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  197. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  198. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  199. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  200. package/bundle/shared/dist/findings.d.ts +46 -0
  201. package/bundle/shared/dist/findings.js +17 -0
  202. package/bundle/shared/dist/findings.test.d.ts +1 -0
  203. package/bundle/shared/dist/findings.test.js +20 -0
  204. package/bundle/shared/dist/github-repo.d.ts +28 -0
  205. package/bundle/shared/dist/github-repo.js +98 -0
  206. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  207. package/bundle/shared/dist/github-repo.test.js +32 -0
  208. package/bundle/shared/dist/human-actions.d.ts +66 -0
  209. package/bundle/shared/dist/human-actions.js +51 -0
  210. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  211. package/bundle/shared/dist/human-actions.test.js +57 -0
  212. package/bundle/shared/dist/index.d.ts +567 -757
  213. package/bundle/shared/dist/index.js +22 -36
  214. package/bundle/shared/dist/issues.d.ts +385 -0
  215. package/bundle/shared/dist/issues.js +169 -0
  216. package/bundle/shared/dist/issues.test.d.ts +1 -0
  217. package/bundle/shared/dist/issues.test.js +72 -0
  218. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  219. package/bundle/shared/dist/outbound-draft.js +4 -0
  220. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  221. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  222. package/bundle/shared/dist/profile-snapshot.js +145 -0
  223. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  224. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  225. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  226. package/bundle/shared/dist/queue-entries.js +19 -0
  227. package/bundle/shared/dist/result-qa.d.ts +2 -2
  228. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  229. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  230. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  231. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  232. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  233. package/bundle/shared/dist/runtime-availability.js +13 -0
  234. package/bundle/shared/dist/runtime.d.ts +1 -1
  235. package/bundle/shared/dist/runtime.js +1 -1
  236. package/bundle/shared/dist/usage-events.d.ts +36 -0
  237. package/bundle/shared/dist/usage-events.js +13 -0
  238. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  239. package/bundle/shared/dist/worker-sessions.js +70 -0
  240. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  241. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  242. package/bundle/shared/dist/workflow.d.ts +78 -0
  243. package/bundle/shared/dist/workflow.js +59 -0
  244. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  245. package/bundle/shared/dist/workflow.test.js +61 -0
  246. package/bundle/shared/package.json +1 -1
  247. package/dist/action.d.ts +1 -0
  248. package/dist/action.js +50 -0
  249. package/dist/action.test.d.ts +1 -0
  250. package/dist/action.test.js +70 -0
  251. package/dist/agent.d.ts +1 -0
  252. package/dist/agent.js +17 -0
  253. package/dist/agent.test.d.ts +1 -0
  254. package/dist/agent.test.js +19 -0
  255. package/dist/cli-check.d.ts +7 -0
  256. package/dist/cli-check.js +40 -0
  257. package/dist/doctor.js +59 -1
  258. package/dist/http.d.ts +5 -0
  259. package/dist/http.js +19 -0
  260. package/dist/index.js +28 -0
  261. package/dist/issue.d.ts +35 -0
  262. package/dist/issue.js +143 -0
  263. package/dist/issue.test.d.ts +1 -0
  264. package/dist/issue.test.js +240 -0
  265. package/dist/lifecycle.contract.test.d.ts +1 -0
  266. package/dist/lifecycle.contract.test.js +135 -0
  267. package/dist/queue.d.ts +11 -0
  268. package/dist/queue.js +60 -0
  269. package/dist/queue.test.d.ts +1 -0
  270. package/dist/queue.test.js +46 -0
  271. package/dist/test-fetch-stub.d.ts +6 -0
  272. package/dist/test-fetch-stub.js +31 -0
  273. package/package.json +2 -1
  274. package/templates/prod.env.example +1 -1
  275. package/bundle/server/static-ui/assets/index-B_7S7xJb.js +0 -79
  276. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,612 @@
1
+ // packages/server/src/coordinator/reviewer-effect.test.ts
2
+ //
3
+ // NOT-62 acceptance test: a real Git repository (temp repo + bare "origin" remote) drives
4
+ // the reviewer's detached-HEAD worktree, diff computation, and stale-head re-verification,
5
+ // while the agent session itself and GitHub publication are faked — the same confirmed
6
+ // scope as NOT-61 (no paid CLI spawn, no real GitHub calls). The developer half runs for
7
+ // real too (fake spawn, fake GitHub) so each test reaches a genuine coordinator-verified
8
+ // head SHA the reviewer is pinned to, exactly as it would end to end.
9
+ import { test, before, beforeEach, after } from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { execFileSync } from "node:child_process";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reveff-home-"));
16
+ process.env.MAX_COORDINATOR_CONCURRENCY = "2";
17
+ process.env.COORDINATOR_HEARTBEAT_MS = "20";
18
+ process.env.COORDINATOR_FAIL_BACKOFF_MS = "0";
19
+ process.env.CHECKS_POLL_TIMEOUT_MS = "60";
20
+ process.env.CHECKS_POLL_INTERVAL_MS = "10";
21
+ process.env.REVIEWER_TIMEOUT_MS = "5000";
22
+ process.env.REVIEWER_PUBLISH_WAIT_ATTEMPTS = "3";
23
+ process.env.REVIEWER_PUBLISH_WAIT_INTERVAL_MS = "10";
24
+ const { migrate, getDb } = await import("../db/index.js");
25
+ const { createAgent } = await import("../repository/agents.js");
26
+ const { createIssue, getIssue } = await import("../repository/issues.js");
27
+ const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
28
+ const { listWorkItemsForIssue, cancelWorkItem } = await import("../repository/work-items.js");
29
+ const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
30
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
31
+ const { listFindingsForIssue } = await import("../repository/findings.js");
32
+ const { listUsageEventsForIssue } = await import("../repository/usage-events.js");
33
+ const { getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
34
+ const { createWorkerSession } = await import("../repository/worker-sessions.js");
35
+ const { claimReviewPublication, recordReviewPublishFailed } = await import("../repository/review-publications.js");
36
+ const { startWorkflow } = await import("./commands.js");
37
+ const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
38
+ const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js");
39
+ const { runDeveloperEffect } = await import("./developer-effect.js");
40
+ const { runReviewerEffect } = await import("./reviewer-effect.js");
41
+ const { routeReviewerOutcome } = await import("./routing.js");
42
+ const { realDeveloperSpawn, realReviewerSpawn } = await import("./spawn.js");
43
+ const { realGithubAdapter } = await import("../adapters/github.js");
44
+ before(() => migrate());
45
+ // review_publications rows FK-reference work_items — deleted first, or the delete below
46
+ // (needed between tests since each makes its own issue/work items) fails the constraint.
47
+ beforeEach(() => getDb().exec("DELETE FROM review_publications; DELETE FROM work_items;"));
48
+ after(() => resetEffectHandlers());
49
+ let repo;
50
+ let remote;
51
+ function git(cwd, ...args) {
52
+ return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
53
+ }
54
+ before(() => {
55
+ repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reveff-repo-"));
56
+ git(repo, "init", "-q", "-b", "main");
57
+ git(repo, "config", "user.email", "test@example.com");
58
+ git(repo, "config", "user.name", "Test");
59
+ fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
60
+ git(repo, "add", ".");
61
+ git(repo, "commit", "-q", "-m", "init");
62
+ remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reveff-remote-"));
63
+ execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
64
+ git(repo, "remote", "add", "origin", remote);
65
+ git(repo, "push", "-q", "origin", "main");
66
+ });
67
+ after(() => {
68
+ fs.rmSync(repo, { recursive: true, force: true });
69
+ fs.rmSync(remote, { recursive: true, force: true });
70
+ });
71
+ function issueBranchName(issueId) {
72
+ return `issue-${issueId}`;
73
+ }
74
+ /** NOT-149: agents always carry a deckId; effect tests that do not exercise Deck failures
75
+ * inject a get_bound_deck stub that reports the test deck as bound. */
76
+ const TEST_DECK_ID = "00000000-0000-4000-a000-000000000099";
77
+ const okDeckCallTool = async (name, _args) => ({
78
+ content: [
79
+ {
80
+ type: "text",
81
+ text: JSON.stringify({ id: TEST_DECK_ID, name: "test-deck" }),
82
+ },
83
+ ],
84
+ });
85
+ async function makeIssue(opts = {}) {
86
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
87
+ const rev = createAgent({
88
+ name: `rev-${Math.random()}`,
89
+ runtime: "claude_code",
90
+ deckId: opts.reviewerDeck?.deckId ?? "00000000-0000-4000-a000-000000000099",
91
+ });
92
+ if (opts.reviewerDeck?.playbookIds?.length) {
93
+ getDb()
94
+ .prepare("UPDATE agents SET playbook_ids_json = ? WHERE id = ?")
95
+ .run(JSON.stringify(opts.reviewerDeck.playbookIds), rev.id);
96
+ }
97
+ return createIssue({
98
+ title: "Add widget",
99
+ description: "Build the widget.",
100
+ acceptanceCriteria: "Widget renders.",
101
+ repo,
102
+ baseBranch: "main",
103
+ developerAgentId: dev.id,
104
+ reviewerAgentId: rev.id,
105
+ maxReviewRounds: 3,
106
+ maxInfraAttempts: opts.maxInfraAttempts ?? 3,
107
+ source: "manual"
108
+ }).id;
109
+ }
110
+ /**
111
+ * Directly manipulates the work item's live lease, simulating what claim/recovery would
112
+ * otherwise do. `expiresInMs` defaults far enough in the future for a live lease; pass a
113
+ * negative value to simulate one that has expired but recovery hasn't swept yet — same
114
+ * `status = 'leased'`, same token, just past its `lease_expires_at`.
115
+ */
116
+ function setWorkItemLease(workItemId, leaseToken, expiresInMs = 60_000) {
117
+ const expiresAt = new Date(Date.now() + expiresInMs).toISOString();
118
+ getDb()
119
+ .prepare("UPDATE work_items SET lease_token = ?, status = 'leased', lease_expires_at = ? WHERE id = ?")
120
+ .run(leaseToken, expiresAt, workItemId);
121
+ }
122
+ async function pump(max = 20) {
123
+ for (let i = 0; i < max; i++) {
124
+ const started = await runCoordinatorTick({ leaseOwner: "pump" });
125
+ await drainCoordinator();
126
+ if (started === 0)
127
+ return;
128
+ }
129
+ }
130
+ /** Commits a file in the developer worktree — the "agent implemented the task" fake. */
131
+ const commitingSpawn = async (input) => {
132
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented\n");
133
+ git(input.cwd, "add", ".");
134
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "implement");
135
+ return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
136
+ };
137
+ function reviewerTranscript(opts) {
138
+ const body = {
139
+ verdict: opts.verdict,
140
+ baseSha: opts.baseSha,
141
+ headSha: opts.headSha,
142
+ acceptanceCriteriaAssessment: "Assessed.",
143
+ evidenceAssessment: "Evidence checked.",
144
+ findings: opts.findings ?? [],
145
+ risks: [],
146
+ ...(opts.productScopeQuestion ? { productScopeQuestion: opts.productScopeQuestion } : {})
147
+ };
148
+ return `Some preamble.\n\`\`\`json\n${JSON.stringify(body)}\n\`\`\`\n`;
149
+ }
150
+ /** The reviewer prompt echoes the exact SHAs it must report (prompts.ts) — pulling them
151
+ * back out of the prompt keeps this fake honest against reviewer-effect.ts's own SHA
152
+ * validation, instead of hardcoding values that would now be rejected. */
153
+ function shasFromPrompt(prompt) {
154
+ const baseSha = prompt.match(/"baseSha" to exactly "([0-9a-f]+)"/)?.[1];
155
+ const headSha = prompt.match(/"headSha" to exactly "([0-9a-f]+)"/)?.[1];
156
+ if (!baseSha || !headSha)
157
+ throw new Error("could not extract SHAs from reviewer prompt");
158
+ return { baseSha, headSha };
159
+ }
160
+ function verdictSpawn(opts) {
161
+ return async (input) => {
162
+ const { baseSha, headSha } = shasFromPrompt(input.prompt);
163
+ return { exitCode: 0, transcript: reviewerTranscript({ ...opts, baseSha, headSha }), logPath: "/dev/null", timedOut: false };
164
+ };
165
+ }
166
+ /** Reports SHAs that do not match what the coordinator actually pinned/verified. */
167
+ const wrongShaSpawn = async () => ({
168
+ exitCode: 0,
169
+ transcript: reviewerTranscript({ verdict: "approved", baseSha: "0".repeat(40), headSha: "1".repeat(40) }),
170
+ logPath: "/dev/null",
171
+ timedOut: false
172
+ });
173
+ const garbageSpawn = async () => ({ exitCode: 0, transcript: "not json at all", logPath: "/dev/null", timedOut: false });
174
+ const crashingReviewerSpawn = async () => ({ exitCode: 1, transcript: "boom", logPath: "/dev/null", timedOut: false });
175
+ const timedOutReviewerSpawn = async () => ({ exitCode: 1, transcript: "", logPath: "/dev/null", timedOut: true });
176
+ /**
177
+ * In-memory PR store — real git tells it the branch/head, nothing hits real GitHub.
178
+ *
179
+ * `publishReview` always actually "submits" (increments `publishCallCount()`) — it does
180
+ * NOT deduplicate on its own. That's deliberate: the real duplicate-submission guard now
181
+ * lives entirely in `reviewer-effect.ts` (the durable `review_publications` claim,
182
+ * gating entry before `publishReview` is ever called), not in the adapter — matching
183
+ * round 3's fix removing the GitHub-side existence check from `github.ts` altogether.
184
+ * Keeping the fake this simple means a test asserting `publishCallCount() === 1` after
185
+ * two effect runs is actually exercising the caller's claim, not a dedup the fake would
186
+ * have papered over on its own.
187
+ */
188
+ function fakeGithub(opts = {}) {
189
+ const prsByBranch = new Map();
190
+ const branchByNumber = new Map();
191
+ let nextNumber = 100;
192
+ let publishCalls = 0;
193
+ const currentBranch = (cwd) => git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
194
+ const remoteHead = (branch) => git(remote, "rev-parse", branch);
195
+ const adapter = {
196
+ async viewPr({ cwd, number }) {
197
+ const branch = number != null ? branchByNumber.get(number) : currentBranch(cwd);
198
+ if (!branch)
199
+ return null;
200
+ const pr = prsByBranch.get(branch);
201
+ if (!pr)
202
+ return null;
203
+ return { number: pr.number, url: pr.url, baseRefName: pr.base, headRefName: branch, headRefOid: remoteHead(branch), isDraft: true };
204
+ },
205
+ async createDraftPr({ cwd, base }) {
206
+ const branch = currentBranch(cwd);
207
+ const number = nextNumber++;
208
+ const url = `https://github.com/o/r/pull/${number}`;
209
+ prsByBranch.set(branch, { number, url, base });
210
+ branchByNumber.set(number, branch);
211
+ return { ok: true, number, url };
212
+ },
213
+ async checksSnapshot() {
214
+ return opts.checks ?? "success";
215
+ },
216
+ async publishReview({ event }) {
217
+ publishCalls++;
218
+ if (opts.publishFails)
219
+ return { ok: false, reason: "boom" };
220
+ const finalEvent = opts.rejectSelfReview && event !== "COMMENT" ? "COMMENT" : event;
221
+ return { ok: true, event: finalEvent, usedCommentFallback: finalEvent !== event };
222
+ }
223
+ };
224
+ return Object.assign(adapter, { publishCallCount: () => publishCalls });
225
+ }
226
+ /** Advances the issue to "reviewing" with a real coordinator-verified head SHA. */
227
+ async function advanceToReviewing(issueId, github) {
228
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: commitingSpawn, github }));
229
+ startWorkflow(issueId);
230
+ await pump(1);
231
+ assert.equal(getIssue(issueId).status, "reviewing", "test setup: developer stage did not reach reviewing");
232
+ }
233
+ /** Builds an EffectContext for the pending reviewer work item with a specific (possibly stale) leaseToken, mimicking what worker-loop.ts's processWorkItem does per attempt. */
234
+ function reviewerCtxFactory(issueId) {
235
+ const issue = getIssue(issueId);
236
+ const instance = getActiveWorkflowInstance(issueId);
237
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
238
+ const profileSnapshotJson = (() => {
239
+ try {
240
+ return JSON.parse(workItem.payloadJson ?? "{}").profileSnapshot;
241
+ }
242
+ catch {
243
+ return undefined;
244
+ }
245
+ })();
246
+ return (leaseToken) => {
247
+ const session = createWorkerSession({
248
+ issueId: issue.id,
249
+ role: "reviewer",
250
+ round: workItem.round,
251
+ agentId: issue.reviewerAgentId,
252
+ runtime: "claude_code",
253
+ profileSnapshotJson
254
+ });
255
+ return {
256
+ workItem: { ...workItem, workerSessionId: session.id, leaseToken },
257
+ issue,
258
+ instance,
259
+ signal: new AbortController().signal
260
+ };
261
+ };
262
+ }
263
+ test("approved: the coordinator verifies the pinned SHA, publishes the review, and opens final_review", async () => {
264
+ const issueId = await makeIssue();
265
+ const github = fakeGithub();
266
+ await advanceToReviewing(issueId, github);
267
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
268
+ await pump(1);
269
+ const issue = getIssue(issueId);
270
+ assert.equal(issue.status, "final_review");
271
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "final_review");
272
+ assert.ok(action, "expected a final_review human action");
273
+ const revSession = listWorkerSessionsForIssue(issueId).find((s) => s.role === "reviewer");
274
+ assert.equal(revSession.status, "done");
275
+ const kinds = listArtifactsForIssue(issueId).map((a) => a.kind);
276
+ assert.ok(kinds.includes("reviewer_transcript"));
277
+ const published = listArtifactsForIssue(issueId).find((a) => a.kind === "review_published");
278
+ assert.ok(published);
279
+ assert.equal(JSON.parse(published.contentJson).event, "APPROVE");
280
+ const usage = listUsageEventsForIssue(issueId).filter((u) => u.role === "reviewer");
281
+ assert.equal(usage.length, 1);
282
+ assert.equal(usage[0].workerSessionId, revSession.id);
283
+ });
284
+ test("same-identity fallback: GitHub rejecting APPROVE as a self-review still records the internal verdict and moves to final_review", async () => {
285
+ const issueId = await makeIssue();
286
+ const github = fakeGithub({ rejectSelfReview: true });
287
+ await advanceToReviewing(issueId, github);
288
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
289
+ await pump(1);
290
+ assert.equal(getIssue(issueId).status, "final_review", "the internal verdict is the workflow authority, not the GitHub event");
291
+ const published = listArtifactsForIssue(issueId).find((a) => a.kind === "review_published");
292
+ assert.equal(JSON.parse(published.contentJson).event, "COMMENT");
293
+ assert.equal(JSON.parse(published.contentJson).usedCommentFallback, true);
294
+ });
295
+ test("session_failed: a verdict reporting SHAs that don't match the coordinator-verified revision is rejected, not accepted", async () => {
296
+ const issueId = await makeIssue({ maxInfraAttempts: 0 });
297
+ const github = fakeGithub();
298
+ await advanceToReviewing(issueId, github);
299
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: wrongShaSpawn, github }));
300
+ await pump(1);
301
+ const issue = getIssue(issueId);
302
+ assert.equal(issue.status, "needs_human", "a wrong-SHA verdict must never reach final_review");
303
+ assert.equal(issue.currentRound, 1, "a rejected verdict never consumes a round");
304
+ assert.equal(github.publishCallCount(), 0, "a rejected verdict is never published");
305
+ });
306
+ test("session_failed: bounded infra retry re-queues a fresh reviewer session at the SAME pinned head before any escalation", async () => {
307
+ const issueId = await makeIssue(); // default maxInfraAttempts: 3
308
+ const github = fakeGithub();
309
+ await advanceToReviewing(issueId, github);
310
+ const before = getIssue(issueId);
311
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: garbageSpawn, github }));
312
+ await pump(1);
313
+ const issue = getIssue(issueId);
314
+ assert.equal(issue.status, "reviewing", "one infra failure with attempts left retries, it does not escalate");
315
+ assert.equal(issue.infraAttempts, 1);
316
+ assert.equal(issue.currentRound, before.currentRound, "an infra retry never spends a review round");
317
+ assert.equal(issue.headSha, before.headSha, "the retry is pinned to the SAME head, not a new one");
318
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer" && i.status === "pending").length, 1);
319
+ // Cost is incurred the moment the process runs — recorded even though the transcript
320
+ // could not be parsed as a verdict (see reviewer-effect.ts: recorded before that check).
321
+ assert.equal(listUsageEventsForIssue(issueId).filter((u) => u.role === "reviewer").length, 1);
322
+ });
323
+ test("deck_failure: reviewer preflight preserves and routes the failing deck bind reason", async () => {
324
+ const deckId = "11111111-1111-4111-a111-111111111111";
325
+ const issueId = await makeIssue({
326
+ reviewerDeck: { deckId },
327
+ });
328
+ const github = fakeGithub();
329
+ await advanceToReviewing(issueId, github);
330
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
331
+ const ctxWithLease = reviewerCtxFactory(issueId);
332
+ setWorkItemLease(workItem.id, "token-deck-failure");
333
+ let spawnCalled = false;
334
+ const outcome = await runReviewerEffect(ctxWithLease("token-deck-failure"), {
335
+ spawn: async (input) => {
336
+ spawnCalled = true;
337
+ return verdictSpawn({ verdict: "approved" })(input);
338
+ },
339
+ github,
340
+ deckCallTool: async () => ({
341
+ isError: true,
342
+ content: [{ type: "text", text: "deck bind refused" }],
343
+ }),
344
+ });
345
+ assert.equal(spawnCalled, false, "failed deck preflight must stop before reviewer spawn");
346
+ assert.equal(outcome.kind, "deck_failure");
347
+ if (outcome.kind === "deck_failure") {
348
+ assert.match(outcome.reason ?? "", /deck bind refused|get_bound_deck/);
349
+ const routed = routeReviewerOutcome(outcome, { currentRound: 1, maxReviewRounds: 3, infraAttempts: 0, maxInfraAttempts: 3 }, getIssue(issueId).headSha);
350
+ assert.equal(routed.next, "retry_reviewer");
351
+ if (routed.next === "retry_reviewer") {
352
+ assert.equal(routed.reason, "Agent Deck preflight failed: get_bound_deck returned an error: deck bind refused");
353
+ }
354
+ }
355
+ });
356
+ test("publish is idempotent: re-running the effect for the same PR/head (simulating a crash before the work item's completion CAS, then recovery) does not submit a second review", async () => {
357
+ const issueId = await makeIssue();
358
+ const github = fakeGithub();
359
+ await advanceToReviewing(issueId, github);
360
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
361
+ const ctxWithLease = reviewerCtxFactory(issueId);
362
+ // Realistic: the first attempt holds "token-1" while it actually runs; recovery only
363
+ // reclaims to "token-2" for the retry afterward (simulating the crash-and-recover).
364
+ setWorkItemLease(workItem.id, "token-1");
365
+ const first = await runReviewerEffect(ctxWithLease("token-1"), { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github });
366
+ setWorkItemLease(workItem.id, "token-2");
367
+ // Two genuinely independent reviewer sessions can disagree (they're two separate model
368
+ // calls over the same diff) — this is exactly what round 3 found unhandled: the loser
369
+ // must report what the winner actually published, not its own different verdict.
370
+ const second = await runReviewerEffect(ctxWithLease("token-2"), { deckCallTool: okDeckCallTool,
371
+ spawn: verdictSpawn({
372
+ verdict: "changes_requested",
373
+ findings: [{ fingerprint: "disagreement", severity: "blocking", title: "Second session found this", rationale: "..." }]
374
+ }),
375
+ github });
376
+ assert.equal(first.kind, "verdict");
377
+ assert.equal(second.kind, "verdict");
378
+ assert.equal(github.publishCallCount(), 1, "GitHub must only actually be mutated once");
379
+ if (first.kind === "verdict" && second.kind === "verdict") {
380
+ assert.equal(second.result.verdict, first.result.verdict, "the loser must report the winner's verdict, not its own conflicting one");
381
+ assert.deepEqual(second.result.findings, first.result.findings, "the loser must report the winner's findings, not its own");
382
+ }
383
+ });
384
+ test("publish claim: a zombie holding a stale lease token can never win the publication claim — only the current lease holder can", async () => {
385
+ const issueId = await makeIssue();
386
+ const github = fakeGithub();
387
+ await advanceToReviewing(issueId, github);
388
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
389
+ const ctxWithLease = reviewerCtxFactory(issueId);
390
+ // The zombie held "old-token" when its session started, but recovery has already
391
+ // reclaimed the lease to "new-token" (the current holder) by the time the zombie
392
+ // — delayed, e.g. a slow `gh` call earlier in its own flow — reaches the publish step.
393
+ setWorkItemLease(workItem.id, "old-token");
394
+ const zombieCtx = ctxWithLease("old-token");
395
+ setWorkItemLease(workItem.id, "new-token");
396
+ const zombieOutcome = await runReviewerEffect(zombieCtx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github });
397
+ assert.equal(zombieOutcome.kind, "publish_failed", "a zombie whose lease was already reclaimed must never win the claim");
398
+ assert.equal(github.publishCallCount(), 0, "the zombie must never actually call gh, no matter how the wall-clock timing falls");
399
+ const currentOutcome = await runReviewerEffect(ctxWithLease("new-token"), { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github });
400
+ assert.equal(currentOutcome.kind, "verdict", "the current lease holder — not the zombie — is the one that actually publishes");
401
+ assert.equal(github.publishCallCount(), 1);
402
+ });
403
+ test("publish claim: a lease that has expired but recovery hasn't swept yet is not \"live\" just because status still says leased", async () => {
404
+ const issueId = await makeIssue();
405
+ const github = fakeGithub();
406
+ await advanceToReviewing(issueId, github);
407
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
408
+ const ctxWithLease = reviewerCtxFactory(issueId);
409
+ // Same token, status still 'leased' — but lease_expires_at is already in the past.
410
+ // Recovery's sweep hasn't run yet, so nothing has moved this row off 'leased', but the
411
+ // lease itself is no longer actually held by anyone.
412
+ setWorkItemLease(workItem.id, "expired-token", -60_000);
413
+ const outcome = await runReviewerEffect(ctxWithLease("expired-token"), { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github });
414
+ assert.equal(outcome.kind, "publish_failed", "an expired lease must never be treated as live, even with a matching token and status");
415
+ assert.equal(github.publishCallCount(), 0, "an attempt resuming on an expired lease must never actually call gh");
416
+ });
417
+ test("publish claim: a stuck in-flight claim (prior holder never settles) is waited on, then given up on as publish_failed", async () => {
418
+ const issueId = await makeIssue();
419
+ const github = fakeGithub();
420
+ await advanceToReviewing(issueId, github);
421
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
422
+ const ctxWithLease = reviewerCtxFactory(issueId);
423
+ // Simulates a prior lease holder that claimed publication and then never recorded any
424
+ // outcome (crashed mid-flight, before ever calling `gh`) — the current holder must
425
+ // wait, not immediately assume failure, but ultimately escalate rather than guess.
426
+ setWorkItemLease(workItem.id, "old-token");
427
+ assert.equal(claimReviewPublication(workItem.id, "old-token"), true);
428
+ setWorkItemLease(workItem.id, "new-token");
429
+ const outcome = await runReviewerEffect(ctxWithLease("new-token"), { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github });
430
+ assert.equal(outcome.kind, "publish_failed", "a claim that never settles must escalate, never silently approve");
431
+ assert.equal(github.publishCallCount(), 0, "this attempt must never call gh while the claim is held elsewhere");
432
+ });
433
+ test("publish claim: a prior claimant that recorded failure is safely reclaimed by the current lease holder and the review is published normally", async () => {
434
+ const issueId = await makeIssue();
435
+ const github = fakeGithub();
436
+ await advanceToReviewing(issueId, github);
437
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
438
+ const ctxWithLease = reviewerCtxFactory(issueId);
439
+ // Simulates a prior lease holder that claimed publication and genuinely failed (its
440
+ // own `gh` call errored) — the current holder taking over the lease must be able to
441
+ // reclaim from `failed` and publish normally.
442
+ setWorkItemLease(workItem.id, "old-token");
443
+ assert.equal(claimReviewPublication(workItem.id, "old-token"), true);
444
+ recordReviewPublishFailed(workItem.id);
445
+ setWorkItemLease(workItem.id, "new-token");
446
+ const outcome = await runReviewerEffect(ctxWithLease("new-token"), { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github });
447
+ assert.equal(outcome.kind, "verdict", "reclaiming a failed prior claim must let publication proceed normally");
448
+ assert.equal(github.publishCallCount(), 1);
449
+ });
450
+ test("diff truncated: an oversized diff forces the verdict to escalate in code, regardless of what the reviewer reports", async () => {
451
+ const issueId = await makeIssue();
452
+ const github = fakeGithub();
453
+ // A single file whose diff alone exceeds prompts.ts's TOTAL_DIFF_LIMIT (300,000 chars).
454
+ const bigFileSpawn = async (input) => {
455
+ const lines = Array.from({ length: 20_000 }, (_, i) => `line ${i} of a very large generated file`);
456
+ fs.writeFileSync(path.join(input.cwd, "big.txt"), `${lines.join("\n")}\n`);
457
+ git(input.cwd, "add", ".");
458
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "add a big file");
459
+ return { exitCode: 0, transcript: "Implementation conclusion: added a big file.", logPath: "/dev/null", timedOut: false };
460
+ };
461
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: okDeckCallTool, spawn: bigFileSpawn, github }));
462
+ startWorkflow(issueId);
463
+ await pump(1);
464
+ assert.equal(getIssue(issueId).status, "reviewing", "test setup: developer stage did not reach reviewing");
465
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
466
+ await pump(1);
467
+ const issue = getIssue(issueId);
468
+ assert.equal(issue.status, "needs_human", "an oversized diff must never reach final_review, even though the reviewer reported approved");
469
+ assert.ok(listArtifactsForIssue(issueId).find((a) => a.kind === "diff_truncated_evidence"));
470
+ // The override still publishes — as a COMMENT (escalated's mapped event), never as the
471
+ // reviewer's actually-reported APPROVE.
472
+ const published = listArtifactsForIssue(issueId).find((a) => a.kind === "review_published");
473
+ assert.equal(JSON.parse(published.contentJson).event, "COMMENT");
474
+ });
475
+ test("changes_requested: findings thread onto the issue and a fresh developer repair round is queued", async () => {
476
+ const issueId = await makeIssue();
477
+ const github = fakeGithub();
478
+ await advanceToReviewing(issueId, github);
479
+ const finding = { fingerprint: "missing-test", severity: "blocking", title: "No test", rationale: "Add one." };
480
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "changes_requested", findings: [finding] }), github }));
481
+ await pump(1);
482
+ const issue = getIssue(issueId);
483
+ assert.equal(issue.status, "repairing");
484
+ assert.equal(issue.currentRound, 2);
485
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "developer" && i.round === 2).length, 1);
486
+ const findings = listFindingsForIssue(issueId);
487
+ assert.equal(findings.length, 1);
488
+ assert.equal(findings[0].fingerprint, "missing-test");
489
+ assert.equal(findings[0].status, "open");
490
+ });
491
+ test("escalated with a product scope question opens product_scope_decision", async () => {
492
+ const issueId = await makeIssue();
493
+ const github = fakeGithub();
494
+ await advanceToReviewing(issueId, github);
495
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "escalated", productScopeQuestion: "Should this support X?" }), github }));
496
+ await pump(1);
497
+ const issue = getIssue(issueId);
498
+ assert.equal(issue.status, "needs_human");
499
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "product_scope_decision");
500
+ assert.ok(action);
501
+ assert.equal(action.reason, "Should this support X?");
502
+ });
503
+ test("escalated with no product scope question opens policy_escalation", async () => {
504
+ const issueId = await makeIssue();
505
+ const github = fakeGithub();
506
+ await advanceToReviewing(issueId, github);
507
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "escalated" }), github }));
508
+ await pump(1);
509
+ const issue = getIssue(issueId);
510
+ assert.equal(issue.status, "needs_human");
511
+ assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
512
+ });
513
+ test("stale: a head that moved since the reviewer was queued is re-reviewed at the new head, not silently published against the old one", async () => {
514
+ const issueId = await makeIssue();
515
+ const github = fakeGithub();
516
+ await advanceToReviewing(issueId, github);
517
+ const branch = issueBranchName(issueId);
518
+ // Simulate a race: another push lands on the branch after this reviewer item was
519
+ // enqueued (pinned to the old head) but before its session runs.
520
+ const other = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-reveff-race-"));
521
+ execFileSync("git", ["clone", "-q", remote, other]);
522
+ git(other, "checkout", "-q", branch);
523
+ git(other, "config", "user.email", "test@example.com");
524
+ git(other, "config", "user.name", "Test");
525
+ fs.writeFileSync(path.join(other, "race.txt"), "x");
526
+ git(other, "add", ".");
527
+ git(other, "-c", "user.email=race@test", "-c", "user.name=Race", "commit", "-q", "-m", "concurrent push");
528
+ git(other, "push", "-q", "origin", branch);
529
+ const newHead = git(other, "rev-parse", "HEAD");
530
+ fs.rmSync(other, { recursive: true, force: true });
531
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
532
+ await pump(1);
533
+ const issue = getIssue(issueId);
534
+ assert.equal(issue.status, "reviewing", "a stale re-review stays in reviewing, not final_review");
535
+ assert.equal(issue.headSha, newHead);
536
+ const items = listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer");
537
+ assert.equal(items.filter((i) => i.status === "pending").length, 1, "a fresh reviewer item is queued at the new head");
538
+ assert.ok(listArtifactsForIssue(issueId).find((a) => a.kind === "stale_review_evidence"));
539
+ });
540
+ test("session_failed: an unparseable transcript escalates without consuming a round", async () => {
541
+ const issueId = await makeIssue({ maxInfraAttempts: 0 });
542
+ const github = fakeGithub();
543
+ await advanceToReviewing(issueId, github);
544
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: garbageSpawn, github }));
545
+ await pump(1);
546
+ const issue = getIssue(issueId);
547
+ assert.equal(issue.status, "needs_human");
548
+ assert.equal(issue.currentRound, 1, "a failed reviewer session never consumes a round");
549
+ assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
550
+ const rev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "reviewer");
551
+ assert.equal(rev.status, "failed");
552
+ });
553
+ test("session_failed: the reviewer process exits non-zero", async () => {
554
+ const issueId = await makeIssue({ maxInfraAttempts: 0 });
555
+ const github = fakeGithub();
556
+ await advanceToReviewing(issueId, github);
557
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: crashingReviewerSpawn, github }));
558
+ await pump(1);
559
+ assert.equal(getIssue(issueId).status, "needs_human");
560
+ });
561
+ test("session_failed: the reviewer's own wall-clock timeout is folded into session_failed (no separate timed_out kind for reviewers)", async () => {
562
+ const issueId = await makeIssue({ maxInfraAttempts: 0 });
563
+ const github = fakeGithub();
564
+ await advanceToReviewing(issueId, github);
565
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: timedOutReviewerSpawn, github }));
566
+ await pump(1);
567
+ const rev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "reviewer");
568
+ assert.equal(rev.status, "failed");
569
+ assert.equal(getIssue(issueId).status, "needs_human");
570
+ });
571
+ test("publish_failed: gh pr review itself fails — escalates as infrastructure, not a code finding", async () => {
572
+ const issueId = await makeIssue({ maxInfraAttempts: 0 });
573
+ const github = fakeGithub({ publishFails: true });
574
+ await advanceToReviewing(issueId, github);
575
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
576
+ await pump(1);
577
+ const issue = getIssue(issueId);
578
+ assert.equal(issue.status, "needs_human");
579
+ assert.equal(issue.currentRound, 1, "a publish failure never consumes a round");
580
+ assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "policy_escalation"));
581
+ });
582
+ test("publish_failed: the PR cannot be re-verified after the reviewer session ends", async () => {
583
+ const issueId = await makeIssue({ maxInfraAttempts: 0 });
584
+ const github = fakeGithub();
585
+ await advanceToReviewing(issueId, github);
586
+ github.viewPr = async () => null;
587
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { deckCallTool: okDeckCallTool, spawn: verdictSpawn({ verdict: "approved" }), github }));
588
+ await pump(1);
589
+ assert.equal(getIssue(issueId).status, "needs_human");
590
+ });
591
+ test("NOT-83 review: a reviewer item cancelled during worktree/deck-bind setup (before spawn) is never spawned", async () => {
592
+ const issueId = await makeIssue();
593
+ const github = fakeGithub();
594
+ await advanceToReviewing(issueId, github);
595
+ const workItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.status === "pending");
596
+ // Captured before cancelling — the factory's own internal lookup requires `status ===
597
+ // "pending"`, which cancelWorkItem below no longer satisfies.
598
+ const ctxWithLease = reviewerCtxFactory(issueId);
599
+ setWorkItemLease(workItem.id, "token-1");
600
+ // Simulates an abort landing while this handler is still inside worktree/deck-bind setup
601
+ // (reviewer-effect.ts's guard runs after that, right before spawn) — cancelWorkItem is
602
+ // exactly what abortIssue (commands.ts) calls in that scenario.
603
+ assert.ok(cancelWorkItem(workItem.id), "expected the leased item to still be cancellable");
604
+ let spawnCalled = false;
605
+ const spySpawn = async (input) => {
606
+ spawnCalled = true;
607
+ return verdictSpawn({ verdict: "approved" })(input);
608
+ };
609
+ const outcome = await runReviewerEffect(ctxWithLease("token-1"), { deckCallTool: okDeckCallTool, spawn: spySpawn, github });
610
+ assert.equal(spawnCalled, false, "a cancelled item must never reach the real spawn");
611
+ assert.deepEqual(outcome, { kind: "session_failed" });
612
+ });
@@ -0,0 +1,38 @@
1
+ import { z } from "zod";
2
+ export const ReviewerVerdict = z.enum(["approved", "changes_requested", "escalated"]);
3
+ export const ReviewerFinding = z.object({
4
+ fingerprint: z.string(),
5
+ severity: z.enum(["blocking", "non_blocking"]),
6
+ title: z.string(),
7
+ rationale: z.string(),
8
+ file: z.string().optional(),
9
+ line: z.number().int().optional(),
10
+ });
11
+ export const ReviewerResult = z.object({
12
+ verdict: ReviewerVerdict,
13
+ baseSha: z.string(),
14
+ headSha: z.string(),
15
+ acceptanceCriteriaAssessment: z.string(),
16
+ evidenceAssessment: z.string(),
17
+ findings: z.array(ReviewerFinding),
18
+ risks: z.array(z.string()),
19
+ /** Present only when verdict is "escalated" and the reviewer identifies a missing product call. */
20
+ productScopeQuestion: z.string().optional(),
21
+ });
22
+ /** Mirrors the plan-triage/reflect JSON-fence parsing pattern already used elsewhere. */
23
+ export function parseReviewerResult(text) {
24
+ const trimmed = text.trim();
25
+ const fenceMatch = trimmed.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
26
+ const candidates = [fenceMatch?.[1] ?? trimmed, trimmed];
27
+ for (const candidate of candidates) {
28
+ try {
29
+ const parsed = ReviewerResult.safeParse(JSON.parse(candidate));
30
+ if (parsed.success)
31
+ return parsed.data;
32
+ }
33
+ catch {
34
+ // try next candidate
35
+ }
36
+ }
37
+ return null;
38
+ }