agent-dealer 1.2.11 → 1.2.13
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/linear-inbox.js +72 -16
- package/bundle/server/dist/adapters/linear-inbox.test.js +183 -1
- package/bundle/server/dist/coordinator/commands.js +204 -5
- package/bundle/server/dist/coordinator/commands.test.js +288 -3
- package/bundle/server/dist/repository/intake-settings.js +50 -0
- package/bundle/server/dist/repository/intake-settings.test.js +72 -0
- package/bundle/server/dist/routes/index.js +6 -3
- package/bundle/server/dist/routes/intake-linear-config.js +31 -0
- package/bundle/server/dist/routes/intake-linear.test.js +95 -5
- package/bundle/server/dist/routes/issues.js +54 -7
- package/bundle/server/dist/routes/issues.test.js +326 -7
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-BzLjMBI9.js +64 -0
- package/bundle/server/static-ui/assets/index-COYJxD1y.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/index.d.ts +303 -0
- package/bundle/shared/dist/index.js +47 -0
- package/bundle/shared/dist/linear-intake.d.ts +14 -0
- package/bundle/shared/dist/linear-intake.js +23 -0
- package/bundle/shared/dist/linear-intake.test.js +41 -2
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +2 -0
- package/dist/issue.d.ts +8 -0
- package/dist/issue.js +35 -0
- package/dist/issue.test.js +92 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-Cf4dipuK.js +0 -64
- package/bundle/server/static-ui/assets/index-K_YcYkQU.css +0 -1
|
@@ -9,7 +9,8 @@ import { listUsageEventsForIssue, summarizeIssueUsage } from "../repository/usag
|
|
|
9
9
|
import { listWorkflowEventsForIssue, appendWorkflowEvent, listWorkflowInstancesForIssue, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
10
10
|
import { listHumanActionsForIssue, listOpenHumanActions } from "../repository/human-actions.js";
|
|
11
11
|
import { listFindingsForIssue } from "../repository/findings.js";
|
|
12
|
-
import { abortIssueAsync, canEditParkedIssue, checkIssueReadiness, closeReadyIssue } from "../coordinator/commands.js";
|
|
12
|
+
import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, parkCapWait, PARKED_EDITABLE_FIELDS } from "../coordinator/commands.js";
|
|
13
|
+
import { getAgent } from "../repository/agents.js";
|
|
13
14
|
import { executeIssueNow, isStartable, queueStatusForIssue, refreshQueueWaitReasonForIssue, startIssueViaQueue, } from "../coordinator/admission.js";
|
|
14
15
|
import { computeHumanWaitMs } from "../coordinator/metrics.js";
|
|
15
16
|
import { enqueueIssue, enqueueIssueWithOutcome, getQueuedEntryForIssue } from "../repository/queue-entries.js";
|
|
@@ -19,8 +20,6 @@ import { deriveLiveProgressFromLog } from "../coordinator/session-progress.js";
|
|
|
19
20
|
import { branchTipStatusForIssue } from "../coordinator/branch-tip-status.js";
|
|
20
21
|
const TRACE_DEFAULT_MAX_CHARS = 50_000;
|
|
21
22
|
const TRACE_HARD_MAX_CHARS = 200_000;
|
|
22
|
-
/** NOT-185: the only fields PATCH accepts at an open attempts_exhausted park. */
|
|
23
|
-
const PARKED_EDITABLE_FIELDS = new Set(["title", "description", "acceptanceCriteria"]);
|
|
24
23
|
/** Non-numeric, non-finite, zero, or negative all fall back to the default rather than
|
|
25
24
|
* disabling the cap — `Number("not-a-number")` is NaN, and `Math.min(NaN, N)` is NaN,
|
|
26
25
|
* which made the original `.slice(-NaN)` behave as `.slice(0)` (the whole file). */
|
|
@@ -142,6 +141,9 @@ export async function registerIssueRoutes(app) {
|
|
|
142
141
|
queued: getQueuedEntryForIssue(id) != null,
|
|
143
142
|
// NOT-118: position + current wait reason so a queued `ready` issue never reads as idle.
|
|
144
143
|
queueEntry: queueStatusForIssue(id),
|
|
144
|
+
// NOT-358: usage-cap / deck-outage wait behind an availability window, if any —
|
|
145
|
+
// the dashboard offers "Park for human" next to the wait notice.
|
|
146
|
+
capWait: capWaitForIssue(id),
|
|
145
147
|
};
|
|
146
148
|
});
|
|
147
149
|
/** NOT-173: stable issue-level execution read model. The existing
|
|
@@ -272,17 +274,23 @@ export async function registerIssueRoutes(app) {
|
|
|
272
274
|
if (fresh.status !== "ready" && fresh.status !== "needs_human") {
|
|
273
275
|
return `Cannot edit an issue that is ${fresh.status}`;
|
|
274
276
|
}
|
|
277
|
+
// NOT-358: an agent swap must never race a live worker — a running session
|
|
278
|
+
// owns the frozen profile snapshot the next spawn would read.
|
|
279
|
+
if (getActiveWorkerSessionForIssue(id)) {
|
|
280
|
+
return "Cannot edit an issue with an actively running session";
|
|
281
|
+
}
|
|
275
282
|
// NOT-185: the one active-workflow exception — parked at an open attempts_exhausted
|
|
276
|
-
// action with nothing pending/leased.
|
|
283
|
+
// or policy_escalation action with nothing pending/leased. A resume re-freezes the
|
|
284
|
+
// snapshot (task fields) and the next round's profile snapshot (agent fields).
|
|
277
285
|
if (getActiveWorkflowInstance(id)) {
|
|
278
286
|
if (!canEditParkedIssue(fresh)) {
|
|
279
287
|
return "Cannot edit an issue with an active workflow";
|
|
280
288
|
}
|
|
281
|
-
// Only the
|
|
282
|
-
//
|
|
289
|
+
// Only the re-frozen inputs may change at the park: repo/base branch, the review
|
|
290
|
+
// budget and autoMerge stay as the running workflow saw them.
|
|
283
291
|
const blocked = Object.keys(parsed.data).filter((k) => !PARKED_EDITABLE_FIELDS.has(k));
|
|
284
292
|
if (blocked.length > 0) {
|
|
285
|
-
return `Only title, description and
|
|
293
|
+
return `Only title, description, acceptanceCriteria, developerAgentId and reviewerAgentId can be edited while parked (got: ${blocked.join(", ")})`;
|
|
286
294
|
}
|
|
287
295
|
}
|
|
288
296
|
return null;
|
|
@@ -290,6 +298,19 @@ export async function registerIssueRoutes(app) {
|
|
|
290
298
|
const fastPath = guardConflict(issue);
|
|
291
299
|
if (fastPath)
|
|
292
300
|
return reply.status(409).send({ error: fastPath });
|
|
301
|
+
// An active workflow instance here is a parked edit — the fast-path guard already
|
|
302
|
+
// established it is editable. The kernel command re-checks that guard inside its own
|
|
303
|
+
// transaction, so an edit that races a resume fails closed instead of landing on a
|
|
304
|
+
// live snapshot. Queue order is untouched — position is preserved by construction.
|
|
305
|
+
if (getActiveWorkflowInstance(id)) {
|
|
306
|
+
const result = applyParkedEdit(id, parsed.data);
|
|
307
|
+
if (!result.ok)
|
|
308
|
+
return reply.status(result.code).send({ error: result.error });
|
|
309
|
+
// NOT-217: re-derive the visible wait reason now that admission may see a
|
|
310
|
+
// different (e.g. healthy) agent.
|
|
311
|
+
await refreshQueueWaitReasonForIssue(id);
|
|
312
|
+
return result.issue;
|
|
313
|
+
}
|
|
293
314
|
// Guard re-check + row write + `issue.reassigned` audit in one transaction, so an
|
|
294
315
|
// edit that races admission fails closed instead of landing on a live snapshot.
|
|
295
316
|
// Queue order is untouched (updateIssue never writes queue_entries) — position is
|
|
@@ -303,6 +324,14 @@ export async function registerIssueRoutes(app) {
|
|
|
303
324
|
const conflict = guardConflict(fresh);
|
|
304
325
|
if (conflict)
|
|
305
326
|
throw Object.assign(new Error(conflict), { code: 409 });
|
|
327
|
+
// NOT-358: an agent assignment must reference an existing profile — a dangling
|
|
328
|
+
// id would only surface later as a failed admission or a broken resume.
|
|
329
|
+
for (const role of ["developerAgentId", "reviewerAgentId"]) {
|
|
330
|
+
const agentId = parsed.data[role];
|
|
331
|
+
if (agentId !== undefined && !getAgent(agentId)) {
|
|
332
|
+
throw Object.assign(new Error(`Unknown agent profile: ${agentId}`), { code: 400 });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
306
335
|
const next = updateIssue(id, parsed.data);
|
|
307
336
|
// NOT-217/NOT-240: durable configuration audit — only when an execution input
|
|
308
337
|
// actually changed, comparing against the freshly read row so a concurrent edit
|
|
@@ -339,6 +368,8 @@ export async function registerIssueRoutes(app) {
|
|
|
339
368
|
}
|
|
340
369
|
const code = err.code;
|
|
341
370
|
const message = err instanceof Error ? err.message : String(err);
|
|
371
|
+
if (code === 400)
|
|
372
|
+
return reply.status(400).send({ error: message });
|
|
342
373
|
if (code === 404)
|
|
343
374
|
return reply.status(404).send({ error: message });
|
|
344
375
|
if (code === 409)
|
|
@@ -390,6 +421,22 @@ export async function registerIssueRoutes(app) {
|
|
|
390
421
|
waitReason: queued?.waitReason ?? null,
|
|
391
422
|
});
|
|
392
423
|
});
|
|
424
|
+
/**
|
|
425
|
+
* NOT-358: park an issue that is waiting on a usage-cap or deck-outage window for a
|
|
426
|
+
* human. Cancels the wait without touching the worktree, moves the issue to
|
|
427
|
+
* `needs_human` with one open `policy_escalation` naming the cap and its `until`,
|
|
428
|
+
* and answers the updated issue plus the new action id. A `resume` of that action
|
|
429
|
+
* continues with whichever agents are then set.
|
|
430
|
+
*/
|
|
431
|
+
app.post("/api/issues/:id/park", async (req, reply) => {
|
|
432
|
+
const { id } = req.params;
|
|
433
|
+
const body = (req.body ?? {});
|
|
434
|
+
const parkedBy = typeof body.parkedBy === "string" && body.parkedBy.trim() ? body.parkedBy.trim() : "human";
|
|
435
|
+
const result = parkCapWait(id, parkedBy);
|
|
436
|
+
if (!result.ok)
|
|
437
|
+
return reply.status(result.code).send({ error: result.error });
|
|
438
|
+
return { ...getIssue(id), humanActionId: result.humanActionId };
|
|
439
|
+
});
|
|
393
440
|
app.post("/api/issues/:id/abort", async (req, reply) => {
|
|
394
441
|
const { id } = req.params;
|
|
395
442
|
const body = req.body;
|
|
@@ -13,20 +13,21 @@ function tmpTraceFile(content) {
|
|
|
13
13
|
}
|
|
14
14
|
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-issue-routes-"));
|
|
15
15
|
const { migrate, getDb } = await import("../db/index.js");
|
|
16
|
-
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
16
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CODEX_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
17
17
|
const { registerIssueRoutes } = await import("./issues.js");
|
|
18
18
|
const { transitionIssue, listIssuesByExternalId, getIssue } = await import("../repository/issues.js");
|
|
19
19
|
const { createHumanAction } = await import("../repository/human-actions.js");
|
|
20
20
|
const { createIssueArtifact } = await import("../repository/artifacts.js");
|
|
21
|
-
const { claimWorkItem } = await import("../repository/work-items.js");
|
|
21
|
+
const { claimWorkItem, cancelWorkItem } = await import("../repository/work-items.js");
|
|
22
|
+
const { randomUUID } = await import("node:crypto");
|
|
22
23
|
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
23
|
-
const { applyCompletion, resolveHumanActionAndAdvance, getTaskSnapshot } = await import("../coordinator/commands.js");
|
|
24
|
+
const { applyCompletion, resolveHumanActionAndAdvance, getTaskSnapshot, startWorkflow } = await import("../coordinator/commands.js");
|
|
24
25
|
const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
|
|
25
26
|
const { getQueuedEntryForIssue, listQueuedEntries } = await import("../repository/queue-entries.js");
|
|
26
27
|
const { admitNext, setAdmissionHealthCheckerForTests, queueStatusForIssue } = await import("../coordinator/admission.js");
|
|
27
28
|
const { listWorkflowInstancesForIssue } = await import("../repository/workflow-events.js");
|
|
28
29
|
const { listWorkItemsForIssue } = await import("../repository/work-items.js");
|
|
29
|
-
const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
|
|
30
|
+
const { createWorkerSession, listWorkerSessionsForIssue, startSession } = await import("../repository/worker-sessions.js");
|
|
30
31
|
const { createAgent } = await import("../repository/agents.js");
|
|
31
32
|
const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
32
33
|
before(() => {
|
|
@@ -281,14 +282,14 @@ test("NOT-185: PATCH /api/issues/:id succeeds while parked at attempts_exhausted
|
|
|
281
282
|
risks: []
|
|
282
283
|
})
|
|
283
284
|
});
|
|
284
|
-
// Non-task fields stay frozen at the park — notably the review budget
|
|
285
|
+
// Non-task, non-agent fields stay frozen at the park — notably the review budget
|
|
286
|
+
// (a non-goal). Agent profiles are swappable at the park (NOT-358, covered below),
|
|
287
|
+
// so they are no longer in this blocked list.
|
|
285
288
|
for (const payload of [
|
|
286
289
|
{ maxReviewRounds: 5 },
|
|
287
290
|
{ maxInfraAttempts: 3 },
|
|
288
291
|
{ repo: "acme/other" },
|
|
289
292
|
{ baseBranch: "develop" },
|
|
290
|
-
{ developerAgentId: BUILTIN_AGENT_CURSOR_ID },
|
|
291
|
-
{ reviewerAgentId: BUILTIN_AGENT_CLAUDE_ID },
|
|
292
293
|
{ autoMerge: true },
|
|
293
294
|
{ title: "Mixed", maxReviewRounds: 5 },
|
|
294
295
|
]) {
|
|
@@ -1158,3 +1159,321 @@ test("NOT-239: closed issues leave the default paginated list but stay findable
|
|
|
1158
1159
|
assert.ok(legacy.some((i) => i.id === closedId));
|
|
1159
1160
|
await app.close();
|
|
1160
1161
|
});
|
|
1162
|
+
// NOT-358: parked agent-swap and cap-wait park shared helpers.
|
|
1163
|
+
async function n358ParkAtAttemptsExhausted(app, extra = {}) {
|
|
1164
|
+
const created = (await app.inject({
|
|
1165
|
+
method: "POST",
|
|
1166
|
+
url: "/api/issues",
|
|
1167
|
+
payload: {
|
|
1168
|
+
title: "Swap me",
|
|
1169
|
+
description: "old",
|
|
1170
|
+
repo: "acme/app",
|
|
1171
|
+
baseBranch: "main",
|
|
1172
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1173
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1174
|
+
maxReviewRounds: 1,
|
|
1175
|
+
acceptanceCriteria: "Old criteria",
|
|
1176
|
+
...extra,
|
|
1177
|
+
},
|
|
1178
|
+
})).json();
|
|
1179
|
+
// Force-started: an HTTP start would queue behind other in-flight issues in the
|
|
1180
|
+
// same test — the PATCH surface under test is identical either way.
|
|
1181
|
+
assert.equal(startWorkflow(created.id).ok, true);
|
|
1182
|
+
const complete = async (outcome) => {
|
|
1183
|
+
const item = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
1184
|
+
await applyCompletion(item.id, item.leaseToken, outcome);
|
|
1185
|
+
};
|
|
1186
|
+
await complete({ kind: "clean_handoff", branch: "swap-b", headSha: "abc", baseSha: "base", prNumber: 1, prUrl: "https://gh/pr/1" });
|
|
1187
|
+
await complete({
|
|
1188
|
+
kind: "verdict",
|
|
1189
|
+
result: ReviewerResult.parse({
|
|
1190
|
+
verdict: "changes_requested",
|
|
1191
|
+
baseSha: "b",
|
|
1192
|
+
headSha: "h",
|
|
1193
|
+
acceptanceCriteriaAssessment: "ok",
|
|
1194
|
+
evidenceAssessment: "ok",
|
|
1195
|
+
findings: [],
|
|
1196
|
+
risks: [],
|
|
1197
|
+
}),
|
|
1198
|
+
});
|
|
1199
|
+
return created.id;
|
|
1200
|
+
}
|
|
1201
|
+
/** The frozen profile snapshot the resumed round carries — what the next session spawns from. */
|
|
1202
|
+
function n358PendingSnapshot(issueId, kind) {
|
|
1203
|
+
const item = listWorkItemsForIssue(issueId).find((i) => i.status === "pending" && i.kind === kind);
|
|
1204
|
+
assert.ok(item, `expected a pending ${kind} work item`);
|
|
1205
|
+
const payload = JSON.parse(item.payloadJson);
|
|
1206
|
+
const snapshot = JSON.parse(payload.profileSnapshot);
|
|
1207
|
+
return snapshot;
|
|
1208
|
+
}
|
|
1209
|
+
test("NOT-358: PATCH swaps the developer alone at an attempts_exhausted park, and resume freezes the new profile", async () => {
|
|
1210
|
+
const app = await buildApp();
|
|
1211
|
+
const issueId = await n358ParkAtAttemptsExhausted(app);
|
|
1212
|
+
const patch = (payload) => app.inject({ method: "PATCH", url: `/api/issues/${issueId}`, payload });
|
|
1213
|
+
const res = await patch({ developerAgentId: BUILTIN_AGENT_CODEX_ID });
|
|
1214
|
+
assert.equal(res.statusCode, 200);
|
|
1215
|
+
const body = res.json();
|
|
1216
|
+
assert.equal(body.developerAgentId, BUILTIN_AGENT_CODEX_ID);
|
|
1217
|
+
assert.equal(body.reviewerAgentId, BUILTIN_AGENT_CURSOR_ID, "the other role is untouched");
|
|
1218
|
+
assert.equal(body.branch, "swap-b", "the swap keeps the existing branch");
|
|
1219
|
+
assert.deepEqual([body.currentRound, body.maxReviewRounds], [1, 1], "rounds are unchanged");
|
|
1220
|
+
const events = listWorkflowEventsForIssue(issueId).filter((e) => e.type === "issue.reassigned");
|
|
1221
|
+
assert.equal(events.length, 1);
|
|
1222
|
+
assert.deepEqual(JSON.parse(events[0].payloadJson), {
|
|
1223
|
+
fromRepo: "github.com/acme/app",
|
|
1224
|
+
toRepo: "github.com/acme/app",
|
|
1225
|
+
fromDeveloperAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1226
|
+
toDeveloperAgentId: BUILTIN_AGENT_CODEX_ID,
|
|
1227
|
+
fromReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1228
|
+
toReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1229
|
+
});
|
|
1230
|
+
const action = listHumanActionsForIssue(issueId).find((a) => a.actionType === "attempts_exhausted");
|
|
1231
|
+
assert.equal(resolveHumanActionAndAdvance(action.id, "op", "retry").ok, true);
|
|
1232
|
+
// The resumed developer round carries the NEW profile — the worker loop builds the
|
|
1233
|
+
// spawned session (agent + runtime) from exactly this snapshot.
|
|
1234
|
+
const snapshot = n358PendingSnapshot(issueId, "developer");
|
|
1235
|
+
assert.equal(snapshot.agentId, BUILTIN_AGENT_CODEX_ID);
|
|
1236
|
+
assert.equal(snapshot.runtime, "codex_local");
|
|
1237
|
+
assert.equal(snapshot.role, "developer");
|
|
1238
|
+
await app.close();
|
|
1239
|
+
});
|
|
1240
|
+
test("NOT-358: PATCH swaps the reviewer alone and both roles together at a policy_escalation park", async () => {
|
|
1241
|
+
const app = await buildApp();
|
|
1242
|
+
const created = (await app.inject({
|
|
1243
|
+
method: "POST",
|
|
1244
|
+
url: "/api/issues",
|
|
1245
|
+
payload: {
|
|
1246
|
+
title: "Capped",
|
|
1247
|
+
repo: "acme/app",
|
|
1248
|
+
baseBranch: "main",
|
|
1249
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1250
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1251
|
+
acceptanceCriteria: "It works",
|
|
1252
|
+
},
|
|
1253
|
+
})).json();
|
|
1254
|
+
assert.equal((await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` })).statusCode, 200);
|
|
1255
|
+
const item = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
1256
|
+
const until = new Date(Date.now() + 300_000).toISOString();
|
|
1257
|
+
await applyCompletion(item.id, item.leaseToken, {
|
|
1258
|
+
kind: "usage_capped",
|
|
1259
|
+
until,
|
|
1260
|
+
reason: "claude_code usage capped — five_hour limit rejected",
|
|
1261
|
+
});
|
|
1262
|
+
// The detail names the wait — the dashboard parks from this notice.
|
|
1263
|
+
const waiting = (await app.inject({ method: "GET", url: `/api/issues/${created.id}` })).json();
|
|
1264
|
+
assert.deepEqual(waiting.capWait, {
|
|
1265
|
+
kind: "usage_capped",
|
|
1266
|
+
until,
|
|
1267
|
+
reason: "claude_code usage capped — five_hour limit rejected",
|
|
1268
|
+
});
|
|
1269
|
+
const parked = await app.inject({ method: "POST", url: `/api/issues/${created.id}/park`, payload: {} });
|
|
1270
|
+
assert.equal(parked.statusCode, 200);
|
|
1271
|
+
const parkedBody = parked.json();
|
|
1272
|
+
assert.equal(parkedBody.status, "needs_human");
|
|
1273
|
+
assert.ok(parkedBody.humanActionId);
|
|
1274
|
+
const patch = (payload) => app.inject({ method: "PATCH", url: `/api/issues/${created.id}`, payload });
|
|
1275
|
+
const revRes = await patch({ reviewerAgentId: BUILTIN_AGENT_CLAUDE_ID });
|
|
1276
|
+
assert.equal(revRes.statusCode, 200);
|
|
1277
|
+
assert.equal(revRes.json().reviewerAgentId, BUILTIN_AGENT_CLAUDE_ID);
|
|
1278
|
+
const bothRes = await patch({ developerAgentId: BUILTIN_AGENT_CODEX_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID });
|
|
1279
|
+
assert.equal(bothRes.statusCode, 200);
|
|
1280
|
+
const bothBody = bothRes.json();
|
|
1281
|
+
assert.equal(bothBody.developerAgentId, BUILTIN_AGENT_CODEX_ID);
|
|
1282
|
+
assert.equal(bothBody.reviewerAgentId, BUILTIN_AGENT_CURSOR_ID);
|
|
1283
|
+
assert.equal(listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned").length, 2, "one reassigned event per PATCH");
|
|
1284
|
+
const action = listHumanActionsForIssue(created.id).find((a) => a.status === "open");
|
|
1285
|
+
assert.equal(action.actionType, "policy_escalation");
|
|
1286
|
+
assert.equal(resolveHumanActionAndAdvance(action.id, "op", "resume").ok, true);
|
|
1287
|
+
const snapshot = n358PendingSnapshot(created.id, "developer");
|
|
1288
|
+
assert.equal(snapshot.agentId, BUILTIN_AGENT_CODEX_ID);
|
|
1289
|
+
assert.equal(snapshot.runtime, "codex_local");
|
|
1290
|
+
await app.close();
|
|
1291
|
+
});
|
|
1292
|
+
test("NOT-358: PATCH agent IDs are rejected while running, queued behind work, completed, or with an active session (409), and unknown IDs are 400", async () => {
|
|
1293
|
+
const app = await buildApp();
|
|
1294
|
+
const mk = async (title) => (await app.inject({
|
|
1295
|
+
method: "POST",
|
|
1296
|
+
url: "/api/issues",
|
|
1297
|
+
payload: {
|
|
1298
|
+
title,
|
|
1299
|
+
repo: "acme/app",
|
|
1300
|
+
baseBranch: "main",
|
|
1301
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1302
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1303
|
+
acceptanceCriteria: "It works",
|
|
1304
|
+
},
|
|
1305
|
+
})).json().id;
|
|
1306
|
+
const patch = (id, payload) => app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload });
|
|
1307
|
+
// `claimWorkItem` takes the oldest claimable item across issues — cancel each flow's
|
|
1308
|
+
// leftovers so later flows claim their own items.
|
|
1309
|
+
const cancelPending = (id) => {
|
|
1310
|
+
for (const i of listWorkItemsForIssue(id)) {
|
|
1311
|
+
if (i.status === "pending" || i.status === "leased")
|
|
1312
|
+
cancelWorkItem(i.id);
|
|
1313
|
+
}
|
|
1314
|
+
};
|
|
1315
|
+
// Running: a pending developer item owns the snapshot.
|
|
1316
|
+
const runningId = await mk("Running swap");
|
|
1317
|
+
assert.equal((await app.inject({ method: "POST", url: `/api/issues/${runningId}/start` })).statusCode, 200);
|
|
1318
|
+
assert.equal((await patch(runningId, { developerAgentId: BUILTIN_AGENT_CODEX_ID })).statusCode, 409);
|
|
1319
|
+
assert.equal(getIssue(runningId).developerAgentId, BUILTIN_AGENT_CLAUDE_ID);
|
|
1320
|
+
assert.equal(listWorkflowEventsForIssue(runningId).filter((e) => e.type === "issue.reassigned").length, 0);
|
|
1321
|
+
cancelPending(runningId);
|
|
1322
|
+
// Queued behind work: parked at policy_escalation but a work item is still pending.
|
|
1323
|
+
// Force-started directly: an HTTP start would queue behind the running issue above.
|
|
1324
|
+
const queuedId = await mk("Queued swap");
|
|
1325
|
+
assert.equal(startWorkflow(queuedId).ok, true);
|
|
1326
|
+
transitionIssue(queuedId, "needs_human");
|
|
1327
|
+
createHumanAction({
|
|
1328
|
+
issueId: queuedId,
|
|
1329
|
+
actionType: "policy_escalation",
|
|
1330
|
+
reason: "test park",
|
|
1331
|
+
question: "Resume?",
|
|
1332
|
+
});
|
|
1333
|
+
assert.equal((await patch(queuedId, { reviewerAgentId: BUILTIN_AGENT_CLAUDE_ID })).statusCode, 409);
|
|
1334
|
+
assert.equal(getIssue(queuedId).reviewerAgentId, BUILTIN_AGENT_CURSOR_ID);
|
|
1335
|
+
cancelPending(queuedId);
|
|
1336
|
+
// Completed: history is immutable.
|
|
1337
|
+
const doneId = await mk("Done swap");
|
|
1338
|
+
transitionIssue(doneId, "developing");
|
|
1339
|
+
transitionIssue(doneId, "reviewing");
|
|
1340
|
+
transitionIssue(doneId, "final_review");
|
|
1341
|
+
transitionIssue(doneId, "done");
|
|
1342
|
+
assert.equal((await patch(doneId, { developerAgentId: BUILTIN_AGENT_CODEX_ID })).statusCode, 409);
|
|
1343
|
+
// Active session: parked with nothing pending/leased, but a worker still runs.
|
|
1344
|
+
const sessionId = await mk("Session swap");
|
|
1345
|
+
assert.equal(startWorkflow(sessionId).ok, true);
|
|
1346
|
+
const pending = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
1347
|
+
assert.equal(pending.issueId, sessionId);
|
|
1348
|
+
cancelWorkItem(pending.id);
|
|
1349
|
+
transitionIssue(sessionId, "needs_human");
|
|
1350
|
+
createHumanAction({
|
|
1351
|
+
issueId: sessionId,
|
|
1352
|
+
actionType: "policy_escalation",
|
|
1353
|
+
reason: "test park",
|
|
1354
|
+
question: "Resume?",
|
|
1355
|
+
});
|
|
1356
|
+
const worker = createWorkerSession({
|
|
1357
|
+
issueId: sessionId,
|
|
1358
|
+
role: "developer",
|
|
1359
|
+
round: 1,
|
|
1360
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1361
|
+
runtime: "claude_code",
|
|
1362
|
+
});
|
|
1363
|
+
startSession(worker.id);
|
|
1364
|
+
const sessionRes = await patch(sessionId, { developerAgentId: BUILTIN_AGENT_CODEX_ID });
|
|
1365
|
+
assert.equal(sessionRes.statusCode, 409);
|
|
1366
|
+
assert.match(sessionRes.json().error, /running session/);
|
|
1367
|
+
assert.equal(getIssue(sessionId).developerAgentId, BUILTIN_AGENT_CLAUDE_ID);
|
|
1368
|
+
// Unknown agent profiles are 400 and change nothing — at both park flavors.
|
|
1369
|
+
const parkedId = await n358ParkAtAttemptsExhausted(app);
|
|
1370
|
+
const unknownId = randomUUID();
|
|
1371
|
+
assert.equal((await patch(parkedId, { developerAgentId: unknownId })).statusCode, 400);
|
|
1372
|
+
assert.equal((await patch(parkedId, { reviewerAgentId: unknownId })).statusCode, 400);
|
|
1373
|
+
assert.equal(getIssue(parkedId).developerAgentId, BUILTIN_AGENT_CLAUDE_ID);
|
|
1374
|
+
assert.equal(getIssue(parkedId).reviewerAgentId, BUILTIN_AGENT_CURSOR_ID);
|
|
1375
|
+
// Locked fields stay locked at a policy_escalation park too.
|
|
1376
|
+
assert.equal((await patch(queuedId, { maxReviewRounds: 5 })).statusCode, 409);
|
|
1377
|
+
await app.close();
|
|
1378
|
+
});
|
|
1379
|
+
test("NOT-358: POST /api/issues/:id/park parks cap and outage waits, and rejects anything else", async () => {
|
|
1380
|
+
const app = await buildApp();
|
|
1381
|
+
assert.equal((await app.inject({ method: "POST", url: "/api/issues/does-not-exist/park", payload: {} })).statusCode, 404);
|
|
1382
|
+
// No active workflow: nothing to park.
|
|
1383
|
+
const ready = (await app.inject({
|
|
1384
|
+
method: "POST",
|
|
1385
|
+
url: "/api/issues",
|
|
1386
|
+
payload: {
|
|
1387
|
+
title: "Never started",
|
|
1388
|
+
repo: "acme/app",
|
|
1389
|
+
baseBranch: "main",
|
|
1390
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1391
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1392
|
+
acceptanceCriteria: "It works",
|
|
1393
|
+
},
|
|
1394
|
+
})).json();
|
|
1395
|
+
assert.equal((await app.inject({ method: "POST", url: `/api/issues/${ready.id}/park`, payload: {} })).statusCode, 409);
|
|
1396
|
+
// A plain pending item is not a cap wait.
|
|
1397
|
+
const plain = (await app.inject({
|
|
1398
|
+
method: "POST",
|
|
1399
|
+
url: "/api/issues",
|
|
1400
|
+
payload: {
|
|
1401
|
+
title: "Plain pending",
|
|
1402
|
+
repo: "acme/app",
|
|
1403
|
+
baseBranch: "main",
|
|
1404
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1405
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1406
|
+
acceptanceCriteria: "It works",
|
|
1407
|
+
},
|
|
1408
|
+
})).json();
|
|
1409
|
+
assert.equal((await app.inject({ method: "POST", url: `/api/issues/${plain.id}/start` })).statusCode, 200);
|
|
1410
|
+
assert.equal((await app.inject({ method: "POST", url: `/api/issues/${plain.id}/park`, payload: {} })).statusCode, 409);
|
|
1411
|
+
// Cancel the plain pending item — `claimWorkItem` takes the oldest claimable item
|
|
1412
|
+
// across issues, so later flows must not see it.
|
|
1413
|
+
for (const i of listWorkItemsForIssue(plain.id)) {
|
|
1414
|
+
if (i.status === "pending" || i.status === "leased")
|
|
1415
|
+
cancelWorkItem(i.id);
|
|
1416
|
+
}
|
|
1417
|
+
// A deck-outage wait parks the same way a usage-cap wait does.
|
|
1418
|
+
const outage = (await app.inject({
|
|
1419
|
+
method: "POST",
|
|
1420
|
+
url: "/api/issues",
|
|
1421
|
+
payload: {
|
|
1422
|
+
title: "Outage wait",
|
|
1423
|
+
repo: "acme/app",
|
|
1424
|
+
baseBranch: "main",
|
|
1425
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1426
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1427
|
+
acceptanceCriteria: "It works",
|
|
1428
|
+
},
|
|
1429
|
+
})).json();
|
|
1430
|
+
// Force-started: the plain issue above still holds the admission slot.
|
|
1431
|
+
assert.equal(startWorkflow(outage.id).ok, true);
|
|
1432
|
+
const outageItem = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
1433
|
+
assert.equal(outageItem.issueId, outage.id);
|
|
1434
|
+
await applyCompletion(outageItem.id, outageItem.leaseToken, {
|
|
1435
|
+
kind: "deck_unavailable",
|
|
1436
|
+
reason: "Agent Deck unreachable — connection refused",
|
|
1437
|
+
});
|
|
1438
|
+
const outagePark = await app.inject({ method: "POST", url: `/api/issues/${outage.id}/park`, payload: {} });
|
|
1439
|
+
assert.equal(outagePark.statusCode, 200);
|
|
1440
|
+
const outageBody = outagePark.json();
|
|
1441
|
+
assert.equal(outageBody.status, "needs_human");
|
|
1442
|
+
const outageAction = listHumanActionsForIssue(outage.id).find((a) => a.id === outageBody.humanActionId);
|
|
1443
|
+
assert.equal(outageAction.actionType, "policy_escalation");
|
|
1444
|
+
assert.match(outageAction.reason, /Agent Deck outage/);
|
|
1445
|
+
// A running session blocks the park even when a cap wait is pending.
|
|
1446
|
+
const live = (await app.inject({
|
|
1447
|
+
method: "POST",
|
|
1448
|
+
url: "/api/issues",
|
|
1449
|
+
payload: {
|
|
1450
|
+
title: "Live session",
|
|
1451
|
+
repo: "acme/app",
|
|
1452
|
+
baseBranch: "main",
|
|
1453
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1454
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
1455
|
+
acceptanceCriteria: "It works",
|
|
1456
|
+
},
|
|
1457
|
+
})).json();
|
|
1458
|
+
assert.equal(startWorkflow(live.id).ok, true);
|
|
1459
|
+
const liveItem = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
1460
|
+
assert.equal(liveItem.issueId, live.id);
|
|
1461
|
+
await applyCompletion(liveItem.id, liveItem.leaseToken, {
|
|
1462
|
+
kind: "usage_capped",
|
|
1463
|
+
until: new Date(Date.now() + 300_000).toISOString(),
|
|
1464
|
+
reason: "claude_code usage capped — test",
|
|
1465
|
+
});
|
|
1466
|
+
const liveSession = createWorkerSession({
|
|
1467
|
+
issueId: live.id,
|
|
1468
|
+
role: "developer",
|
|
1469
|
+
round: 1,
|
|
1470
|
+
agentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
1471
|
+
runtime: "claude_code",
|
|
1472
|
+
});
|
|
1473
|
+
startSession(liveSession.id);
|
|
1474
|
+
const livePark = await app.inject({ method: "POST", url: `/api/issues/${live.id}/park`, payload: {} });
|
|
1475
|
+
assert.equal(livePark.statusCode, 409);
|
|
1476
|
+
assert.equal(getIssue(live.id).status, "developing");
|
|
1477
|
+
assert.ok(listWorkItemsForIssue(live.id).some((i) => i.status === "pending"), "the rejected park cancels nothing");
|
|
1478
|
+
await app.close();
|
|
1479
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"typecheck": "tsc --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@agent-dealer/shared": "1.2.
|
|
25
|
+
"@agent-dealer/shared": "1.2.13",
|
|
26
26
|
"@fastify/cors": "^11.0.1",
|
|
27
27
|
"@fastify/static": "^8.2.0",
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.29.0",
|