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,74 @@
1
+ // packages/server/src/coordinator/abort.integration.test.ts
2
+ //
3
+ // NOT-83 acceptance scenario that needs the real worker loop wired up: abort while a
4
+ // developer effect handler is actually in flight (leased, session running), then let that
5
+ // zombie attempt's late completion arrive — it must be fenced (a no-op) and must never
6
+ // reopen or mutate the now-closed issue. Mirrors execution-env.integration.test.ts's
7
+ // fake-effect-handler + runCoordinatorTick/drainCoordinator pump pattern.
8
+ import { test, before, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-abort-"));
14
+ const { migrate, getDb } = await import("../db/index.js");
15
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
16
+ const { createIssue, getIssue } = await import("../repository/issues.js");
17
+ const { getWorkItem, listWorkItemsForIssue } = await import("../repository/work-items.js");
18
+ const { getWorkerSession } = await import("../repository/worker-sessions.js");
19
+ const { startWorkflow, abortIssue } = await import("./commands.js");
20
+ const { registerEffectHandler, resetEffectHandlers } = await import("./effect-registry.js");
21
+ const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js");
22
+ before(() => migrate());
23
+ beforeEach(() => getDb().exec("DELETE FROM work_items"));
24
+ afterEach(() => resetEffectHandlers());
25
+ function newIssue() {
26
+ return createIssue({
27
+ title: "Abort mid-flight",
28
+ description: "d",
29
+ acceptanceCriteria: "It works",
30
+ repo: "acme/app",
31
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
32
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
33
+ baseBranch: "main",
34
+ maxReviewRounds: 3,
35
+ maxInfraAttempts: 3,
36
+ source: "manual"
37
+ }).id;
38
+ }
39
+ test("abortIssue fences a developer effect that completes after the abort already committed", async () => {
40
+ const issueId = newIssue();
41
+ startWorkflow(issueId);
42
+ // A deferred the test controls: the fake developer effect blocks here until the test
43
+ // lets it resolve, simulating a session still genuinely running when the abort lands.
44
+ let releaseEffect;
45
+ const blocked = new Promise((resolve) => {
46
+ releaseEffect = resolve;
47
+ });
48
+ registerEffectHandler("developer", async () => {
49
+ await blocked;
50
+ return { kind: "clean_handoff", branch: "issue-x", headSha: "deadbeef", baseSha: "base1", prNumber: 1, prUrl: "https://gh/pr/1" };
51
+ });
52
+ // Synchronous up to the handler's own first await (session create/bind/start all run
53
+ // inside one non-async transaction before the handler is ever invoked) — by the time
54
+ // this resolves, the work item is already `leased` and its session `running`.
55
+ const started = await runCoordinatorTick({ leaseOwner: "test" });
56
+ assert.equal(started, 1);
57
+ const item = listWorkItemsForIssue(issueId)[0];
58
+ assert.equal(item.status, "leased");
59
+ const sessionId = item.workerSessionId;
60
+ assert.ok(sessionId, "expected the session to already be bound before the handler blocks");
61
+ assert.equal(getWorkerSession(sessionId).status, "running");
62
+ const killed = [];
63
+ const result = abortIssue(issueId, "tester", { killProcess: (id) => (killed.push(id), true) });
64
+ assert.deepEqual(result, { ok: true, issueStatus: "closed", alreadyClosed: false });
65
+ assert.deepEqual(killed, [sessionId], "the running session's registered child process must be terminated");
66
+ assert.equal(getWorkItem(item.id).status, "cancelled");
67
+ assert.equal(getWorkerSession(sessionId).status, "cancelled");
68
+ // The zombie attempt now finishes and tries to hand off its (otherwise valid) outcome.
69
+ releaseEffect();
70
+ await drainCoordinator();
71
+ assert.equal(getWorkItem(item.id).status, "cancelled", "a late completion must never re-advance a cancelled work item");
72
+ assert.equal(getIssue(issueId).status, "closed", "a late completion must never reopen or mutate a closed issue");
73
+ assert.equal(getIssue(issueId).prNumber, null, "the late clean_handoff must never be projected onto the issue");
74
+ });
@@ -0,0 +1,330 @@
1
+ // packages/server/src/coordinator/admission.ts
2
+ //
3
+ // NOT-103: when to call startWorkflowCore on which queued issue.
4
+ // Order (queue_entries) + eligibility rules + CapacityPolicy — not a second execution engine.
5
+ //
6
+ // NOT-118: this is also the *only* entry point a route may use to start an issue
7
+ // (`startIssueViaQueue`). Manual Start moves the entry to the front and admits it when a
8
+ // slot is free — it never bypasses the queue. The single ungated exception is a human
9
+ // action resolving into startWorkflowCore (commands.ts).
10
+ import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
11
+ import { healthForAgent } from "../adapters/agent-health.js";
12
+ import { getDb } from "../db/index.js";
13
+ import { getAgent } from "../repository/agents.js";
14
+ import { getIssue, listIssues } from "../repository/issues.js";
15
+ import { enqueueIssue, getQueuedEntryForIssue, listQueuedEntries, markQueueEntryAdmitted, markQueueEntryRemoved, moveQueueEntryToTop, setQueueWaitReason, } from "../repository/queue-entries.js";
16
+ import { runtimeAvailability } from "../repository/runtime-availability.js";
17
+ import { getActiveWorkflowInstance } from "../repository/workflow-events.js";
18
+ import { listWorkItemsForIssue } from "../repository/work-items.js";
19
+ import { activeWorkflowConflictMessage, checkIssueReadiness, startWorkflowCore, StartPreconditionError, } from "./commands.js";
20
+ import { blockerSnapshotFor, DEPENDENCY_STATE_UNAVAILABLE, isDependencyTracked, unsatisfiedBlockerReason, } from "./dependencies.js";
21
+ /** Statuses that occupy an admission slot (Decision 2). */
22
+ export const occupyingStatuses = new Set(["developing", "reviewing", "repairing"]);
23
+ /**
24
+ * Statuses admission can actually start: `ready`, or `needs_human` parked back for a human
25
+ * to unblock. Everything else — mid-flight (`developing`/`reviewing`/`repairing`), awaiting
26
+ * a human's merge call (`final_review`), or terminal — is not startable.
27
+ */
28
+ export const startableStatuses = new Set(["ready", "needs_human"]);
29
+ /**
30
+ * The single "may this issue enter the queue" predicate, shared by the eligibility rule,
31
+ * Manual Start and the create route's idempotent re-enqueue. Enqueueing on a looser
32
+ * predicate creates a row admission can never accept: a migrated `final_review` issue has
33
+ * no *active* instance but a completed one, so "nonterminal + no active workflow" would
34
+ * queue it forever with `status final_review — not startable`.
35
+ */
36
+ export function isStartable(issue) {
37
+ return startableStatuses.has(issue.status) && !getActiveWorkflowInstance(issue.id);
38
+ }
39
+ export const sequentialCapacityPolicy = (activeIssues) => {
40
+ const occupying = activeIssues.filter((i) => occupyingStatuses.has(i.status)).length;
41
+ return Math.max(0, 1 - occupying);
42
+ };
43
+ let capacityPolicy = sequentialCapacityPolicy;
44
+ /** Test / future parallel wiring — swap the single CapacityPolicy function. */
45
+ export function setCapacityPolicyForTests(policy) {
46
+ capacityPolicy = policy;
47
+ }
48
+ export function resetCapacityPolicyForTests() {
49
+ capacityPolicy = sequentialCapacityPolicy;
50
+ }
51
+ export function countOccupyingIssues() {
52
+ return listOccupyingIssues().length;
53
+ }
54
+ function listOccupyingIssues() {
55
+ return listIssues([...occupyingStatuses]).map((i) => ({ id: i.id, status: i.status }));
56
+ }
57
+ let healthChecker = null;
58
+ /** Tests inject a pure checker so admission does not hit real CLIs. */
59
+ export function setAdmissionHealthCheckerForTests(checker) {
60
+ healthChecker = checker;
61
+ }
62
+ async function defaultAgentHealth(agent, deckOnline) {
63
+ const health = await healthForAgent(agent, deckOnline);
64
+ // usage_capped is owned by runtimeAvailable — keep agentsHealthy for CLI/workspace/deck.
65
+ const nonCap = health.issues.filter((i) => i.code !== "usage_capped");
66
+ if (nonCap.length > 0) {
67
+ return {
68
+ ok: false,
69
+ reason: `agent unhealthy: ${agent.name} — ${nonCap.map((i) => i.message).join("; ")}`,
70
+ };
71
+ }
72
+ return { ok: true };
73
+ }
74
+ async function checkAgentsHealthy(issue, ctx) {
75
+ const check = healthChecker ?? ((agent) => defaultAgentHealth(agent, ctx.deckOnline));
76
+ for (const role of ["developer", "reviewer"]) {
77
+ const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
78
+ if (!agentId)
79
+ return { ok: false, reason: `missing ${role} agent` };
80
+ const agent = getAgent(agentId);
81
+ if (!agent)
82
+ return { ok: false, reason: `${role} agent not found` };
83
+ const result = await check(agent);
84
+ if (!result.ok)
85
+ return result;
86
+ }
87
+ return { ok: true };
88
+ }
89
+ /**
90
+ * Status must be startable (`ready`, or `needs_human` with no active workflow) and
91
+ * checkIssueReadiness must pass — never open product_scope_decision.
92
+ */
93
+ function issueReadinessRule(issue) {
94
+ if (!startableStatuses.has(issue.status)) {
95
+ return { ok: false, reason: `issue status is ${issue.status} — not startable` };
96
+ }
97
+ if (getActiveWorkflowInstance(issue.id)) {
98
+ return { ok: false, reason: "issue already has an active workflow" };
99
+ }
100
+ const readiness = checkIssueReadiness(issue);
101
+ if (!readiness.ok) {
102
+ if (readiness.missing.includes("acceptance criteria") && readiness.missing.length === 1) {
103
+ return { ok: false, reason: "missing acceptance criteria" };
104
+ }
105
+ return { ok: false, reason: `missing ${readiness.missing.join(", ")}` };
106
+ }
107
+ return { ok: true };
108
+ }
109
+ function runtimeAvailableRule(issue) {
110
+ for (const role of ["developer", "reviewer"]) {
111
+ const agentId = role === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
112
+ if (!agentId)
113
+ continue;
114
+ const agent = getAgent(agentId);
115
+ if (!agent)
116
+ continue;
117
+ const avail = runtimeAvailability(agent.runtime);
118
+ if (!avail.available) {
119
+ return {
120
+ ok: false,
121
+ reason: `runtime capped: ${agent.runtime} until ${avail.until} (${avail.reason})`,
122
+ };
123
+ }
124
+ }
125
+ return { ok: true };
126
+ }
127
+ /**
128
+ * NOT-104: park a Linear-sourced issue while its *declared* blockers are unsatisfied, so a
129
+ * dependent never branches from a base that is missing its upstream work.
130
+ *
131
+ * Manual (`source != 'linear'`) issues have no declared dependency data, so this is a
132
+ * permanent no-op for them — including while Linear is unreachable, which is what keeps a
133
+ * Linear outage from pausing the whole queue.
134
+ *
135
+ * There is no bypass flag: the escape hatch is editing Linear (drop the relation, or cancel
136
+ * an abandoned blocker), which fixes the cause for every future dependent too. Cycles are
137
+ * not detected — both entries simply park pointing at each other, which is a stalled pair
138
+ * the operator can see and fix, never a wrong run.
139
+ */
140
+ function blockedByDependency(issue, ctx) {
141
+ if (!isDependencyTracked(issue))
142
+ return { ok: true };
143
+ const blockers = ctx.blockers.get(issue.externalId);
144
+ // Absent means unknown, not unblocked (see BlockerSnapshot) — park until the next fetch.
145
+ if (!blockers)
146
+ return { ok: false, reason: DEPENDENCY_STATE_UNAVAILABLE };
147
+ const reason = unsatisfiedBlockerReason(blockers);
148
+ return reason ? { ok: false, reason } : { ok: true };
149
+ }
150
+ /** Initial eligibility rules — NOT-104 adds blockedByDependency here, not new machinery. */
151
+ export const defaultEligibilityRules = [
152
+ issueReadinessRule,
153
+ checkAgentsHealthy,
154
+ runtimeAvailableRule,
155
+ blockedByDependency,
156
+ ];
157
+ let eligibilityRules = defaultEligibilityRules;
158
+ export function setEligibilityRulesForTests(rules) {
159
+ eligibilityRules = rules;
160
+ }
161
+ export function resetEligibilityRulesForTests() {
162
+ eligibilityRules = defaultEligibilityRules;
163
+ }
164
+ async function evaluateEligibility(issue, ctx) {
165
+ for (const rule of eligibilityRules) {
166
+ const result = await rule(issue, ctx);
167
+ if (!result.ok)
168
+ return result;
169
+ }
170
+ return { ok: true };
171
+ }
172
+ export async function admitNext() {
173
+ const freeSlots = capacityPolicy(listOccupyingIssues());
174
+ const entries = listQueuedEntries();
175
+ // Housekeeping pass — independent of free slots.
176
+ for (const entry of entries) {
177
+ const issue = getIssue(entry.issueId);
178
+ if (!issue || issue.status === "done" || issue.status === "closed") {
179
+ markQueueEntryRemoved(entry.issueId);
180
+ continue;
181
+ }
182
+ if (getActiveWorkflowInstance(entry.issueId)) {
183
+ markQueueEntryAdmitted(entry.issueId);
184
+ }
185
+ }
186
+ if (freeSlots <= 0)
187
+ return null;
188
+ // Re-list after housekeeping so admitted/removed rows are gone.
189
+ const remaining = listQueuedEntries();
190
+ if (remaining.length === 0)
191
+ return null;
192
+ // One deck-health check per tick, shared across every queued entry and role. Each
193
+ // agent's health check otherwise re-hits agent-deck's uncached /health endpoint per
194
+ // entry per role; with several issues queued and agent-deck slow/unreachable that
195
+ // serially stalls this loop, which worker-loop runs before any real work dispatch.
196
+ //
197
+ // Same shape for NOT-104's blocker snapshot: one batched, timeout-bounded Linear query
198
+ // for the whole remaining queue, and only on a tick that actually has a slot to fill —
199
+ // the early return above means a busy system makes zero Linear calls.
200
+ const queuedIssues = remaining
201
+ .map((entry) => getIssue(entry.issueId))
202
+ .filter((issue) => issue !== null);
203
+ const [deckOnline, blockers] = await Promise.all([
204
+ checkAgentDeckHealth(),
205
+ blockerSnapshotFor(queuedIssues),
206
+ ]);
207
+ const ctx = { deckOnline, blockers };
208
+ for (const entry of remaining) {
209
+ const issue = getIssue(entry.issueId);
210
+ if (!issue) {
211
+ markQueueEntryRemoved(entry.issueId);
212
+ continue;
213
+ }
214
+ const eligibility = await evaluateEligibility(issue, ctx);
215
+ if (!eligibility.ok) {
216
+ setQueueWaitReason(entry.id, eligibility.reason);
217
+ continue;
218
+ }
219
+ try {
220
+ const started = getDb().transaction(() => {
221
+ // Dequeue race: operator may have removed the entry during async eligibility checks.
222
+ if (!getQueuedEntryForIssue(entry.issueId)) {
223
+ throw new StartPreconditionError(409, "queue entry no longer queued");
224
+ }
225
+ // Re-check capacity inside the txn so a concurrent Manual Start cannot double-admit.
226
+ if (capacityPolicy(listOccupyingIssues()) <= 0) {
227
+ throw new StartPreconditionError(409, "no free admission slots");
228
+ }
229
+ // Force-admit lives inside startWorkflowCore — single owner for start-path-queue-sync.
230
+ return startWorkflowCore(entry.issueId);
231
+ })();
232
+ return { issueId: entry.issueId, ...started };
233
+ }
234
+ catch (err) {
235
+ const message = err instanceof StartPreconditionError
236
+ ? err.message
237
+ : err instanceof Error
238
+ ? err.message
239
+ : String(err);
240
+ // Entry was dequeued — nothing to record; try the next entry.
241
+ if (message.includes("no longer queued"))
242
+ continue;
243
+ // Capacity exhausted mid-walk (concurrent Manual Start) — stop; do not write wait reasons.
244
+ if (message.includes("no free admission slots"))
245
+ return null;
246
+ setQueueWaitReason(entry.id, message);
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+ /**
252
+ * NOT-118 read-time wait reason. `admitNext` returns before evaluating anything when no
253
+ * slot is free, so a full system would otherwise leave every queued entry with no reason
254
+ * (or a stale one from an earlier tick). Derived on read instead of written every tick.
255
+ */
256
+ export function slotWaitReason() {
257
+ const running = listIssues([...occupyingStatuses]);
258
+ if (capacityPolicy(running.map((i) => ({ id: i.id, status: i.status }))) > 0)
259
+ return null;
260
+ const titles = running.map((i) => i.title).join(", ");
261
+ return titles ? `waiting for slot — running: ${titles}` : "waiting for slot";
262
+ }
263
+ /**
264
+ * The queue as operators read it: 1-based positions (rank, not the raw stored column, which
265
+ * legacy rows may have left sparse) and the current blocker per entry. While capacity is
266
+ * full that blocker *is* the missing slot, so the derived reason wins over whatever an
267
+ * earlier tick persisted; once a slot frees, the entry's own reason (missing acceptance
268
+ * criteria, unhealthy agent, capped runtime) surfaces again on the next tick.
269
+ */
270
+ export function listQueuedEntriesForRead() {
271
+ const slotReason = slotWaitReason();
272
+ return listQueuedEntries().map((entry, index) => ({
273
+ ...entry,
274
+ position: index + 1,
275
+ waitReason: slotReason ?? entry.waitReason,
276
+ }));
277
+ }
278
+ export function queueStatusForIssue(issueId) {
279
+ const entry = listQueuedEntriesForRead().find((e) => e.issueId === issueId);
280
+ return entry ? { position: entry.position, waitReason: entry.waitReason } : null;
281
+ }
282
+ /** The round-1 developer item an admitted start enqueued, for a start that raced a tick. */
283
+ function developerWorkItemFor(issueId, instanceId) {
284
+ return (listWorkItemsForIssue(issueId).find((w) => w.workflowInstanceId === instanceId && w.kind === "developer") ?? null);
285
+ }
286
+ /**
287
+ * NOT-118 Start: make sure the issue is queued, move its entry to position 1, then admit
288
+ * synchronously so an idle system still starts immediately. There is no "start now, skip
289
+ * the queue" escape hatch — when no slot is free (or the issue isn't eligible yet) it waits
290
+ * at the top of the queue with a reason, and is the next one admitted.
291
+ *
292
+ * Every route/CLI/agent start path goes through here. The one ungated exception is a human
293
+ * action resuming a workflow (resolveHumanActionAndAdvance → startWorkflowCore), which may
294
+ * briefly exceed capacity by design (NOT-103 decision 5).
295
+ */
296
+ export async function startIssueViaQueue(issueId) {
297
+ const issue = getIssue(issueId);
298
+ if (!issue)
299
+ return { state: "error", code: 404, error: "Issue not found" };
300
+ if (getActiveWorkflowInstance(issueId)) {
301
+ return { state: "error", code: 409, error: activeWorkflowConflictMessage(issueId) };
302
+ }
303
+ if (!startableStatuses.has(issue.status)) {
304
+ return { state: "error", code: 409, error: `Issue is ${issue.status} — not startable` };
305
+ }
306
+ try {
307
+ enqueueIssue(issueId); // idempotent when it is already queued
308
+ }
309
+ catch (err) {
310
+ // Only reachable if the issue turned terminal / started between the checks above and
311
+ // here; enqueueIssue tags those with an HTTP code.
312
+ const code = err.code ?? 409;
313
+ return { state: "error", code, error: err instanceof Error ? err.message : String(err) };
314
+ }
315
+ moveQueueEntryToTop(issueId);
316
+ const admitted = await admitNext();
317
+ if (admitted?.issueId === issueId) {
318
+ return { state: "admitted", instance: admitted.instance, workItem: admitted.workItem };
319
+ }
320
+ const queued = queueStatusForIssue(issueId);
321
+ if (queued)
322
+ return { state: "queued", ...queued };
323
+ // No longer queued and not admitted by this call — a concurrent coordinator tick admitted
324
+ // it between the enqueue and the walk, or an operator dequeued it mid-flight.
325
+ const instance = getActiveWorkflowInstance(issueId);
326
+ const workItem = instance ? developerWorkItemFor(issueId, instance.id) : null;
327
+ if (instance && workItem)
328
+ return { state: "admitted", instance, workItem };
329
+ return { state: "error", code: 409, error: "Issue left the queue before it could start" };
330
+ }