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,234 @@
1
+ // packages/server/src/coordinator/repair-cycle.integration.test.ts
2
+ //
3
+ // NOT-63 acceptance test: the ticket's required end-to-end scenario — developer round 1
4
+ // → reviewer changes_requested → developer round 2 on the SAME branch → reviewer
5
+ // approved — driven through the real coordinator (startWorkflow → tick → routing → next
6
+ // effect) against a real Git repository (temp repo + bare "origin" remote) and real
7
+ // worktrees. Only the agent CLI session and GitHub publication are faked (the confirmed
8
+ // NOT-61/62 scope: no paid CLI spawn, no real GitHub calls) — everything else (git,
9
+ // worktree lifecycle, PR/SHA verification, findings persistence, budget bookkeeping) is
10
+ // exercised for real.
11
+ import { test, before, beforeEach, after } from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { execFileSync } from "node:child_process";
14
+ import fs from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-repair-home-"));
18
+ process.env.MAX_COORDINATOR_CONCURRENCY = "2";
19
+ process.env.COORDINATOR_HEARTBEAT_MS = "20";
20
+ process.env.COORDINATOR_FAIL_BACKOFF_MS = "0";
21
+ process.env.CHECKS_POLL_TIMEOUT_MS = "60";
22
+ process.env.CHECKS_POLL_INTERVAL_MS = "10";
23
+ process.env.REVIEWER_TIMEOUT_MS = "5000";
24
+ process.env.REVIEWER_PUBLISH_WAIT_ATTEMPTS = "3";
25
+ process.env.REVIEWER_PUBLISH_WAIT_INTERVAL_MS = "10";
26
+ const { migrate, getDb } = await import("../db/index.js");
27
+ const { createAgent } = await import("../repository/agents.js");
28
+ const { createIssue, getIssue } = await import("../repository/issues.js");
29
+ const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
30
+ const { listWorkItemsForIssue } = await import("../repository/work-items.js");
31
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
32
+ const { listFindingsForIssue } = await import("../repository/findings.js");
33
+ const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
34
+ const { startWorkflow } = await import("./commands.js");
35
+ const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
36
+ const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js");
37
+ const { runDeveloperEffect } = await import("./developer-effect.js");
38
+ const { runReviewerEffect } = await import("./reviewer-effect.js");
39
+ const { realDeveloperSpawn, realReviewerSpawn } = await import("./spawn.js");
40
+ const { realGithubAdapter } = await import("../adapters/github.js");
41
+ before(() => migrate());
42
+ beforeEach(() => getDb().exec("DELETE FROM review_publications; DELETE FROM work_items;"));
43
+ after(() => resetEffectHandlers());
44
+ /** NOT-149: agents always carry a deckId; hermetic effect tests stub get_bound_deck. */
45
+ const TEST_DECK_ID = "00000000-0000-4000-a000-000000000099";
46
+ const okDeckCallTool = async (_name, _args) => ({
47
+ content: [{ type: "text", text: JSON.stringify({ id: TEST_DECK_ID, name: "test-deck" }) }],
48
+ });
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-repair-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-repair-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
+ async function makeIssue() {
75
+ const dev = createAgent({ name: `dev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
76
+ const rev = createAgent({ name: `rev-${Math.random()}`, runtime: "claude_code", deckId: "00000000-0000-4000-a000-000000000099" });
77
+ return createIssue({
78
+ title: "Add widget",
79
+ description: "Build the widget.",
80
+ acceptanceCriteria: "Widget renders.",
81
+ repo,
82
+ baseBranch: "main",
83
+ developerAgentId: dev.id,
84
+ reviewerAgentId: rev.id,
85
+ maxReviewRounds: 3,
86
+ maxInfraAttempts: 3,
87
+ source: "manual"
88
+ }).id;
89
+ }
90
+ async function pump(max = 20) {
91
+ for (let i = 0; i < max; i++) {
92
+ const started = await runCoordinatorTick({ leaseOwner: "pump" });
93
+ await drainCoordinator();
94
+ if (started === 0)
95
+ return;
96
+ }
97
+ }
98
+ /** Round 1: implements the widget for real (a genuine first commit). */
99
+ const round1Spawn = async (input) => {
100
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented v1\n");
101
+ git(input.cwd, "add", ".");
102
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "implement widget");
103
+ return { exitCode: 0, transcript: "Implementation conclusion: added the widget.", logPath: "/dev/null", timedOut: false };
104
+ };
105
+ /** Round 2: a genuinely different commit addressing the round-1 finding — proves the
106
+ * retry actually re-ran the agent rather than replaying the same diff. */
107
+ const round2Spawn = async (input) => {
108
+ fs.writeFileSync(path.join(input.cwd, "feature.txt"), "implemented v1\nadded a test\n");
109
+ git(input.cwd, "add", ".");
110
+ git(input.cwd, "-c", "user.email=agent@test", "-c", "user.name=Agent", "commit", "-q", "-m", "add the missing test");
111
+ return { exitCode: 0, transcript: "Implementation conclusion: added the missing test the reviewer asked for.", logPath: "/dev/null", timedOut: false };
112
+ };
113
+ function roundAwareDeveloperSpawn() {
114
+ let calls = 0;
115
+ return async (input) => {
116
+ calls++;
117
+ return calls === 1 ? round1Spawn(input) : round2Spawn(input);
118
+ };
119
+ }
120
+ const ROUND1_FINDING = { fingerprint: "missing-test", severity: "blocking", title: "No test for the widget", rationale: "Add a test." };
121
+ function reviewerTranscript(verdict, baseSha, headSha) {
122
+ const body = {
123
+ verdict,
124
+ baseSha,
125
+ headSha,
126
+ acceptanceCriteriaAssessment: verdict === "approved" ? "Met." : "Not yet — missing test coverage.",
127
+ evidenceAssessment: "Evidence checked.",
128
+ findings: verdict === "changes_requested" ? [ROUND1_FINDING] : [],
129
+ risks: []
130
+ };
131
+ return `\`\`\`json\n${JSON.stringify(body)}\n\`\`\`\n`;
132
+ }
133
+ function shasFromPrompt(prompt) {
134
+ const baseSha = prompt.match(/"baseSha" to exactly "([0-9a-f]+)"/)?.[1];
135
+ const headSha = prompt.match(/"headSha" to exactly "([0-9a-f]+)"/)?.[1];
136
+ if (!baseSha || !headSha)
137
+ throw new Error("could not extract SHAs from reviewer prompt");
138
+ return { baseSha, headSha };
139
+ }
140
+ /** Round 1 requests changes; round 2 approves — the ticket's exact required scenario. */
141
+ function roundAwareReviewerSpawn() {
142
+ let calls = 0;
143
+ return async (input) => {
144
+ calls++;
145
+ const { baseSha, headSha } = shasFromPrompt(input.prompt);
146
+ const verdict = calls === 1 ? "changes_requested" : "approved";
147
+ return { exitCode: 0, transcript: reviewerTranscript(verdict, baseSha, headSha), logPath: "/dev/null", timedOut: false };
148
+ };
149
+ }
150
+ /** In-memory PR store — real git tells it the branch/head, nothing hits real GitHub. */
151
+ function fakeGithub() {
152
+ const prsByBranch = new Map();
153
+ const branchByNumber = new Map();
154
+ let nextNumber = 200;
155
+ const currentBranch = (cwd) => git(cwd, "rev-parse", "--abbrev-ref", "HEAD");
156
+ const remoteHead = (branch) => git(remote, "rev-parse", branch);
157
+ const adapter = {
158
+ async viewPr({ cwd, number }) {
159
+ const branch = number != null ? branchByNumber.get(number) : currentBranch(cwd);
160
+ if (!branch)
161
+ return null;
162
+ const pr = prsByBranch.get(branch);
163
+ if (!pr)
164
+ return null;
165
+ return { number: pr.number, url: pr.url, baseRefName: pr.base, headRefName: branch, headRefOid: remoteHead(branch), isDraft: true };
166
+ },
167
+ async createDraftPr({ cwd, base }) {
168
+ const branch = currentBranch(cwd);
169
+ const number = nextNumber++;
170
+ const url = `https://github.com/o/r/pull/${number}`;
171
+ prsByBranch.set(branch, { number, url, base });
172
+ branchByNumber.set(number, branch);
173
+ return { ok: true, number, url };
174
+ },
175
+ async checksSnapshot() {
176
+ return "success";
177
+ },
178
+ async publishReview({ event }) {
179
+ const finalEvent = event;
180
+ return { ok: true, event: finalEvent, usedCommentFallback: false };
181
+ }
182
+ };
183
+ return adapter;
184
+ }
185
+ test("developer round 1 → reviewer changes_requested → developer round 2 on the same branch → reviewer approved", async () => {
186
+ const issueId = await makeIssue();
187
+ const github = fakeGithub();
188
+ // Each spawn's call-counter must persist ACROSS attempts — build it once, outside the
189
+ // handler closure, or every invocation would reset to round 1's behavior.
190
+ const devSpawn = roundAwareDeveloperSpawn();
191
+ const revSpawn = roundAwareReviewerSpawn();
192
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { spawn: devSpawn, github, deckCallTool: okDeckCallTool }));
193
+ registerEffectHandler("reviewer", (ctx) => runReviewerEffect(ctx, { spawn: revSpawn, github, deckCallTool: okDeckCallTool }));
194
+ startWorkflow(issueId);
195
+ await pump();
196
+ const issue = getIssue(issueId);
197
+ assert.equal(issue.status, "final_review", "the cycle must end at a human final_review, not stuck or escalated");
198
+ assert.equal(issue.currentRound, 2, "exactly one genuine repair round — changes_requested — was spent");
199
+ assert.equal(issue.infraAttempts, 0, "nothing in this run was an infra failure");
200
+ assert.ok(listHumanActionsForIssue(issueId).find((a) => a.actionType === "final_review" && a.status === "open"));
201
+ // Same branch reused across both rounds — never re-created.
202
+ const branch = issueBranchName(issueId);
203
+ assert.equal(issue.branch, branch);
204
+ // Two real developer sessions and two real reviewer sessions, one per round.
205
+ const devSessions = listWorkerSessionsForIssue(issueId).filter((s) => s.role === "developer");
206
+ const revSessions = listWorkerSessionsForIssue(issueId).filter((s) => s.role === "reviewer");
207
+ assert.equal(devSessions.length, 2);
208
+ assert.equal(revSessions.length, 2);
209
+ assert.ok(devSessions.every((s) => s.status === "done"));
210
+ assert.ok(revSessions.every((s) => s.status === "done"));
211
+ // Round 2's reviewer was pinned to round 2's real, freshly-verified head — never the
212
+ // round-1 head (no retry path used a stale SHA).
213
+ const round1DevWorkItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "developer" && i.round === 1);
214
+ const round2DevWorkItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "developer" && i.round === 2);
215
+ const round2ReviewerWorkItem = listWorkItemsForIssue(issueId).find((i) => i.kind === "reviewer" && i.round === 2);
216
+ const round1HeadSha = (round1DevWorkItem.resultJson && JSON.parse(round1DevWorkItem.resultJson).headSha);
217
+ const round2HeadSha = (round2DevWorkItem.resultJson && JSON.parse(round2DevWorkItem.resultJson).headSha);
218
+ assert.notEqual(round1HeadSha, round2HeadSha, "round 2 must produce a genuinely new head");
219
+ assert.equal(JSON.parse(round2ReviewerWorkItem.payloadJson).inputSha, round2HeadSha);
220
+ assert.equal(issue.headSha, round2HeadSha);
221
+ // Real git: the branch on the bare remote actually carries both commits.
222
+ const log = git(remote, "log", "--oneline", branch);
223
+ assert.equal(log.split("\n").length, 3, "init + round-1 commit + round-2 commit");
224
+ // Round 1's finding is threaded onto the issue and reached round 2's developer prompt.
225
+ const findings = listFindingsForIssue(issueId);
226
+ assert.equal(findings.length, 1);
227
+ assert.equal(findings[0].fingerprint, ROUND1_FINDING.fingerprint);
228
+ // Invariant: at every intermediate step the issue had a next effect (never neither a
229
+ // pending work item nor an open human action) — reconstructed from the event log.
230
+ const events = listWorkflowEventsForIssue(issueId).map((e) => e.type);
231
+ assert.ok(events.includes("repair.started"), "the changes_requested round transition was recorded");
232
+ assert.ok(events.includes("review.submitted"));
233
+ assert.ok(events.includes("final_review.requested"));
234
+ });
@@ -0,0 +1,363 @@
1
+ // packages/server/src/coordinator/restart-recovery.integration.test.ts
2
+ //
3
+ // NOT-131: a coordinator RESTART must not kill live workers.
4
+ //
5
+ // NOT-124 made lease expiry a liveness check, but scoped the evidence to the process that
6
+ // produced it — so every restart (`tsx watch` reload, crash, operator restart, deploy)
7
+ // regenerated COORDINATOR_PROCESS_OWNER, every in-flight session read "unknown", and the
8
+ // guard could never fire. Observed 2026-09-16: four developer attempts in 22 minutes, each
9
+ // failed "presumed dead" by a different coordinator owner, each leaving its CLI running —
10
+ // at peak three `claude -p` processes implementing one ticket in three worktrees. One was
11
+ // failed 13 seconds after an operator restart, heartbeating up to the moment the old
12
+ // process went away.
13
+ //
14
+ // These are that run's acceptance scenarios. The restart is simulated the way it actually
15
+ // happens: the session row carries a process_owner from a coordinator that is gone, while
16
+ // the child it names is still running.
17
+ import { test, before, beforeEach, after } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { spawn } from "node:child_process";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-restart-"));
24
+ process.env.COORDINATOR_FAIL_BACKOFF_MS = "0";
25
+ process.env.COORDINATOR_LEASE_MS = "60000";
26
+ const { migrate, getDb } = await import("../db/index.js");
27
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
28
+ const { createIssue } = await import("../repository/issues.js");
29
+ const { createWorkerSession, startSession, recordSessionProcess, listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
30
+ const { getWorkItem, listWorkItemsForIssue, claimWorkItem, bindWorkItemSession } = await import("../repository/work-items.js");
31
+ const { startWorkflow } = await import("./commands.js");
32
+ const { recoverCoordinator } = await import("./recovery.js");
33
+ const { COORDINATOR_PROCESS_OWNER, inspectWorkerProcess, processLiveness, readProcessStartTime, terminateWorkerProcess } = await import("./process-liveness.js");
34
+ const LEASE_MS = 60_000;
35
+ /** A clock far enough past any lease that timestamp-only recovery would always reclaim. */
36
+ const LONG_AFTER = () => Date.now() + 3_600_000;
37
+ /**
38
+ * A process_owner naming a coordinator that no longer exists, on THIS host — exactly what a
39
+ * session row holds a moment after a restart. The pid it names is irrelevant; what matters
40
+ * is that it is not COORDINATOR_PROCESS_OWNER.
41
+ */
42
+ const DEAD_COORDINATOR = `${os.hostname()}:999999:0badc0de`;
43
+ /** The other machine case, where no local probe is meaningful at all. */
44
+ const OTHER_HOST = "some-other-host:999:abcd1234";
45
+ before(() => migrate());
46
+ beforeEach(() => {
47
+ getDb().exec("DELETE FROM work_items");
48
+ });
49
+ const spawnedChildren = [];
50
+ /** A genuinely alive, same-user process standing in for a spawned agent CLI. */
51
+ function spawnLiveChild() {
52
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
53
+ const pid = child.pid;
54
+ const startTime = readProcessStartTime(pid);
55
+ assert.ok(startTime, "ps must report a start time for a process we just spawned");
56
+ const live = {
57
+ pid,
58
+ startTime,
59
+ exited: new Promise((resolve) => child.once("exit", () => resolve("exited"))),
60
+ kill: () => {
61
+ try {
62
+ child.kill("SIGKILL");
63
+ }
64
+ catch {
65
+ /* already gone */
66
+ }
67
+ }
68
+ };
69
+ spawnedChildren.push(live);
70
+ return live;
71
+ }
72
+ /** Resolves "exited" or "still-running" — never hangs the runner on a process that lives. */
73
+ function exitedWithin(child, ms) {
74
+ return Promise.race([
75
+ child.exited,
76
+ new Promise((resolve) => {
77
+ const t = setTimeout(() => resolve("still-running"), ms);
78
+ t.unref?.();
79
+ }),
80
+ ]);
81
+ }
82
+ after(async () => {
83
+ for (const child of spawnedChildren)
84
+ child.kill();
85
+ await Promise.all(spawnedChildren.map((c) => exitedWithin(c, 2_000)));
86
+ });
87
+ /** A pid that certainly no longer exists: spawn a process, then wait for it to exit. */
88
+ async function deadPid() {
89
+ const child = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
90
+ const pid = child.pid;
91
+ const startTime = readProcessStartTime(pid) ?? "Thu Jan 1 00:00:00 1970";
92
+ await new Promise((resolve) => child.on("close", () => resolve()));
93
+ return { pid, startTime };
94
+ }
95
+ function newIssue() {
96
+ return createIssue({
97
+ title: "Survive the restart",
98
+ acceptanceCriteria: "It survives",
99
+ repo: "acme/app",
100
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
101
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
102
+ baseBranch: "main",
103
+ maxReviewRounds: 3,
104
+ maxInfraAttempts: 3,
105
+ source: "manual"
106
+ }).id;
107
+ }
108
+ /** A leased developer work item with a running session bound to it, as a live attempt has. */
109
+ function leasedAttempt(leaseMs = LEASE_MS) {
110
+ const issueId = newIssue();
111
+ startWorkflow(issueId);
112
+ const item = listWorkItemsForIssue(issueId).find((i) => i.kind === "developer");
113
+ const claimed = claimWorkItem("loop-test", { leaseMs });
114
+ const session = createWorkerSession({
115
+ issueId,
116
+ role: "developer",
117
+ round: 1,
118
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
119
+ runtime: null
120
+ });
121
+ startSession(session.id);
122
+ assert.equal(bindWorkItemSession(item.id, session.id, claimed.leaseToken), true);
123
+ return { issueId, itemId: item.id, sessionId: session.id };
124
+ }
125
+ // ------------------------------------------------- the ticket's headline acceptance case
126
+ test("NOT-131: a restart does not reclaim a session whose CLI is still running", async () => {
127
+ const { itemId, sessionId, issueId } = leasedAttempt();
128
+ const child = spawnLiveChild();
129
+ // The restart: the row names a coordinator that is gone, the child it names is not.
130
+ recordSessionProcess(sessionId, child.pid, DEAD_COORDINATOR, child.startTime);
131
+ const res = await recoverCoordinator({ now: LONG_AFTER(), clockJump: null });
132
+ assert.deepEqual(res.reclaimed, [], "the successor must not presume a live worker dead");
133
+ assert.deepEqual(res.deadLettered, []);
134
+ assert.deepEqual(res.heldAlive, [itemId]);
135
+ // No duplicate spawn: the item never returns to the pool a dispatcher claims from.
136
+ assert.equal(getWorkItem(itemId).status, "leased");
137
+ assert.equal(listWorkerSessionsForIssue(issueId)[0].status, "running");
138
+ assert.equal(await exitedWithin(child, 200), "still-running", "and the CLI is left alone");
139
+ });
140
+ test("NOT-131: the restarted coordinator extends the lease, so the item stops re-raising", async () => {
141
+ const { itemId, sessionId } = leasedAttempt(0);
142
+ const child = spawnLiveChild();
143
+ const before = getWorkItem(itemId).leaseExpiresAt;
144
+ recordSessionProcess(sessionId, child.pid, DEAD_COORDINATOR, child.startTime);
145
+ const first = await recoverCoordinator({ now: Date.now() + 1_000, clockJump: null });
146
+ assert.deepEqual(first.heldAlive, [itemId]);
147
+ assert.ok(Date.parse(getWorkItem(itemId).leaseExpiresAt) > Date.parse(before));
148
+ const second = await recoverCoordinator({ now: Date.now() + 1_000, clockJump: null });
149
+ assert.deepEqual(second.heldAlive, [], "no longer an expired lease at all");
150
+ assert.deepEqual(second.reclaimed, []);
151
+ });
152
+ // ------------------------------------------------------------- NOT-116 must not regress
153
+ test("NOT-116: a restart still reclaims a lease whose CLI really exited", async () => {
154
+ const { itemId, sessionId, issueId } = leasedAttempt();
155
+ const { pid, startTime } = await deadPid();
156
+ recordSessionProcess(sessionId, pid, DEAD_COORDINATOR, startTime);
157
+ const res = await recoverCoordinator({ now: LONG_AFTER(), clockJump: null });
158
+ assert.deepEqual(res.reclaimed, [itemId], "a genuinely dead worker is still reclaimed");
159
+ assert.deepEqual(res.heldAlive, []);
160
+ assert.equal(getWorkItem(itemId).status, "pending");
161
+ assert.match(listWorkerSessionsForIssue(issueId)[0].errorJson ?? "", /presumed dead/);
162
+ });
163
+ test("NOT-131: a recycled pid is not mistaken for the worker that recorded it", async () => {
164
+ // The hazard the owner-scoping existed to prevent, and the reason a bare pid is not
165
+ // portable evidence: this pid is alive, but it is not the process the session spawned.
166
+ const { itemId, sessionId } = leasedAttempt();
167
+ const stranger = spawnLiveChild();
168
+ recordSessionProcess(sessionId, stranger.pid, DEAD_COORDINATOR, "Thu Jan 1 00:00:00 1970");
169
+ const res = await recoverCoordinator({ now: LONG_AFTER(), clockJump: null });
170
+ assert.deepEqual(res.reclaimed, [itemId], "a start-time mismatch is positive evidence of death");
171
+ assert.deepEqual(res.heldAlive, []);
172
+ assert.equal(await exitedWithin(stranger, 200), "still-running", "and the unrelated program that inherited the pid is never signalled");
173
+ });
174
+ // --------------------------------------------------- the cases that stay "unknown" (AC 2)
175
+ test("NOT-131: a pre-upgrade row is never read as alive, and never signalled", async () => {
176
+ // A session in flight across the upgrade: NOT-124 wrote a pid and an owner, the
177
+ // start-time column is NULL. Reading that as evidence would be strictly wrong in both
178
+ // directions — so it degrades to timestamp-only, and the pid is left untouched because
179
+ // nothing here identifies it.
180
+ const { itemId, sessionId } = leasedAttempt();
181
+ const child = spawnLiveChild();
182
+ recordSessionProcess(sessionId, child.pid, DEAD_COORDINATOR, null);
183
+ assert.equal(processLiveness(child.pid, DEAD_COORDINATOR, null), "unknown");
184
+ const res = await recoverCoordinator({ now: LONG_AFTER(), clockJump: null });
185
+ assert.deepEqual(res.reclaimed, [itemId]);
186
+ assert.deepEqual(res.unverifiedOrphans, [itemId], "reported, not silently assumed clean");
187
+ assert.equal(await exitedWithin(child, 200), "still-running", "an unidentifiable pid is not killed");
188
+ });
189
+ test("NOT-131: another host's pid is neither probed nor signalled", async () => {
190
+ const { itemId, sessionId } = leasedAttempt();
191
+ const child = spawnLiveChild();
192
+ recordSessionProcess(sessionId, child.pid, OTHER_HOST, child.startTime);
193
+ assert.equal(processLiveness(child.pid, OTHER_HOST, child.startTime), "unknown");
194
+ const res = await recoverCoordinator({ now: LONG_AFTER(), clockJump: null });
195
+ assert.deepEqual(res.reclaimed, [itemId]);
196
+ assert.deepEqual(res.unverifiedOrphans, [itemId]);
197
+ assert.equal(await exitedWithin(child, 200), "still-running");
198
+ });
199
+ // ------------------------------------------- the bound that keeps "alive" from stalling
200
+ test("NOT-131: an alive worker held past its ceiling is killed, then reclaimed", async () => {
201
+ // Without this bound, making the verdict survive restarts trades a destructive reclaim
202
+ // for a permanent stall: the CLI's own timeout lived in the process that died, so a hung
203
+ // worker would re-extend its lease on every tick forever.
204
+ const { itemId, sessionId, issueId } = leasedAttempt();
205
+ const child = spawnLiveChild();
206
+ recordSessionProcess(sessionId, child.pid, DEAD_COORDINATOR, child.startTime);
207
+ const prev = process.env.COORDINATOR_MAX_ALIVE_HOLD_MS;
208
+ process.env.COORDINATOR_MAX_ALIVE_HOLD_MS = "1";
209
+ try {
210
+ const res = await recoverCoordinator({ now: Date.now() + 60_000, clockJump: null });
211
+ assert.deepEqual(res.heldAliveExpired, [itemId]);
212
+ assert.deepEqual(res.heldAlive, [], "the ceiling wins over the liveness hold");
213
+ assert.deepEqual(res.reclaimed, [itemId]);
214
+ assert.deepEqual(res.unverifiedOrphans, [], "a verified pid is confirmed stopped");
215
+ assert.equal(getWorkItem(itemId).status, "pending");
216
+ assert.match(listWorkerSessionsForIssue(issueId)[0].errorJson ?? "", /presumed dead/);
217
+ }
218
+ finally {
219
+ if (prev === undefined)
220
+ delete process.env.COORDINATOR_MAX_ALIVE_HOLD_MS;
221
+ else
222
+ process.env.COORDINATOR_MAX_ALIVE_HOLD_MS = prev;
223
+ }
224
+ // AC 2: never two live agents on one issue. The predecessor is gone BEFORE the reclaimed
225
+ // item is available for a successor to claim.
226
+ assert.equal(await exitedWithin(child, 5_000), "exited");
227
+ });
228
+ test("NOT-131: a sleep grace still protects a lease the ceiling would otherwise reclaim", async () => {
229
+ // A host that slept burned the session's wall-clock budget without the CLI running for
230
+ // any of it, so the ceiling can fire on a perfectly healthy run. NOT-125's window must
231
+ // still win, and the CLI must survive it.
232
+ const { itemId, sessionId } = leasedAttempt();
233
+ const child = spawnLiveChild();
234
+ recordSessionProcess(sessionId, child.pid, DEAD_COORDINATOR, child.startTime);
235
+ const prev = process.env.COORDINATOR_MAX_ALIVE_HOLD_MS;
236
+ process.env.COORDINATOR_MAX_ALIVE_HOLD_MS = "1";
237
+ try {
238
+ const now = Date.now() + 60_000;
239
+ const res = await recoverCoordinator({
240
+ now,
241
+ clockJump: { detectedAt: now, graceUntil: now + 60_000, wallGapMs: 0, unelapsedMs: 0, unobservedMs: 0 }
242
+ });
243
+ assert.deepEqual(res.reclaimed, []);
244
+ assert.deepEqual(res.heldAliveExpired, [], "held, so not reported as reclaimed either");
245
+ assert.deepEqual(res.heldAcrossClockJump, [itemId]);
246
+ }
247
+ finally {
248
+ if (prev === undefined)
249
+ delete process.env.COORDINATOR_MAX_ALIVE_HOLD_MS;
250
+ else
251
+ process.env.COORDINATOR_MAX_ALIVE_HOLD_MS = prev;
252
+ }
253
+ assert.equal(await exitedWithin(child, 200), "still-running");
254
+ });
255
+ // ------------------------------------------------------------------------ unit coverage
256
+ test("readProcessStartTime identifies a process and distinguishes the next one", async () => {
257
+ const child = spawnLiveChild();
258
+ assert.equal(readProcessStartTime(child.pid), child.startTime, "stable for one process");
259
+ const gone = await deadPid();
260
+ assert.equal(readProcessStartTime(gone.pid), null, "null once the process is gone");
261
+ assert.equal(readProcessStartTime(0), null);
262
+ assert.equal(readProcessStartTime(null), null);
263
+ });
264
+ test("PR #50 [P1]: a current-owner pid is corroborated too, not trusted on the owner alone", () => {
265
+ // `COORDINATOR_PROCESS_OWNER` proves which coordinator wrote the row, not that the pid
266
+ // still belongs to the child it named. This module keeps no `ChildProcess` handle, so a
267
+ // child that exited while its row stayed `running` can have its pid recycled under a
268
+ // coordinator that is still alive — and before this, that recycled stranger read "alive"
269
+ // AND was authorized for a SIGKILL purely because the owner string matched.
270
+ const child = spawnLiveChild();
271
+ const recycled = inspectWorkerProcess(child.pid, COORDINATOR_PROCESS_OWNER, "Thu Jan 1 00:00:00 1970");
272
+ assert.equal(recycled.verdict, "dead", "a start-time mismatch is not our worker");
273
+ assert.equal(recycled.signalable, false, "and must never authorize a kill");
274
+ const real = inspectWorkerProcess(child.pid, COORDINATOR_PROCESS_OWNER, child.startTime);
275
+ assert.equal(real.verdict, "alive");
276
+ assert.equal(real.signalable, true);
277
+ });
278
+ test("a current-owner pid with no recorded start time keeps NOT-124's verdict, without the kill", () => {
279
+ // `ps` can fail at spawn time, leaving the column null. The verdict must stay "alive" —
280
+ // downgrading it would re-expose the sleeping-laptop teardown NOT-124 fixed — but with
281
+ // nothing to corroborate, signalling is still refused.
282
+ const child = spawnLiveChild();
283
+ const check = inspectWorkerProcess(child.pid, COORDINATOR_PROCESS_OWNER, null);
284
+ assert.equal(check.verdict, "alive");
285
+ assert.equal(check.signalable, false);
286
+ assert.equal(processLiveness(process.pid, COORDINATOR_PROCESS_OWNER), "alive");
287
+ });
288
+ /**
289
+ * A `ps` that fails its FIRST invocation and then works normally — the transient failure
290
+ * that made the old two-probe design kill a healthy worker. Prepended to PATH so
291
+ * `readProcessStartTime`'s `execFileSync("ps", ...)` resolves to it.
292
+ */
293
+ function installFlakyPs() {
294
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-flaky-ps-"));
295
+ const state = path.join(dir, "used");
296
+ fs.writeFileSync(path.join(dir, "ps"), `#!/bin/sh\nif [ ! -f "${state}" ]; then : > "${state}"; exit 1; fi\nexec /bin/ps "$@"\n`, { mode: 0o755 });
297
+ const realPath = process.env.PATH;
298
+ process.env.PATH = `${dir}:${realPath ?? ""}`;
299
+ return { dir, restore: () => { process.env.PATH = realPath; } };
300
+ }
301
+ test("PR #50 [P1]: one transient ps failure must not turn into a kill", async () => {
302
+ // The exact interleaving the review found. The verdict and the kill authorization used to
303
+ // be two SEPARATE `ps` calls: the first failed, producing verdict "unknown" (hold nothing,
304
+ // fall through to reclaim), then the second succeeded, matched the recorded start time,
305
+ // and authorized SIGTERM/SIGKILL against a perfectly healthy worker — recreating the
306
+ // restart failure this whole change exists to prevent, from a single flaky probe.
307
+ //
308
+ // With one observation serving both answers, "unknown" and "signalable" cannot co-occur.
309
+ const { itemId, sessionId } = leasedAttempt();
310
+ const child = spawnLiveChild();
311
+ recordSessionProcess(sessionId, child.pid, DEAD_COORDINATOR, child.startTime);
312
+ const ps = installFlakyPs();
313
+ let res;
314
+ try {
315
+ res = await recoverCoordinator({ now: LONG_AFTER(), clockJump: null });
316
+ assert.equal(await exitedWithin(child, 200), "still-running", "a healthy worker must survive a flaky ps");
317
+ // The fake is spent, so `ps` works again — proving the outage really was transient, and
318
+ // that the run above had a *second*, successful call available to it had it asked. That
319
+ // second call is precisely what used to authorize the kill.
320
+ assert.equal(readProcessStartTime(child.pid), child.startTime);
321
+ }
322
+ finally {
323
+ ps.restore();
324
+ }
325
+ // Reclaiming on the timestamp alone is the correct fallback for an inconclusive probe —
326
+ // killing is not.
327
+ assert.deepEqual(res.reclaimed, [itemId]);
328
+ assert.deepEqual(res.unverifiedOrphans, [itemId], "surfaced, since the orphan may survive");
329
+ });
330
+ test("a host with no usable ps reaches no verdict, and signals nothing", async () => {
331
+ // The permanent version (BusyBox has no `lstart`): every cross-owner pid degrades to the
332
+ // timestamp-only behaviour NOT-116 shipped, rather than every live worker being reclaimed.
333
+ const child = spawnLiveChild();
334
+ const emptyBin = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-no-ps-"));
335
+ const realPath = process.env.PATH;
336
+ process.env.PATH = emptyBin;
337
+ try {
338
+ assert.equal(readProcessStartTime(child.pid), null, "ps is genuinely unavailable here");
339
+ const foreign = inspectWorkerProcess(child.pid, DEAD_COORDINATOR, child.startTime);
340
+ assert.equal(foreign.verdict, "unknown");
341
+ assert.equal(foreign.signalable, false);
342
+ // Our own child still holds — a `ps` outage must not re-expose the NOT-124 teardown.
343
+ const ours = inspectWorkerProcess(child.pid, COORDINATOR_PROCESS_OWNER, child.startTime);
344
+ assert.equal(ours.verdict, "alive");
345
+ assert.equal(ours.signalable, false, "but still nothing to corroborate a kill with");
346
+ }
347
+ finally {
348
+ process.env.PATH = realPath;
349
+ }
350
+ });
351
+ test("terminateWorkerProcess declines a pid the caller could not identify", async () => {
352
+ const child = spawnLiveChild();
353
+ assert.equal(await terminateWorkerProcess(child.pid, false), "unverified");
354
+ assert.equal(await exitedWithin(child, 200), "still-running");
355
+ });
356
+ test("terminateWorkerProcess stops an identified process and reports an already-dead one", async () => {
357
+ const child = spawnLiveChild();
358
+ assert.equal(await terminateWorkerProcess(child.pid, true), "stopped");
359
+ assert.equal(await exitedWithin(child, 5_000), "exited");
360
+ const gone = await deadPid();
361
+ assert.equal(await terminateWorkerProcess(gone.pid, true), "stopped");
362
+ assert.equal(await terminateWorkerProcess(null, true), "stopped");
363
+ });