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,363 @@
1
+ // packages/server/src/coordinator/worker-loop.ts
2
+ //
3
+ // The leased effect worker + its poll loop. A dispatcher claims a queued work item with a
4
+ // compare-and-set (so concurrent dispatchers never run one twice), records a running
5
+ // worker_session, keeps a token-fenced heartbeat while the effect handler runs, then
6
+ // applies the structured completion transactionally. Bounded concurrency; non-overlapping
7
+ // polling.
8
+ //
9
+ // NOT-59 exports startCoordinatorLoop/stopCoordinatorLoop but does NOT wire them into the
10
+ // server — index.ts integration lands with NOT-60.
11
+ import { v4 as uuid } from "uuid";
12
+ import { getDb } from "../db/index.js";
13
+ import { getAgent } from "../repository/agents.js";
14
+ import { getIssue } from "../repository/issues.js";
15
+ import { getWorkflowInstance, getActiveWorkflowInstance, appendWorkflowEvent, } from "../repository/workflow-events.js";
16
+ import { createWorkerSession, startSession, heartbeatSession, completeSession, } from "../repository/worker-sessions.js";
17
+ import { attemptCapReached, bindWorkItemSession, claimWorkItem, finishWorkItem, getWorkItem, refreshHeartbeat, requeueWorkItem, } from "../repository/work-items.js";
18
+ import { applyCompletion, routeAppliedOutcome, routeCapEscalation } from "./commands.js";
19
+ import { getEffectHandler } from "./effect-registry.js";
20
+ import { parseProfileSnapshot } from "@agent-dealer/shared";
21
+ import { buildProfileSnapshot, serializeProfileSnapshot } from "./profile-snapshot.js";
22
+ import { recoverCoordinator } from "./recovery.js";
23
+ import { observeClockJump } from "./clock-jump.js";
24
+ import { admitNext } from "./admission.js";
25
+ import { workerSessionPayload } from "./session-progress.js";
26
+ import { runtimeAvailability } from "../repository/runtime-availability.js";
27
+ import { deferLeasedWorkItemForUsageCap } from "./usage-cap-defer.js";
28
+ import { outcomeShouldRecordError, reasonForWorkerFailedEvent } from "./failure-reason.js";
29
+ const num = (name, dflt) => Number(process.env[name] ?? dflt);
30
+ export const coordinatorConfig = {
31
+ get maxConcurrency() {
32
+ return num("MAX_COORDINATOR_CONCURRENCY", 2);
33
+ },
34
+ get leaseMs() {
35
+ // Default 60s — coordinator Node-worker liveness, not Cursor think time. See
36
+ // docs/TROUBLESHOOTING.md#coordinator-lease--heartbeat-not-113 (NOT-113 decision).
37
+ return num("COORDINATOR_LEASE_MS", 60_000);
38
+ },
39
+ get heartbeatMs() {
40
+ return num("COORDINATOR_HEARTBEAT_MS", 15_000);
41
+ },
42
+ get failBackoffMs() {
43
+ return num("COORDINATOR_FAIL_BACKOFF_MS", 10_000);
44
+ },
45
+ get pollIntervalMs() {
46
+ return num("COORDINATOR_POLL_INTERVAL_MS", 3_000);
47
+ },
48
+ };
49
+ const roleFor = {
50
+ developer: "developer",
51
+ reviewer: "reviewer",
52
+ };
53
+ // Keyed by lease token, not work-item id: recovery can requeue an expired item while its
54
+ // zombie handler is still unwinding, and a later tick may re-claim the same id. Keying by
55
+ // the per-attempt token keeps the two tracked separately, so the concurrency bound holds
56
+ // and drainCoordinator awaits both.
57
+ const active = new Map();
58
+ /** Visible for tests — the number of effect attempts this process is running. */
59
+ export function activeAttemptCount() {
60
+ return active.size;
61
+ }
62
+ function isFailureOutcome(outcome) {
63
+ return outcome.kind === "session_failed" || outcome.kind === "publish_failed" || outcome.kind === "deck_failure";
64
+ }
65
+ /** `timed_out` is the only outcome meaning the agent process itself hit its wall clock —
66
+ * every other kind (no_pr, dirty_worktree, checks_failed, ...) is a session that ran to
67
+ * completion and handed off a (possibly unwanted) verified result. */
68
+ function isTimedOutOutcome(outcome) {
69
+ return outcome.kind === "timed_out";
70
+ }
71
+ /** completeSession is bookkeeping — its failure must never re-route or revive a work item. */
72
+ function safeCompleteSession(sessionId, status, error) {
73
+ try {
74
+ completeSession(sessionId, {
75
+ status,
76
+ errorJson: error !== undefined ? JSON.stringify(error) : undefined,
77
+ });
78
+ }
79
+ catch (err) {
80
+ console.error("[coordinator] completeSession", sessionId, err);
81
+ }
82
+ }
83
+ /**
84
+ * A failed effect (handler threw, or lost its lease mid-run). One transaction, fenced on
85
+ * the lease token: either requeue with backoff, or — at the attempt cap — CAS to `dead`
86
+ * AND route it as `session_failed` together, so a crash can't strand the workflow with a
87
+ * dead item and no next effect.
88
+ */
89
+ function handleEffectFailure(itemId, leaseToken, error) {
90
+ getDb().transaction(() => {
91
+ const item = getWorkItem(itemId);
92
+ if (!item || item.status !== "leased")
93
+ return; // already reclaimed / finished by a peer
94
+ if (!attemptCapReached(item)) {
95
+ requeueWorkItem(itemId, leaseToken, error, { backoffMs: coordinatorConfig.failBackoffMs });
96
+ return;
97
+ }
98
+ const dead = finishWorkItem(itemId, leaseToken, { status: "dead", error });
99
+ if (!dead)
100
+ return; // a peer won the CAS
101
+ const issue = getIssue(dead.issueId);
102
+ const instance = getActiveWorkflowInstance(dead.issueId);
103
+ if (issue && instance && instance.id === dead.workflowInstanceId) {
104
+ const reason = error && typeof error === "object" && "reason" in error && typeof error.reason === "string"
105
+ ? error.reason
106
+ : error && typeof error === "object" && "error" in error && typeof error.error === "string"
107
+ ? error.error
108
+ : String(error);
109
+ routeAppliedOutcome(issue, instance, dead, { kind: "session_failed", reason });
110
+ }
111
+ })();
112
+ }
113
+ async function processWorkItem(claimed) {
114
+ const leaseToken = claimed.leaseToken;
115
+ if (!leaseToken)
116
+ return; // not actually leased — defensive
117
+ const issue = getIssue(claimed.issueId);
118
+ const instance = getWorkflowInstance(claimed.workflowInstanceId);
119
+ if (!issue || !instance) {
120
+ handleEffectFailure(claimed.id, leaseToken, { reason: "issue or workflow instance vanished" });
121
+ return;
122
+ }
123
+ let payload = {};
124
+ try {
125
+ if (claimed.payloadJson)
126
+ payload = JSON.parse(claimed.payloadJson);
127
+ }
128
+ catch {
129
+ payload = {};
130
+ }
131
+ const inputSha = payload.inputSha ?? null;
132
+ // The execution-profile snapshot was frozen into the work-item payload when the item
133
+ // was *queued* (commands.ts), so a profile edit between enqueue and claim can never
134
+ // change this session (design §"Immutable execution-profile snapshot" / NOT-60
135
+ // acceptance criteria). Fall back to a live resolve only for a legacy item queued
136
+ // before the snapshot was carried on the payload.
137
+ const role = roleFor[claimed.kind];
138
+ const agentId = claimed.kind === "developer" ? issue.developerAgentId : issue.reviewerAgentId;
139
+ let snapshot = parseProfileSnapshot(payload.profileSnapshot);
140
+ if (!snapshot) {
141
+ const agent = agentId ? getAgent(agentId) : null;
142
+ snapshot = agent ? buildProfileSnapshot(agent, role) : null;
143
+ }
144
+ const runtime = snapshot?.runtime ?? null;
145
+ if (runtime) {
146
+ const avail = runtimeAvailability(runtime);
147
+ if (!avail.available) {
148
+ const cap = {
149
+ kind: "usage_capped",
150
+ until: avail.until,
151
+ reason: avail.reason,
152
+ };
153
+ const deferResult = deferLeasedWorkItemForUsageCap(claimed, leaseToken, cap, issue, instance);
154
+ if (deferResult.deferred)
155
+ return;
156
+ if (deferResult.escalated) {
157
+ getDb().transaction(() => {
158
+ const finished = finishWorkItem(claimed.id, leaseToken, { status: "done", result: cap });
159
+ if (finished)
160
+ routeCapEscalation(issue, instance, finished, cap);
161
+ })();
162
+ }
163
+ return;
164
+ }
165
+ }
166
+ // Create + bind + start the session
167
+ // leaves a running session that recovery (which keys off work_items) cannot locate. The
168
+ // bind is fenced on the lease token: if this attempt lost its lease between claim and
169
+ // here, the transaction rolls back (session creation undone) and the attempt is dropped.
170
+ let session;
171
+ try {
172
+ session = getDb().transaction(() => {
173
+ const s = createWorkerSession({
174
+ issueId: claimed.issueId,
175
+ role,
176
+ round: claimed.round,
177
+ agentId,
178
+ runtime: snapshot?.runtime ?? null,
179
+ model: snapshot?.model ?? null,
180
+ budgetJson: snapshot?.budgetJson ?? null,
181
+ inputSha,
182
+ metadataJson: JSON.stringify({ workItemId: claimed.id }),
183
+ profileSnapshotJson: snapshot ? serializeProfileSnapshot(snapshot) : null,
184
+ });
185
+ if (!bindWorkItemSession(claimed.id, s.id, leaseToken)) {
186
+ throw new Error("lease lost before session setup");
187
+ }
188
+ startSession(s.id);
189
+ // NOT-109: attribute start to the role (not system) so Issue Detail shows
190
+ // "Developer started" / "Reviewer started" with a role badge, and carry runtime /
191
+ // model / session id for ops debugging.
192
+ appendWorkflowEvent({
193
+ issueId: claimed.issueId,
194
+ workflowInstanceId: instance.id,
195
+ workerSessionId: s.id,
196
+ type: "worker.started",
197
+ actorType: role,
198
+ stage: issue.status,
199
+ round: claimed.round,
200
+ payload: workerSessionPayload({
201
+ runtime: snapshot?.runtime ?? null,
202
+ model: snapshot?.model ?? null,
203
+ sessionId: s.id,
204
+ }),
205
+ });
206
+ return s;
207
+ })();
208
+ }
209
+ catch (err) {
210
+ console.error("[coordinator] session setup", claimed.id, err);
211
+ return; // lost the lease — recovery will reprocess the item when the lease expires
212
+ }
213
+ const controller = new AbortController();
214
+ const heartbeat = setInterval(() => {
215
+ if (refreshHeartbeat(claimed.id, leaseToken, { leaseMs: coordinatorConfig.leaseMs })) {
216
+ heartbeatSession(session.id);
217
+ }
218
+ else {
219
+ controller.abort();
220
+ }
221
+ }, coordinatorConfig.heartbeatMs);
222
+ let outcome;
223
+ try {
224
+ outcome = await getEffectHandler(claimed.kind)({
225
+ workItem: { ...claimed, workerSessionId: session.id },
226
+ issue,
227
+ instance,
228
+ signal: controller.signal,
229
+ });
230
+ }
231
+ catch (err) {
232
+ clearInterval(heartbeat);
233
+ try {
234
+ handleEffectFailure(claimed.id, leaseToken, { error: String(err) });
235
+ }
236
+ catch (routeErr) {
237
+ // routing threw — leave the item leased for recovery to retry
238
+ console.error("[coordinator] handleEffectFailure", claimed.id, routeErr);
239
+ }
240
+ safeCompleteSession(session.id, "failed", { error: String(err) });
241
+ return;
242
+ }
243
+ clearInterval(heartbeat);
244
+ // The completion CAS is fenced on leaseToken and is the only path to a terminal state —
245
+ // a duplicate or a reclaimed-then-late completion applies nothing.
246
+ const result = await applyCompletion(claimed.id, leaseToken, outcome);
247
+ if (!result.applied) {
248
+ safeCompleteSession(session.id, "cancelled", { reason: result.reason });
249
+ return;
250
+ }
251
+ if (result.triggerReflect) {
252
+ const { triggerIssueReflect } = await import("./reflect-trigger.js");
253
+ void triggerIssueReflect(claimed.issueId).catch(() => { });
254
+ }
255
+ // NOT-136: a deck-unavailable session never spawned anything — `cancelled`, not `failed`
256
+ // (which would read as a worker crash) and not `done` (which would claim it ran).
257
+ if (outcome.kind === "deck_unavailable") {
258
+ safeCompleteSession(session.id, "cancelled", { reason: outcome.reason });
259
+ return;
260
+ }
261
+ const sessionStatus = outcome.kind === "usage_capped"
262
+ ? "done"
263
+ : isTimedOutOutcome(outcome)
264
+ ? "timed_out"
265
+ : isFailureOutcome(outcome)
266
+ ? "failed"
267
+ : "done";
268
+ // NOT-113: persist a prose reason on failure-ish completions (including dirty_worktree,
269
+ // which stays status=done for preservation but still needs errorJson for the detail strip).
270
+ const errorPayload = outcomeShouldRecordError(outcome)
271
+ ? {
272
+ reason: reasonForWorkerFailedEvent({
273
+ outcome,
274
+ logPath: session.logPath,
275
+ runtime: session.runtime ?? undefined,
276
+ }),
277
+ }
278
+ : undefined;
279
+ safeCompleteSession(session.id, sessionStatus, errorPayload);
280
+ }
281
+ /**
282
+ * Claims and starts as many work items as free concurrency slots allow, then returns the
283
+ * count started. Not awaited internally — each item runs to completion in the background
284
+ * and is tracked in `active`.
285
+ *
286
+ * Each tick also runs `recoverCoordinator` so expired leases are reclaimed without waiting
287
+ * for another process restart (NOT-116). Startup recovery alone is too early when the
288
+ * coordinator restarts while leases are still valid — heartbeats are gone, then leases
289
+ * expire later with nothing polling reclaim.
290
+ */
291
+ export async function runCoordinatorTick(opts) {
292
+ // NOT-116 / NOT-102: reclaim expired leases + finish auto-merges stranded before a crash.
293
+ // recoverCoordinator also runs recoverStrandedAutoMerges.
294
+ try {
295
+ await recoverCoordinator({ now: opts?.now });
296
+ }
297
+ catch (err) {
298
+ console.error("[coordinator] recoverCoordinator", err);
299
+ }
300
+ // NOT-103: level-triggered admission — free slot + eligible queued entry → startWorkflowCore.
301
+ // Not hooked into terminal transitions; polling recovers correctly after restart.
302
+ try {
303
+ await admitNext();
304
+ }
305
+ catch (err) {
306
+ console.error("[coordinator] admitNext", err);
307
+ }
308
+ const leaseOwner = opts?.leaseOwner ?? `loop-${process.pid}-${uuid().slice(0, 8)}`;
309
+ let started = 0;
310
+ while (active.size < coordinatorConfig.maxConcurrency) {
311
+ const item = claimWorkItem(leaseOwner, { leaseMs: coordinatorConfig.leaseMs });
312
+ if (!item || !item.leaseToken)
313
+ break;
314
+ const key = item.leaseToken;
315
+ const job = processWorkItem(item)
316
+ .catch((err) => console.error("[coordinator] processWorkItem", item.id, err))
317
+ .finally(() => active.delete(key));
318
+ active.set(key, job);
319
+ started++;
320
+ }
321
+ return started;
322
+ }
323
+ /** Awaits every in-flight work item — for tests and graceful shutdown. */
324
+ export async function drainCoordinator() {
325
+ await Promise.all([...active.values()]);
326
+ }
327
+ let loopTimer = null;
328
+ let ticking = false;
329
+ export function startCoordinatorLoop() {
330
+ if (loopTimer)
331
+ return;
332
+ const leaseOwner = `loop-${process.pid}-${uuid().slice(0, 8)}`;
333
+ loopTimer = setInterval(() => {
334
+ // NOT-125: sampled HERE, in the timer itself, rather than inside runCoordinatorTick.
335
+ // Half of what this measures is "did the poll timer fire on schedule?", and only this
336
+ // callback runs on a schedule — runCoordinatorTick is also driven ad hoc (tests, manual
337
+ // recovery), where the gap between two calls means nothing and would read as a sleep.
338
+ // Before the `ticking` guard, too, so a tick that overruns the interval still leaves the
339
+ // clock baseline advancing at the true cadence.
340
+ const jump = observeClockJump();
341
+ if (jump) {
342
+ console.warn(`[coordinator] host clock jumped ${Math.round(jump.wallGapMs / 1000)}s between poll ticks — likely host sleep`, {
343
+ unelapsedMs: Math.round(jump.unelapsedMs),
344
+ unobservedMs: Math.round(jump.unobservedMs),
345
+ graceUntil: new Date(jump.graceUntil).toISOString(),
346
+ });
347
+ }
348
+ if (ticking)
349
+ return; // non-overlapping polling
350
+ ticking = true;
351
+ runCoordinatorTick({ leaseOwner })
352
+ .catch((err) => console.error("[coordinator] tick", err))
353
+ .finally(() => {
354
+ ticking = false;
355
+ });
356
+ }, coordinatorConfig.pollIntervalMs);
357
+ }
358
+ export function stopCoordinatorLoop() {
359
+ if (loopTimer)
360
+ clearInterval(loopTimer);
361
+ loopTimer = null;
362
+ ticking = false;
363
+ }