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,141 @@
1
+ // packages/server/src/pid-marker.ts
2
+ //
3
+ // A generic, path-parameterized exclusive pid marker: a JSON file naming the pid that
4
+ // currently "owns" whatever it's guarding, claimed via an atomic exclusive create and
5
+ // released only by its own owner. Two independent callers build on this:
6
+ //
7
+ // - server-liveness.ts: the server's own entrypoint claims a marker at
8
+ // AGENT_DEALER_HOME/server.pid for its whole running lifetime.
9
+ // - db/migrate-to-issues.ts: the cutover script claims that SAME file for the duration
10
+ // of the migration, so a server trying to start concurrently sees the migration's own
11
+ // (live) pid as the current owner and refuses to start, and the migration's claim
12
+ // itself fails if a server is already running — the same primitive closes the race
13
+ // from both directions instead of one side merely observing the other's state.
14
+ //
15
+ // Kept path-parameterized (not bound to AGENT_DEALER_HOME/getDataDir()) so the migration
16
+ // script — which operates on an arbitrary caller-supplied database path, not necessarily
17
+ // this process's own AGENT_DEALER_HOME — can guard the directory the target database
18
+ // actually lives in.
19
+ import fs from "node:fs";
20
+ import Database from "better-sqlite3";
21
+ function isPidAlive(pid) {
22
+ if (!Number.isFinite(pid) || pid <= 0)
23
+ return false;
24
+ try {
25
+ process.kill(pid, 0);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function readRaw(filePath) {
33
+ if (!fs.existsSync(filePath))
34
+ return null;
35
+ try {
36
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** Whoever currently owns this marker, or null if the file is absent or unreadable.
43
+ * `alive` reflects a live process.kill(pid, 0) probe against the recorded pid — a
44
+ * present-but-stale marker (dead pid) still returns a result, with `alive: false`, so a
45
+ * caller can distinguish "nothing here" from "something here, but it's stale." */
46
+ export function readPidMarkerOwner(filePath) {
47
+ const raw = readRaw(filePath);
48
+ if (!raw)
49
+ return null;
50
+ const pid = raw.pid;
51
+ if (typeof pid !== "number" || !Number.isFinite(pid) || pid <= 0)
52
+ return null;
53
+ return { pid, alive: isPidAlive(pid), raw };
54
+ }
55
+ /**
56
+ * Runs `fn` under a real OS-level advisory lock scoped to this marker, via a tiny
57
+ * dedicated SQLite file (`<filePath>.arbiter`) and a `BEGIN EXCLUSIVE` transaction —
58
+ * not a hand-rolled lock *file*, which would face the exact same "check it, decide it's
59
+ * stale, delete it, recreate it" race this exists to close, just one level further down
60
+ * (a prior version of this code tried exactly that with a `.reclaim-lock` file, and it
61
+ * had the identical bug: two processes could both observe the lock file as abandoned and
62
+ * both "steal" it, one destroying the other's fresh claim).
63
+ *
64
+ * SQLite's `BEGIN EXCLUSIVE` acquires a real filesystem advisory lock (fcntl/flock under
65
+ * the hood in rollback-journal mode) that a second connection's own `BEGIN EXCLUSIVE`
66
+ * genuinely blocks on — `busy_timeout` only bounds how long a live contender waits its
67
+ * turn. Critically, this sidesteps the entire "how do I tell a stale lock from a held one"
68
+ * problem: if the holding process dies for any reason (including a crash), the OS
69
+ * releases the underlying advisory lock immediately, so there is no lease/timeout
70
+ * heuristic to get wrong here at all.
71
+ */
72
+ function withArbiterLock(filePath, fn) {
73
+ const db = new Database(`${filePath}.arbiter`);
74
+ try {
75
+ db.pragma("busy_timeout = 3000");
76
+ return db.transaction(fn).exclusive();
77
+ }
78
+ finally {
79
+ db.close();
80
+ }
81
+ }
82
+ /**
83
+ * Claims the marker for this process (process.pid), refusing only when a *different*,
84
+ * still-alive pid already holds it. A stale marker (dead pid, or unreadable content) is
85
+ * reclaimed rather than treated as a blocker — a crashed prior owner must never
86
+ * permanently wedge this lock. `extra` fields (e.g. `port`, `role`) are merged into the
87
+ * written state alongside `pid`/`startedAt`.
88
+ *
89
+ * The fast path (nobody home) is a single atomic exclusive create (`wx`), so two processes
90
+ * racing an *absent* marker cannot both believe they claimed it. Reclaiming a *stale*
91
+ * marker is different: "read it, decide it's dead, replace it" is not one atomic step on
92
+ * its own, so the decide-and-replace sequence runs under withArbiterLock — a real OS-level
93
+ * advisory lock, not a second hand-rolled lock file with the same race one level down.
94
+ */
95
+ export function claimPidMarker(filePath, extra = {}) {
96
+ const state = { pid: process.pid, startedAt: new Date().toISOString(), ...extra };
97
+ const payload = JSON.stringify(state, null, 2);
98
+ try {
99
+ fs.writeFileSync(filePath, payload, { flag: "wx" });
100
+ return true;
101
+ }
102
+ catch (err) {
103
+ if (err.code !== "EEXIST")
104
+ throw err;
105
+ }
106
+ const owner = readPidMarkerOwner(filePath);
107
+ if (owner && owner.pid !== process.pid && owner.alive) {
108
+ return false; // a different, live owner holds this marker — do not touch it
109
+ }
110
+ if (owner && owner.pid === process.pid) {
111
+ return true; // already ours (e.g. a re-entrant claim) — nothing to replace
112
+ }
113
+ // Stale (dead pid) or unreadable — the destructive replace happens under the arbiter
114
+ // lock, not on the strength of the read above alone.
115
+ return withArbiterLock(filePath, () => {
116
+ // Re-check under the lock: the marker may have been legitimately reclaimed by whoever
117
+ // held this lock just before us.
118
+ const ownerUnderLock = readPidMarkerOwner(filePath);
119
+ if (ownerUnderLock && ownerUnderLock.pid !== process.pid && ownerUnderLock.alive) {
120
+ return false;
121
+ }
122
+ // No `wx` here: holding the arbiter lock is what makes this replace exclusive, not the
123
+ // create flag — the destination may legitimately still exist (the stale file itself).
124
+ fs.writeFileSync(filePath, payload);
125
+ return true;
126
+ });
127
+ }
128
+ /** Releases the marker only if it currently names *this* process — never a different
129
+ * (possibly still-alive) owner's marker, including one this process never actually claimed
130
+ * because claimPidMarker() returned false for it. */
131
+ export function releasePidMarker(filePath) {
132
+ const owner = readPidMarkerOwner(filePath);
133
+ if (!owner || owner.pid !== process.pid)
134
+ return;
135
+ try {
136
+ fs.unlinkSync(filePath);
137
+ }
138
+ catch {
139
+ // already gone — fine on a second release call
140
+ }
141
+ }
@@ -0,0 +1,208 @@
1
+ // packages/server/src/pid-marker.test.ts
2
+ import { test } 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
+ import { spawn } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { claimPidMarker, releasePidMarker, readPidMarkerOwner } from "./pid-marker.js";
10
+ import { resolveTsxBin } from "./resolve-tsx-bin.js";
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const childScript = path.join(__dirname, "pid-marker-claim-child.ts");
13
+ const holdArbiterScript = path.join(__dirname, "pid-marker-hold-arbiter-child.ts");
14
+ const tsxBin = resolveTsxBin(path.resolve(__dirname, "..", "..", ".."));
15
+ /** Resolves once the child has actually exited — killing it alone isn't enough to keep
16
+ * the test process from hanging afterward: an unresolved 'exit' event leaves the child's
17
+ * handle open, and node:test won't let the process exit while one is outstanding. */
18
+ function waitForExit(child) {
19
+ if (child.exitCode !== null || child.signalCode !== null)
20
+ return Promise.resolve();
21
+ return new Promise((resolve) => child.once("exit", () => resolve()));
22
+ }
23
+ function tempMarkerPath() {
24
+ return path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-pid-marker-")), "marker.json");
25
+ }
26
+ /** A genuinely alive, same-user child pid — safe to signal, unlike e.g. pid 1 (which is
27
+ * alive but throws EPERM rather than ESRCH for an unprivileged process.kill probe). */
28
+ function spawnLiveChild() {
29
+ const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
30
+ return { pid: child.pid, kill: () => child.kill("SIGKILL") };
31
+ }
32
+ test("claimPidMarker claims an absent marker", () => {
33
+ const filePath = tempMarkerPath();
34
+ assert.equal(claimPidMarker(filePath), true);
35
+ const owner = readPidMarkerOwner(filePath);
36
+ assert.equal(owner?.pid, process.pid);
37
+ assert.equal(owner?.alive, true);
38
+ });
39
+ test("claimPidMarker stores extra fields alongside pid/startedAt", () => {
40
+ const filePath = tempMarkerPath();
41
+ claimPidMarker(filePath, { role: "migration", port: 1234 });
42
+ const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
43
+ assert.equal(raw.role, "migration");
44
+ assert.equal(raw.port, 1234);
45
+ assert.equal(typeof raw.startedAt, "string");
46
+ });
47
+ test("claimPidMarker refuses a marker owned by a different, live pid — and does not touch it", () => {
48
+ const filePath = tempMarkerPath();
49
+ const other = spawnLiveChild();
50
+ try {
51
+ fs.writeFileSync(filePath, JSON.stringify({ pid: other.pid, startedAt: "x" }));
52
+ assert.equal(claimPidMarker(filePath), false);
53
+ const owner = readPidMarkerOwner(filePath);
54
+ assert.equal(owner?.pid, other.pid);
55
+ assert.equal(owner?.alive, true);
56
+ }
57
+ finally {
58
+ other.kill();
59
+ }
60
+ });
61
+ test("claimPidMarker reclaims a stale marker (dead pid)", () => {
62
+ const filePath = tempMarkerPath();
63
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" }));
64
+ assert.equal(claimPidMarker(filePath), true);
65
+ assert.equal(readPidMarkerOwner(filePath)?.pid, process.pid);
66
+ });
67
+ test("claimPidMarker reclaims unreadable/corrupt content", () => {
68
+ const filePath = tempMarkerPath();
69
+ fs.writeFileSync(filePath, "not json");
70
+ assert.equal(claimPidMarker(filePath), true);
71
+ assert.equal(readPidMarkerOwner(filePath)?.pid, process.pid);
72
+ });
73
+ test("releasePidMarker removes a marker this process owns", () => {
74
+ const filePath = tempMarkerPath();
75
+ claimPidMarker(filePath);
76
+ releasePidMarker(filePath);
77
+ assert.equal(fs.existsSync(filePath), false);
78
+ });
79
+ test("releasePidMarker never removes a marker owned by a different pid", () => {
80
+ const filePath = tempMarkerPath();
81
+ const other = spawnLiveChild();
82
+ try {
83
+ fs.writeFileSync(filePath, JSON.stringify({ pid: other.pid, startedAt: "x" }));
84
+ releasePidMarker(filePath);
85
+ assert.ok(fs.existsSync(filePath));
86
+ assert.equal(readPidMarkerOwner(filePath)?.pid, other.pid);
87
+ }
88
+ finally {
89
+ other.kill();
90
+ }
91
+ });
92
+ test("releasePidMarker on an absent file is a no-op", () => {
93
+ const filePath = tempMarkerPath();
94
+ assert.doesNotThrow(() => releasePidMarker(filePath));
95
+ });
96
+ test("readPidMarkerOwner distinguishes absent from stale", () => {
97
+ const filePath = tempMarkerPath();
98
+ assert.equal(readPidMarkerOwner(filePath), null);
99
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" }));
100
+ const owner = readPidMarkerOwner(filePath);
101
+ assert.equal(owner?.pid, 999999);
102
+ assert.equal(owner?.alive, false);
103
+ });
104
+ test("concurrent reclaim of the same stale marker: exactly one real process wins, never a corrupted mix of both", { timeout: 20000 }, () => {
105
+ // Real child processes, not an in-process simulation — a fake interleaving inside one
106
+ // JS event loop can't actually exercise the OS-level race between two independent
107
+ // processes both reading "stale", both deciding to reclaim, and both replacing the
108
+ // marker. Before the reclaim-lock fix, this reliably produced runs where more than one
109
+ // child reported claimed:true for the same marker.
110
+ const filePath = tempMarkerPath();
111
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" })); // stale
112
+ const N = 8;
113
+ const children = Array.from({ length: N }, () => spawn(tsxBin, [childScript, filePath], { stdio: ["ignore", "pipe", "inherit"] }));
114
+ // The winner(s) stay alive on purpose (see the helper script) — so wait for each
115
+ // child's single result line, not for the process to exit.
116
+ const firstLines = children.map((child) => new Promise((resolve) => {
117
+ let buf = "";
118
+ child.stdout.on("data", (d) => {
119
+ buf += d.toString();
120
+ const nl = buf.indexOf("\n");
121
+ if (nl !== -1)
122
+ resolve(buf.slice(0, nl));
123
+ });
124
+ }));
125
+ return Promise.all(firstLines)
126
+ .then((lines) => {
127
+ const results = lines.map((l) => JSON.parse(l));
128
+ const winners = results.filter((r) => r.claimed);
129
+ assert.equal(winners.length, 1, `expected exactly one winner, got: ${JSON.stringify(results)}`);
130
+ const finalOwner = readPidMarkerOwner(filePath);
131
+ assert.equal(finalOwner?.pid, winners[0].pid, "the file must name the one process that actually won");
132
+ })
133
+ .finally(() => {
134
+ // SIGTERM, not SIGKILL: the tsx launcher forwards SIGTERM to the real node process
135
+ // it forks to actually run the script before exiting; SIGKILL only kills the
136
+ // launcher itself (an unblockable signal it never gets to react to), orphaning
137
+ // that grandchild — which is exactly the live process holding server.pid's
138
+ // real-world equivalent open, so it (and this whole test process) would hang.
139
+ for (const child of children)
140
+ child.kill("SIGTERM");
141
+ return Promise.all(children.map(waitForExit));
142
+ });
143
+ });
144
+ test("the arbiter lock does not linger after a normal claim — a second claim proceeds immediately", () => {
145
+ const filePath = tempMarkerPath();
146
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" }));
147
+ claimPidMarker(filePath);
148
+ releasePidMarker(filePath);
149
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999998, startedAt: "x" }));
150
+ const start = Date.now();
151
+ assert.equal(claimPidMarker(filePath), true);
152
+ assert.ok(Date.now() - start < 500, "a second claim must not be blocked by a stale arbiter lock");
153
+ });
154
+ test("a process that crashes while holding the arbiter lock does not wedge it — the OS releases it, no lease timeout needed", { timeout: 20000 }, async () => {
155
+ // This is the regression the reviewer asked for: instead of a hand-rolled
156
+ // ".reclaim-lock" file with its own "is it stale" heuristic (which had the exact same
157
+ // check-then-delete race the main marker's reclaim did), the arbiter lock is a real
158
+ // OS-level advisory lock (via SQLite's BEGIN EXCLUSIVE) that the kernel releases the
159
+ // instant its holder dies, for any reason — including SIGKILL, simulating a crash
160
+ // mid-decision. Proves this empirically rather than by argument: hold the lock in a
161
+ // real child process, kill it, and confirm a subsequent claim succeeds promptly
162
+ // instead of waiting out the full busy_timeout (or hanging forever).
163
+ const filePath = tempMarkerPath();
164
+ fs.writeFileSync(filePath, JSON.stringify({ pid: 999999, startedAt: "x" })); // stale main marker
165
+ const holder = spawn(tsxBin, [holdArbiterScript, filePath], { stdio: ["ignore", "pipe", "inherit"] });
166
+ const realHolderPid = await new Promise((resolve) => {
167
+ let buf = "";
168
+ holder.stdout.on("data", (d) => {
169
+ buf += d.toString();
170
+ const nl = buf.indexOf("\n");
171
+ if (nl === -1)
172
+ return;
173
+ const msg = JSON.parse(buf.slice(0, nl));
174
+ resolve(msg.pid);
175
+ });
176
+ });
177
+ // Kill the *actual* process holding the lock, not the tsx launcher — see the helper
178
+ // script's comment for why those are different pids and why SIGKILL specifically
179
+ // (an unblockable signal the launcher cannot forward) has to target the real one.
180
+ process.kill(realHolderPid, "SIGKILL");
181
+ holder.kill("SIGTERM"); // clean up the launcher wrapper too
182
+ await waitForExit(holder);
183
+ const start = Date.now();
184
+ const claimed = claimPidMarker(filePath);
185
+ const elapsedMs = Date.now() - start;
186
+ assert.equal(claimed, true);
187
+ assert.equal(readPidMarkerOwner(filePath)?.pid, process.pid);
188
+ assert.ok(elapsedMs < 1000, `claim should succeed promptly once the OS releases the dead holder's lock, took ${elapsedMs}ms`);
189
+ });
190
+ test("sanity: the child helper script actually claims when run alone", async () => {
191
+ const filePath = tempMarkerPath();
192
+ const child = spawn(tsxBin, [childScript, filePath], { stdio: ["ignore", "pipe", "inherit"] });
193
+ await new Promise((resolve) => {
194
+ let buf = "";
195
+ child.stdout.on("data", (d) => {
196
+ buf += d.toString();
197
+ const nl = buf.indexOf("\n");
198
+ if (nl === -1)
199
+ return;
200
+ const parsed = JSON.parse(buf.slice(0, nl));
201
+ assert.equal(parsed.claimed, true);
202
+ assert.equal(readPidMarkerOwner(filePath)?.pid, parsed.pid);
203
+ resolve();
204
+ });
205
+ });
206
+ child.kill("SIGTERM"); // see the concurrent-reclaim test for why SIGTERM, not SIGKILL
207
+ await waitForExit(child);
208
+ });
@@ -1,9 +1,62 @@
1
- import { deliverOutboundDraft } from "../adapters/agent-deck.js";
1
+ import { deliverOutboundDraft } from "../adapters/outbound-delivery.js";
2
2
  import { syncLinearForRun } from "../adapters/linear-sync.js";
3
- import { workspaceForRun } from "../runners/prompts.js";
4
- import { addArtifact, appendEvent, getRun, transitionRun, } from "../repository/runs.js";
5
- import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, patchPendingOutboundBody, revertOutboundDraftToPending, } from "../repository/outbound-drafts.js";
6
- import { scheduleReflect } from "./dispatcher.js";
3
+ import { addArtifact, appendEvent, getRun, transitionRun } from "../repository/runs.js";
4
+ import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, patchPendingOutboundBody, rejectPendingOutboundDrafts, revertOutboundDraftToPending, } from "../repository/outbound-drafts.js";
5
+ import { createHumanAction, findOpenHumanActionForRun, getHumanAction, resolveHumanAction, } from "../repository/human-actions.js";
6
+ import { scheduleReflect } from "./reflect-schedule.js";
7
+ /** Who/what resolved a parked `outbound_delivery_interaction_required` action when
8
+ * `finalizeRunWithoutDelivery` auto-closes it — `resolveOutboundDeliveryAction`'s
9
+ * `retry_send` passes the real operator + "retry_send" through; every other caller (a plain
10
+ * caller with no operator identity) gets this generic marker. */
11
+ const DEFAULT_ACTION_RESOLUTION = { resolvedBy: "system", choice: "resolved_via_approve" };
12
+ /**
13
+ * Resolves a run's open `outbound_delivery_interaction_required` action, if any — a no-op
14
+ * otherwise. Any code path that terminalizes a run (finalize-on-success below, but also
15
+ * `resolveOutboundDeliveryAction`'s `reject`) must call this, or a
16
+ * parked action is left open forever: once the run leaves `review`, `resolveOutboundDeliveryAction`'s
17
+ * `retry_send` 400s on "Run must be in review" and there is no other way to close the item.
18
+ */
19
+ export function resolveOpenDeliveryParkForRun(runId, actionResolution) {
20
+ const openAction = findOpenHumanActionForRun(runId, "outbound_delivery_interaction_required");
21
+ if (openAction) {
22
+ resolveHumanAction(openAction.id, actionResolution.resolvedBy, { choice: actionResolution.choice });
23
+ }
24
+ }
25
+ /**
26
+ * Finalizes a run that has no more delivery work to do (no pending draft, or the pending
27
+ * draft was just sent). Also auto-resolves any still-open `outbound_delivery_interaction_required`
28
+ * action for this run: an operator can clear a park either by resolving it directly
29
+ * (`retry_send`/`reject`) or by an approve path that finds nothing left to deliver — either
30
+ * must close the queue item, not just the run.
31
+ */
32
+ function finalizeRunWithoutDelivery(runId, actionResolution = DEFAULT_ACTION_RESOLUTION) {
33
+ resolveOpenDeliveryParkForRun(runId, actionResolution);
34
+ const updated = transitionRun(runId, "done");
35
+ syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
36
+ scheduleReflect(updated, { trigger: "approve" });
37
+ return updated;
38
+ }
39
+ /** Raises the one operator action this run's ambiguous-delivery blockage calls for — at most
40
+ * one open `outbound_delivery_interaction_required` action per run at a time (same idempotent
41
+ * re-raise convention as `product_scope_decision`'s `findOpenHumanAction`). Action type kept
42
+ * for existing parks even though the trigger is now ambiguous-only (NOT-106). */
43
+ function parkOnAmbiguousDelivery(runId, draftArtifactId, toolCall, reason) {
44
+ const existing = findOpenHumanActionForRun(runId, "outbound_delivery_interaction_required");
45
+ if (existing)
46
+ return;
47
+ createHumanAction({
48
+ runId,
49
+ actionType: "outbound_delivery_interaction_required",
50
+ reason,
51
+ question: "Outbound delivery may have already reached the provider. Retry the send or reject the draft?",
52
+ evidence: { draftArtifactId, serviceId: toolCall.serviceName, toolName: toolCall.toolName },
53
+ responseOptions: [
54
+ { choice: "retry_send", label: "Retry send" },
55
+ { choice: "reject", label: "Reject draft" },
56
+ ],
57
+ requestId: null,
58
+ });
59
+ }
7
60
  export async function approveRunWithDeliver(runId, deps) {
8
61
  const run = getRun(runId);
9
62
  if (!run)
@@ -29,48 +82,95 @@ export async function approveRunWithDeliver(runId, deps) {
29
82
  }
30
83
  }
31
84
  if (!pending) {
32
- const updated = transitionRun(runId, "done");
33
- syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
34
- scheduleReflect(updated, { trigger: "approve" });
35
- return { ok: true, run: updated, delivered: false };
85
+ return { ok: true, run: finalizeRunWithoutDelivery(runId, deps?.actionResolution), delivered: false };
36
86
  }
37
87
  if (!run.deckId) {
38
88
  return { ok: false, code: 502, error: "No deck bound — cannot deliver outbound draft" };
39
89
  }
40
90
  const deliver = deps?.deliver ?? deliverOutboundDraft;
91
+ const draftArtifactId = pending.artifact.id;
92
+ const toolCall = pending.content.draft.toolCall;
93
+ const deckId = run.deckId;
41
94
  deliverInFlight.add(runId);
42
95
  try {
43
- const claimed = markOutboundDraftSent(pending.artifact.id);
96
+ const claimed = markOutboundDraftSent(draftArtifactId);
44
97
  if (!claimed) {
45
98
  return { ok: false, code: 409, error: "Draft already sent or rejected" };
46
99
  }
47
- let toolResult;
48
- let permalink;
100
+ let result;
49
101
  try {
50
- const result = await deliver(run.deckId, pending.content.draft.toolCall, {
51
- workspaceRoot: workspaceForRun(run),
52
- });
53
- toolResult = result.toolResult;
54
- permalink = result.permalink;
102
+ result = await deliver(deckId, toolCall);
55
103
  }
56
104
  catch (err) {
57
- revertOutboundDraftToPending(pending.artifact.id);
58
- const message = err instanceof Error ? err.message : String(err);
59
- appendEvent(runId, "deliver_failed", { error: message });
60
- return { ok: false, code: 502, error: message };
105
+ revertOutboundDraftToPending(draftArtifactId);
106
+ const reason = err instanceof Error ? err.message : String(err);
107
+ appendEvent(runId, "deliver_failed", { error: reason, errorCode: "DELIVERY_FAILED" });
108
+ return { ok: false, code: 502, error: reason, errorCode: "DELIVERY_FAILED" };
109
+ }
110
+ if (!result.ok) {
111
+ revertOutboundDraftToPending(draftArtifactId);
112
+ const errorCode = result.kind === "ambiguous" ? "AMBIGUOUS_RESULT" : "DELIVERY_FAILED";
113
+ appendEvent(runId, "deliver_failed", { error: result.reason, errorCode });
114
+ // Ambiguous timeout is never safe to silently bounded-retry (NOT-91) — it needs an
115
+ // explicit "retry send or reject" human decision.
116
+ if (result.kind === "ambiguous") {
117
+ parkOnAmbiguousDelivery(runId, draftArtifactId, toolCall, result.reason);
118
+ }
119
+ return { ok: false, code: 502, error: result.reason, errorCode };
61
120
  }
62
121
  addArtifact(runId, "send_receipt", {
63
- draftArtifactId: pending.artifact.id,
122
+ draftArtifactId,
64
123
  sentAt: new Date().toISOString(),
65
- toolResult: toolResult,
66
- permalink,
124
+ toolResult: result.toolResult,
125
+ permalink: result.permalink,
67
126
  }, "system");
68
- const updated = transitionRun(runId, "done");
69
- syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
70
- scheduleReflect(updated, { trigger: "approve" });
71
- return { ok: true, run: updated, delivered: true };
127
+ return { ok: true, run: finalizeRunWithoutDelivery(runId, deps?.actionResolution), delivered: true };
72
128
  }
73
129
  finally {
74
130
  deliverInFlight.delete(runId);
75
131
  }
76
132
  }
133
+ /**
134
+ * Resolves an open `outbound_delivery_interaction_required` action — Run-scoped, so this
135
+ * (like NOT-94's `resolveReflectionInteractionAction`) never goes through
136
+ * `resolveHumanActionAndAdvance`, which assumes an Issue + workflow_instance.
137
+ *
138
+ * `reject` is always terminal: no provider call, action resolved immediately, run finalized
139
+ * without delivering.
140
+ *
141
+ * `retry_send` re-attempts delivery of the still-pending draft. The action is resolved only
142
+ * once that attempt actually *succeeds* — on any failure (ordinary infra, or another
143
+ * ambiguous result) the action is left open rather than resolved out from under the
144
+ * operator. `approveRunWithDeliver`'s own park dedup (`findOpenHumanActionForRun`) finds
145
+ * this same still-open action on a repeat ambiguous result and does not raise a second one.
146
+ */
147
+ export async function resolveOutboundDeliveryAction(actionId, resolvedBy, choice, deps) {
148
+ const action = getHumanAction(actionId);
149
+ if (!action)
150
+ return { ok: false, code: 404, error: "Human action not found" };
151
+ if (action.actionType !== "outbound_delivery_interaction_required") {
152
+ return { ok: false, code: 400, error: `Action ${actionId} is not an outbound_delivery_interaction_required action` };
153
+ }
154
+ if (!action.runId)
155
+ return { ok: false, code: 500, error: "Human action has no run" };
156
+ if (action.status !== "open")
157
+ return { ok: false, code: 409, error: "Human action already resolved" };
158
+ if (choice !== "retry_send" && choice !== "reject") {
159
+ return { ok: false, code: 400, error: `Invalid choice "${choice}" for outbound_delivery_interaction_required` };
160
+ }
161
+ if (choice === "reject") {
162
+ resolveHumanAction(actionId, resolvedBy, { choice });
163
+ rejectPendingOutboundDrafts(action.runId);
164
+ const updated = finalizeRunWithoutDelivery(action.runId);
165
+ return { ok: true, runStatus: updated.status, delivered: false };
166
+ }
167
+ // retry_send — the draft is still pending; re-attempt under the run's deck header.
168
+ // actionResolution carries the real operator identity through to
169
+ // finalizeRunWithoutDelivery's auto-close on success.
170
+ const result = await approveRunWithDeliver(action.runId, { ...deps, actionResolution: { resolvedBy, choice } });
171
+ if (!result.ok) {
172
+ // Left open on purpose — see the doc comment above.
173
+ return { ok: false, code: result.code, error: result.error };
174
+ }
175
+ return { ok: true, runStatus: result.run.status, delivered: result.delivered };
176
+ }
@@ -0,0 +1,25 @@
1
+ import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
2
+ import { runReflect } from "../runners/reflect.js";
3
+ const activeReflects = new Set();
4
+ /** Fire-and-forget: post-review playbook reflect (D3 learning loop). */
5
+ export function scheduleReflect(run, opts) {
6
+ if (!run.playbookId || !run.deckId)
7
+ return;
8
+ if (activeReflects.has(run.id))
9
+ return;
10
+ void (async () => {
11
+ const online = await checkAgentDeckHealth();
12
+ if (!online)
13
+ return;
14
+ activeReflects.add(run.id);
15
+ try {
16
+ await runReflect(run, opts);
17
+ }
18
+ catch (e) {
19
+ console.error("[reflect]", run.id, e);
20
+ }
21
+ finally {
22
+ activeReflects.delete(run.id);
23
+ }
24
+ })();
25
+ }
@@ -3,7 +3,7 @@ import { addArtifact, getRun } from "../repository/runs.js";
3
3
  import { hasPendingQaExchange, latestExecuteSessionId } from "../repository/result-qa.js";
4
4
  import { runQa } from "../runners/qa.js";
5
5
  const ASKABLE_STATUSES = new Set(["review", "done"]);
6
- const QA_RUNTIMES = new Set(["claude_code", "cursor_local"]);
6
+ const QA_RUNTIMES = new Set(["claude_code", "cursor_local", "codex_local"]);
7
7
  /** Ask the run's agent about its finished result. Read-only; never changes run status. */
8
8
  export function askResultQuestion(runId, question, opts) {
9
9
  // An empty question would persist a result_qa artifact that ResultQaContent then