agent-dealer 0.1.2 → 0.1.3
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/queue/answers.test.js +117 -0
- package/bundle/server/dist/queue/dispatcher.js +164 -2
- package/bundle/server/dist/queue/plan-gate.test.js +55 -0
- package/bundle/server/dist/repository/runs.js +17 -1
- package/bundle/server/dist/routes/index.js +34 -8
- package/bundle/server/dist/runners/claude.js +9 -8
- package/bundle/server/dist/runners/persist.js +17 -4
- package/bundle/server/dist/runners/prompts.js +52 -1
- package/bundle/server/dist/runners/prompts.test.js +63 -0
- package/bundle/server/dist/runners/reflect.js +1 -1
- package/bundle/server/dist/runners/stream-json.js +28 -0
- package/bundle/server/dist/runners/stream-json.test.js +57 -0
- package/bundle/server/dist/usage-summary.js +9 -4
- package/bundle/server/package.json +1 -1
- package/bundle/server/static-ui/assets/index-CH7u5bXC.js +78 -0
- package/bundle/server/static-ui/assets/index-CKuYOeDN.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/budget.d.ts +1 -1
- package/bundle/shared/dist/budget.js +3 -3
- package/bundle/shared/dist/index.d.ts +306 -91
- package/bundle/shared/dist/index.js +15 -0
- package/bundle/shared/dist/plan-triage.d.ts +317 -0
- package/bundle/shared/dist/plan-triage.js +65 -0
- package/bundle/shared/dist/plan-triage.test.d.ts +1 -0
- package/bundle/shared/dist/plan-triage.test.js +66 -0
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-ChdPKYDd.css +0 -1
- package/bundle/server/static-ui/assets/index-hIzZ8TM8.js +0 -77
|
@@ -0,0 +1,117 @@
|
|
|
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-answers-"));
|
|
7
|
+
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { updateAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { addArtifact, createRun, getLatestArtifact, getRun, markPlanTriageConsumed, updateRunFields } = await import("../repository/runs.js");
|
|
12
|
+
const { submitPlanAnswers } = await import("./dispatcher.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{ id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
|
|
15
|
+
{ id: "q2", question: "Ship docs too?", options: [{ label: "Yes" }, { label: "No" }] },
|
|
16
|
+
];
|
|
17
|
+
before(() => {
|
|
18
|
+
migrate();
|
|
19
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
20
|
+
});
|
|
21
|
+
function seedQuestionRun() {
|
|
22
|
+
const run = createRun({
|
|
23
|
+
title: "Answers test",
|
|
24
|
+
taskCategory: "other",
|
|
25
|
+
status: "plan_pending",
|
|
26
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
27
|
+
});
|
|
28
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan", sessionId: "sess-1" }, "agent");
|
|
29
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r", questions: QUESTIONS, sessionId: "sess-1", parseFallback: false, consumed: false }, "agent");
|
|
30
|
+
return getRun(run.id);
|
|
31
|
+
}
|
|
32
|
+
test("all-structured answers approve and mark plan_answers approved", () => {
|
|
33
|
+
const run = seedQuestionRun();
|
|
34
|
+
const res = submitPlanAnswers(run.id, {
|
|
35
|
+
answers: [
|
|
36
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
37
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
assert.equal(res.ok, true);
|
|
41
|
+
assert.equal(res.ok && res.outcome, "approved");
|
|
42
|
+
assert.equal(getRun(run.id).status, "plan_approved");
|
|
43
|
+
const ans = getLatestArtifact(run.id, "plan_answers");
|
|
44
|
+
assert.match(ans.contentJson, /"outcome":"approved"/);
|
|
45
|
+
assert.equal(getLatestArtifact(run.id, "approved_plan").author, "human");
|
|
46
|
+
const triage = getLatestArtifact(run.id, "plan_triage");
|
|
47
|
+
assert.match(triage.contentJson, /"consumed":true/);
|
|
48
|
+
});
|
|
49
|
+
test("executeModel null does not wipe seeded execute_model", () => {
|
|
50
|
+
const run = seedQuestionRun();
|
|
51
|
+
updateRunFields(run.id, { execute_model: "claude-sonnet-4" });
|
|
52
|
+
const res = submitPlanAnswers(run.id, {
|
|
53
|
+
answers: [
|
|
54
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
55
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
56
|
+
],
|
|
57
|
+
executeModel: null,
|
|
58
|
+
});
|
|
59
|
+
assert.equal(res.ok, true);
|
|
60
|
+
assert.equal(getRun(run.id).executeModel, "claude-sonnet-4");
|
|
61
|
+
});
|
|
62
|
+
test("free-form answer schedules a redraft instead of approving", () => {
|
|
63
|
+
const run = seedQuestionRun();
|
|
64
|
+
let redrafted = false;
|
|
65
|
+
const res = submitPlanAnswers(run.id, {
|
|
66
|
+
answers: [
|
|
67
|
+
{ questionId: "q1", freeText: "Do it a third way" },
|
|
68
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
69
|
+
],
|
|
70
|
+
}, { onRedraft: () => { redrafted = true; } });
|
|
71
|
+
assert.equal(res.ok && res.outcome, "redraft");
|
|
72
|
+
assert.equal(redrafted, true);
|
|
73
|
+
assert.equal(getRun(run.id).status, "plan_pending");
|
|
74
|
+
});
|
|
75
|
+
test("answers must cover every open question exactly once", () => {
|
|
76
|
+
const run = seedQuestionRun();
|
|
77
|
+
const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
|
|
78
|
+
assert.equal(res.ok, false);
|
|
79
|
+
assert.equal(!res.ok && res.code, 400);
|
|
80
|
+
});
|
|
81
|
+
test("double submit returns 409", () => {
|
|
82
|
+
const run = seedQuestionRun();
|
|
83
|
+
submitPlanAnswers(run.id, {
|
|
84
|
+
answers: [
|
|
85
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
86
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const again = submitPlanAnswers(run.id, {
|
|
90
|
+
answers: [
|
|
91
|
+
{ questionId: "q1", selectedLabel: "B" },
|
|
92
|
+
{ questionId: "q2", selectedLabel: "No" },
|
|
93
|
+
],
|
|
94
|
+
});
|
|
95
|
+
assert.equal(!again.ok && again.code, 409);
|
|
96
|
+
});
|
|
97
|
+
test("run without open questions returns 409", () => {
|
|
98
|
+
const run = createRun({
|
|
99
|
+
title: "No questions",
|
|
100
|
+
taskCategory: "other",
|
|
101
|
+
status: "plan_pending",
|
|
102
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
103
|
+
});
|
|
104
|
+
const res = submitPlanAnswers(run.id, { answers: [{ questionId: "q1", selectedLabel: "A" }] });
|
|
105
|
+
assert.equal(!res.ok && res.code, 409);
|
|
106
|
+
});
|
|
107
|
+
test("consumed triage rejects answers", () => {
|
|
108
|
+
const run = seedQuestionRun();
|
|
109
|
+
markPlanTriageConsumed(run.id);
|
|
110
|
+
const res = submitPlanAnswers(run.id, {
|
|
111
|
+
answers: [
|
|
112
|
+
{ questionId: "q1", selectedLabel: "A" },
|
|
113
|
+
{ questionId: "q2", selectedLabel: "Yes" },
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
assert.equal(!res.ok && res.code, 409);
|
|
117
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { planGateDecision } from "@agent-dealer/shared";
|
|
2
|
+
import { buildPlanRevisePrompt } from "../runners/prompts.js";
|
|
3
|
+
import { addArtifact, countByStatus, getLatestArtifact, getRun, listArtifacts, listRuns, markPlanTriageConsumed, patchRunPhaseBudget, transitionRun, updateRunFields, } from "../repository/runs.js";
|
|
2
4
|
import { runAgent } from "../runners/claude.js";
|
|
3
5
|
import { persistRunOutput, seedDeliverableFromParent } from "../runners/persist.js";
|
|
4
6
|
import { checkAgentDeckHealth } from "../adapters/agent-deck.js";
|
|
@@ -42,6 +44,24 @@ function isReadyForPlanReview(run) {
|
|
|
42
44
|
!!getLatestArtifact(run.id, "draft_plan") &&
|
|
43
45
|
!activePlanDrafts.has(run.id));
|
|
44
46
|
}
|
|
47
|
+
/** Open (unanswered, unconsumed) question count for a plan_pending run. */
|
|
48
|
+
function openQuestionCount(runId) {
|
|
49
|
+
const tri = getLatestArtifact(runId, "plan_triage");
|
|
50
|
+
if (!tri?.contentJson)
|
|
51
|
+
return 0;
|
|
52
|
+
try {
|
|
53
|
+
const c = JSON.parse(tri.contentJson);
|
|
54
|
+
if (c.consumed || !c.questions?.length)
|
|
55
|
+
return 0;
|
|
56
|
+
const ans = getLatestArtifact(runId, "plan_answers");
|
|
57
|
+
if (ans && ans.createdAt > tri.createdAt)
|
|
58
|
+
return 0;
|
|
59
|
+
return c.questions.length;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
45
65
|
export function getSnapshot() {
|
|
46
66
|
const all = listRuns();
|
|
47
67
|
const runningRuns = all.filter((r) => r.status === "running");
|
|
@@ -57,6 +77,16 @@ export function getSnapshot() {
|
|
|
57
77
|
const awaitingPlanReview = planningPhase.filter(isReadyForPlanReview);
|
|
58
78
|
const planningActiveRuns = planningPhase.filter((r) => activePlanDrafts.has(r.id));
|
|
59
79
|
const planningQueuedRuns = planningPhase.filter((r) => !activePlanDrafts.has(r.id) && !isReadyForPlanReview(r));
|
|
80
|
+
const openQuestionCounts = {};
|
|
81
|
+
for (const run of awaitingPlanReview) {
|
|
82
|
+
const n = openQuestionCount(run.id);
|
|
83
|
+
if (n > 0)
|
|
84
|
+
openQuestionCounts[run.id] = n;
|
|
85
|
+
}
|
|
86
|
+
const awaitingAnswerRuns = awaitingPlanReview.filter((r) => openQuestionCounts[r.id]);
|
|
87
|
+
const autoApprovedRunIds = [...waitingExecution, ...runningRuns]
|
|
88
|
+
.filter((r) => getLatestArtifact(r.id, "approved_plan")?.author === "system")
|
|
89
|
+
.map((r) => r.id);
|
|
60
90
|
return {
|
|
61
91
|
planReviewCount: awaitingPlanReview.length,
|
|
62
92
|
resultReviewCount: resultReviewRuns.length,
|
|
@@ -68,6 +98,9 @@ export function getSnapshot() {
|
|
|
68
98
|
resultReviewRuns,
|
|
69
99
|
recentDone,
|
|
70
100
|
awaitingPlanReview,
|
|
101
|
+
awaitingAnswerRuns,
|
|
102
|
+
openQuestionCounts,
|
|
103
|
+
autoApprovedRunIds,
|
|
71
104
|
runs: all,
|
|
72
105
|
agentDeckOnline: false,
|
|
73
106
|
agents: [],
|
|
@@ -195,6 +228,49 @@ export async function kickRun(run) {
|
|
|
195
228
|
}
|
|
196
229
|
await dispatch();
|
|
197
230
|
}
|
|
231
|
+
function priorQuestionRounds(runId, currentTriageArtifactId) {
|
|
232
|
+
return listArtifacts(runId).filter((a) => {
|
|
233
|
+
if (a.kind !== "plan_triage" || a.id === currentTriageArtifactId || !a.contentJson)
|
|
234
|
+
return false;
|
|
235
|
+
try {
|
|
236
|
+
const c = JSON.parse(a.contentJson);
|
|
237
|
+
return (c.questions?.length ?? 0) > 0;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}).length;
|
|
243
|
+
}
|
|
244
|
+
/** Self-triage gate after a plan draft persists (PRD F2). Exported for tests. */
|
|
245
|
+
export function applyPlanGate(run) {
|
|
246
|
+
const art = getLatestArtifact(run.id, "plan_triage");
|
|
247
|
+
if (!art?.contentJson)
|
|
248
|
+
return "await_review";
|
|
249
|
+
let triage;
|
|
250
|
+
try {
|
|
251
|
+
triage = JSON.parse(art.contentJson);
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return "await_review";
|
|
255
|
+
}
|
|
256
|
+
const decision = planGateDecision({
|
|
257
|
+
triage,
|
|
258
|
+
priorQuestionRounds: priorQuestionRounds(run.id, art.id),
|
|
259
|
+
});
|
|
260
|
+
if (decision !== "auto_approve")
|
|
261
|
+
return decision;
|
|
262
|
+
const fresh = getRun(run.id);
|
|
263
|
+
if (!fresh || fresh.status !== "plan_pending")
|
|
264
|
+
return "await_review";
|
|
265
|
+
const draft = getLatestArtifact(run.id, "draft_plan");
|
|
266
|
+
if (!draft?.contentJson)
|
|
267
|
+
return "await_review";
|
|
268
|
+
const plan = JSON.parse(draft.contentJson);
|
|
269
|
+
addArtifact(run.id, "approved_plan", { ...plan, autoApproved: true, rationale: triage.rationale }, "system");
|
|
270
|
+
transitionRun(run.id, "plan_approved");
|
|
271
|
+
void forceDispatch();
|
|
272
|
+
return "auto_approve";
|
|
273
|
+
}
|
|
198
274
|
export async function draftPlan(run, opts) {
|
|
199
275
|
if (run.status === "queued") {
|
|
200
276
|
transitionRun(run.id, "plan_pending");
|
|
@@ -208,7 +284,7 @@ export async function draftPlan(run, opts) {
|
|
|
208
284
|
}
|
|
209
285
|
const rt = runtimeFor(updated);
|
|
210
286
|
try {
|
|
211
|
-
const result = await runAgent(updated, "plan");
|
|
287
|
+
const result = await runAgent(updated, "plan", opts?.revise);
|
|
212
288
|
const persisted = persistRunOutput({
|
|
213
289
|
run: updated,
|
|
214
290
|
phase: "plan",
|
|
@@ -225,6 +301,18 @@ export async function draftPlan(run, opts) {
|
|
|
225
301
|
}
|
|
226
302
|
if (persisted.planMarkdown) {
|
|
227
303
|
addArtifact(updated.id, "draft_plan", { markdown: persisted.planMarkdown, sessionId: persisted.sessionId }, "agent", result.logPath);
|
|
304
|
+
const triage = persisted.planTriage;
|
|
305
|
+
if (triage) {
|
|
306
|
+
addArtifact(updated.id, "plan_triage", {
|
|
307
|
+
verdict: triage.verdict,
|
|
308
|
+
rationale: triage.rationale,
|
|
309
|
+
questions: triage.questions,
|
|
310
|
+
sessionId: persisted.sessionId,
|
|
311
|
+
parseFallback: triage.parseFallback,
|
|
312
|
+
consumed: false,
|
|
313
|
+
}, "agent");
|
|
314
|
+
applyPlanGate(getRun(run.id));
|
|
315
|
+
}
|
|
228
316
|
}
|
|
229
317
|
else if (!opts?.replace) {
|
|
230
318
|
addArtifact(updated.id, "feedback", { error: "Agent did not return a plan" }, "system");
|
|
@@ -245,6 +333,80 @@ export async function draftPlan(run, opts) {
|
|
|
245
333
|
await notify();
|
|
246
334
|
return getRun(run.id);
|
|
247
335
|
}
|
|
336
|
+
/** Answer open plan questions (PRD F3). Structured answers approve + dispatch; free-form revises the plan. */
|
|
337
|
+
export function submitPlanAnswers(runId, input, opts) {
|
|
338
|
+
const run = getRun(runId);
|
|
339
|
+
if (!run)
|
|
340
|
+
return { ok: false, code: 404, error: "Not found" };
|
|
341
|
+
if (run.status !== "plan_pending") {
|
|
342
|
+
return { ok: false, code: 409, error: `No open questions — run is ${run.status}` };
|
|
343
|
+
}
|
|
344
|
+
const triageArt = getLatestArtifact(runId, "plan_triage");
|
|
345
|
+
if (!triageArt?.contentJson)
|
|
346
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
347
|
+
let triage;
|
|
348
|
+
try {
|
|
349
|
+
triage = JSON.parse(triageArt.contentJson);
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
353
|
+
}
|
|
354
|
+
if (triage.consumed || triage.questions.length === 0) {
|
|
355
|
+
return { ok: false, code: 409, error: "No open questions" };
|
|
356
|
+
}
|
|
357
|
+
const priorAnswers = getLatestArtifact(runId, "plan_answers");
|
|
358
|
+
if (priorAnswers && priorAnswers.createdAt > triageArt.createdAt) {
|
|
359
|
+
return { ok: false, code: 409, error: "Questions already answered" };
|
|
360
|
+
}
|
|
361
|
+
const expected = triage.questions.map((q) => q.id).sort().join(",");
|
|
362
|
+
const got = input.answers.map((a) => a.questionId).sort().join(",");
|
|
363
|
+
if (expected !== got) {
|
|
364
|
+
return { ok: false, code: 400, error: "Answers must cover every open question exactly once" };
|
|
365
|
+
}
|
|
366
|
+
const freeForm = input.answers.some((a) => a.freeText);
|
|
367
|
+
const outcome = freeForm ? "redraft" : "approved";
|
|
368
|
+
addArtifact(runId, "plan_answers", { answers: input.answers, outcome, answeredAt: new Date().toISOString() }, "human");
|
|
369
|
+
if (!freeForm) {
|
|
370
|
+
// null/empty = "Default" — do not wipe a seeded execute_model; only set explicit picks
|
|
371
|
+
if (input.executeModel?.trim()) {
|
|
372
|
+
updateRunFields(runId, { execute_model: input.executeModel.trim() });
|
|
373
|
+
}
|
|
374
|
+
if (input.executeBudget !== undefined) {
|
|
375
|
+
patchRunPhaseBudget(runId, "execute", input.executeBudget);
|
|
376
|
+
}
|
|
377
|
+
markPlanTriageConsumed(runId);
|
|
378
|
+
try {
|
|
379
|
+
transitionRun(runId, "plan_approved");
|
|
380
|
+
}
|
|
381
|
+
catch (e) {
|
|
382
|
+
return { ok: false, code: 409, error: String(e) };
|
|
383
|
+
}
|
|
384
|
+
const draft = getLatestArtifact(runId, "draft_plan");
|
|
385
|
+
const plan = draft?.contentJson ? JSON.parse(draft.contentJson) : { markdown: "" };
|
|
386
|
+
addArtifact(runId, "approved_plan", plan, "human");
|
|
387
|
+
void forceDispatch();
|
|
388
|
+
return { ok: true, outcome, run: getRun(runId) };
|
|
389
|
+
}
|
|
390
|
+
(opts?.onRedraft ?? scheduleRevisePlan)(getRun(runId), triage, input.answers);
|
|
391
|
+
return { ok: true, outcome, run: getRun(runId) };
|
|
392
|
+
}
|
|
393
|
+
function scheduleRevisePlan(run, triage, answers) {
|
|
394
|
+
if (activePlanDrafts.has(run.id))
|
|
395
|
+
return;
|
|
396
|
+
activePlanDrafts.add(run.id);
|
|
397
|
+
void draftPlan(run, {
|
|
398
|
+
replace: true,
|
|
399
|
+
revise: {
|
|
400
|
+
resumeSessionId: triage.sessionId,
|
|
401
|
+
prompt: buildPlanRevisePrompt(run, triage.questions, answers),
|
|
402
|
+
},
|
|
403
|
+
})
|
|
404
|
+
.catch((e) => console.error("[plan-revise]", run.id, e))
|
|
405
|
+
.finally(() => {
|
|
406
|
+
activePlanDrafts.delete(run.id);
|
|
407
|
+
notify().catch(console.error);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
248
410
|
/** Fire-and-forget: human requested a new agent plan (replaces draft). */
|
|
249
411
|
export function scheduleRedraft(run) {
|
|
250
412
|
if (activePlanDrafts.has(run.id))
|
|
@@ -0,0 +1,55 @@
|
|
|
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-gate-"));
|
|
7
|
+
process.env.MAX_CONCURRENT_RUNS = "0";
|
|
8
|
+
const { migrate } = await import("../db/index.js");
|
|
9
|
+
const { BUILTIN_AGENT_CLAUDE_ID } = await import("@agent-dealer/shared");
|
|
10
|
+
const { updateAgent } = await import("../repository/agents.js");
|
|
11
|
+
const { addArtifact, createRun, getLatestArtifact, getRun } = await import("../repository/runs.js");
|
|
12
|
+
const { applyPlanGate, getSnapshot } = await import("./dispatcher.js");
|
|
13
|
+
const QUESTIONS = [
|
|
14
|
+
{ id: "q1", question: "Which approach?", options: [{ label: "A" }, { label: "B" }] },
|
|
15
|
+
];
|
|
16
|
+
before(() => {
|
|
17
|
+
migrate();
|
|
18
|
+
updateAgent(BUILTIN_AGENT_CLAUDE_ID, { workspaceRoot: process.env.AGENT_DEALER_HOME });
|
|
19
|
+
});
|
|
20
|
+
function seedRun(triage) {
|
|
21
|
+
const run = createRun({
|
|
22
|
+
title: "Gate test",
|
|
23
|
+
taskCategory: "other",
|
|
24
|
+
status: "plan_pending",
|
|
25
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
26
|
+
});
|
|
27
|
+
addArtifact(run.id, "draft_plan", { markdown: "# Plan" }, "agent");
|
|
28
|
+
addArtifact(run.id, "plan_triage", triage, "agent");
|
|
29
|
+
return getRun(run.id);
|
|
30
|
+
}
|
|
31
|
+
test("trivial triage auto-approves: system approved_plan + plan_approved status", () => {
|
|
32
|
+
const run = seedRun({ verdict: "trivial", rationale: "tiny", questions: [], parseFallback: false, consumed: false });
|
|
33
|
+
assert.equal(applyPlanGate(run), "auto_approve");
|
|
34
|
+
assert.equal(getRun(run.id).status, "plan_approved");
|
|
35
|
+
const approved = getLatestArtifact(run.id, "approved_plan");
|
|
36
|
+
assert.equal(approved.author, "system");
|
|
37
|
+
assert.match(approved.contentJson, /tiny/);
|
|
38
|
+
});
|
|
39
|
+
test("questions await answers and leave status untouched", () => {
|
|
40
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
41
|
+
assert.equal(applyPlanGate(run), "await_answers");
|
|
42
|
+
assert.equal(getRun(run.id).status, "plan_pending");
|
|
43
|
+
});
|
|
44
|
+
test("third question round goes to manual review", () => {
|
|
45
|
+
const run = seedRun({ verdict: "needs_review", rationale: "r1", questions: QUESTIONS, parseFallback: false, consumed: false });
|
|
46
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r2", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
47
|
+
addArtifact(run.id, "plan_triage", { verdict: "needs_review", rationale: "r3", questions: QUESTIONS, parseFallback: false, consumed: false }, "agent");
|
|
48
|
+
assert.equal(applyPlanGate(getRun(run.id)), "await_review");
|
|
49
|
+
});
|
|
50
|
+
test("snapshot exposes awaitingAnswerRuns, openQuestionCounts, autoApprovedRunIds", () => {
|
|
51
|
+
const snap = getSnapshot();
|
|
52
|
+
assert.equal(Array.isArray(snap.awaitingAnswerRuns), true);
|
|
53
|
+
assert.equal(typeof snap.openQuestionCounts, "object");
|
|
54
|
+
assert.equal(Array.isArray(snap.autoApprovedRunIds), true);
|
|
55
|
+
});
|
|
@@ -168,7 +168,8 @@ export function updateRunBudget(id, patch) {
|
|
|
168
168
|
export function patchRunPhaseBudget(id, phase, budget) {
|
|
169
169
|
if (budget === undefined)
|
|
170
170
|
return getRun(id);
|
|
171
|
-
|
|
171
|
+
// null = clear run-level override for this phase (revert to agent defaults)
|
|
172
|
+
return updateRunBudget(id, { [phase]: budget });
|
|
172
173
|
}
|
|
173
174
|
export function resolveModelForPhase(run, phase) {
|
|
174
175
|
const agent = run.agentId ? getAgent(run.agentId) : null;
|
|
@@ -387,6 +388,21 @@ export function getLatestArtifact(runId, kind) {
|
|
|
387
388
|
createdAt: row.created_at,
|
|
388
389
|
};
|
|
389
390
|
}
|
|
391
|
+
/** Latest plan_triage stops auto-approving / accepting answers once a human takes over the plan. */
|
|
392
|
+
export function markPlanTriageConsumed(runId) {
|
|
393
|
+
const art = getLatestArtifact(runId, "plan_triage");
|
|
394
|
+
if (!art?.contentJson)
|
|
395
|
+
return;
|
|
396
|
+
try {
|
|
397
|
+
const content = JSON.parse(art.contentJson);
|
|
398
|
+
if (content.consumed === true)
|
|
399
|
+
return;
|
|
400
|
+
updateArtifactContent(art.id, { ...content, consumed: true });
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* leave malformed triage untouched */
|
|
404
|
+
}
|
|
405
|
+
}
|
|
390
406
|
const OPS_BOARD_STATUSES = [
|
|
391
407
|
"queued",
|
|
392
408
|
"plan_pending",
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, BUILTIN_AGENT_CLAUDE_ID, } from "@agent-dealer/shared";
|
|
2
|
+
import { CreateRunInput, AgentConfigInput, AgentDeckConfigPatch, CreateAgentInput, UpdateAgentInput, DraftPlanInput, KickRunInput, PlanAnswersInput, LinearIntakeConfigPatch, PromoteLinearInput, RetryRunInput, Runtime, UpdatePlanInput, PlaybookPatchContent, 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, patchRunPhaseBudget, updateRunBudget, } from "../repository/runs.js";
|
|
4
|
+
import { addArtifact, createRun, findActiveByExternalId, getRun, listArtifacts, listEvents, listRuns, updateRunFields, updateArtifactContent, 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, subscribe, } from "../queue/dispatcher.js";
|
|
7
|
+
import { forceDispatch, getSnapshotAsync, kickRun, schedulePlanDraft, scheduleRedraft, scheduleReflect, submitPlanAnswers, subscribe, } from "../queue/dispatcher.js";
|
|
8
8
|
import { fetchAgentDeckDecks, updatePlaybookBody } from "../adapters/agent-deck.js";
|
|
9
9
|
import { testAgentDeckConnection } from "../adapters/agent-deck.js";
|
|
10
10
|
import { getLinearIssue, listLinearCandidates, testLinearConnection, } from "../adapters/linear-inbox.js";
|
|
@@ -53,11 +53,12 @@ export async function registerRoutes(app) {
|
|
|
53
53
|
const run = getRun(id);
|
|
54
54
|
if (!run)
|
|
55
55
|
return reply.status(404).send({ error: "Not found" });
|
|
56
|
+
const artifacts = listArtifacts(id);
|
|
56
57
|
return {
|
|
57
58
|
run,
|
|
58
|
-
artifacts
|
|
59
|
+
artifacts,
|
|
59
60
|
events: listEvents(id),
|
|
60
|
-
usageSummary: buildLineageUsageSummary(run),
|
|
61
|
+
usageSummary: buildLineageUsageSummary(run, { artifactsByRunId: { [id]: artifacts } }),
|
|
61
62
|
traceSummary: buildLineageTraceSummary(run),
|
|
62
63
|
};
|
|
63
64
|
});
|
|
@@ -272,8 +273,6 @@ export async function registerRoutes(app) {
|
|
|
272
273
|
if (!run)
|
|
273
274
|
return reply.status(404).send({ error: "Not found" });
|
|
274
275
|
const input = UpdatePlanInput.parse(req.body);
|
|
275
|
-
const kind = input.approve ? "approved_plan" : "draft_plan";
|
|
276
|
-
addArtifact(id, kind, { markdown: input.planMarkdown }, "human");
|
|
277
276
|
if (input.approve) {
|
|
278
277
|
const hasPlan = listArtifacts(id).some((a) => a.kind === "draft_plan") ||
|
|
279
278
|
input.planMarkdown.trim().length > 0;
|
|
@@ -301,16 +300,40 @@ export async function registerRoutes(app) {
|
|
|
301
300
|
if (input.executeBudget !== undefined) {
|
|
302
301
|
patchRunPhaseBudget(id, "execute", input.executeBudget);
|
|
303
302
|
}
|
|
304
|
-
|
|
303
|
+
markPlanTriageConsumed(id);
|
|
304
|
+
try {
|
|
305
|
+
transitionRun(id, "plan_approved");
|
|
306
|
+
}
|
|
307
|
+
catch (e) {
|
|
308
|
+
return reply.status(409).send({ error: String(e) });
|
|
309
|
+
}
|
|
310
|
+
addArtifact(id, "approved_plan", { markdown: input.planMarkdown }, "human");
|
|
311
|
+
const updated = getRun(id);
|
|
305
312
|
syncLinearForRun(updated, "plan_approved").catch((e) => console.error("[linear-sync] plan_approved:", e));
|
|
306
313
|
await forceDispatch();
|
|
307
314
|
return updated;
|
|
308
315
|
}
|
|
316
|
+
addArtifact(id, "draft_plan", { markdown: input.planMarkdown }, "human");
|
|
317
|
+
markPlanTriageConsumed(id);
|
|
309
318
|
if (run.status === "queued") {
|
|
310
319
|
return transitionRun(id, "plan_pending");
|
|
311
320
|
}
|
|
312
321
|
return getRun(id);
|
|
313
322
|
});
|
|
323
|
+
app.post("/api/runs/:id/plan/answers", async (req, reply) => {
|
|
324
|
+
const { id } = req.params;
|
|
325
|
+
let input;
|
|
326
|
+
try {
|
|
327
|
+
input = PlanAnswersInput.parse(req.body);
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
return reply.status(400).send({ error: String(e) });
|
|
331
|
+
}
|
|
332
|
+
const result = submitPlanAnswers(id, input);
|
|
333
|
+
if (!result.ok)
|
|
334
|
+
return reply.status(result.code).send({ error: result.error });
|
|
335
|
+
return { run: result.run, outcome: result.outcome };
|
|
336
|
+
});
|
|
314
337
|
app.post("/api/runs/:id/kick", async (req, reply) => {
|
|
315
338
|
const { id } = req.params;
|
|
316
339
|
const run = getRun(id);
|
|
@@ -394,6 +417,9 @@ export async function registerRoutes(app) {
|
|
|
394
417
|
runtime: run.runtime,
|
|
395
418
|
execute_model: input.executeModel ?? input.planModel ?? run.executeModel,
|
|
396
419
|
});
|
|
420
|
+
if (input.executeBudget !== undefined) {
|
|
421
|
+
patchRunPhaseBudget(retry.id, "execute", input.executeBudget);
|
|
422
|
+
}
|
|
397
423
|
try {
|
|
398
424
|
addArtifact(retry.id, "approved_plan", JSON.parse(approvedPlan.contentJson), approvedPlan.author);
|
|
399
425
|
}
|
|
@@ -25,14 +25,15 @@ function logPathFor(run, mode) {
|
|
|
25
25
|
const logDir = getTemporalLogsDir();
|
|
26
26
|
return path.join(logDir, `${run.id}-${mode}-${Date.now()}.ndjson`);
|
|
27
27
|
}
|
|
28
|
-
export async function runClaude(run, mode = "execute", model,
|
|
28
|
+
export async function runClaude(run, mode = "execute", model, opts) {
|
|
29
29
|
const mcpConfig = process.env.CLAUDE_MCP_CONFIG ?? path.join(process.env.HOME ?? "", ".claude.json");
|
|
30
30
|
if (!fs.existsSync(mcpConfig)) {
|
|
31
31
|
throw new Error(`MCP config not found: ${mcpConfig}. Set CLAUDE_MCP_CONFIG.`);
|
|
32
32
|
}
|
|
33
33
|
const phaseBudget = resolveBudgetForPhase(run, mode);
|
|
34
|
-
const resumeSessionId =
|
|
35
|
-
|
|
34
|
+
const resumeSessionId = opts?.resumeSessionId ??
|
|
35
|
+
(mode === "execute" && humanFeedbackText(run) ? lineageParentExecuteSessionId(run) : null);
|
|
36
|
+
const prompt = opts?.promptOverride ??
|
|
36
37
|
(mode === "plan"
|
|
37
38
|
? buildPlanPrompt(run)
|
|
38
39
|
: mode === "reflect"
|
|
@@ -54,13 +55,13 @@ export async function runClaude(run, mode = "execute", model, promptOverride) {
|
|
|
54
55
|
"--verbose",
|
|
55
56
|
];
|
|
56
57
|
if (mode === "plan") {
|
|
57
|
-
args.push("--allowedTools", "Read,Glob,Grep,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
58
|
+
args.push("--allowedTools", "Read,Glob,Grep,Skill,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
58
59
|
}
|
|
59
60
|
else if (mode === "reflect") {
|
|
60
|
-
args.push("--allowedTools", "Read,Glob,Grep,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
61
|
+
args.push("--allowedTools", "Read,Glob,Grep,Skill,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
61
62
|
}
|
|
62
63
|
else {
|
|
63
|
-
args.push("--add-dir", getTemporalOutputDir(), "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
64
|
+
args.push("--add-dir", getTemporalOutputDir(), "--allowedTools", "Read,Write,Edit,Glob,Grep,Bash,Skill,mcp__agent-deck__get_playbook,mcp__agent-deck__get_bound_deck,mcp__agent-deck__bind_workspace");
|
|
64
65
|
}
|
|
65
66
|
const { exitCode, transcript } = await spawnCli(resolveClaudeBin(), args, workspaceForRun(run));
|
|
66
67
|
if (transcript)
|
|
@@ -85,12 +86,12 @@ export async function runCursor(run, mode = "execute", model) {
|
|
|
85
86
|
fs.writeFileSync(logPath, transcript);
|
|
86
87
|
return { exitCode, transcript, logPath };
|
|
87
88
|
}
|
|
88
|
-
export async function runAgent(run, mode = "execute") {
|
|
89
|
+
export async function runAgent(run, mode = "execute", revise) {
|
|
89
90
|
const fresh = getRun(run.id) ?? run;
|
|
90
91
|
const model = resolveModelForPhase(fresh, mode);
|
|
91
92
|
if (fresh.runtime === "cursor_local")
|
|
92
93
|
return runCursor(fresh, mode, model);
|
|
93
|
-
return runClaude(fresh, mode, model);
|
|
94
|
+
return runClaude(fresh, mode, model, revise ? { promptOverride: revise.prompt, resumeSessionId: revise.resumeSessionId } : undefined);
|
|
94
95
|
}
|
|
95
96
|
/** @deprecated use stream-json extractPlanMarkdown via persistRunOutput */
|
|
96
97
|
export function extractPlanFromTranscript(transcript) {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { detectExecutionBlocker } from "@agent-dealer/shared";
|
|
4
|
-
import { addArtifact, getLatestArtifact, getLineageParentRun } from "../repository/runs.js";
|
|
4
|
+
import { addArtifact, getLatestArtifact, getLineageParentRun, resolveBudgetForPhase } from "../repository/runs.js";
|
|
5
5
|
import { getTemporalOutputDir } from "../paths.js";
|
|
6
|
-
import { buildStreamTrace, extractPlanMarkdown, extractResultIsError, extractResultText, extractSessionId, extractUsage, parseNdjson, } from "./stream-json.js";
|
|
6
|
+
import { buildStreamTrace, extractPlanMarkdown, extractPlanTriage, extractResultIsError, extractResultText, extractSessionId, extractUsage, parseNdjson, } from "./stream-json.js";
|
|
7
7
|
import { humanFeedbackText } from "./run-context.js";
|
|
8
8
|
export function seedDeliverableFromParent(run) {
|
|
9
9
|
const parent = getLineageParentRun(run);
|
|
@@ -49,6 +49,11 @@ export function persistRunOutput(input) {
|
|
|
49
49
|
const blocker = detectExecutionBlocker(resultText);
|
|
50
50
|
const isError = exitCode !== 0 || streamError || blocker.detected;
|
|
51
51
|
const usage = extractUsage(events, phase, runtime);
|
|
52
|
+
const caps = resolveBudgetForPhase(run, phase);
|
|
53
|
+
if (caps?.maxTurns != null)
|
|
54
|
+
usage.maxTurns = caps.maxTurns;
|
|
55
|
+
if (caps?.maxBudgetUsd != null)
|
|
56
|
+
usage.maxBudgetUsd = caps.maxBudgetUsd;
|
|
52
57
|
const trace = buildStreamTrace(events);
|
|
53
58
|
if (sessionId) {
|
|
54
59
|
addArtifact(run.id, "agent_session", { phase, runtime, sessionId }, "agent");
|
|
@@ -67,8 +72,16 @@ export function persistRunOutput(input) {
|
|
|
67
72
|
captureDocumentArtifact(run, resultText);
|
|
68
73
|
}
|
|
69
74
|
if (phase === "plan") {
|
|
70
|
-
const
|
|
71
|
-
|
|
75
|
+
const rawPlan = extractPlanMarkdown(events);
|
|
76
|
+
const planTriage = extractPlanTriage(rawPlan);
|
|
77
|
+
return {
|
|
78
|
+
planMarkdown: planTriage.markdown,
|
|
79
|
+
planTriage,
|
|
80
|
+
resultText,
|
|
81
|
+
sessionId,
|
|
82
|
+
blocked: blocker.detected,
|
|
83
|
+
blockerSummary: blocker.summary,
|
|
84
|
+
};
|
|
72
85
|
}
|
|
73
86
|
return { resultText, sessionId, blocked: blocker.detected, blockerSummary: blocker.summary };
|
|
74
87
|
}
|