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.
@@ -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. Retry re-freezes the snapshot from these fields.
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 fields the snapshot is frozen from may change at the park: the review budget,
282
- // repo/base branch, agents and autoMerge stay as the running workflow saw them.
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 acceptanceCriteria can be edited while parked at attempts_exhausted (got: ${blocked.join(", ")})`;
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 (a non-goal).
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.11",
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.11",
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",