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
@@ -7,19 +7,10 @@ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-pr
7
7
  process.env.MAX_CONCURRENT_RUNS = "0";
8
8
  const { migrate } = await import("../db/index.js");
9
9
  const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
10
- const { updateAgent } = await import("../repository/agents.js");
11
10
  const { addArtifact, createRun } = await import("../repository/runs.js");
12
- const { buildExecutionPrompt, buildPlanPrompt, buildPlanEditedReplanPrompt, buildPlanFeedbackPrompt, buildPlanRevisePrompt, buildQaPrompt } = await import("./prompts.js");
13
- const QUESTIONS = [
14
- {
15
- id: "q1",
16
- question: "Which storage backend?",
17
- options: [{ label: "SQLite" }, { label: "Postgres" }],
18
- },
19
- ];
11
+ const { buildReflectPrompt } = await import("./prompts.js");
20
12
  before(() => {
21
13
  migrate();
22
- updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
23
14
  });
24
15
  function makeRun() {
25
16
  return createRun({
@@ -27,159 +18,37 @@ function makeRun() {
27
18
  taskCategory: "other",
28
19
  status: "plan_pending",
29
20
  agentId: BUILTIN_AGENT_CLAUDE_ID,
21
+ repo: "acme/app"
30
22
  });
31
23
  }
32
- test("plan prompt includes the triage contract", () => {
33
- const prompt = buildPlanPrompt(makeRun());
34
- assert.match(prompt, /"verdict"/);
35
- assert.match(prompt, /needs_review/);
36
- assert.match(prompt, /at most 3 questions/i);
24
+ test("reflect prompt restates the task and the read-only patch contract", () => {
25
+ const prompt = buildReflectPrompt(makeRun(), { trigger: "approve" });
26
+ assert.match(prompt, /Prompt test task/);
27
+ assert.match(prompt, /do NOT call update_playbook or propose_playbook_patch/);
28
+ assert.match(prompt, /"rationale"/);
29
+ assert.match(prompt, /Human approved this run without retry feedback/);
37
30
  });
38
- test("communication plan prompt favors trivial triage and defers send details to send gate", () => {
39
- const run = createRun({
40
- title: "Send a test message to Yusuke",
41
- description: "Say hello from agent-dealer",
42
- taskCategory: "communication",
43
- status: "plan_pending",
44
- agentId: BUILTIN_AGENT_CLAUDE_ID,
45
- });
46
- const prompt = buildPlanPrompt(run);
47
- assert.match(prompt, /Outbound send gate/);
48
- assert.match(prompt, /send gate reviews the exact payload/i);
49
- assert.match(prompt, /Do not ask plan questions about recipient/i);
50
- assert.doesNotMatch(prompt, /When in doubt, use "needs_review"/);
51
- assert.match(prompt, /brief bullet plan/i);
52
- assert.doesNotMatch(prompt, /step-by-step plan with risks/);
53
- });
54
- test("other category plan prompt keeps conservative when-in-doubt rule", () => {
55
- const prompt = buildPlanPrompt(makeRun());
56
- assert.match(prompt, /When in doubt, use "needs_review"/);
57
- assert.match(prompt, /step-by-step plan with risks/);
58
- });
59
- test("execution prompt renders human answers as Q→A pairs", () => {
60
- const run = makeRun();
61
- addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
62
- addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
63
- addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
64
- addArtifact(run.id, "plan_answers", {
65
- answers: [{ questionId: "q1", selectedLabel: "SQLite" }],
66
- outcome: "approved",
67
- answeredAt: new Date().toISOString(),
68
- }, "human");
69
- const prompt = buildExecutionPrompt(run);
70
- assert.match(prompt, /## Human answers to plan questions/);
71
- assert.match(prompt, /Which storage backend\?.*SQLite/s);
72
- });
73
- test("execution prompt omits answers section when none exist", () => {
74
- const run = makeRun();
75
- addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
76
- assert.doesNotMatch(buildExecutionPrompt(run), /Human answers to plan questions/);
77
- });
78
- test("feedback replan prompt includes human comments and contract", () => {
79
- const run = makeRun();
80
- const prompt = buildPlanFeedbackPrompt(run, "Add integration tests and skip the migration step.");
81
- assert.match(prompt, /Add integration tests/);
82
- assert.match(prompt, /"verdict"/);
83
- });
84
- test("edited replan prompt includes human-edited markdown", () => {
85
- const run = makeRun();
86
- const prompt = buildPlanEditedReplanPrompt(run, "# Revised plan\n\n1. Ship feature");
87
- assert.match(prompt, /Revised plan/);
88
- assert.match(prompt, /Ship feature/);
89
- });
90
- test("revise prompt pairs each answer with its question and re-states the contract", () => {
91
- const run = makeRun();
92
- const prompt = buildPlanRevisePrompt(run, QUESTIONS, [{ questionId: "q1", freeText: "Use flat files instead" }]);
93
- assert.match(prompt, /Which storage backend\?/);
94
- assert.match(prompt, /Use flat files instead/);
95
- assert.match(prompt, /"verdict"/);
96
- });
97
- test("delegated plan answers surface the unanswered questions to the executor", () => {
98
- const run = makeRun();
99
- addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
100
- addArtifact(run.id, "plan_triage", {
101
- verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: true,
102
- }, "agent");
103
- addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
104
- addArtifact(run.id, "plan_answers", { answers: [], outcome: "delegated", answeredAt: new Date().toISOString() }, "human");
105
- const prompt = buildExecutionPrompt(run);
106
- assert.match(prompt, /## Unanswered plan questions/);
107
- assert.match(prompt, /Which storage backend\?/);
108
- assert.match(prompt, /SQLite/);
109
- assert.match(prompt, /best judgment/i);
110
- assert.doesNotMatch(prompt, /## Human answers to plan questions/);
111
- });
112
- test("execution prompt renders answered review Q&A from the lineage parent", () => {
113
- const parent = makeRun();
114
- addArtifact(parent.id, "approved_plan", { markdown: "# Plan" }, "human");
115
- addArtifact(parent.id, "result_qa", {
116
- exchangeId: "x1",
117
- question: "Did you run the tests?",
118
- answer: "Yes, all 12 pass.",
119
- status: "answered",
120
- sessionResumed: true,
121
- askedAt: "2026-07-08T00:00:00.000Z",
122
- answeredAt: "2026-07-08T00:01:00.000Z",
123
- }, "agent");
124
- addArtifact(parent.id, "result_qa", {
125
- exchangeId: "x2",
126
- question: "Pending one",
127
- status: "pending",
128
- sessionResumed: true,
129
- askedAt: "2026-07-08T00:02:00.000Z",
130
- }, "human");
131
- const retry = createRun({
132
- title: "Prompt test task",
133
- taskCategory: "other",
134
- status: "plan_approved",
135
- agentId: BUILTIN_AGENT_CLAUDE_ID,
136
- }, { lineageId: parent.lineageId ?? parent.id });
137
- addArtifact(retry.id, "approved_plan", { markdown: "# Plan" }, "human");
138
- addArtifact(retry.id, "feedback", { markdown: "Tighten the summary" }, "human");
139
- const prompt = buildExecutionPrompt(retry);
140
- assert.match(prompt, /## Review Q&A/);
141
- assert.match(prompt, /Did you run the tests\?/);
142
- assert.match(prompt, /Yes, all 12 pass\./);
143
- assert.doesNotMatch(prompt, /Pending one/);
144
- });
145
- test("execution prompt omits the Q&A section when there are no answered exchanges", () => {
146
- const run = makeRun();
147
- addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
148
- assert.doesNotMatch(buildExecutionPrompt(run), /## Review Q&A/);
149
- });
150
- test("grounded qa prompt asks the question without re-stating artifacts", () => {
151
- const run = makeRun();
152
- addArtifact(run.id, "approved_plan", { markdown: "# Plan\n1. Use SQLite" }, "human");
153
- const prompt = buildQaPrompt(run, "Why SQLite?", { grounded: true });
154
- assert.match(prompt, /Why SQLite\?/);
155
- assert.match(prompt, /Do NOT modify anything/i);
156
- assert.doesNotMatch(prompt, /## Approved plan/);
157
- });
158
- test("ungrounded qa prompt rebuilds context from artifacts", () => {
31
+ test("reflect prompt carries the plan and execution artifacts when they exist", () => {
159
32
  const run = makeRun();
160
33
  addArtifact(run.id, "approved_plan", { markdown: "# Plan\n1. Use SQLite" }, "human");
161
34
  addArtifact(run.id, "execution_result", { phase: "execute", exitCode: 0, resultText: "Wrote the doc", isError: false }, "agent");
162
- addArtifact(run.id, "document", { path: "/tmp/x.md", title: "x", markdown: "# Deliverable body" }, "agent");
163
- const prompt = buildQaPrompt(run, "Why SQLite?", { grounded: false });
35
+ const prompt = buildReflectPrompt(run, { trigger: "approve" });
164
36
  assert.match(prompt, /## Approved plan/);
37
+ assert.match(prompt, /Use SQLite/);
165
38
  assert.match(prompt, /## Execution outcome/);
166
- assert.match(prompt, /## Deliverable/);
167
- assert.match(prompt, /# Deliverable body/);
168
- assert.match(prompt, /Why SQLite\?/);
39
+ assert.match(prompt, /Wrote the doc/);
169
40
  });
170
- test("qa prompt never asks for a json block or an outbound draft", () => {
41
+ test("retry reflect prompt prefers the explicit feedback over the stored artifact", () => {
171
42
  const run = makeRun();
172
- const prompt = buildQaPrompt(run, "What did you check?", { grounded: true });
173
- assert.doesNotMatch(prompt, /Outbound actions/);
174
- assert.doesNotMatch(prompt, /"verdict"/);
43
+ addArtifact(run.id, "feedback", { markdown: "Stored feedback" }, "human");
44
+ const prompt = buildReflectPrompt(run, { trigger: "retry", feedback: "Tighten the summary" });
45
+ assert.match(prompt, /## Human feedback \(highest signal\)/);
46
+ assert.match(prompt, /Tighten the summary/);
47
+ assert.doesNotMatch(prompt, /Stored feedback/);
175
48
  });
176
- test("execution prompt includes outbound draft contract", () => {
49
+ test("retry reflect prompt falls back to the run's own human feedback artifact", () => {
177
50
  const run = makeRun();
178
- addArtifact(run.id, "approved_plan", { markdown: "# Plan" }, "human");
179
- const prompt = buildExecutionPrompt(run);
180
- assert.match(prompt, /Outbound actions/);
181
- assert.match(prompt, /call_service_tool IS available/);
182
- assert.doesNotMatch(prompt, /call_service_tool is blocked/);
183
- assert.match(prompt, /service_tool_call/);
184
- assert.match(prompt, /"actionType"/);
51
+ addArtifact(run.id, "feedback", { markdown: "Stored feedback" }, "human");
52
+ const prompt = buildReflectPrompt(run, { trigger: "retry" });
53
+ assert.match(prompt, /Stored feedback/);
185
54
  });
@@ -1,6 +1,8 @@
1
1
  import { runClaude, runCursor } from "./claude.js";
2
+ import { runCodex } from "./codex.js";
2
3
  import { buildQaPrompt } from "./prompts.js";
3
4
  import { extractResultIsError, extractResultText, extractUsage, parseNdjson } from "./stream-json.js";
5
+ import { normalizeCodexEvents, parseCodexJsonl } from "./codex-jsonl.js";
4
6
  /** One read-only Q&A turn against a finished run. Resumes the execute session when we still have it. */
5
7
  export async function runQa(run, question, resumeSessionId) {
6
8
  let result;
@@ -19,13 +21,27 @@ export async function runQa(run, question, resumeSessionId) {
19
21
  });
20
22
  }
21
23
  }
24
+ else if (runtime === "codex_local") {
25
+ const prompt = buildQaPrompt(run, question, { grounded: Boolean(resumeSessionId) });
26
+ result = await runCodex(run, "qa", executeModel ?? undefined, {
27
+ promptOverride: prompt,
28
+ ...(resumeSessionId ? { resumeSessionId } : {}),
29
+ });
30
+ if (result.exitCode !== 0 && resumeSessionId) {
31
+ result = await runCodex(run, "qa", executeModel ?? undefined, {
32
+ promptOverride: buildQaPrompt(run, question, { grounded: false }),
33
+ });
34
+ }
35
+ }
22
36
  else {
23
37
  result = await runClaude(run, "qa", executeModel, {
24
38
  promptOverride: buildQaPrompt(run, question, { grounded: Boolean(resumeSessionId) }),
25
39
  ...(resumeSessionId ? { resumeSessionId } : {}),
26
40
  });
27
41
  }
28
- const events = parseNdjson(result.transcript);
42
+ const events = runtime === "codex_local"
43
+ ? normalizeCodexEvents(parseCodexJsonl(result.transcript))
44
+ : parseNdjson(result.transcript);
29
45
  const usage = extractUsage(events, "qa", runtime);
30
46
  const answer = extractResultText(events)?.trim() ?? "";
31
47
  const failed = result.exitCode !== 0 || extractResultIsError(events) || !answer;
@@ -1,7 +1,7 @@
1
- import { PlaybookPatchContent, ReflectProposalSchema } from "@agent-dealer/shared";
2
- import { addArtifact, getLatestArtifact } from "../repository/runs.js";
1
+ import { ReflectProposalSchema } from "@agent-dealer/shared";
2
+ import { addArtifact } from "../repository/runs.js";
3
3
  import { agentDeckPatchesUrl, checkAgentDeckHealth, fetchPlaybook, proposePlaybookPatch, } from "../adapters/agent-deck.js";
4
- import { runClaude } from "./claude.js";
4
+ import { runClaudeReflect } from "./claude.js";
5
5
  import { buildReflectPrompt } from "./prompts.js";
6
6
  import { extractResultText, parseNdjson } from "./stream-json.js";
7
7
  function parseReflectProposal(text) {
@@ -35,7 +35,7 @@ export async function runReflect(run, opts) {
35
35
  return;
36
36
  }
37
37
  const playbook = await fetchPlaybook(run.playbookId);
38
- const result = await runClaude(run, "reflect", undefined, { promptOverride: buildReflectPrompt(run, opts) });
38
+ const result = await runClaudeReflect(run, { promptOverride: buildReflectPrompt(run, opts) });
39
39
  const events = parseNdjson(result.transcript);
40
40
  const resultText = extractResultText(events) ?? result.transcript;
41
41
  const proposal = parseReflectProposal(resultText);
@@ -76,15 +76,3 @@ export async function runReflect(run, opts) {
76
76
  addArtifact(run.id, "reflect_status", { status: "failed", trigger: opts.trigger, error: String(err) }, "system");
77
77
  }
78
78
  }
79
- export function latestProposedPatch(runId) {
80
- const art = getLatestArtifact(runId, "playbook_patch");
81
- if (!art?.contentJson)
82
- return null;
83
- try {
84
- const parsed = PlaybookPatchContent.parse(JSON.parse(art.contentJson));
85
- return parsed.status === "proposed" ? parsed : null;
86
- }
87
- catch {
88
- return null;
89
- }
90
- }
@@ -0,0 +1,197 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { getTemporalLogsDir } from "../paths.js";
5
+ import { acquireSpawnSlot, killRunProcess, registerChild, releaseSpawnSlot, unregisterChild, } from "./process-registry.js";
6
+ export { getActiveLogPath, killRunProcess } from "./process-registry.js";
7
+ export function timeoutMsForMode(mode) {
8
+ const envKey = mode === "plan"
9
+ ? "PLAN_TIMEOUT_MS"
10
+ : mode === "execute"
11
+ ? "EXECUTE_TIMEOUT_MS"
12
+ : mode === "reflect"
13
+ ? "REFLECT_TIMEOUT_MS"
14
+ : "QA_TIMEOUT_MS";
15
+ const defaults = {
16
+ plan: 15 * 60_000,
17
+ execute: 60 * 60_000,
18
+ reflect: 10 * 60_000,
19
+ qa: 5 * 60_000,
20
+ };
21
+ const raw = process.env[envKey];
22
+ if (raw !== undefined && raw !== "")
23
+ return Number(raw);
24
+ return defaults[mode];
25
+ }
26
+ /**
27
+ * Grace period between the SIGTERM an abort sends and the SIGKILL that follows it. The
28
+ * agent CLIs spawn their own children (login shells, test runners), and they reap those
29
+ * themselves on SIGTERM the way they do on Ctrl-C — so terminating politely first is what
30
+ * actually cleans up the whole tree. SIGKILL is only the backstop for a CLI that ignores
31
+ * the first signal.
32
+ */
33
+ function abortKillGraceMs() {
34
+ return Number(process.env.SPAWN_ABORT_KILL_GRACE_MS ?? 5_000);
35
+ }
36
+ export async function spawnCli(runId, cmd, args, cwd, opts) {
37
+ await acquireSpawnSlot();
38
+ try {
39
+ return await new Promise((resolve, reject) => {
40
+ const stdoutChunks = [];
41
+ const stderrChunks = [];
42
+ let timedOut = false;
43
+ let settled = false;
44
+ let killEscalation;
45
+ const logStream = fs.createWriteStream(opts.logPath, { flags: "w" });
46
+ // A WriteStream's 'error' event has no default handler — left unguarded, any
47
+ // stream error (a write after end, ENOSPC, a permissions problem) is an uncaught
48
+ // exception that crashes this entire process, taking down every other in-flight
49
+ // issue's coordinator work along with it. Best-effort: the transcript this
50
+ // function resolves with is already buffered in memory regardless of whether the
51
+ // log file write succeeds.
52
+ logStream.on("error", (err) => {
53
+ console.error(`[spawn-cli] log stream error for ${opts.logPath}`, err);
54
+ });
55
+ const child = spawn(cmd, args, {
56
+ cwd,
57
+ // Extra vars (e.g. codex's bearer-token env var for its per-attempt CODEX_HOME
58
+ // MCP config, agent-deck-bind.ts) are added on top of, never in place of, the
59
+ // process env the CLI itself needs to run.
60
+ env: { ...process.env, ...opts.env },
61
+ stdio: ["ignore", "pipe", "pipe"],
62
+ });
63
+ registerChild(runId, child, opts.logPath);
64
+ // Best-effort bookkeeping: a throwing callback must never take down the spawn.
65
+ if (child.pid !== undefined) {
66
+ try {
67
+ opts.onSpawn?.(child.pid);
68
+ }
69
+ catch (err) {
70
+ console.error(`[spawn-cli] onSpawn for ${runId}`, err);
71
+ }
72
+ }
73
+ const onAbort = () => {
74
+ if (settled)
75
+ return;
76
+ try {
77
+ child.kill("SIGTERM");
78
+ }
79
+ catch {
80
+ // Already exited between the abort firing and this kill — nothing to signal.
81
+ }
82
+ killEscalation = setTimeout(() => {
83
+ try {
84
+ child.kill("SIGKILL");
85
+ }
86
+ catch {
87
+ // Same race as above; the 'close' handler still settles the promise.
88
+ }
89
+ }, abortKillGraceMs());
90
+ killEscalation.unref?.();
91
+ };
92
+ // Not cleared on settle alone — `cleanupAbort` also drops the listener, so a
93
+ // long-lived signal (one AbortController per work item, reused across the
94
+ // handler's later stages) never retains this closure after the child is gone.
95
+ const cleanupAbort = () => {
96
+ if (killEscalation)
97
+ clearTimeout(killEscalation);
98
+ opts.signal?.removeEventListener("abort", onAbort);
99
+ };
100
+ if (opts.signal) {
101
+ if (opts.signal.aborted)
102
+ onAbort();
103
+ else
104
+ opts.signal.addEventListener("abort", onAbort, { once: true });
105
+ }
106
+ const finish = (exitCode) => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ cleanupAbort();
112
+ unregisterChild(runId);
113
+ // Write any stderr BEFORE ending the stream — writing after end() throws
114
+ // ERR_STREAM_WRITE_AFTER_END (this crashed the whole process on any real CLI
115
+ // invocation that produced stderr output; every fixture-based test's fake spawn
116
+ // never wrote stderr, so this path went unexercised until a real session hit it).
117
+ const stderr = stderrChunks.join("");
118
+ // write()/end() only queue the I/O — resolving immediately (PR #27 review) races
119
+ // the actual flush, so a caller reading opts.logPath right after this promise
120
+ // settles can see a file missing the stderr trailer (or, on a slow disk, even
121
+ // the tail of stdout). Wait for the stream to actually finish (or, if the
122
+ // underlying write itself errors, the 'error' handler above already logged it —
123
+ // finalize anyway rather than hang forever on a transcript that's already fully
124
+ // buffered in memory regardless of the file write's outcome).
125
+ let finalized = false;
126
+ let finalizeTimer;
127
+ const finalize = () => {
128
+ if (finalized)
129
+ return;
130
+ finalized = true;
131
+ if (finalizeTimer)
132
+ clearTimeout(finalizeTimer);
133
+ resolve({
134
+ exitCode,
135
+ transcript: stdoutChunks.join(""),
136
+ timedOut,
137
+ });
138
+ };
139
+ if (logStream.destroyed || logStream.errored) {
140
+ // The stream already failed (e.g. ENOENT on open, or a write error during
141
+ // stdout streaming — before this function ever attached the listeners below)
142
+ // — end() on an already-destroyed stream emits neither 'finish' nor a fresh
143
+ // 'error', so waiting for either would hang this promise forever and leak the
144
+ // spawn slot (PR #27 review, round 2). The transcript is already fully
145
+ // buffered in memory regardless of the file write's outcome.
146
+ finalize();
147
+ }
148
+ else {
149
+ logStream.once("finish", finalize);
150
+ logStream.once("error", finalize);
151
+ // Belt-and-suspenders: 'finish'/'error' are expected to fire quickly once
152
+ // end() is called, but a stream wedged on some other, unanticipated failure
153
+ // mode must still never pin this work item's spawn slot indefinitely.
154
+ finalizeTimer = setTimeout(finalize, 2000);
155
+ finalizeTimer.unref?.();
156
+ if (stderr.trim()) {
157
+ logStream.end(`\n--- stderr ---\n${stderr}`);
158
+ }
159
+ else {
160
+ logStream.end();
161
+ }
162
+ }
163
+ };
164
+ const timer = setTimeout(() => {
165
+ timedOut = true;
166
+ killRunProcess(runId);
167
+ setTimeout(() => finish(124), 500);
168
+ }, opts.timeoutMs);
169
+ child.stdout?.on("data", (buf) => {
170
+ const chunk = buf.toString();
171
+ stdoutChunks.push(chunk);
172
+ logStream.write(buf);
173
+ });
174
+ child.stderr?.on("data", (buf) => {
175
+ stderrChunks.push(buf.toString());
176
+ });
177
+ child.on("error", (err) => {
178
+ if (settled)
179
+ return;
180
+ settled = true;
181
+ clearTimeout(timer);
182
+ cleanupAbort();
183
+ unregisterChild(runId);
184
+ logStream.end();
185
+ reject(err);
186
+ });
187
+ child.on("close", (code) => finish(code ?? 1));
188
+ });
189
+ }
190
+ finally {
191
+ releaseSpawnSlot();
192
+ }
193
+ }
194
+ export function logPathFor(run, mode) {
195
+ const logDir = getTemporalLogsDir();
196
+ return path.join(logDir, `${run.id}-${mode}-${Date.now()}.ndjson`);
197
+ }
@@ -0,0 +1,130 @@
1
+ // Regression test for a crash discovered live on the very first real (non-fixture) dev
2
+ // session: `finish()` called `logStream.end()` and then, if the child produced any
3
+ // stderr, `logStream.write(...)` on the now-ended stream -- ERR_STREAM_WRITE_AFTER_END,
4
+ // an unhandled stream 'error' event, which crashes the entire coordinator process (every
5
+ // fixture-based test's fake spawn never wrote real stderr, so this path was never
6
+ // exercised until a real CLI invocation hit it).
7
+ import { test } from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { spawnCli } from "./spawn-cli.js";
13
+ test("spawnCli does not crash when the child process writes to stderr", async () => {
14
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
15
+ const result = await spawnCli("test-run-stderr", "sh", ["-c", "echo out-line; echo err-line 1>&2"], process.cwd(), { logPath, timeoutMs: 5000 });
16
+ assert.equal(result.exitCode, 0);
17
+ assert.equal(result.timedOut, false);
18
+ assert.match(result.transcript, /out-line/);
19
+ // The stderr section was actually appended to the log file, not lost -- proves the
20
+ // write happened before end(), not just that it failed to throw.
21
+ const logged = fs.readFileSync(logPath, "utf8");
22
+ assert.match(logged, /out-line/);
23
+ assert.match(logged, /--- stderr ---/);
24
+ assert.match(logged, /err-line/);
25
+ });
26
+ test("spawnCli does not crash when the child writes only stdout (no stderr)", async () => {
27
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
28
+ const result = await spawnCli("test-run-nostderr", "sh", ["-c", "echo out-only"], process.cwd(), {
29
+ logPath,
30
+ timeoutMs: 5000,
31
+ });
32
+ assert.equal(result.exitCode, 0);
33
+ assert.match(result.transcript, /out-only/);
34
+ const logged = fs.readFileSync(logPath, "utf8");
35
+ assert.doesNotMatch(logged, /--- stderr ---/);
36
+ });
37
+ // PR #27 review, round 2: waiting for finish()/error on the write stream only helps if
38
+ // those listeners are attached before the stream already failed. A stream that errors
39
+ // (and self-destroys) BEFORE the child closes -- e.g. ENOENT opening the log file --
40
+ // never emits either event again once end() is called, so a naive wait-then-resolve
41
+ // would hang forever, leaking the spawn slot. Set a short per-test timeout so a
42
+ // regression fails fast instead of hanging the whole suite.
43
+ test("spawnCli still resolves (does not hang) when the log stream already errored before the child closed", { timeout: 5000 }, async () => {
44
+ // A path whose parent directory doesn't exist -- fs.createWriteStream fails with
45
+ // ENOENT almost immediately, well before this "sleep" child exits.
46
+ const logPath = path.join(os.tmpdir(), `dealer-spawn-cli-missing-${Date.now()}`, "nested", "out.ndjson");
47
+ const result = await spawnCli("test-run-stream-error", "sh", ["-c", "sleep 0.2; echo done"], process.cwd(), {
48
+ logPath,
49
+ timeoutMs: 5000,
50
+ });
51
+ assert.equal(result.exitCode, 0);
52
+ assert.match(result.transcript, /done/, "the in-memory transcript is unaffected by the log file write failing");
53
+ });
54
+ // NOT-126: an attempt that loses its lease aborts, but the abort used to stop at the
55
+ // handler -- the spawned CLI kept running, kept editing the worktree, and the successor
56
+ // attempt was then handed that same worktree, so two live agents shared one tree. These
57
+ // use `process.execPath` rather than `sh -c '... sleep 30'` deliberately: a shell's
58
+ // orphaned grandchild inherits the stdout pipe and holds it open, so 'close' would not
59
+ // fire promptly and the test would pass for the wrong reason.
60
+ const nodeChild = (body) => ({ cmd: process.execPath, args: ["-e", body] });
61
+ async function waitForStart(logPath, timeoutMs = 5000) {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ try {
65
+ if (fs.readFileSync(logPath, "utf8").includes("started"))
66
+ return;
67
+ }
68
+ catch {
69
+ // The log file is created asynchronously by the write stream -- keep polling.
70
+ }
71
+ await new Promise((r) => setTimeout(r, 25));
72
+ }
73
+ throw new Error("child never reported that it had started");
74
+ }
75
+ test("spawnCli terminates the child when the attempt is aborted", { timeout: 15_000 }, async () => {
76
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
77
+ const { cmd, args } = nodeChild("console.log('started'); setInterval(() => {}, 1000);");
78
+ const controller = new AbortController();
79
+ const startedAt = Date.now();
80
+ // timeoutMs is deliberately far longer than the test timeout: if the abort does not
81
+ // kill the child, nothing else will, and this test fails by timing out.
82
+ const promise = spawnCli("test-run-abort", cmd, args, process.cwd(), {
83
+ logPath,
84
+ timeoutMs: 600_000,
85
+ signal: controller.signal,
86
+ });
87
+ await waitForStart(logPath);
88
+ controller.abort();
89
+ const result = await promise;
90
+ assert.ok(Date.now() - startedAt < 15_000, "resolved on the abort, not on the 10-minute timeout");
91
+ assert.equal(result.timedOut, false, "an abort is a lost lease, not a wall-clock timeout");
92
+ assert.match(result.transcript, /started/, "output produced before the abort is still returned");
93
+ });
94
+ test("spawnCli escalates to SIGKILL when the child ignores SIGTERM", { timeout: 15_000 }, async () => {
95
+ const previous = process.env.SPAWN_ABORT_KILL_GRACE_MS;
96
+ process.env.SPAWN_ABORT_KILL_GRACE_MS = "250";
97
+ try {
98
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
99
+ const { cmd, args } = nodeChild("process.on('SIGTERM', () => {}); console.log('started'); setInterval(() => {}, 1000);");
100
+ const controller = new AbortController();
101
+ const promise = spawnCli("test-run-abort-sigkill", cmd, args, process.cwd(), {
102
+ logPath,
103
+ timeoutMs: 600_000,
104
+ signal: controller.signal,
105
+ });
106
+ await waitForStart(logPath);
107
+ controller.abort();
108
+ // Only the SIGKILL backstop can end this child -- it swallows SIGTERM outright.
109
+ const result = await promise;
110
+ assert.equal(result.timedOut, false);
111
+ }
112
+ finally {
113
+ if (previous === undefined)
114
+ delete process.env.SPAWN_ABORT_KILL_GRACE_MS;
115
+ else
116
+ process.env.SPAWN_ABORT_KILL_GRACE_MS = previous;
117
+ }
118
+ });
119
+ test("spawnCli runs normally when an un-aborted signal is supplied", { timeout: 10_000 }, async () => {
120
+ const logPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "dealer-spawn-cli-")), "out.ndjson");
121
+ const controller = new AbortController();
122
+ const result = await spawnCli("test-run-abort-unused", "sh", ["-c", "echo untouched"], process.cwd(), {
123
+ logPath,
124
+ timeoutMs: 5000,
125
+ signal: controller.signal,
126
+ });
127
+ assert.equal(result.exitCode, 0);
128
+ assert.equal(result.timedOut, false);
129
+ assert.match(result.transcript, /untouched/);
130
+ });