agent-dealer 0.3.0 → 1.0.1

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 (264) 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 +215 -45
  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 +179 -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 +10 -0
  25. package/bundle/server/dist/coordinator/abort.integration.test.js +74 -0
  26. package/bundle/server/dist/coordinator/admission.js +330 -0
  27. package/bundle/server/dist/coordinator/admission.test.js +338 -0
  28. package/bundle/server/dist/coordinator/args.js +128 -0
  29. package/bundle/server/dist/coordinator/args.test.js +132 -0
  30. package/bundle/server/dist/coordinator/authority-recovery.integration.test.js +661 -0
  31. package/bundle/server/dist/coordinator/auto-merge.integration.test.js +414 -0
  32. package/bundle/server/dist/coordinator/auto-merge.js +354 -0
  33. package/bundle/server/dist/coordinator/auto-merge.timeout.test.js +56 -0
  34. package/bundle/server/dist/coordinator/branch-progress.js +81 -0
  35. package/bundle/server/dist/coordinator/clock-jump.js +98 -0
  36. package/bundle/server/dist/coordinator/commands.js +1094 -0
  37. package/bundle/server/dist/coordinator/commands.test.js +571 -0
  38. package/bundle/server/dist/coordinator/deck-outage-config.js +32 -0
  39. package/bundle/server/dist/coordinator/deck-outage.integration.test.js +200 -0
  40. package/bundle/server/dist/coordinator/dependencies.js +242 -0
  41. package/bundle/server/dist/coordinator/dependency-readiness.integration.test.js +535 -0
  42. package/bundle/server/dist/coordinator/developer-effect.js +959 -0
  43. package/bundle/server/dist/coordinator/developer-effect.test.js +1143 -0
  44. package/bundle/server/dist/coordinator/dispatcher.js +58 -0
  45. package/bundle/server/dist/coordinator/dispatcher.test.js +81 -0
  46. package/bundle/server/dist/coordinator/effect-registry.js +17 -0
  47. package/bundle/server/dist/coordinator/execution-env.integration.test.js +122 -0
  48. package/bundle/server/dist/coordinator/failure-reason.js +273 -0
  49. package/bundle/server/dist/coordinator/failure-reason.test.js +234 -0
  50. package/bundle/server/dist/coordinator/guidance.js +33 -0
  51. package/bundle/server/dist/coordinator/guidance.test.js +110 -0
  52. package/bundle/server/dist/coordinator/human-resolution.js +91 -0
  53. package/bundle/server/dist/coordinator/human-resolution.test.js +84 -0
  54. package/bundle/server/dist/coordinator/intent-forecast.js +13 -0
  55. package/bundle/server/dist/coordinator/intent-forecast.test.js +34 -0
  56. package/bundle/server/dist/coordinator/latest-failure.js +100 -0
  57. package/bundle/server/dist/coordinator/latest-failure.test.js +125 -0
  58. package/bundle/server/dist/coordinator/metrics.js +35 -0
  59. package/bundle/server/dist/coordinator/metrics.test.js +52 -0
  60. package/bundle/server/dist/coordinator/permissions.js +127 -0
  61. package/bundle/server/dist/coordinator/permissions.test.js +184 -0
  62. package/bundle/server/dist/coordinator/presumed-dead-budget.integration.test.js +146 -0
  63. package/bundle/server/dist/coordinator/process-liveness.js +191 -0
  64. package/bundle/server/dist/coordinator/profile-snapshot.js +20 -0
  65. package/bundle/server/dist/coordinator/profile-snapshot.test.js +118 -0
  66. package/bundle/server/dist/coordinator/projection.js +147 -0
  67. package/bundle/server/dist/coordinator/projection.test.js +121 -0
  68. package/bundle/server/dist/coordinator/prompts.js +172 -0
  69. package/bundle/server/dist/coordinator/prompts.test.js +198 -0
  70. package/bundle/server/dist/coordinator/queue-by-default.integration.test.js +271 -0
  71. package/bundle/server/dist/coordinator/reclaim-republish.integration.test.js +372 -0
  72. package/bundle/server/dist/coordinator/recovery.js +466 -0
  73. package/bundle/server/dist/coordinator/recovery.test.js +164 -0
  74. package/bundle/server/dist/coordinator/reflect-trigger.js +218 -0
  75. package/bundle/server/dist/coordinator/reflect-trigger.test.js +324 -0
  76. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +234 -0
  77. package/bundle/server/dist/coordinator/restart-recovery.integration.test.js +363 -0
  78. package/bundle/server/dist/coordinator/reviewer-effect.js +552 -0
  79. package/bundle/server/dist/coordinator/reviewer-effect.test.js +670 -0
  80. package/bundle/server/dist/coordinator/reviewer-result.js +86 -0
  81. package/bundle/server/dist/coordinator/reviewer-result.test.js +83 -0
  82. package/bundle/server/dist/coordinator/routing.js +171 -0
  83. package/bundle/server/dist/coordinator/routing.test.js +351 -0
  84. package/bundle/server/dist/coordinator/session-lifecycle.integration.test.js +127 -0
  85. package/bundle/server/dist/coordinator/session-lifecycle.js +280 -0
  86. package/bundle/server/dist/coordinator/session-lifecycle.test.js +121 -0
  87. package/bundle/server/dist/coordinator/session-progress.js +351 -0
  88. package/bundle/server/dist/coordinator/session-progress.test.js +190 -0
  89. package/bundle/server/dist/coordinator/session-timeouts.js +33 -0
  90. package/bundle/server/dist/coordinator/sleep-recovery.integration.test.js +233 -0
  91. package/bundle/server/dist/coordinator/spawn.js +49 -0
  92. package/bundle/server/dist/coordinator/sync-issue-base-branch.js +18 -0
  93. package/bundle/server/dist/coordinator/usage-cap-config.js +10 -0
  94. package/bundle/server/dist/coordinator/usage-cap-defer.js +189 -0
  95. package/bundle/server/dist/coordinator/usage-cap.integration.test.js +193 -0
  96. package/bundle/server/dist/coordinator/usage.js +40 -0
  97. package/bundle/server/dist/coordinator/usage.test.js +69 -0
  98. package/bundle/server/dist/coordinator/verification-receipt.js +333 -0
  99. package/bundle/server/dist/coordinator/verification-receipt.test.js +382 -0
  100. package/bundle/server/dist/coordinator/worker-loop.js +363 -0
  101. package/bundle/server/dist/coordinator/worker-loop.test.js +329 -0
  102. package/bundle/server/dist/coordinator/workflow-instances.test.js +45 -0
  103. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +82 -0
  104. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +199 -0
  105. package/bundle/server/dist/db/artifacts-issue-columns.test.js +18 -0
  106. package/bundle/server/dist/db/authority-attempts-stale-at-migration.test.js +65 -0
  107. package/bundle/server/dist/db/idempotency-index-migration.test.js +57 -0
  108. package/bundle/server/dist/db/index.js +357 -6
  109. package/bundle/server/dist/db/linear-inbox-unlock.test.js +67 -0
  110. package/bundle/server/dist/db/migrate-to-issues.js +824 -0
  111. package/bundle/server/dist/db/migrate-to-issues.test.js +843 -0
  112. package/bundle/server/dist/db/schema.sql +311 -1
  113. package/bundle/server/dist/db/schema.test.js +66 -0
  114. package/bundle/server/dist/db/worker-session-pid-migration.test.js +112 -0
  115. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +339 -0
  116. package/bundle/server/dist/direct-start-liveness.integration.test.js +350 -0
  117. package/bundle/server/dist/index.js +58 -5
  118. package/bundle/server/dist/paths.js +40 -4
  119. package/bundle/server/dist/paths.test.js +42 -0
  120. package/bundle/server/dist/pid-marker-claim-child.js +18 -0
  121. package/bundle/server/dist/pid-marker-hold-arbiter-child.js +24 -0
  122. package/bundle/server/dist/pid-marker.js +141 -0
  123. package/bundle/server/dist/pid-marker.test.js +208 -0
  124. package/bundle/server/dist/queue/approve-deliver.js +128 -28
  125. package/bundle/server/dist/queue/reflect-schedule.js +25 -0
  126. package/bundle/server/dist/queue/send-gate.test.js +172 -36
  127. package/bundle/server/dist/repository/agents.js +55 -18
  128. package/bundle/server/dist/repository/artifacts-for-issue.js +42 -0
  129. package/bundle/server/dist/repository/artifacts-for-issue.test.js +48 -0
  130. package/bundle/server/dist/repository/artifacts.js +47 -0
  131. package/bundle/server/dist/repository/artifacts.test.js +48 -0
  132. package/bundle/server/dist/repository/authority-attempts.js +189 -0
  133. package/bundle/server/dist/repository/findings.js +70 -0
  134. package/bundle/server/dist/repository/findings.test.js +68 -0
  135. package/bundle/server/dist/repository/human-actions.js +142 -0
  136. package/bundle/server/dist/repository/human-actions.test.js +123 -0
  137. package/bundle/server/dist/repository/intake-settings.js +5 -59
  138. package/bundle/server/dist/repository/issues.js +305 -0
  139. package/bundle/server/dist/repository/issues.test.js +69 -0
  140. package/bundle/server/dist/repository/outbound-drafts.js +28 -0
  141. package/bundle/server/dist/repository/queue-entries.js +162 -0
  142. package/bundle/server/dist/repository/review-publications.js +153 -0
  143. package/bundle/server/dist/repository/review-publications.test.js +78 -0
  144. package/bundle/server/dist/repository/runs-external-id.test.js +5 -3
  145. package/bundle/server/dist/repository/runs.js +4 -211
  146. package/bundle/server/dist/repository/runtime-availability.js +54 -0
  147. package/bundle/server/dist/repository/usage-events.js +60 -0
  148. package/bundle/server/dist/repository/usage-events.test.js +56 -0
  149. package/bundle/server/dist/repository/work-items.js +305 -0
  150. package/bundle/server/dist/repository/work-items.test.js +142 -0
  151. package/bundle/server/dist/repository/worker-sessions.js +219 -0
  152. package/bundle/server/dist/repository/worker-sessions.test.js +60 -0
  153. package/bundle/server/dist/repository/workflow-events.js +193 -0
  154. package/bundle/server/dist/repository/workflow-events.test.js +89 -0
  155. package/bundle/server/dist/resolve-tsx-bin.js +19 -0
  156. package/bundle/server/dist/routes/human-actions.js +68 -0
  157. package/bundle/server/dist/routes/human-actions.test.js +286 -0
  158. package/bundle/server/dist/routes/index.js +29 -496
  159. package/bundle/server/dist/routes/issues.js +240 -0
  160. package/bundle/server/dist/routes/issues.test.js +451 -0
  161. package/bundle/server/dist/routes/queue.js +41 -0
  162. package/bundle/server/dist/routes/queue.test.js +78 -0
  163. package/bundle/server/dist/runners/claude-args.js +18 -30
  164. package/bundle/server/dist/runners/claude.js +19 -82
  165. package/bundle/server/dist/runners/claude.test.js +15 -70
  166. package/bundle/server/dist/runners/process-registry.js +11 -0
  167. package/bundle/server/dist/runners/prompts.js +13 -280
  168. package/bundle/server/dist/runners/prompts.test.js +22 -153
  169. package/bundle/server/dist/runners/reflect.js +4 -16
  170. package/bundle/server/dist/runners/spawn-cli.js +116 -9
  171. package/bundle/server/dist/runners/spawn-cli.test.js +130 -0
  172. package/bundle/server/dist/runners/stream-json.js +0 -141
  173. package/bundle/server/dist/runners/usage-cap.js +242 -0
  174. package/bundle/server/dist/runners/usage-cap.test.js +97 -0
  175. package/bundle/server/dist/server-liveness.js +37 -0
  176. package/bundle/server/dist/server-liveness.test.js +82 -0
  177. package/bundle/server/dist/static-ui.test.js +44 -0
  178. package/bundle/server/package.json +4 -2
  179. package/bundle/server/static-ui/assets/index-BTtPGYpY.js +60 -0
  180. package/bundle/server/static-ui/assets/index-BxTx4b1d.css +1 -0
  181. package/bundle/server/static-ui/index.html +2 -2
  182. package/bundle/shared/dist/agents.d.ts +254 -126
  183. package/bundle/shared/dist/agents.js +84 -19
  184. package/bundle/shared/dist/cursor-auth-health.d.ts +12 -0
  185. package/bundle/shared/dist/cursor-auth-health.js +36 -0
  186. package/bundle/shared/dist/cursor-auth-health.test.d.ts +1 -0
  187. package/bundle/shared/dist/cursor-auth-health.test.js +40 -0
  188. package/bundle/shared/dist/findings.d.ts +46 -0
  189. package/bundle/shared/dist/findings.js +17 -0
  190. package/bundle/shared/dist/findings.test.d.ts +1 -0
  191. package/bundle/shared/dist/findings.test.js +20 -0
  192. package/bundle/shared/dist/github-repo.d.ts +28 -0
  193. package/bundle/shared/dist/github-repo.js +98 -0
  194. package/bundle/shared/dist/github-repo.test.d.ts +1 -0
  195. package/bundle/shared/dist/github-repo.test.js +32 -0
  196. package/bundle/shared/dist/human-actions.d.ts +66 -0
  197. package/bundle/shared/dist/human-actions.js +51 -0
  198. package/bundle/shared/dist/human-actions.test.d.ts +1 -0
  199. package/bundle/shared/dist/human-actions.test.js +57 -0
  200. package/bundle/shared/dist/index.d.ts +488 -678
  201. package/bundle/shared/dist/index.js +22 -36
  202. package/bundle/shared/dist/issues.d.ts +385 -0
  203. package/bundle/shared/dist/issues.js +169 -0
  204. package/bundle/shared/dist/issues.test.d.ts +1 -0
  205. package/bundle/shared/dist/issues.test.js +72 -0
  206. package/bundle/shared/dist/outbound-draft.d.ts +6 -0
  207. package/bundle/shared/dist/outbound-draft.js +4 -0
  208. package/bundle/shared/dist/plan-triage.d.ts +6 -6
  209. package/bundle/shared/dist/profile-snapshot.d.ts +154 -0
  210. package/bundle/shared/dist/profile-snapshot.js +145 -0
  211. package/bundle/shared/dist/profile-snapshot.test.d.ts +1 -0
  212. package/bundle/shared/dist/profile-snapshot.test.js +67 -0
  213. package/bundle/shared/dist/queue-entries.d.ts +89 -0
  214. package/bundle/shared/dist/queue-entries.js +19 -0
  215. package/bundle/shared/dist/result-qa.d.ts +2 -2
  216. package/bundle/shared/dist/runtime-auth-health.d.ts +66 -0
  217. package/bundle/shared/dist/runtime-auth-health.js +186 -0
  218. package/bundle/shared/dist/runtime-auth-health.test.d.ts +1 -0
  219. package/bundle/shared/dist/runtime-auth-health.test.js +200 -0
  220. package/bundle/shared/dist/runtime-availability.d.ts +51 -0
  221. package/bundle/shared/dist/runtime-availability.js +13 -0
  222. package/bundle/shared/dist/usage-events.d.ts +36 -0
  223. package/bundle/shared/dist/usage-events.js +13 -0
  224. package/bundle/shared/dist/worker-sessions.d.ts +140 -0
  225. package/bundle/shared/dist/worker-sessions.js +70 -0
  226. package/bundle/shared/dist/worker-sessions.test.d.ts +1 -0
  227. package/bundle/shared/dist/worker-sessions.test.js +39 -0
  228. package/bundle/shared/dist/workflow.d.ts +78 -0
  229. package/bundle/shared/dist/workflow.js +59 -0
  230. package/bundle/shared/dist/workflow.test.d.ts +1 -0
  231. package/bundle/shared/dist/workflow.test.js +61 -0
  232. package/bundle/shared/package.json +1 -1
  233. package/dist/action.d.ts +1 -0
  234. package/dist/action.js +50 -0
  235. package/dist/action.test.d.ts +1 -0
  236. package/dist/action.test.js +70 -0
  237. package/dist/agent.d.ts +1 -0
  238. package/dist/agent.js +17 -0
  239. package/dist/agent.test.d.ts +1 -0
  240. package/dist/agent.test.js +19 -0
  241. package/dist/cli-check.d.ts +7 -0
  242. package/dist/cli-check.js +40 -0
  243. package/dist/doctor.js +59 -1
  244. package/dist/http.d.ts +5 -0
  245. package/dist/http.js +19 -0
  246. package/dist/index.js +28 -0
  247. package/dist/install.js +1 -1
  248. package/dist/issue.d.ts +35 -0
  249. package/dist/issue.js +143 -0
  250. package/dist/issue.test.d.ts +1 -0
  251. package/dist/issue.test.js +240 -0
  252. package/dist/lifecycle.contract.test.d.ts +1 -0
  253. package/dist/lifecycle.contract.test.js +135 -0
  254. package/dist/queue.d.ts +11 -0
  255. package/dist/queue.js +60 -0
  256. package/dist/queue.test.d.ts +1 -0
  257. package/dist/queue.test.js +46 -0
  258. package/dist/setup.js +5 -1
  259. package/dist/test-fetch-stub.d.ts +6 -0
  260. package/dist/test-fetch-stub.js +31 -0
  261. package/package.json +2 -1
  262. package/templates/prod.env.example +1 -1
  263. package/bundle/server/static-ui/assets/index-DTcianAH.js +0 -79
  264. package/bundle/server/static-ui/assets/index-QYv3zmE6.css +0 -1
@@ -0,0 +1,329 @@
1
+ // packages/server/src/coordinator/worker-loop.test.ts
2
+ //
3
+ // End-to-end kernel behaviour with fake effect handlers — the ticket's acceptance
4
+ // scenarios: crash before effect, crash after effect / duplicate completion, lease expiry,
5
+ // restart recovery, and duplicate dispatch.
6
+ import { test, before, beforeEach, afterEach } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-loop-"));
12
+ process.env.MAX_COORDINATOR_CONCURRENCY = "4";
13
+ process.env.COORDINATOR_HEARTBEAT_MS = "20";
14
+ process.env.COORDINATOR_FAIL_BACKOFF_MS = "0";
15
+ const { migrate, getDb } = await import("../db/index.js");
16
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
17
+ const { createIssue, getIssue } = await import("../repository/issues.js");
18
+ const { getActiveWorkflowInstance, listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
19
+ const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
20
+ const { listWorkerSessionsForIssue, createWorkerSession, startSession } = await import("../repository/worker-sessions.js");
21
+ const { listWorkItemsForIssue, claimWorkItem, getWorkItem, bindWorkItemSession } = await import("../repository/work-items.js");
22
+ const { startWorkflow, applyCompletion, resolveHumanActionAndAdvanceAsync } = await import("./commands.js");
23
+ const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
24
+ const { runCoordinatorTick, drainCoordinator, activeAttemptCount, startCoordinatorLoop, stopCoordinatorLoop } = await import("./worker-loop.js");
25
+ const { recoverCoordinator } = await import("./recovery.js");
26
+ const { ReviewerResult } = await import("./reviewer-result.js");
27
+ const { setMergePrForTests, clearFinalizeInflightForTests } = await import("./auto-merge.js");
28
+ const { stubManagedCloneForTests } = await import("../adapters/managed-repo.js");
29
+ before(() => migrate());
30
+ // claimWorkItem / recovery scan the whole table (one loop in production); start each
31
+ // case from an empty queue so a prior test's un-processed item is never claimed here.
32
+ beforeEach(() => {
33
+ getDb().exec("DELETE FROM work_items");
34
+ clearFinalizeInflightForTests();
35
+ setMergePrForTests(async () => ({ ok: true }));
36
+ stubManagedCloneForTests("acme/app");
37
+ });
38
+ afterEach(() => resetEffectHandlers());
39
+ function newIssue(maxReviewRounds = 3) {
40
+ return createIssue({
41
+ title: "Loop me",
42
+ acceptanceCriteria: "It works",
43
+ repo: "acme/app",
44
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
45
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
46
+ baseBranch: "main",
47
+ maxReviewRounds,
48
+ maxInfraAttempts: 3,
49
+ source: "manual"
50
+ }).id;
51
+ }
52
+ const cleanHandoff = (headSha = "head1") => ({
53
+ kind: "clean_handoff",
54
+ branch: "issue-1",
55
+ headSha,
56
+ baseSha: "base1",
57
+ prNumber: 7,
58
+ prUrl: "https://gh/pr/7"
59
+ });
60
+ const approvedVerdict = {
61
+ kind: "verdict",
62
+ result: ReviewerResult.parse({
63
+ verdict: "approved",
64
+ baseSha: "base1",
65
+ headSha: "head1",
66
+ acceptanceCriteriaAssessment: "ok",
67
+ evidenceAssessment: "ok",
68
+ findings: [],
69
+ risks: []
70
+ })
71
+ };
72
+ /** Runs ticks until nothing is claimable and all in-flight work has drained. */
73
+ async function pump(max = 20) {
74
+ for (let i = 0; i < max; i++) {
75
+ const started = await runCoordinatorTick({ leaseOwner: "pump" });
76
+ await drainCoordinator();
77
+ if (started === 0)
78
+ return;
79
+ }
80
+ }
81
+ test("happy path: developer → reviewer(approved) → final_review, resolved complete → done", async () => {
82
+ const issueId = newIssue();
83
+ registerEffectHandler("developer", async () => cleanHandoff());
84
+ registerEffectHandler("reviewer", async () => approvedVerdict);
85
+ startWorkflow(issueId);
86
+ await pump();
87
+ assert.equal(getIssue(issueId).status, "final_review");
88
+ const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "final_review");
89
+ await resolveHumanActionAndAdvanceAsync(action.id, "yusuke", "complete");
90
+ assert.equal(getIssue(issueId).status, "done");
91
+ assert.equal(getActiveWorkflowInstance(issueId), null);
92
+ const roles = listWorkerSessionsForIssue(issueId).map((s) => s.role);
93
+ assert.deepEqual(roles, ["developer", "reviewer"]);
94
+ assert.ok(listWorkerSessionsForIssue(issueId).every((s) => s.status === "done"));
95
+ });
96
+ test("changes_requested drives an automatic repair round with no human involvement", async () => {
97
+ const issueId = newIssue(3);
98
+ let devCalls = 0;
99
+ registerEffectHandler("developer", async () => {
100
+ devCalls++;
101
+ return cleanHandoff(`head${devCalls}`);
102
+ });
103
+ registerEffectHandler("reviewer", async () => devCalls === 1
104
+ ? {
105
+ kind: "verdict",
106
+ result: ReviewerResult.parse({
107
+ verdict: "changes_requested",
108
+ baseSha: "b",
109
+ headSha: "head1",
110
+ acceptanceCriteriaAssessment: "partial",
111
+ evidenceAssessment: "ok",
112
+ findings: [{ fingerprint: "f1", severity: "blocking", title: "T", rationale: "R" }],
113
+ risks: []
114
+ })
115
+ }
116
+ : approvedVerdict);
117
+ startWorkflow(issueId);
118
+ await pump();
119
+ assert.equal(devCalls, 2);
120
+ assert.equal(getIssue(issueId).status, "final_review");
121
+ assert.equal(getIssue(issueId).currentRound, 2);
122
+ assert.equal(listHumanActionsForIssue(issueId).filter((a) => a.actionType !== "final_review").length, 0);
123
+ });
124
+ test("crash before effect: a leased item with no completion is recovered and advances once", async () => {
125
+ const issueId = newIssue();
126
+ startWorkflow(issueId);
127
+ const devItem = listWorkItemsForIssue(issueId)[0];
128
+ // Simulate the crash: claim the item and never complete it, bypassing the loop.
129
+ claimWorkItem("dead-worker", { leaseMs: 60_000 });
130
+ assert.equal(getWorkItem(devItem.id).status, "leased");
131
+ // Recovery requeues the orphaned lease once it has expired…
132
+ await recoverCoordinator({ now: Date.now() + 120_000 });
133
+ assert.equal(getWorkItem(devItem.id).status, "pending");
134
+ // …now a working handler picks it up and the issue advances exactly once.
135
+ registerEffectHandler("developer", async () => cleanHandoff());
136
+ registerEffectHandler("reviewer", async () => approvedVerdict);
137
+ await pump();
138
+ assert.equal(getIssue(issueId).status, "final_review");
139
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "developer").length, 1, "the recovered item was reused, not duplicated");
140
+ });
141
+ test("NOT-116: startup too early to reclaim → later poll tick reclaims without a second restart", async () => {
142
+ // Mid-run restart: leases still valid at boot, so recoverCoordinator() at startup is a
143
+ // no-op. Heartbeats are gone; once the lease expires the *next poll tick* must reclaim
144
+ // — not wait for another process restart.
145
+ const issueId = newIssue();
146
+ startWorkflow(issueId);
147
+ const devItem = listWorkItemsForIssue(issueId)[0];
148
+ const claimed = claimWorkItem("loop-old-pid", { leaseMs: 60_000 });
149
+ const session = createWorkerSession({
150
+ issueId,
151
+ role: "developer",
152
+ round: 1,
153
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
154
+ runtime: null
155
+ });
156
+ startSession(session.id);
157
+ assert.equal(bindWorkItemSession(devItem.id, session.id, claimed.leaseToken), true);
158
+ assert.equal(getWorkItem(devItem.id).status, "leased");
159
+ assert.equal(listWorkerSessionsForIssue(issueId)[0].status, "running");
160
+ // Restart recovery while the lease is still valid — no reclaim (the observed bug window).
161
+ await runCoordinatorTick({ leaseOwner: "loop-restart", now: Date.now() });
162
+ assert.equal(getWorkItem(devItem.id).status, "leased");
163
+ assert.equal(listWorkerSessionsForIssue(issueId)[0].status, "running");
164
+ // Later tick after lease expiry — reclaim via the poll path (manipulate clock; do not
165
+ // hang a fake handler to simulate the dead worker).
166
+ registerEffectHandler("developer", async () => cleanHandoff());
167
+ registerEffectHandler("reviewer", async () => approvedVerdict);
168
+ await runCoordinatorTick({ leaseOwner: "loop-later", now: Date.now() + 120_000 });
169
+ await drainCoordinator();
170
+ const orphan = listWorkerSessionsForIssue(issueId).find((s) => s.id === session.id);
171
+ assert.equal(orphan.status, "failed");
172
+ assert.match(orphan.errorJson ?? "", /presumed dead/);
173
+ // Reclaim requeued then this same tick claimed + completed the developer work item.
174
+ assert.equal(getWorkItem(devItem.id).status, "done");
175
+ assert.ok(listWorkerSessionsForIssue(issueId).some((s) => s.id !== session.id && s.role === "developer"), "a fresh developer session ran after reclaim");
176
+ });
177
+ test("crash after effect: a duplicate applyCompletion (same or stale token) is a no-op", async () => {
178
+ const issueId = newIssue();
179
+ const handoff = cleanHandoff();
180
+ startWorkflow(issueId);
181
+ const claimed = claimWorkItem("w1", { leaseMs: 60_000 });
182
+ const first = await applyCompletion(claimed.id, claimed.leaseToken, handoff);
183
+ assert.equal(first.applied, true);
184
+ // The worker "crashed" after committing but before recording success — it retries.
185
+ const second = await applyCompletion(claimed.id, claimed.leaseToken, handoff);
186
+ assert.equal(second.applied, false);
187
+ // Recovery also races an apply against the same (now stale) observed token.
188
+ const stale = await applyCompletion(claimed.id, claimed.leaseToken, { kind: "session_failed" });
189
+ assert.equal(stale.applied, false);
190
+ assert.equal(listWorkItemsForIssue(issueId).filter((i) => i.kind === "reviewer").length, 1);
191
+ assert.equal(listWorkflowEventsForIssue(issueId).filter((e) => e.type === "pull_request.opened").length, 1);
192
+ });
193
+ test("fail-after-done: a completeSession failure never revives a work item that already advanced", async () => {
194
+ // Regression for the reviewer's "failure path can undo a successful apply".
195
+ const issueId = newIssue();
196
+ startWorkflow(issueId);
197
+ const claimed = claimWorkItem("w1", { leaseMs: 60_000 });
198
+ await applyCompletion(claimed.id, claimed.leaseToken, cleanHandoff());
199
+ assert.equal(getWorkItem(claimed.id).status, "done");
200
+ // A late token-fenced failure attempt (what the old catch path would have done) is a no-op.
201
+ const { requeueWorkItem, finishWorkItem } = await import("../repository/work-items.js");
202
+ assert.equal(requeueWorkItem(claimed.id, claimed.leaseToken, { e: 1 }, { backoffMs: 0 }), false);
203
+ assert.equal(finishWorkItem(claimed.id, claimed.leaseToken, { status: "dead" }), null);
204
+ assert.equal(getWorkItem(claimed.id).status, "done");
205
+ });
206
+ test("lease expiry: a hung worker's late completion is dropped; the requeued item wins", async () => {
207
+ const issueId = newIssue();
208
+ registerEffectHandler("developer", async () => cleanHandoff());
209
+ registerEffectHandler("reviewer", async () => approvedVerdict);
210
+ startWorkflow(issueId);
211
+ const devItemId = listWorkItemsForIssue(issueId)[0].id;
212
+ // A hung worker: it holds the lease but its heartbeat stopped (process frozen).
213
+ const hung = claimWorkItem("hung", { leaseMs: 20 });
214
+ await new Promise((r) => setTimeout(r, 40));
215
+ // The periodic reclaim requeues the now-expired lease.
216
+ const res = await recoverCoordinator({ now: Date.now() });
217
+ assert.deepEqual(res.reclaimed, [devItemId]);
218
+ assert.equal(getWorkItem(devItemId).status, "pending");
219
+ // The hung worker finally wakes and reports success — but its token is dead: no-op.
220
+ const late = await applyCompletion(devItemId, hung.leaseToken, cleanHandoff());
221
+ assert.equal(late.applied, false);
222
+ // A fresh worker reprocesses the requeued item and drives the workflow forward.
223
+ await pump();
224
+ assert.equal(getIssue(issueId).status, "final_review");
225
+ });
226
+ test("duplicate dispatch: two racing ticks run one worker session, one advance", async () => {
227
+ const issueId = newIssue();
228
+ registerEffectHandler("developer", async () => {
229
+ await new Promise((r) => setTimeout(r, 15));
230
+ return cleanHandoff();
231
+ });
232
+ registerEffectHandler("reviewer", async () => approvedVerdict);
233
+ startWorkflow(issueId);
234
+ await Promise.all([
235
+ runCoordinatorTick({ leaseOwner: "A" }),
236
+ runCoordinatorTick({ leaseOwner: "B" }),
237
+ ]);
238
+ await drainCoordinator();
239
+ assert.equal(listWorkerSessionsForIssue(issueId).filter((s) => s.role === "developer").length, 1);
240
+ await pump();
241
+ assert.equal(getIssue(issueId).status, "final_review");
242
+ });
243
+ test("a recovery-requeued re-attempt is tracked separately from its zombie predecessor", async () => {
244
+ // Regression for "active.set(item.id) replaces the old promise" — key by lease token.
245
+ const prevHb = process.env.COORDINATOR_HEARTBEAT_MS;
246
+ const prevLease = process.env.COORDINATOR_LEASE_MS;
247
+ process.env.COORDINATOR_HEARTBEAT_MS = "100000"; // effectively never during the test
248
+ process.env.COORDINATOR_LEASE_MS = "10";
249
+ try {
250
+ const issueId = newIssue();
251
+ let calls = 0;
252
+ let releaseA = () => { };
253
+ const gateA = new Promise((r) => {
254
+ releaseA = r;
255
+ });
256
+ registerEffectHandler("developer", async () => {
257
+ calls++;
258
+ if (calls === 1)
259
+ await gateA;
260
+ return cleanHandoff(`h${calls}`);
261
+ });
262
+ registerEffectHandler("reviewer", async () => approvedVerdict);
263
+ startWorkflow(issueId);
264
+ await runCoordinatorTick({ leaseOwner: "A" }); // attempt A starts, hangs on gateA
265
+ assert.equal(activeAttemptCount(), 1);
266
+ await new Promise((r) => setTimeout(r, 25)); // the 10ms lease expires; no heartbeat fires
267
+ await recoverCoordinator({ now: Date.now() }); // requeues the item + fails A's session
268
+ const predecessorSessionId = listWorkerSessionsForIssue(issueId)[0].id;
269
+ assert.equal(listWorkerSessionsForIssue(issueId)[0].status, "failed");
270
+ await runCoordinatorTick({ leaseOwner: "B" }); // attempt B claims the requeued item
271
+ assert.equal(activeAttemptCount(), 2, "zombie A + re-attempt B tracked separately");
272
+ releaseA();
273
+ await drainCoordinator();
274
+ assert.equal(activeAttemptCount(), 0);
275
+ // A's late completion must NOT overwrite recovery's terminal evidence on its session.
276
+ const predecessor = listWorkerSessionsForIssue(issueId).find((s) => s.id === predecessorSessionId);
277
+ assert.equal(predecessor.status, "failed");
278
+ assert.match(predecessor.errorJson ?? "", /presumed dead/);
279
+ await pump(); // run the reviewer B enqueued
280
+ assert.equal(getIssue(issueId).status, "final_review");
281
+ }
282
+ finally {
283
+ restoreEnv("COORDINATOR_HEARTBEAT_MS", prevHb);
284
+ restoreEnv("COORDINATOR_LEASE_MS", prevLease);
285
+ }
286
+ });
287
+ function restoreEnv(key, prev) {
288
+ if (prev === undefined)
289
+ delete process.env[key];
290
+ else
291
+ process.env[key] = prev;
292
+ }
293
+ test("startCoordinatorLoop actually polls and drives an issue forward without any manual tick (NOT-65 wiring)", async () => {
294
+ const prevPoll = process.env.COORDINATOR_POLL_INTERVAL_MS;
295
+ process.env.COORDINATOR_POLL_INTERVAL_MS = "10";
296
+ try {
297
+ const issueId = newIssue();
298
+ registerEffectHandler("developer", async () => cleanHandoff());
299
+ registerEffectHandler("reviewer", async () => approvedVerdict);
300
+ startWorkflow(issueId);
301
+ startCoordinatorLoop();
302
+ try {
303
+ // Poll the DB (not runCoordinatorTick) — this is the same interval-driven loop
304
+ // index.ts wires into the real server, not a test harness calling the tick fn.
305
+ const deadline = Date.now() + 5000;
306
+ while (getIssue(issueId).status !== "final_review" && Date.now() < deadline) {
307
+ await new Promise((r) => setTimeout(r, 20));
308
+ }
309
+ }
310
+ finally {
311
+ stopCoordinatorLoop();
312
+ await drainCoordinator();
313
+ }
314
+ assert.equal(getIssue(issueId).status, "final_review");
315
+ }
316
+ finally {
317
+ restoreEnv("COORDINATOR_POLL_INTERVAL_MS", prevPoll);
318
+ }
319
+ });
320
+ test("placeholder handlers escalate rather than fabricating a PR", async () => {
321
+ const issueId = newIssue();
322
+ // no handlers registered → defaults return session_failed, an infra-class outcome
323
+ startWorkflow(issueId);
324
+ await pump();
325
+ // maxInfraAttempts 3 → session_failed retries until exhausted, then policy_escalation
326
+ // (never attempts_exhausted — that stays pure to a reviewer's changes_requested).
327
+ assert.equal(getIssue(issueId).status, "needs_human");
328
+ assert.equal(listHumanActionsForIssue(issueId).find((a) => a.status === "open").actionType, "policy_escalation");
329
+ });
@@ -0,0 +1,45 @@
1
+ // packages/server/src/coordinator/workflow-instances.test.ts
2
+ import { test, before } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wfinst-"));
8
+ const { migrate } = await import("../db/index.js");
9
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
10
+ const { createIssue } = await import("../repository/issues.js");
11
+ const { startWorkflowInstance, completeWorkflowInstance, getActiveWorkflowInstance, getWorkflowInstance, listWorkflowInstancesForIssue, WorkflowAlreadyActiveError } = await import("../repository/workflow-events.js");
12
+ before(() => migrate());
13
+ function newIssue() {
14
+ return createIssue({
15
+ title: "WF host",
16
+ repo: "acme/app",
17
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
18
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
19
+ baseBranch: "main",
20
+ maxReviewRounds: 3,
21
+ maxInfraAttempts: 3,
22
+ source: "manual"
23
+ }).id;
24
+ }
25
+ test("startWorkflowInstance creates one active instance", () => {
26
+ const issueId = newIssue();
27
+ const inst = startWorkflowInstance(issueId, "dev_reviewer_v1");
28
+ assert.equal(inst.completedAt, null);
29
+ assert.equal(getActiveWorkflowInstance(issueId).id, inst.id);
30
+ assert.deepEqual(getWorkflowInstance(inst.id), inst);
31
+ });
32
+ test("a second start throws WorkflowAlreadyActiveError", () => {
33
+ const issueId = newIssue();
34
+ startWorkflowInstance(issueId, "dev_reviewer_v1");
35
+ assert.throws(() => startWorkflowInstance(issueId, "dev_reviewer_v1"), (err) => err instanceof WorkflowAlreadyActiveError);
36
+ });
37
+ test("completing an instance frees the issue for a new one", () => {
38
+ const issueId = newIssue();
39
+ const first = startWorkflowInstance(issueId, "dev_reviewer_v1");
40
+ completeWorkflowInstance(first.id, "done");
41
+ assert.equal(getActiveWorkflowInstance(issueId), null);
42
+ const second = startWorkflowInstance(issueId, "dev_reviewer_v1");
43
+ assert.equal(listWorkflowInstancesForIssue(issueId).length, 2);
44
+ assert.equal(getActiveWorkflowInstance(issueId).id, second.id);
45
+ });
@@ -0,0 +1,82 @@
1
+ // packages/server/src/coordinator/worktree-owner-liveness.ts
2
+ //
3
+ // NOT-127: before resolveDeveloperWorktree reuses or conflicts on a leftover, ask whether
4
+ // the session that owns that path still has a live CLI. Filesystem clean/dirty alone is
5
+ // wrong in both directions when the predecessor was wrongly presumed dead (NOT-124): a
6
+ // clean leftover gets adopted while an agent is mid-run; a dirty leftover escalates as a
7
+ // worktree conflict when the "conflict" is just the live owner's WIP.
8
+ import fs from "node:fs";
9
+ import { getDb } from "../db/index.js";
10
+ import { sessionIdFromRoleWorktreePath, } from "../adapters/git-worktree.js";
11
+ import { getWorkerSession } from "../repository/worker-sessions.js";
12
+ import { processLiveness } from "./process-liveness.js";
13
+ function tryRealpath(p) {
14
+ try {
15
+ return fs.realpathSync(p);
16
+ }
17
+ catch {
18
+ return p;
19
+ }
20
+ }
21
+ /**
22
+ * Every worker_session that may own this coordinator-managed worktree path.
23
+ *
24
+ * Basename id (`<id>-developer`) is not enough on its own: after a dead-and-clean *reuse*,
25
+ * the directory keeps the predecessor's name while the successor's `worktree_path` also
26
+ * points there. Preferring only the basename then hides the live successor and lets the
27
+ * next retry adopt (or mis-escalate) the tree — the exact NOT-127 failure, one hop later.
28
+ */
29
+ export function listWorkerSessionsOwningWorktree(worktreePath) {
30
+ const seen = new Set();
31
+ const out = [];
32
+ const push = (s) => {
33
+ if (!s || seen.has(s.id))
34
+ return;
35
+ seen.add(s.id);
36
+ out.push(s);
37
+ };
38
+ const fromPath = sessionIdFromRoleWorktreePath(worktreePath);
39
+ if (fromPath)
40
+ push(getWorkerSession(fromPath));
41
+ const real = tryRealpath(worktreePath);
42
+ const rows = getDb()
43
+ .prepare(`SELECT id FROM worker_sessions
44
+ WHERE worktree_path = ? OR worktree_path = ?
45
+ ORDER BY created_at DESC`)
46
+ .all(worktreePath, real);
47
+ for (const row of rows)
48
+ push(getWorkerSession(row.id));
49
+ return out;
50
+ }
51
+ /** First candidate from {@link listWorkerSessionsOwningWorktree} — tests that only need one row. */
52
+ export function findWorkerSessionOwningWorktree(worktreePath) {
53
+ const all = listWorkerSessionsOwningWorktree(worktreePath);
54
+ return all[0] ?? null;
55
+ }
56
+ function sessionLooksLive(session) {
57
+ const verdict = processLiveness(session.processPid, session.processOwner, session.processStartedAt ?? null);
58
+ if (session.status === "running") {
59
+ // Dead pid → the process is gone even if the row hasn't been completed yet.
60
+ if (verdict === "dead")
61
+ return false;
62
+ // alive or unknown (including running with no pid yet, pre-spawn) → still owns the tree.
63
+ return true;
64
+ }
65
+ // Terminal session whose recorded pid still answers alive (wrongly presumed dead).
66
+ return verdict === "alive";
67
+ }
68
+ /**
69
+ * Live owner → block adoption. Dead / unknown / no session → filesystem clean/dirty path.
70
+ *
71
+ * Returns alive if **any** candidate session for this path is live — basename predecessor
72
+ * and every `worktree_path` match (see {@link listWorkerSessionsOwningWorktree}).
73
+ */
74
+ export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
75
+ const candidates = listWorkerSessionsOwningWorktree(worktreePath);
76
+ for (const session of candidates) {
77
+ if (sessionLooksLive(session)) {
78
+ return { state: "alive", sessionId: session.id };
79
+ }
80
+ }
81
+ return { state: "dead" };
82
+ }
@@ -0,0 +1,199 @@
1
+ // packages/server/src/coordinator/worktree-owner-liveness.test.ts
2
+ //
3
+ // NOT-127: session + pid lookup that feeds resolveDeveloperWorktree's ownerLiveness hook.
4
+ import { test, before, beforeEach } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { spawn } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-wt-owner-"));
11
+ const { migrate, getDb } = await import("../db/index.js");
12
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
13
+ const { createIssue } = await import("../repository/issues.js");
14
+ const { createWorkerSession, startSession, completeSession, recordSessionProcess, patchRunningSession } = await import("../repository/worker-sessions.js");
15
+ const { COORDINATOR_PROCESS_OWNER, readProcessStartTime } = await import("./process-liveness.js");
16
+ const { checkDeveloperWorktreeOwnerLiveness, findWorkerSessionOwningWorktree } = await import("./worktree-owner-liveness.js");
17
+ let issueId;
18
+ before(() => {
19
+ migrate();
20
+ issueId = createIssue({
21
+ title: "owner-liveness host",
22
+ repo: "acme/app",
23
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
24
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
25
+ baseBranch: "main",
26
+ maxReviewRounds: 3,
27
+ maxInfraAttempts: 3,
28
+ source: "manual"
29
+ }).id;
30
+ });
31
+ beforeEach(() => {
32
+ getDb().exec("DELETE FROM worker_sessions");
33
+ });
34
+ function spawnLiveChild() {
35
+ const child = spawn("sleep", ["30"], { stdio: "ignore" });
36
+ const startTime = readProcessStartTime(child.pid) ?? "unknown";
37
+ return {
38
+ pid: child.pid,
39
+ startTime,
40
+ kill: () => {
41
+ try {
42
+ child.kill("SIGKILL");
43
+ }
44
+ catch {
45
+ /* already gone */
46
+ }
47
+ }
48
+ };
49
+ }
50
+ test("findWorkerSessionOwningWorktree resolves the session id encoded in the path basename", () => {
51
+ const session = createWorkerSession({
52
+ issueId,
53
+ role: "developer",
54
+ round: 1,
55
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
56
+ runtime: "claude_code"
57
+ });
58
+ startSession(session.id);
59
+ const wt = `/repo/.agent-dealer-worktrees/${session.id}-developer`;
60
+ patchRunningSession(session.id, { worktreePath: wt });
61
+ assert.equal(findWorkerSessionOwningWorktree(wt)?.id, session.id);
62
+ });
63
+ test("checkDeveloperWorktreeOwnerLiveness: running session with live pid is alive", () => {
64
+ const child = spawnLiveChild();
65
+ try {
66
+ const session = createWorkerSession({
67
+ issueId,
68
+ role: "developer",
69
+ round: 1,
70
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
71
+ runtime: "claude_code"
72
+ });
73
+ startSession(session.id);
74
+ const wt = `/repo/.agent-dealer-worktrees/${session.id}-developer`;
75
+ patchRunningSession(session.id, { worktreePath: wt });
76
+ recordSessionProcess(session.id, child.pid, COORDINATOR_PROCESS_OWNER, child.startTime);
77
+ assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), {
78
+ state: "alive",
79
+ sessionId: session.id
80
+ });
81
+ }
82
+ finally {
83
+ child.kill();
84
+ }
85
+ });
86
+ test("checkDeveloperWorktreeOwnerLiveness: terminal session whose pid is still alive is alive (NOT-127)", () => {
87
+ const child = spawnLiveChild();
88
+ try {
89
+ const session = createWorkerSession({
90
+ issueId,
91
+ role: "developer",
92
+ round: 1,
93
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
94
+ runtime: "claude_code"
95
+ });
96
+ startSession(session.id);
97
+ const wt = `/repo/.agent-dealer-worktrees/${session.id}-developer`;
98
+ patchRunningSession(session.id, { worktreePath: wt });
99
+ recordSessionProcess(session.id, child.pid, COORDINATOR_PROCESS_OWNER, child.startTime);
100
+ // Recovery wrongly marked it failed while the CLI kept running.
101
+ completeSession(session.id, {
102
+ status: "failed",
103
+ errorJson: JSON.stringify({ reason: "recovered — worker process presumed dead" })
104
+ });
105
+ assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), {
106
+ state: "alive",
107
+ sessionId: session.id
108
+ });
109
+ }
110
+ finally {
111
+ child.kill();
112
+ }
113
+ });
114
+ test("checkDeveloperWorktreeOwnerLiveness: terminal session with a dead pid is dead", async () => {
115
+ const child = spawnLiveChild();
116
+ const { pid, startTime } = child;
117
+ child.kill();
118
+ // Wait until the OS reaps it so kill(pid, 0) fails.
119
+ for (let i = 0; i < 50; i++) {
120
+ try {
121
+ process.kill(pid, 0);
122
+ await new Promise((r) => setTimeout(r, 20));
123
+ }
124
+ catch {
125
+ break;
126
+ }
127
+ }
128
+ const session = createWorkerSession({
129
+ issueId,
130
+ role: "developer",
131
+ round: 1,
132
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
133
+ runtime: "claude_code"
134
+ });
135
+ startSession(session.id);
136
+ const wt = `/repo/.agent-dealer-worktrees/${session.id}-developer`;
137
+ patchRunningSession(session.id, { worktreePath: wt });
138
+ recordSessionProcess(session.id, pid, COORDINATOR_PROCESS_OWNER, startTime);
139
+ completeSession(session.id, { status: "failed", errorJson: JSON.stringify({ reason: "gone" }) });
140
+ assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), { state: "dead" });
141
+ });
142
+ test("checkDeveloperWorktreeOwnerLiveness: unknown path with no session is dead", () => {
143
+ assert.deepEqual(checkDeveloperWorktreeOwnerLiveness("/repo/.agent-dealer-worktrees/no-such-session-developer"), { state: "dead" });
144
+ });
145
+ test("checkDeveloperWorktreeOwnerLiveness: running session with no pid yet still owns the worktree", () => {
146
+ const session = createWorkerSession({
147
+ issueId,
148
+ role: "developer",
149
+ round: 1,
150
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
151
+ runtime: "claude_code"
152
+ });
153
+ startSession(session.id);
154
+ const wt = `/repo/.agent-dealer-worktrees/${session.id}-developer`;
155
+ patchRunningSession(session.id, { worktreePath: wt });
156
+ // No recordSessionProcess yet — worktree.ready before spawn.
157
+ assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), {
158
+ state: "alive",
159
+ sessionId: session.id
160
+ });
161
+ });
162
+ test("checkDeveloperWorktreeOwnerLiveness: after clean reuse, live successor on predecessor's path basename is still detected (Lens stale-basename-owner)", () => {
163
+ const child = spawnLiveChild();
164
+ try {
165
+ const predecessor = createWorkerSession({
166
+ issueId,
167
+ role: "developer",
168
+ round: 1,
169
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
170
+ runtime: "claude_code"
171
+ });
172
+ startSession(predecessor.id);
173
+ const wt = `/repo/.agent-dealer-worktrees/${predecessor.id}-developer`;
174
+ patchRunningSession(predecessor.id, { worktreePath: wt });
175
+ completeSession(predecessor.id, {
176
+ status: "failed",
177
+ errorJson: JSON.stringify({ reason: "presumed dead" })
178
+ });
179
+ // Successor reused the leftover path in place — directory still named for predecessor.
180
+ const successor = createWorkerSession({
181
+ issueId,
182
+ role: "developer",
183
+ round: 1,
184
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
185
+ runtime: "claude_code"
186
+ });
187
+ startSession(successor.id);
188
+ patchRunningSession(successor.id, { worktreePath: wt });
189
+ recordSessionProcess(successor.id, child.pid, COORDINATOR_PROCESS_OWNER, child.startTime);
190
+ const verdict = checkDeveloperWorktreeOwnerLiveness(wt);
191
+ assert.equal(verdict.state, "alive");
192
+ if (verdict.state === "alive") {
193
+ assert.equal(verdict.sessionId, successor.id, "must prefer the live successor over the dead basename predecessor");
194
+ }
195
+ }
196
+ finally {
197
+ child.kill();
198
+ }
199
+ });
@@ -0,0 +1,18 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import Database from "better-sqlite3";
7
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-artifact-cols-"));
8
+ const { migrate, getDbPath } = await import("./index.js");
9
+ test("migrate() adds issue_id and worker_session_id to the legacy artifacts table", () => {
10
+ migrate();
11
+ const db = new Database(getDbPath(), { readonly: true });
12
+ const cols = db.prepare("PRAGMA table_info(artifacts)").all();
13
+ const names = cols.map((c) => c.name);
14
+ assert.ok(names.includes("issue_id"), "expected artifacts.issue_id to exist");
15
+ assert.ok(names.includes("worker_session_id"), "expected artifacts.worker_session_id to exist");
16
+ assert.ok(names.includes("run_id"), "run_id must remain for legacy rows");
17
+ db.close();
18
+ });