agent-dealer 0.1.7 → 0.1.9

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 (48) hide show
  1. package/bundle/server/dist/adapters/agent-deck.js +47 -11
  2. package/bundle/server/dist/index.js +6 -2
  3. package/bundle/server/dist/queue/approve-deliver.js +20 -6
  4. package/bundle/server/dist/queue/dispatcher-recovery.test.js +37 -0
  5. package/bundle/server/dist/queue/dispatcher.js +110 -15
  6. package/bundle/server/dist/queue/send-gate.test.js +34 -0
  7. package/bundle/server/dist/repository/outbound-drafts.js +48 -1
  8. package/bundle/server/dist/routes/index.js +20 -52
  9. package/bundle/server/dist/runners/claude.js +86 -22
  10. package/bundle/server/dist/runners/process-registry.js +46 -0
  11. package/bundle/server/dist/runners/prompts.js +5 -5
  12. package/bundle/server/dist/runners/qa.js +0 -3
  13. package/bundle/server/dist/runners/reflect.js +26 -13
  14. package/bundle/server/dist/runners/reflect.test.js +47 -0
  15. package/bundle/server/dist/usage-summary.js +1 -1
  16. package/bundle/server/package.json +1 -1
  17. package/bundle/server/static-ui/assets/index-CeVdPLB9.css +1 -0
  18. package/bundle/server/static-ui/assets/index-CsKknkak.js +78 -0
  19. package/bundle/server/static-ui/index.html +2 -2
  20. package/bundle/shared/dist/index.d.ts +26 -20
  21. package/bundle/shared/dist/index.js +11 -7
  22. package/bundle/shared/dist/outbound-draft.d.ts +2 -0
  23. package/bundle/shared/dist/outbound-draft.js +15 -0
  24. package/bundle/shared/dist/outbound-draft.test.js +7 -1
  25. package/bundle/shared/dist/playbook-reflect.d.ts +204 -0
  26. package/bundle/shared/dist/playbook-reflect.js +24 -0
  27. package/bundle/shared/dist/result-qa.d.ts +0 -5
  28. package/bundle/shared/dist/result-qa.js +0 -2
  29. package/bundle/shared/dist/result-qa.test.js +1 -4
  30. package/bundle/shared/package.json +1 -1
  31. package/dist/daemon-logs.d.ts +7 -0
  32. package/dist/daemon-logs.js +25 -0
  33. package/dist/doctor.js +11 -4
  34. package/dist/index.js +25 -5
  35. package/dist/ports.d.ts +8 -0
  36. package/dist/ports.js +72 -0
  37. package/dist/runtime-state.d.ts +12 -0
  38. package/dist/runtime-state.js +39 -0
  39. package/dist/setup.js +1 -1
  40. package/dist/start.d.ts +5 -0
  41. package/dist/start.js +219 -23
  42. package/dist/status.d.ts +1 -0
  43. package/dist/status.js +35 -0
  44. package/dist/stop.d.ts +1 -0
  45. package/dist/stop.js +64 -0
  46. package/package.json +1 -1
  47. package/bundle/server/static-ui/assets/index-CJbiq5Vq.css +0 -1
  48. package/bundle/server/static-ui/assets/index-D6BT1-TO.js +0 -78
@@ -79,6 +79,35 @@ export async function fetchAgentDeckDecks() {
79
79
  return res.json();
80
80
  }
81
81
  const DASHBOARD_HEADERS = { "x-agent-deck-client": "dashboard" };
82
+ const DEALER_HEADERS = { "x-agent-deck-client": "dealer" };
83
+ export async function proposePlaybookPatch(deckId, runId, proposal) {
84
+ const res = await fetch(`${getAgentDeckApiUrl()}/api/playbook-patches`, {
85
+ method: "POST",
86
+ headers: {
87
+ ...DEALER_HEADERS,
88
+ "Content-Type": "application/json",
89
+ "x-agent-deck-deck-id": deckId,
90
+ "x-agent-deck-source-ref": runId,
91
+ },
92
+ body: JSON.stringify({
93
+ kind: "update",
94
+ playbook_id: proposal.playbook_id,
95
+ ops: proposal.ops,
96
+ rationale: proposal.rationale,
97
+ evidence: proposal.evidence,
98
+ }),
99
+ signal: AbortSignal.timeout(8000),
100
+ });
101
+ if (!res.ok)
102
+ throw new Error(`Agent Deck patch propose failed: ${res.status} ${await res.text()}`);
103
+ const json = (await res.json());
104
+ if (!json.data?.id)
105
+ throw new Error("Playbook patch propose failed");
106
+ return json.data;
107
+ }
108
+ export function agentDeckPatchesUrl() {
109
+ return `${getAgentDeckApiUrl()}/playbook-patches`;
110
+ }
82
111
  export async function fetchPlaybook(playbookId) {
83
112
  const res = await fetch(`${getAgentDeckApiUrl()}/api/playbooks/${playbookId}`, {
84
113
  headers: DASHBOARD_HEADERS,
@@ -253,22 +282,29 @@ function formatToolResultText(tr) {
253
282
  export async function deliverOutboundDraft(deckId, toolCall, opts) {
254
283
  const payload = toCallServiceToolPayload(toolCall);
255
284
  const workspaceRoot = opts?.workspaceRoot ?? process.cwd();
285
+ const timeoutMs = opts?.timeoutMs ?? Number(process.env.DELIVER_TIMEOUT_MS ?? 60_000);
256
286
  const callTool = opts?.callTool ??
257
287
  (async (p) => {
258
288
  const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
259
289
  const transport = new StreamableHTTPClientTransport(new URL(`${mcpBase}/mcp`));
260
290
  const client = new Client({ name: "agent-dealer-deliver", version: "0.0.1" });
261
- await client.connect(transport);
262
- try {
263
- await client.callTool({
264
- name: "bind_workspace",
265
- arguments: { deckId, workspaceRoot },
266
- });
267
- return await client.callTool({ name: "call_service_tool", arguments: p });
268
- }
269
- finally {
270
- await client.close();
271
- }
291
+ const connectAndCall = async () => {
292
+ await client.connect(transport);
293
+ try {
294
+ await client.callTool({
295
+ name: "bind_workspace",
296
+ arguments: { deckId, workspaceRoot },
297
+ });
298
+ return await client.callTool({ name: "call_service_tool", arguments: p });
299
+ }
300
+ finally {
301
+ await client.close();
302
+ }
303
+ };
304
+ return await Promise.race([
305
+ connectAndCall(),
306
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Outbound deliver timed out after ${timeoutMs}ms`)), timeoutMs)),
307
+ ]);
272
308
  });
273
309
  const toolResult = await callTool(payload);
274
310
  assertCallServiceToolSuccess(toolResult);
@@ -4,7 +4,7 @@ import cors from "@fastify/cors";
4
4
  import { enrichPathForCliTools } from "./cli-env.js";
5
5
  import { migrate } from "./db/index.js";
6
6
  import { registerRoutes } from "./routes/index.js";
7
- import { startQueue } from "./queue/dispatcher.js";
7
+ import { startQueue, recoverOrphanedRuns } from "./queue/dispatcher.js";
8
8
  import { registerStaticUi } from "./static-ui.js";
9
9
  const { mode, envFile } = loadAgentDealerEnv();
10
10
  console.log(formatEnvStartupLine(mode, envFile));
@@ -16,8 +16,12 @@ async function main() {
16
16
  await app.register(cors, { origin: true });
17
17
  await registerRoutes(app);
18
18
  const uiDist = await registerStaticUi(app);
19
+ const orphans = recoverOrphanedRuns();
20
+ if (orphans > 0) {
21
+ console.warn(`[startup] recovered ${orphans} orphaned running run(s) → failed`);
22
+ }
19
23
  startQueue();
20
- await app.listen({ port, host: "0.0.0.0" });
24
+ await app.listen({ port, host: "127.0.0.1" });
21
25
  const base = `http://127.0.0.1:${port}`;
22
26
  console.log(`agent-dealer API ${base}`);
23
27
  if (uiDist) {
@@ -2,7 +2,7 @@ import { deliverOutboundDraft } from "../adapters/agent-deck.js";
2
2
  import { syncLinearForRun } from "../adapters/linear-sync.js";
3
3
  import { workspaceForRun } from "../runners/prompts.js";
4
4
  import { addArtifact, appendEvent, getRun, transitionRun, } from "../repository/runs.js";
5
- import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, } from "../repository/outbound-drafts.js";
5
+ import { deliverInFlight, getPendingOutboundDraft, markOutboundDraftSent, patchPendingOutboundBody, revertOutboundDraftToPending, } from "../repository/outbound-drafts.js";
6
6
  import { scheduleReflect } from "./dispatcher.js";
7
7
  export async function approveRunWithDeliver(runId, deps) {
8
8
  const run = getRun(runId);
@@ -14,7 +14,20 @@ export async function approveRunWithDeliver(runId, deps) {
14
14
  if (deliverInFlight.has(runId)) {
15
15
  return { ok: false, code: 409, error: "Deliver already in progress" };
16
16
  }
17
- const pending = getPendingOutboundDraft(runId);
17
+ let pending = getPendingOutboundDraft(runId);
18
+ if (pending && deps?.outboundBody !== undefined) {
19
+ const trimmed = deps.outboundBody.trim();
20
+ if (!trimmed) {
21
+ return { ok: false, code: 400, error: "Outbound message body cannot be empty" };
22
+ }
23
+ if (trimmed !== pending.content.draft.summary.body) {
24
+ const patched = patchPendingOutboundBody(pending.artifact.id, trimmed);
25
+ if (!patched) {
26
+ return { ok: false, code: 409, error: "Draft already sent or rejected" };
27
+ }
28
+ pending = getPendingOutboundDraft(runId);
29
+ }
30
+ }
18
31
  if (!pending) {
19
32
  const updated = transitionRun(runId, "done");
20
33
  syncLinearForRun(updated, "done").catch((e) => console.error("[linear-sync] done:", e));
@@ -27,6 +40,10 @@ export async function approveRunWithDeliver(runId, deps) {
27
40
  const deliver = deps?.deliver ?? deliverOutboundDraft;
28
41
  deliverInFlight.add(runId);
29
42
  try {
43
+ const claimed = markOutboundDraftSent(pending.artifact.id);
44
+ if (!claimed) {
45
+ return { ok: false, code: 409, error: "Draft already sent or rejected" };
46
+ }
30
47
  let toolResult;
31
48
  let permalink;
32
49
  try {
@@ -37,14 +54,11 @@ export async function approveRunWithDeliver(runId, deps) {
37
54
  permalink = result.permalink;
38
55
  }
39
56
  catch (err) {
57
+ revertOutboundDraftToPending(pending.artifact.id);
40
58
  const message = err instanceof Error ? err.message : String(err);
41
59
  appendEvent(runId, "deliver_failed", { error: message });
42
60
  return { ok: false, code: 502, error: message };
43
61
  }
44
- const sent = markOutboundDraftSent(pending.artifact.id);
45
- if (!sent) {
46
- return { ok: false, code: 409, error: "Draft already sent or rejected" };
47
- }
48
62
  addArtifact(runId, "send_receipt", {
49
63
  draftArtifactId: pending.artifact.id,
50
64
  sentAt: new Date().toISOString(),
@@ -0,0 +1,37 @@
1
+ import { test, before } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-recovery-"));
7
+ process.env.MAX_CONCURRENT_RUNS = "2";
8
+ process.env.MAX_PLAN_ATTEMPTS = "3";
9
+ const { migrate } = await import("../db/index.js");
10
+ const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
11
+ const { updateAgent } = await import("../repository/agents.js");
12
+ const { addArtifact, createRun, getRun, transitionRun, } = await import("../repository/runs.js");
13
+ const { recoverOrphanedRuns, cancelActiveRun, } = await import("./dispatcher.js");
14
+ before(() => {
15
+ migrate();
16
+ updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
17
+ });
18
+ test("recoverOrphanedRuns fails stuck running rows", () => {
19
+ const run = createRun({
20
+ title: "Orphan",
21
+ taskCategory: "other",
22
+ status: "plan_pending",
23
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
24
+ });
25
+ transitionRun(run.id, "plan_approved");
26
+ transitionRun(run.id, "running");
27
+ assert.equal(getRun(run.id).status, "running");
28
+ const n = recoverOrphanedRuns();
29
+ assert.equal(n, 1);
30
+ assert.equal(getRun(run.id).status, "failed");
31
+ });
32
+ test("recoverOrphanedRuns is a no-op when queue is clean", () => {
33
+ assert.equal(recoverOrphanedRuns(), 0);
34
+ });
35
+ test("cancelActiveRun delegates to process registry without throwing", () => {
36
+ assert.doesNotThrow(() => cancelActiveRun("missing-run-id"));
37
+ });
@@ -1,7 +1,7 @@
1
- import { planGateDecision } from "@agent-dealer/shared";
1
+ import { canTransition, planGateDecision } from "@agent-dealer/shared";
2
2
  import { buildPlanEditedReplanPrompt, buildPlanFeedbackPrompt, buildPlanRevisePrompt, } from "../runners/prompts.js";
3
3
  import { addArtifact, countByStatus, getLatestArtifact, getRun, listArtifacts, listRuns, markPlanTriageConsumed, patchRunPhaseBudget, transitionRun, updateRunFields, } from "../repository/runs.js";
4
- import { runAgent } from "../runners/claude.js";
4
+ import { runAgent, killRunProcess } from "../runners/claude.js";
5
5
  import { persistRunOutput, seedDeliverableFromParent } from "../runners/persist.js";
6
6
  import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
7
7
  import { pollLinearIssues } from "../adapters/external.js";
@@ -16,6 +16,10 @@ const activeReflects = new Set();
16
16
  let listeners = [];
17
17
  let pollTimer = null;
18
18
  let dispatchTimer = null;
19
+ let healthCache = null;
20
+ const HEALTH_CACHE_MS = 15_000;
21
+ const MAX_PLAN_ATTEMPTS = Number(process.env.MAX_PLAN_ATTEMPTS ?? 3);
22
+ const EMPTY_PLAN_ERROR = "Agent did not return a plan";
19
23
  function maxConcurrent() {
20
24
  return Number(process.env.MAX_CONCURRENT_RUNS ?? 2);
21
25
  }
@@ -121,11 +125,53 @@ export function getSnapshot() {
121
125
  }
122
126
  export async function getSnapshotAsync() {
123
127
  const snap = getSnapshot();
124
- snap.agentDeckOnline = await checkAgentDeckHealth();
125
- snap.agents = await listAgentsWithHealth(listAgents());
128
+ const now = Date.now();
129
+ if (!healthCache || now - healthCache.at > HEALTH_CACHE_MS) {
130
+ healthCache = {
131
+ at: now,
132
+ agentDeckOnline: await checkAgentDeckHealth(),
133
+ agents: await listAgentsWithHealth(listAgents()),
134
+ };
135
+ }
136
+ snap.agentDeckOnline = healthCache.agentDeckOnline;
137
+ snap.agents = healthCache.agents;
126
138
  snap.agentIssueCount = snap.agents.filter((a) => !a.healthy).length;
127
139
  return snap;
128
140
  }
141
+ /** Fail runs left in `running` after a server restart or crash. */
142
+ export function recoverOrphanedRuns() {
143
+ let count = 0;
144
+ for (const run of listRuns("running")) {
145
+ transitionRun(run.id, "failed");
146
+ addArtifact(run.id, "feedback", { error: "Server restarted mid-run — retry from failed", recoverable: true }, "system");
147
+ count++;
148
+ }
149
+ return count;
150
+ }
151
+ function reconcileOrphanedRunning() {
152
+ for (const run of listRuns("running")) {
153
+ if (activeRuns.has(run.id))
154
+ continue;
155
+ transitionRun(run.id, "failed");
156
+ addArtifact(run.id, "feedback", { error: "Run lost active worker — marked failed", recoverable: true }, "system");
157
+ }
158
+ }
159
+ function priorEmptyPlanAttempts(runId) {
160
+ return listArtifacts(runId).filter((a) => {
161
+ if (a.kind !== "feedback" || !a.contentJson)
162
+ return false;
163
+ try {
164
+ const c = JSON.parse(a.contentJson);
165
+ return c.error === EMPTY_PLAN_ERROR;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }).length;
171
+ }
172
+ export function cancelActiveRun(runId) {
173
+ killRunProcess(runId);
174
+ }
129
175
  export function subscribe(listener) {
130
176
  listeners.push(listener);
131
177
  return () => {
@@ -217,6 +263,7 @@ async function dispatchPlanDrafts() {
217
263
  }
218
264
  }
219
265
  async function dispatch() {
266
+ reconcileOrphanedRunning();
220
267
  await dispatchPlanDrafts();
221
268
  const running = countByStatus("running");
222
269
  const slots = maxConcurrent() - running;
@@ -226,7 +273,9 @@ async function dispatch() {
226
273
  for (const run of approved.slice(0, slots)) {
227
274
  if (activeRuns.has(run.id))
228
275
  continue;
229
- const job = executeRun(run).finally(() => {
276
+ const job = executeRun(run)
277
+ .catch((e) => console.error("[execute]", run.id, e))
278
+ .finally(() => {
230
279
  activeRuns.delete(run.id);
231
280
  notify().catch(console.error);
232
281
  });
@@ -277,7 +326,13 @@ export function applyPlanGate(run) {
277
326
  const draft = getLatestArtifact(run.id, "draft_plan");
278
327
  if (!draft?.contentJson)
279
328
  return "await_review";
280
- const plan = JSON.parse(draft.contentJson);
329
+ let plan;
330
+ try {
331
+ plan = JSON.parse(draft.contentJson);
332
+ }
333
+ catch {
334
+ return "await_review";
335
+ }
281
336
  addArtifact(run.id, "approved_plan", { ...plan, autoApproved: true, rationale: triage.rationale }, "system");
282
337
  transitionRun(run.id, "plan_approved");
283
338
  void forceDispatch();
@@ -327,12 +382,27 @@ export async function draftPlan(run, opts) {
327
382
  }
328
383
  }
329
384
  else if (!opts?.replace) {
330
- addArtifact(updated.id, "feedback", { error: "Agent did not return a plan" }, "system");
385
+ addArtifact(updated.id, "feedback", { error: EMPTY_PLAN_ERROR }, "system");
331
386
  }
332
- const planFailed = !persisted.planMarkdown && result.exitCode !== 0;
387
+ const emptyPlanExhausted = !persisted.planMarkdown &&
388
+ !opts?.replace &&
389
+ priorEmptyPlanAttempts(updated.id) >= MAX_PLAN_ATTEMPTS;
390
+ const planFailed = result.timedOut ||
391
+ emptyPlanExhausted ||
392
+ (!persisted.planMarkdown && result.exitCode !== 0);
333
393
  if (planFailed) {
334
- transitionRun(run.id, "failed");
335
- addArtifact(run.id, "feedback", { error: "Planning failed", exitCode: result.exitCode }, "system");
394
+ const stillActive = getRun(run.id);
395
+ if (stillActive && (stillActive.status === "plan_pending" || stillActive.status === "queued")) {
396
+ transitionRun(run.id, "failed");
397
+ addArtifact(run.id, "feedback", {
398
+ error: result.timedOut
399
+ ? "Planning timed out"
400
+ : emptyPlanExhausted
401
+ ? `Planning failed after ${MAX_PLAN_ATTEMPTS} empty results`
402
+ : "Planning failed",
403
+ exitCode: result.exitCode,
404
+ }, "system");
405
+ }
336
406
  }
337
407
  }
338
408
  catch (err) {
@@ -394,7 +464,15 @@ export function submitPlanAnswers(runId, input, opts) {
394
464
  return { ok: false, code: 409, error: String(e) };
395
465
  }
396
466
  const draft = getLatestArtifact(runId, "draft_plan");
397
- const plan = draft?.contentJson ? JSON.parse(draft.contentJson) : { markdown: "" };
467
+ let plan = { markdown: "" };
468
+ if (draft?.contentJson) {
469
+ try {
470
+ plan = JSON.parse(draft.contentJson);
471
+ }
472
+ catch {
473
+ return { ok: false, code: 409, error: "Draft plan is unreadable — request a re-draft" };
474
+ }
475
+ }
398
476
  addArtifact(runId, "approved_plan", plan, "human");
399
477
  void forceDispatch();
400
478
  return { ok: true, outcome, run: getRun(runId) };
@@ -470,6 +548,11 @@ async function executeRun(run) {
470
548
  transitionRun(run.id, "running");
471
549
  await notify();
472
550
  const result = await runAgent(run, "execute");
551
+ const after = getRun(run.id);
552
+ if (!after || after.status === "cancelled") {
553
+ await notify();
554
+ return;
555
+ }
473
556
  const persisted = persistRunOutput({
474
557
  run,
475
558
  phase: "execute",
@@ -478,11 +561,20 @@ async function executeRun(run) {
478
561
  logPath: result.logPath,
479
562
  rawTranscript: result.transcript,
480
563
  });
481
- if (result.exitCode === 0 && !persisted.blocked) {
564
+ const current = getRun(run.id);
565
+ if (!current || current.status === "cancelled") {
566
+ await notify();
567
+ return;
568
+ }
569
+ if (result.timedOut) {
570
+ transitionRun(run.id, "failed");
571
+ addArtifact(run.id, "feedback", { error: "Execution timed out", exitCode: result.exitCode, logPath: result.logPath }, "system");
572
+ }
573
+ else if (result.exitCode === 0 && !persisted.blocked) {
482
574
  const updated = transitionRun(run.id, "review");
483
575
  syncLinearForRun(updated, "review").catch((e) => console.error("[linear-sync] review:", e));
484
576
  }
485
- else {
577
+ else if (canTransition(current.status, "failed")) {
486
578
  transitionRun(run.id, "failed");
487
579
  addArtifact(run.id, "feedback", {
488
580
  error: persisted.blocked ? "Execution blocked" : "Execution failed",
@@ -492,8 +584,11 @@ async function executeRun(run) {
492
584
  }
493
585
  }
494
586
  catch (err) {
495
- transitionRun(run.id, "failed");
496
- addArtifact(run.id, "feedback", { error: String(err) }, "system");
587
+ const after = getRun(run.id);
588
+ if (after && after.status !== "cancelled" && canTransition(after.status, "failed")) {
589
+ transitionRun(run.id, "failed");
590
+ addArtifact(run.id, "feedback", { error: String(err) }, "system");
591
+ }
497
592
  }
498
593
  await notify();
499
594
  }
@@ -73,6 +73,24 @@ test("approve with mock deliver sends byte-identical payload and creates receipt
73
73
  const draftArt = getLatestArtifact(run.id, "slack_draft");
74
74
  assert.match(draftArt.contentJson, /"status":"sent"/);
75
75
  });
76
+ test("approve with human-edited body sends updated payload", async () => {
77
+ const run = seedReviewRun(true);
78
+ let captured = null;
79
+ const res = await approveRunWithDeliver(run.id, {
80
+ outboundBody: "Human tweak before send",
81
+ deliver: async (_deckId, toolCall) => {
82
+ captured = toolCall;
83
+ return { toolResult: { ok: true } };
84
+ },
85
+ });
86
+ assert.equal(res.ok, true);
87
+ assert.deepEqual(captured, {
88
+ ...TOOL_CALL,
89
+ arguments: { ...TOOL_CALL.arguments, text: "Human tweak before send" },
90
+ });
91
+ const draftArt = getLatestArtifact(run.id, "slack_draft");
92
+ assert.match(draftArt.contentJson, /Human tweak before send/);
93
+ });
76
94
  test("deliver failure keeps run in review with pending draft", async () => {
77
95
  const run = seedReviewRun(true);
78
96
  const res = await approveRunWithDeliver(run.id, {
@@ -84,6 +102,22 @@ test("deliver failure keeps run in review with pending draft", async () => {
84
102
  assert.equal(!res.ok && res.code, 502);
85
103
  assert.equal(getRun(run.id).status, "review");
86
104
  assert.equal(pendingSendCount(run.id), 1);
105
+ const draftArt = getLatestArtifact(run.id, "slack_draft");
106
+ assert.match(draftArt.contentJson, /"status":"pending"/);
107
+ });
108
+ test("mark sent before deliver prevents double-send race", async () => {
109
+ const run = seedReviewRun(true);
110
+ let deliverCalls = 0;
111
+ const res = await approveRunWithDeliver(run.id, {
112
+ deliver: async () => {
113
+ deliverCalls++;
114
+ return { toolResult: { ok: true } };
115
+ },
116
+ });
117
+ assert.equal(res.ok, true);
118
+ assert.equal(deliverCalls, 1);
119
+ const draftArt = getLatestArtifact(run.id, "slack_draft");
120
+ assert.match(draftArt.contentJson, /"status":"sent"/);
87
121
  });
88
122
  test("proxy success:false from deck is treated as deliver failure", async () => {
89
123
  const run = seedReviewRun(true);
@@ -1,4 +1,4 @@
1
- import { isOutboundDraftKind } from "@agent-dealer/shared";
1
+ import { applyOutboundBodyEdit, isOutboundDraftKind, outboundMessageMatchesSummary, } from "@agent-dealer/shared";
2
2
  import { getDb } from "../db/index.js";
3
3
  import { listArtifacts } from "./runs.js";
4
4
  export function getPendingOutboundDraft(runId) {
@@ -38,6 +38,53 @@ export function rejectPendingOutboundDrafts(runId) {
38
38
  }
39
39
  }
40
40
  }
41
+ /** Apply human body edit to a pending draft artifact. Returns false if not pending. */
42
+ export function patchPendingOutboundBody(artifactId, body) {
43
+ const db = getDb();
44
+ const row = db.prepare("SELECT content_json FROM artifacts WHERE id = ?").get(artifactId);
45
+ if (!row?.content_json)
46
+ return false;
47
+ let content;
48
+ try {
49
+ content = JSON.parse(row.content_json);
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ if (content.status !== "pending")
55
+ return false;
56
+ const draft = applyOutboundBodyEdit(content.draft, body);
57
+ const next = {
58
+ ...content,
59
+ draft,
60
+ bodyMismatch: !outboundMessageMatchesSummary(draft.toolCall, draft.summary.body),
61
+ };
62
+ const result = db
63
+ .prepare("UPDATE artifacts SET content_json = ? WHERE id = ? AND content_json = ?")
64
+ .run(JSON.stringify(next), artifactId, row.content_json);
65
+ return result.changes === 1;
66
+ }
67
+ /** Revert sent → pending after a failed deliver attempt. Returns false if not sent. */
68
+ export function revertOutboundDraftToPending(artifactId) {
69
+ const db = getDb();
70
+ const row = db.prepare("SELECT content_json FROM artifacts WHERE id = ?").get(artifactId);
71
+ if (!row?.content_json)
72
+ return false;
73
+ let content;
74
+ try {
75
+ content = JSON.parse(row.content_json);
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ if (content.status !== "sent")
81
+ return false;
82
+ const next = { ...content, status: "pending", sentAt: undefined };
83
+ const result = db
84
+ .prepare("UPDATE artifacts SET content_json = ? WHERE id = ? AND content_json = ?")
85
+ .run(JSON.stringify(next), artifactId, row.content_json);
86
+ return result.changes === 1;
87
+ }
41
88
  /** Atomic pending → sent transition. Returns false if not pending. */
42
89
  export function markOutboundDraftSent(artifactId) {
43
90
  const db = getDb();
@@ -1,15 +1,16 @@
1
1
  import fs from "node:fs";
2
- import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, ResultQaInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
2
+ import { ApproveRunInput, CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, ResultQaInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
3
3
  import { parseRunBudget } from "@agent-dealer/shared";
4
- import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, transitionRun, getLatestArtifact, markPlanTriageConsumed, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
4
+ import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, transitionRun, getLatestArtifact, markPlanTriageConsumed, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
5
5
  import { createAgent, deleteAgent, getAgent, listAgents, updateAgent, } from "../repository/agents.js";
6
6
  import { listAgentsWithHealth } from "../adapters/agent-health.js";
7
- import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
7
+ import { forceDispatch, getSnapshotAsync, kickRun, cancelActiveRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
8
8
  import { approveRunWithDeliver } from "../queue/approve-deliver.js";
9
9
  import { recordPlanDelegation } from "../queue/plan-delegation.js";
10
10
  import { askResultQuestion } from "../queue/result-qa.js";
11
- import { rejectPendingOutboundDrafts } from "../repository/outbound-drafts.js";
12
- import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
11
+ import { rejectPendingOutboundDrafts, deliverInFlight } from "../repository/outbound-drafts.js";
12
+ import { getActiveLogPath } from "../runners/claude.js";
13
+ import { fetchAgentDeckDecks } from "../adapters/agent-deck.js";
13
14
  import { testAgentDeckConnection } from "../adapters/agent-deck.js";
14
15
  import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
15
16
  import { syncLinearForRun } from "../adapters/linear-sync.js";
@@ -77,14 +78,16 @@ export async function registerRoutes(app) {
77
78
  const artifact = artifacts.find((a) => a.kind === kind && a.blobPath) ??
78
79
  artifacts.find((a) => a.kind === "transcript" && a.blobPath) ??
79
80
  artifacts.find((a) => a.kind === "stream_trace" && a.blobPath);
80
- if (!artifact?.blobPath)
81
+ const livePath = getActiveLogPath(id);
82
+ const blobPath = artifact?.blobPath ?? livePath;
83
+ if (!blobPath)
81
84
  return reply.status(404).send({ error: "No log artifact" });
82
- if (!fs.existsSync(artifact.blobPath)) {
85
+ if (!fs.existsSync(blobPath)) {
83
86
  return reply.status(404).send({ error: "Log file missing on disk" });
84
87
  }
85
88
  const max = Math.min(Number(req.query.max ?? 50000), 200000);
86
- const raw = fs.readFileSync(artifact.blobPath, "utf8");
87
- return { content: raw.slice(-max), path: artifact.blobPath, kind: artifact.kind };
89
+ const raw = fs.readFileSync(blobPath, "utf8");
90
+ return { content: raw.slice(-max), path: blobPath, kind: artifact?.kind ?? kind, live: !!livePath };
88
91
  });
89
92
  app.get("/api/intake/linear/status", async () => testLinearConnection());
90
93
  app.get("/api/intake/linear/config", async () => getLinearIntakeConfigView());
@@ -395,7 +398,10 @@ export async function registerRoutes(app) {
395
398
  });
396
399
  app.post("/api/runs/:id/approve", async (req, reply) => {
397
400
  const { id } = req.params;
398
- const result = await approveRunWithDeliver(id);
401
+ const input = ApproveRunInput.parse(req.body ?? {});
402
+ const result = await approveRunWithDeliver(id, {
403
+ outboundBody: input.outboundBody,
404
+ });
399
405
  if (!result.ok) {
400
406
  return reply.status(result.code).send({ error: result.error });
401
407
  }
@@ -409,6 +415,9 @@ export async function registerRoutes(app) {
409
415
  if (run.status !== "review" && run.status !== "failed") {
410
416
  return reply.status(400).send({ error: "Retry only from review or failed" });
411
417
  }
418
+ if (deliverInFlight.has(id)) {
419
+ return reply.status(409).send({ error: "Deliver in progress — wait before retrying" });
420
+ }
412
421
  const approvedPlan = getLatestArtifact(id, "approved_plan");
413
422
  if (!approvedPlan?.contentJson) {
414
423
  return reply.status(400).send({ error: "No approved plan — cannot retry execution" });
@@ -471,53 +480,12 @@ export async function registerRoutes(app) {
471
480
  await forceDispatch();
472
481
  return reply.status(201).send(retryRun);
473
482
  });
474
- app.post("/api/runs/:id/playbook-patch/apply", async (req, reply) => {
475
- const { id } = req.params;
476
- const run = getRun(id);
477
- if (!run)
478
- return reply.status(404).send({ error: "Not found" });
479
- const art = getLatestArtifact(id, "playbook_patch");
480
- if (!art?.contentJson)
481
- return reply.status(404).send({ error: "No playbook patch" });
482
- const patch = PlaybookPatchContent.parse(JSON.parse(art.contentJson));
483
- if (patch.status !== "proposed") {
484
- return reply.status(400).send({ error: `Patch already ${patch.status}` });
485
- }
486
- try {
487
- await updatePlaybookBody(patch.playbookId, patch.proposedBody);
488
- const applied = {
489
- ...patch,
490
- status: "applied",
491
- appliedAt: new Date().toISOString(),
492
- };
493
- updateArtifactContent(art.id, applied);
494
- return applied;
495
- }
496
- catch (e) {
497
- return reply.status(502).send({ error: String(e) });
498
- }
499
- });
500
- app.post("/api/runs/:id/playbook-patch/dismiss", async (req, reply) => {
501
- const { id } = req.params;
502
- const run = getRun(id);
503
- if (!run)
504
- return reply.status(404).send({ error: "Not found" });
505
- const art = getLatestArtifact(id, "playbook_patch");
506
- if (!art?.contentJson)
507
- return reply.status(404).send({ error: "No playbook patch" });
508
- const patch = PlaybookPatchContent.parse(JSON.parse(art.contentJson));
509
- if (patch.status !== "proposed") {
510
- return reply.status(400).send({ error: `Patch already ${patch.status}` });
511
- }
512
- const dismissed = { ...patch, status: "dismissed" };
513
- updateArtifactContent(art.id, dismissed);
514
- return dismissed;
515
- });
516
483
  app.post("/api/runs/:id/cancel", async (req, reply) => {
517
484
  const { id } = req.params;
518
485
  const run = getRun(id);
519
486
  if (!run)
520
487
  return reply.status(404).send({ error: "Not found" });
488
+ cancelActiveRun(id);
521
489
  return transitionRun(id, "cancelled");
522
490
  });
523
491
  app.get("/api/snapshot", async () => getSnapshotAsync());