agent-dealer 0.1.8 → 0.1.10
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.
- package/bundle/server/dist/adapters/agent-deck.js +18 -11
- package/bundle/server/dist/index.js +6 -2
- package/bundle/server/dist/queue/approve-deliver.js +20 -6
- package/bundle/server/dist/queue/dispatcher-recovery.test.js +37 -0
- package/bundle/server/dist/queue/dispatcher.js +110 -15
- package/bundle/server/dist/queue/send-gate.test.js +34 -0
- package/bundle/server/dist/repository/outbound-drafts.js +48 -1
- package/bundle/server/dist/routes/index.js +18 -8
- package/bundle/server/dist/runners/claude-args.js +6 -2
- package/bundle/server/dist/runners/claude.js +84 -20
- package/bundle/server/dist/runners/claude.test.js +6 -2
- package/bundle/server/dist/runners/process-registry.js +46 -0
- package/bundle/server/dist/runners/prompts.js +9 -7
- package/bundle/server/dist/runners/prompts.test.js +3 -1
- package/bundle/server/dist/runners/stream-json.js +1 -1
- package/bundle/server/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CeVdPLB9.css +1 -0
- package/bundle/server/static-ui/assets/index-yFzsIs1x.js +78 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +13 -4
- package/bundle/shared/dist/index.js +5 -0
- package/bundle/shared/dist/outbound-draft.d.ts +16 -13
- package/bundle/shared/dist/outbound-draft.js +29 -5
- package/bundle/shared/dist/outbound-draft.test.js +27 -1
- package/bundle/shared/package.json +1 -1
- package/dist/daemon-logs.d.ts +7 -0
- package/dist/daemon-logs.js +25 -0
- package/dist/doctor.js +11 -4
- package/dist/index.js +25 -5
- package/dist/ports.d.ts +8 -0
- package/dist/ports.js +72 -0
- package/dist/runtime-state.d.ts +12 -0
- package/dist/runtime-state.js +39 -0
- package/dist/setup.js +1 -1
- package/dist/start.d.ts +5 -0
- package/dist/start.js +219 -23
- package/dist/status.d.ts +1 -0
- package/dist/status.js +35 -0
- package/dist/stop.d.ts +1 -0
- package/dist/stop.js +64 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CdlVcgfN.css +0 -1
- package/bundle/server/static-ui/assets/index-DYw_OgDt.js +0 -78
|
@@ -282,22 +282,29 @@ function formatToolResultText(tr) {
|
|
|
282
282
|
export async function deliverOutboundDraft(deckId, toolCall, opts) {
|
|
283
283
|
const payload = toCallServiceToolPayload(toolCall);
|
|
284
284
|
const workspaceRoot = opts?.workspaceRoot ?? process.cwd();
|
|
285
|
+
const timeoutMs = opts?.timeoutMs ?? Number(process.env.DELIVER_TIMEOUT_MS ?? 60_000);
|
|
285
286
|
const callTool = opts?.callTool ??
|
|
286
287
|
(async (p) => {
|
|
287
288
|
const mcpBase = getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "");
|
|
288
289
|
const transport = new StreamableHTTPClientTransport(new URL(`${mcpBase}/mcp`));
|
|
289
290
|
const client = new Client({ name: "agent-dealer-deliver", version: "0.0.1" });
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
+
]);
|
|
301
308
|
});
|
|
302
309
|
const toolResult = await callTool(payload);
|
|
303
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: "
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
385
|
+
addArtifact(updated.id, "feedback", { error: EMPTY_PLAN_ERROR }, "system");
|
|
331
386
|
}
|
|
332
|
-
const
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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,14 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, ResultQaInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, 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
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";
|
|
11
|
+
import { rejectPendingOutboundDrafts, deliverInFlight } from "../repository/outbound-drafts.js";
|
|
12
|
+
import { getActiveLogPath } from "../runners/claude.js";
|
|
12
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";
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
87
|
-
return { content: raw.slice(-max), path:
|
|
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
|
|
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" });
|
|
@@ -476,6 +485,7 @@ export async function registerRoutes(app) {
|
|
|
476
485
|
const run = getRun(id);
|
|
477
486
|
if (!run)
|
|
478
487
|
return reply.status(404).send({ error: "Not found" });
|
|
488
|
+
cancelActiveRun(id);
|
|
479
489
|
return transitionRun(id, "cancelled");
|
|
480
490
|
});
|
|
481
491
|
app.get("/api/snapshot", async () => getSnapshotAsync());
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { getTemporalOutputDir } from "../paths.js";
|
|
2
2
|
export const DENY_SEND_TOOL = "mcp__agent-deck__call_service_tool";
|
|
3
3
|
const DECK_READ_TOOLS = "mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace,mcp__agent-deck__list_service_tools";
|
|
4
|
+
const DECK_EXECUTE_TOOLS = `${DECK_READ_TOOLS},mcp__agent-deck__call_service_tool`;
|
|
4
5
|
const PLAN_REFLECT_TOOLS = `Read,Glob,Grep,Skill,${DECK_READ_TOOLS}`;
|
|
5
6
|
/** Q&A about a finished result — read-only, no deck writes, no workspace mutation. */
|
|
6
7
|
const QA_TOOLS = "Read,Glob,Grep,Skill";
|
|
7
8
|
function executeToolsForCategory(category) {
|
|
8
|
-
const base = ["Read", "Write", "Edit", "Glob", "Grep", "Skill",
|
|
9
|
+
const base = ["Read", "Write", "Edit", "Glob", "Grep", "Skill", DECK_EXECUTE_TOOLS];
|
|
9
10
|
if (category !== "communication" && category !== "email") {
|
|
10
11
|
base.splice(5, 0, "Bash");
|
|
11
12
|
}
|
|
@@ -24,6 +25,9 @@ export function buildClaudePhaseArgs(run, mode) {
|
|
|
24
25
|
args.push("--add-dir", getTemporalOutputDir());
|
|
25
26
|
args.push("--allowedTools", executeToolsForCategory(run.taskCategory));
|
|
26
27
|
}
|
|
27
|
-
|
|
28
|
+
// Soft gate: execute may call_service_tool; plan/reflect/qa stay denied.
|
|
29
|
+
if (mode !== "execute") {
|
|
30
|
+
args.push("--disallowedTools", DENY_SEND_TOOL);
|
|
31
|
+
}
|
|
28
32
|
return args;
|
|
29
33
|
}
|