agent-dealer 1.1.7 → 1.1.8

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.
@@ -716,3 +716,38 @@ test("NOT-157: soft Cursor probe timeout waits with probe-timeout reason, not lo
716
716
  setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
717
717
  }
718
718
  });
719
+ // ---------------------------------------------------------------------------
720
+ // NOT-239: Close issue vs the admission tick — exactly one terminal outcome.
721
+ // ---------------------------------------------------------------------------
722
+ test("NOT-239: closing the queue head lets the next tick admit the following issue", async () => {
723
+ const { closeReadyIssue } = await import("./commands.js");
724
+ const head = readyIssue("close-head");
725
+ const next = readyIssue("close-next");
726
+ enqueueIssue(head.id);
727
+ enqueueIssue(next.id);
728
+ // Close wins the race: the head retires without ever starting.
729
+ assert.deepEqual(closeReadyIssue(head.id, "web"), {
730
+ ok: true,
731
+ issueStatus: "closed",
732
+ alreadyClosed: false,
733
+ });
734
+ const admitted = await admitNext();
735
+ assert.equal(admitted?.issueId, next.id, "the tick fills the freed slot with the next issue");
736
+ assert.equal(getIssue(next.id).status, "developing");
737
+ assert.equal(getIssue(head.id).status, "closed");
738
+ assert.equal(instanceCount(head.id), 0, "close created no workflow instance");
739
+ assert.equal(instanceCount(next.id), 1);
740
+ });
741
+ test("NOT-239: a tick that admitted first makes a late close refuse", async () => {
742
+ const { closeReadyIssue } = await import("./commands.js");
743
+ const issue = readyIssue("close-late");
744
+ enqueueIssue(issue.id);
745
+ const admitted = await admitNext();
746
+ assert.equal(admitted?.issueId, issue.id);
747
+ const late = closeReadyIssue(issue.id, "web");
748
+ assert.equal(late.ok, false);
749
+ if (late.ok)
750
+ throw new Error("expected the late close to refuse");
751
+ assert.equal(late.code, 409);
752
+ assert.equal(getIssue(issue.id).status, "developing");
753
+ });
@@ -33,7 +33,7 @@ import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
33
33
  import { externalMergeStateForIssue } from "./external-merge.js";
34
34
  import { detectNonConvergence, formatNonConvergenceReason, reviewHistoryFromEvents, } from "./non-convergence.js";
35
35
  import { capEscalationEvents, deferLeasedWorkItemForBaseFetch, deferLeasedWorkItemForDeckOutage, deferLeasedWorkItemForUsageCap, formatCapEscalationReason, usageCapDeferralStartedAt, } from "./usage-cap-defer.js";
36
- import { markQueueEntryAdmitted } from "../repository/queue-entries.js";
36
+ import { dequeueIssue, getQueuedEntryForIssue, markQueueEntryAdmitted, } from "../repository/queue-entries.js";
37
37
  import { getWorkflow } from "./workflows/registry.js";
38
38
  import { DEV_REVIEWER_V1_VERSION } from "./workflows/dev-reviewer-v1.js";
39
39
  /** Default start template — resolved through the workflow registry (NOT-70). */
@@ -1606,10 +1606,95 @@ export function abortIssue(issueId, resolvedBy, deps = defaultAbortDeps, opts) {
1606
1606
  return { ok: true, issueStatus: tx.issueStatus, alreadyClosed: tx.alreadyClosed };
1607
1607
  }
1608
1608
  /**
1609
- * NOT-196 HTTP/CLI entry for abort: same as `abortIssue`, but pre-reads the PR merge
1610
- * state outside the transaction first so an externally-merged PR lands the issue as
1611
- * `done`. Issues without a PR number skip the `gh` call entirely.
1609
+ * NOT-239: intentionally retire a `ready` issue that should never run — the
1610
+ * pre-execution counterpart to abort, never an alias for it. One transaction
1611
+ * re-reads the issue (never trusting a pre-transaction read), then atomically:
1612
+ * transitions `ready` → `closed`, removes any queue entry, and appends one
1613
+ * human-authored `issue.closed` event naming who closed it and whether it was
1614
+ * queued. No workflow instance, work item, or worker session is created — there
1615
+ * is nothing to create: any live work (an active instance, or pending/leased
1616
+ * items on a `ready` row) refuses with 409 and keeps Abort workflow as the only
1617
+ * way to stop running work. Non-`ready` statuses refuse the same way, so Close
1618
+ * can never silently end in-flight work.
1619
+ *
1620
+ * Idempotent like abort: a repeated call once the issue is `done`/`closed`
1621
+ * returns that state with no new event. A `closed` issue can never be admitted
1622
+ * afterwards — enqueue rejects it, start/execute reject the status, and the
1623
+ * coordinator tick's housekeeping drops any stale queued row.
1612
1624
  */
1625
+ export function closeReadyIssue(issueId, closedBy) {
1626
+ if (!getIssue(issueId))
1627
+ return { ok: false, code: 404, error: "Issue not found" };
1628
+ const coded = (code, error) => {
1629
+ throw Object.assign(new Error(error), { code });
1630
+ };
1631
+ let out;
1632
+ try {
1633
+ out = getDb().transaction(() => {
1634
+ const issue = getIssue(issueId);
1635
+ if (!issue)
1636
+ coded(404, "Issue not found");
1637
+ const fresh = issue;
1638
+ if (fresh.status === "done" || fresh.status === "closed") {
1639
+ return { alreadyClosed: true, issueStatus: fresh.status };
1640
+ }
1641
+ if (fresh.status !== "ready") {
1642
+ coded(409, `Only a ready issue can be closed — issue is ${fresh.status}; use Abort workflow for in-flight work`);
1643
+ }
1644
+ if (getActiveWorkflowInstance(issueId)) {
1645
+ coded(409, "Issue has an active workflow — use Abort workflow instead of Close issue");
1646
+ }
1647
+ for (const item of listWorkItemsForIssue(issueId)) {
1648
+ if (item.status === "pending" || item.status === "leased") {
1649
+ coded(409, "Issue has pending work — use Abort workflow instead of Close issue");
1650
+ }
1651
+ }
1652
+ const wasQueued = getQueuedEntryForIssue(issueId) != null;
1653
+ // Nesting-safe (better-sqlite3 savepoints, same as the admit path): the
1654
+ // removal joins this transaction, so dequeue + transition + event commit
1655
+ // together — a coordinator tick racing this close either started first
1656
+ // (the guards above refuse) or loses (its start preconditions fail).
1657
+ if (wasQueued)
1658
+ dequeueIssue(issueId);
1659
+ // A dormant open action (e.g. on an unready `ready` row) must not outlive
1660
+ // the issue as still-actionable work — resolve it as closed, not resumed.
1661
+ let resolvedActions = 0;
1662
+ for (const action of listHumanActionsForIssue(issueId)) {
1663
+ if (action.status === "open") {
1664
+ resolveHumanAction(action.id, closedBy, { reason: "closed_by_operator" });
1665
+ resolvedActions += 1;
1666
+ }
1667
+ }
1668
+ transitionIssue(issueId, "closed", {
1669
+ currentOwner: "system",
1670
+ currentIntent: "Closed by operator — will not execute",
1671
+ });
1672
+ appendWorkflowEvent({
1673
+ issueId,
1674
+ type: "issue.closed",
1675
+ actorType: "human",
1676
+ actorRef: closedBy,
1677
+ stage: "closed",
1678
+ payload: {
1679
+ reason: "closed_by_operator",
1680
+ closedBy,
1681
+ closedAt: new Date().toISOString(),
1682
+ wasQueued,
1683
+ resolvedActions,
1684
+ },
1685
+ });
1686
+ return { alreadyClosed: false, issueStatus: "closed" };
1687
+ })();
1688
+ }
1689
+ catch (err) {
1690
+ const code = err.code;
1691
+ const message = err instanceof Error ? err.message : String(err);
1692
+ if (code === 404 || code === 409)
1693
+ return { ok: false, code, error: message };
1694
+ throw err;
1695
+ }
1696
+ return { ok: true, ...out };
1697
+ }
1613
1698
  export async function abortIssueAsync(issueId, resolvedBy, deps = defaultAbortDeps) {
1614
1699
  let externalMergeState;
1615
1700
  try {
@@ -1,6 +1,6 @@
1
1
  // packages/server/src/routes/issues.ts
2
2
  import fs from "node:fs";
3
- import { CreateIssueInput, UpdateIssueInput, } from "@agent-dealer/shared";
3
+ import { CreateIssueInput, IssueStatus, UpdateIssueInput, } from "@agent-dealer/shared";
4
4
  import { getDb } from "../db/index.js";
5
5
  import { createIssue, getIssue, listIssues, findActiveIssueByExternalId, listIssuesByExternalId, queryIssues, updateIssue, listRecentRepos } from "../repository/issues.js";
6
6
  import { listWorkerSessionsForIssue, getActiveWorkerSessionForIssue } from "../repository/worker-sessions.js";
@@ -9,7 +9,7 @@ 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 } from "../coordinator/commands.js";
12
+ import { abortIssueAsync, canEditParkedIssue, checkIssueReadiness, closeReadyIssue } from "../coordinator/commands.js";
13
13
  import { executeIssueNow, isStartable, queueStatusForIssue, refreshQueueWaitReasonForIssue, startIssueViaQueue, } from "../coordinator/admission.js";
14
14
  import { computeHumanWaitMs } from "../coordinator/metrics.js";
15
15
  import { enqueueIssue, enqueueIssueWithOutcome, getQueuedEntryForIssue } from "../repository/queue-entries.js";
@@ -66,9 +66,16 @@ export async function registerIssueRoutes(app) {
66
66
  // plus `{ page, limit, total, totalPages }`. Legacy callers (notably the
67
67
  // CLI status query) omit both and keep the existing unpaginated array.
68
68
  if (query.page !== undefined || query.limit !== undefined) {
69
- const statuses = query.status
69
+ // NOT-239: the default Issues view is the *active* list — `closed` work
70
+ // leaves it and is found through an explicit status filter (`closed` or a
71
+ // list naming it) or the direct issue URL, never by hard delete. `done`
72
+ // stays in the default view (done semantics are unchanged).
73
+ const requested = query.status
70
74
  ? query.status.split(",").map((s) => s.trim()).filter(Boolean)
71
- : undefined;
75
+ : [];
76
+ const statuses = requested.length > 0
77
+ ? requested
78
+ : IssueStatus.options.filter((s) => s !== "closed");
72
79
  const result = queryIssues({
73
80
  search: query.q,
74
81
  status: statuses,
@@ -286,9 +293,13 @@ export async function registerIssueRoutes(app) {
286
293
  if (conflict)
287
294
  throw Object.assign(new Error(conflict), { code: 409 });
288
295
  const next = updateIssue(id, parsed.data);
289
- // NOT-217: durable reassignment audit — only when an assignment actually changed,
290
- // comparing against the freshly read row so a concurrent edit is attributed exactly.
291
- if (fresh.developerAgentId !== next.developerAgentId ||
296
+ // NOT-217/NOT-240: durable configuration audit — only when an execution input
297
+ // actually changed, comparing against the freshly read row so a concurrent edit
298
+ // is attributed exactly. Repository changes ride the same `issue.reassigned`
299
+ // event (extended with from/to repo) rather than silently changing where the
300
+ // issue will run.
301
+ if (fresh.repo !== next.repo ||
302
+ fresh.developerAgentId !== next.developerAgentId ||
292
303
  fresh.reviewerAgentId !== next.reviewerAgentId) {
293
304
  appendWorkflowEvent({
294
305
  issueId: id,
@@ -296,6 +307,8 @@ export async function registerIssueRoutes(app) {
296
307
  actorType: "human",
297
308
  stage: next.status,
298
309
  payload: {
310
+ fromRepo: fresh.repo,
311
+ toRepo: next.repo,
299
312
  fromDeveloperAgentId: fresh.developerAgentId,
300
313
  toDeveloperAgentId: next.developerAgentId,
301
314
  fromReviewerAgentId: fresh.reviewerAgentId,
@@ -368,6 +381,23 @@ export async function registerIssueRoutes(app) {
368
381
  return reply.status(result.code).send({ error: result.error });
369
382
  return { issueStatus: result.issueStatus, alreadyClosed: result.alreadyClosed };
370
383
  });
384
+ /**
385
+ * NOT-239: intentionally retire a `ready` issue that should never run — the
386
+ * pre-execution counterpart to abort, never an alias for it. Only `ready`
387
+ * with no active workflow may close (anything else answers 409 and keeps
388
+ * Abort workflow as the stop for running work). Atomically transitions to
389
+ * `closed`, removes any queue entry, and appends one human-authored
390
+ * `issue.closed` event. Idempotent: repeating on `done`/`closed` answers
391
+ * 200 with `alreadyClosed: true` and writes nothing.
392
+ */
393
+ app.post("/api/issues/:id/close", async (req, reply) => {
394
+ const { id } = req.params;
395
+ const body = req.body;
396
+ const result = closeReadyIssue(id, body?.closedBy?.trim() || "human");
397
+ if (!result.ok)
398
+ return reply.status(result.code).send({ error: result.error });
399
+ return { issueStatus: result.issueStatus, alreadyClosed: result.alreadyClosed };
400
+ });
371
401
  app.post("/api/issues/:id/guidance", async (req, reply) => {
372
402
  const { id } = req.params;
373
403
  const issue = getIssue(id);
@@ -23,7 +23,10 @@ const { listHumanActionsForIssue } = await import("../repository/human-actions.j
23
23
  const { applyCompletion, resolveHumanActionAndAdvance, getTaskSnapshot } = await import("../coordinator/commands.js");
24
24
  const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
25
25
  const { getQueuedEntryForIssue, listQueuedEntries } = await import("../repository/queue-entries.js");
26
- const { setAdmissionHealthCheckerForTests, queueStatusForIssue } = await import("../coordinator/admission.js");
26
+ const { admitNext, setAdmissionHealthCheckerForTests, queueStatusForIssue } = await import("../coordinator/admission.js");
27
+ const { listWorkflowInstancesForIssue } = await import("../repository/workflow-events.js");
28
+ const { listWorkItemsForIssue } = await import("../repository/work-items.js");
29
+ const { listWorkerSessionsForIssue } = await import("../repository/worker-sessions.js");
27
30
  const { createAgent } = await import("../repository/agents.js");
28
31
  const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
29
32
  before(() => {
@@ -637,6 +640,8 @@ test("NOT-217: PATCH agent assignments on a queued issue preserves position, ref
637
640
  const events = listWorkflowEventsForIssue(a).filter((e) => e.type === "issue.reassigned");
638
641
  assert.equal(events.length, 1);
639
642
  assert.deepEqual(JSON.parse(events[0].payloadJson), {
643
+ fromRepo: "github.com/acme/app",
644
+ toRepo: "github.com/acme/app",
640
645
  fromDeveloperAgentId: BUILTIN_AGENT_CURSOR_ID,
641
646
  toDeveloperAgentId: BUILTIN_AGENT_CLAUDE_ID,
642
647
  fromReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
@@ -755,6 +760,8 @@ test("NOT-217: PATCH reviewer-only edit records before/after reviewers without t
755
760
  const events = listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned");
756
761
  assert.equal(events.length, 1);
757
762
  assert.deepEqual(JSON.parse(events[0].payloadJson), {
763
+ fromRepo: "github.com/acme/app",
764
+ toRepo: "github.com/acme/app",
758
765
  fromDeveloperAgentId: BUILTIN_AGENT_CURSOR_ID,
759
766
  toDeveloperAgentId: BUILTIN_AGENT_CURSOR_ID,
760
767
  fromReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
@@ -826,3 +833,328 @@ test("NOT-217: PATCH that races admission answers 409 and never mutates the froz
826
833
  assert.equal(listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned").length, 0, "a conflicted edit leaves no audit event");
827
834
  await app.close();
828
835
  });
836
+ test("NOT-240: PATCH repository plus agents on an unqueued ready issue succeeds, stays unqueued, and audits before/after", async () => {
837
+ const app = await buildApp();
838
+ const created = (await app.inject({
839
+ method: "POST",
840
+ url: "/api/issues",
841
+ payload: {
842
+ title: "Unqueued reconfig",
843
+ repo: "acme/old-repo",
844
+ baseBranch: "main",
845
+ developerAgentId: BUILTIN_AGENT_CURSOR_ID,
846
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
847
+ acceptanceCriteria: "It works",
848
+ enqueue: false,
849
+ },
850
+ })).json();
851
+ assert.equal(getQueuedEntryForIssue(created.id), null);
852
+ const res = await app.inject({
853
+ method: "PATCH",
854
+ url: `/api/issues/${created.id}`,
855
+ payload: {
856
+ repo: "https://github.com/acme/new-repo.git",
857
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
858
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
859
+ },
860
+ });
861
+ assert.equal(res.statusCode, 200, res.body);
862
+ const body = res.json();
863
+ assert.equal(body.repo, "github.com/acme/new-repo", "repo is normalized like issue creation");
864
+ assert.equal(body.developerAgentId, BUILTIN_AGENT_CLAUDE_ID);
865
+ assert.equal(body.reviewerAgentId, BUILTIN_AGENT_CURSOR_ID);
866
+ assert.equal(getQueuedEntryForIssue(created.id), null, "an unqueued save stays unqueued");
867
+ const events = listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned");
868
+ assert.equal(events.length, 1);
869
+ assert.deepEqual(JSON.parse(events[0].payloadJson), {
870
+ fromRepo: "github.com/acme/old-repo",
871
+ toRepo: "github.com/acme/new-repo",
872
+ fromDeveloperAgentId: BUILTIN_AGENT_CURSOR_ID,
873
+ toDeveloperAgentId: BUILTIN_AGENT_CLAUDE_ID,
874
+ fromReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
875
+ toReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
876
+ });
877
+ await app.close();
878
+ });
879
+ test("NOT-240: PATCH repository on a queued ready issue preserves position and recomputes the repository wait reason", async () => {
880
+ const { setMaxActiveIssues } = await import("../repository/admission-settings.js");
881
+ const app = await buildApp();
882
+ try {
883
+ // Two global slots: one admitted issue never blocks on capacity, so the
884
+ // per-repository exclusion is the reason under test.
885
+ setMaxActiveIssues(2);
886
+ const mk = (title, repo) => app.inject({
887
+ method: "POST",
888
+ url: "/api/issues",
889
+ payload: {
890
+ title,
891
+ repo,
892
+ baseBranch: "main",
893
+ developerAgentId: BUILTIN_AGENT_CURSOR_ID,
894
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
895
+ acceptanceCriteria: "It works",
896
+ },
897
+ });
898
+ const holderId = (await mk("Repo holder", "acme/hot-repo")).json().id;
899
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${holderId}/execute` })).statusCode, 200);
900
+ const queuedId = (await mk("Queued mover", "acme/cold-repo")).json().id;
901
+ assert.equal(queueStatusForIssue(queuedId)?.position, 1);
902
+ assert.equal(queueStatusForIssue(queuedId)?.waitReason, null);
903
+ // Move onto the occupied repository: same position, repository-slot wait reason.
904
+ const clash = await app.inject({
905
+ method: "PATCH",
906
+ url: `/api/issues/${queuedId}`,
907
+ payload: { repo: "acme/hot-repo" },
908
+ });
909
+ assert.equal(clash.statusCode, 200, clash.body);
910
+ assert.equal(clash.json().repo, "github.com/acme/hot-repo");
911
+ assert.equal(queueStatusForIssue(queuedId)?.position, 1, "a repository change must not reorder");
912
+ assert.match(queueStatusForIssue(queuedId)?.waitReason ?? "", /repository slot/);
913
+ // Move off to a free repository: stale wait text is cleared, position kept.
914
+ const free = await app.inject({
915
+ method: "PATCH",
916
+ url: `/api/issues/${queuedId}`,
917
+ payload: { repo: "acme/free-repo" },
918
+ });
919
+ assert.equal(free.statusCode, 200, free.body);
920
+ assert.equal(queueStatusForIssue(queuedId)?.position, 1);
921
+ assert.equal(queueStatusForIssue(queuedId)?.waitReason, null);
922
+ const events = listWorkflowEventsForIssue(queuedId).filter((e) => e.type === "issue.reassigned");
923
+ assert.equal(events.length, 2, "each repository change is its own audit event");
924
+ assert.deepEqual(JSON.parse(events[0].payloadJson), {
925
+ fromRepo: "github.com/acme/cold-repo",
926
+ toRepo: "github.com/acme/hot-repo",
927
+ fromDeveloperAgentId: BUILTIN_AGENT_CURSOR_ID,
928
+ toDeveloperAgentId: BUILTIN_AGENT_CURSOR_ID,
929
+ fromReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
930
+ toReviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
931
+ });
932
+ }
933
+ finally {
934
+ setMaxActiveIssues(1);
935
+ }
936
+ await app.close();
937
+ });
938
+ test("NOT-240: PATCH rejects an invalid repository and leaves the row and audit untouched", async () => {
939
+ const app = await buildApp();
940
+ const created = (await app.inject({
941
+ method: "POST",
942
+ url: "/api/issues",
943
+ payload: {
944
+ title: "Repo validation",
945
+ repo: "acme/app",
946
+ baseBranch: "main",
947
+ developerAgentId: BUILTIN_AGENT_CURSOR_ID,
948
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
949
+ acceptanceCriteria: "It works",
950
+ enqueue: false,
951
+ },
952
+ })).json();
953
+ const bad = await app.inject({
954
+ method: "PATCH",
955
+ url: `/api/issues/${created.id}`,
956
+ payload: { repo: "/tmp/local-path" },
957
+ });
958
+ assert.equal(bad.statusCode, 400);
959
+ assert.equal(getIssue(created.id).repo, "github.com/acme/app");
960
+ assert.equal(listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned").length, 0);
961
+ // A same-value repository rewrite is a no-op: 200 with no audit event.
962
+ const same = await app.inject({
963
+ method: "PATCH",
964
+ url: `/api/issues/${created.id}`,
965
+ payload: { repo: "acme/app" },
966
+ });
967
+ assert.equal(same.statusCode, 200, same.body);
968
+ assert.equal(listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned").length, 0);
969
+ await app.close();
970
+ });
971
+ test("NOT-240: PATCH repository that races admission answers 409 and never mutates the frozen snapshot", async () => {
972
+ const app = await buildApp();
973
+ const created = (await app.inject({
974
+ method: "POST",
975
+ url: "/api/issues",
976
+ payload: {
977
+ title: "Raced repo edit",
978
+ repo: "acme/app",
979
+ baseBranch: "main",
980
+ developerAgentId: BUILTIN_AGENT_CURSOR_ID,
981
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
982
+ acceptanceCriteria: "It works",
983
+ },
984
+ })).json();
985
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${created.id}/start` })).statusCode, 200);
986
+ const save = await app.inject({
987
+ method: "PATCH",
988
+ url: `/api/issues/${created.id}`,
989
+ payload: { repo: "acme/other", developerAgentId: BUILTIN_AGENT_CLAUDE_ID },
990
+ });
991
+ assert.equal(save.statusCode, 409);
992
+ assert.match(String(save.json().error), /developing|active workflow/);
993
+ const issue = getIssue(created.id);
994
+ assert.equal(issue.repo, "github.com/acme/app", "frozen repository untouched");
995
+ assert.equal(issue.developerAgentId, BUILTIN_AGENT_CURSOR_ID, "frozen assignment untouched");
996
+ assert.equal(listWorkflowEventsForIssue(created.id).filter((e) => e.type === "issue.reassigned").length, 0, "a conflicted edit leaves no audit event");
997
+ await app.close();
998
+ });
999
+ // ---------------------------------------------------------------------------
1000
+ // NOT-239: Close issue — retire a `ready` issue that should never run.
1001
+ // ---------------------------------------------------------------------------
1002
+ /** A startable `ready` issue through the same HTTP create the UI uses. */
1003
+ async function createClosableIssue(app, title, extra = {}) {
1004
+ const res = await app.inject({
1005
+ method: "POST",
1006
+ url: "/api/issues",
1007
+ payload: {
1008
+ title,
1009
+ repo: "acme/app",
1010
+ baseBranch: "main",
1011
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
1012
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
1013
+ acceptanceCriteria: "It works",
1014
+ ...extra,
1015
+ },
1016
+ });
1017
+ assert.equal(res.statusCode, 200, res.body);
1018
+ return res.json().id;
1019
+ }
1020
+ function closeEvents(issueId) {
1021
+ return listWorkflowEventsForIssue(issueId).filter((e) => e.type === "issue.closed");
1022
+ }
1023
+ test("NOT-239: close retires an unqueued ready issue with no execution records", async () => {
1024
+ const app = await buildApp();
1025
+ const id = await createClosableIssue(app, "Obsolete draft", { enqueue: false });
1026
+ const before = getIssue(id);
1027
+ const res = await app.inject({
1028
+ method: "POST",
1029
+ url: `/api/issues/${id}/close`,
1030
+ payload: { closedBy: "yusuke" },
1031
+ });
1032
+ assert.equal(res.statusCode, 200, res.body);
1033
+ assert.deepEqual(res.json(), { issueStatus: "closed", alreadyClosed: false });
1034
+ const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
1035
+ assert.equal(detail.issue.status, "closed");
1036
+ assert.equal(detail.issue.branch, before.branch, "close creates no branch");
1037
+ assert.equal(detail.issue.prNumber, null, "close creates no PR");
1038
+ assert.equal(detail.queued, false);
1039
+ assert.equal(detail.queueEntry, null);
1040
+ // Exactly one durable human-authored close event naming who closed and when.
1041
+ const closed = detail.timeline.filter((e) => e.type === "issue.closed");
1042
+ assert.equal(closed.length, 1);
1043
+ assert.equal(closed[0].actorType, "human");
1044
+ assert.equal(closed[0].actorRef, "yusuke");
1045
+ assert.ok(closed[0].ts, "close event carries its timestamp");
1046
+ const payload = JSON.parse(closed[0].payloadJson);
1047
+ assert.equal(payload.reason, "closed_by_operator");
1048
+ assert.equal(payload.closedBy, "yusuke");
1049
+ assert.equal(typeof payload.closedAt, "string");
1050
+ assert.equal(payload.wasQueued, false);
1051
+ assert.equal(payload.resolvedActions, 0);
1052
+ // No execution records of any kind.
1053
+ assert.equal(listWorkflowInstancesForIssue(id).length, 0);
1054
+ assert.equal(listWorkItemsForIssue(id).length, 0);
1055
+ assert.equal(listWorkerSessionsForIssue(id).length, 0);
1056
+ await app.close();
1057
+ });
1058
+ test("NOT-239: close on a queued ready issue atomically removes the entry and advances the next issue", async () => {
1059
+ const app = await buildApp();
1060
+ const first = await createClosableIssue(app, "First in line");
1061
+ const second = await createClosableIssue(app, "Second in line");
1062
+ assert.equal(queueStatusForIssue(first)?.position, 1);
1063
+ assert.equal(queueStatusForIssue(second)?.position, 2);
1064
+ const res = await app.inject({ method: "POST", url: `/api/issues/${first}/close` });
1065
+ assert.equal(res.statusCode, 200, res.body);
1066
+ assert.deepEqual(res.json(), { issueStatus: "closed", alreadyClosed: false });
1067
+ assert.equal(getIssue(first).status, "closed");
1068
+ assert.equal(getQueuedEntryForIssue(first), null, "queue entry removed with the close");
1069
+ assert.equal(queueStatusForIssue(second)?.position, 1, "next issue advances normally");
1070
+ const payload = JSON.parse(closeEvents(first)[0].payloadJson);
1071
+ assert.equal(payload.wasQueued, true);
1072
+ await app.close();
1073
+ });
1074
+ test("NOT-239: close is idempotent — a repeat on closed writes no new event", async () => {
1075
+ const app = await buildApp();
1076
+ const id = await createClosableIssue(app, "Twice closed", { enqueue: false });
1077
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${id}/close` })).statusCode, 200);
1078
+ const second = await app.inject({ method: "POST", url: `/api/issues/${id}/close` });
1079
+ assert.equal(second.statusCode, 200);
1080
+ assert.deepEqual(second.json(), { issueStatus: "closed", alreadyClosed: true });
1081
+ assert.equal(closeEvents(id).length, 1, "a repeated close must not append another event");
1082
+ await app.close();
1083
+ });
1084
+ test("NOT-239: close 404s for an unknown issue", async () => {
1085
+ const app = await buildApp();
1086
+ const res = await app.inject({ method: "POST", url: "/api/issues/does-not-exist/close" });
1087
+ assert.equal(res.statusCode, 404);
1088
+ await app.close();
1089
+ });
1090
+ test("NOT-239: close refuses once execution started — exactly one terminal outcome, Abort stays the stop", async () => {
1091
+ const app = await buildApp();
1092
+ const id = await createClosableIssue(app, "Already running");
1093
+ const started = await app.inject({ method: "POST", url: `/api/issues/${id}/start` });
1094
+ assert.equal(started.statusCode, 200);
1095
+ assert.equal(started.json().state, "admitted");
1096
+ const res = await app.inject({ method: "POST", url: `/api/issues/${id}/close` });
1097
+ assert.equal(res.statusCode, 409);
1098
+ assert.match(res.json().error, /active workflow|Abort/);
1099
+ // Execution won: still developing, one instance, and no close event.
1100
+ assert.equal(getIssue(id).status, "developing");
1101
+ assert.equal(listWorkflowInstancesForIssue(id).length, 1);
1102
+ assert.equal(closeEvents(id).length, 0);
1103
+ await app.close();
1104
+ });
1105
+ test("NOT-239: close needs no readiness — an underspecified ready issue still closes", async () => {
1106
+ const app = await buildApp();
1107
+ // No acceptance criteria: unstartable, still `ready`, queued with a wait reason.
1108
+ const id = await createClosableIssue(app, "Misconfigured", { acceptanceCriteria: undefined });
1109
+ const cleared = await app.inject({
1110
+ method: "PATCH",
1111
+ url: `/api/issues/${id}`,
1112
+ payload: { acceptanceCriteria: null },
1113
+ });
1114
+ assert.equal(cleared.statusCode, 200, cleared.body);
1115
+ assert.ok(getQueuedEntryForIssue(id), "still queued before close");
1116
+ const res = await app.inject({ method: "POST", url: `/api/issues/${id}/close` });
1117
+ assert.equal(res.statusCode, 200, res.body);
1118
+ assert.equal(getIssue(id).status, "closed");
1119
+ assert.equal(getQueuedEntryForIssue(id), null);
1120
+ await app.close();
1121
+ });
1122
+ test("NOT-239: a closed issue can never be admitted afterwards", async () => {
1123
+ const app = await buildApp();
1124
+ const id = await createClosableIssue(app, "Never run");
1125
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${id}/close` })).statusCode, 200);
1126
+ const start = await app.inject({ method: "POST", url: `/api/issues/${id}/start` });
1127
+ assert.equal(start.statusCode, 409);
1128
+ const execute = await app.inject({ method: "POST", url: `/api/issues/${id}/execute` });
1129
+ assert.equal(execute.statusCode, 409);
1130
+ // (The queue router is not mounted on this issues-only app — enqueue is
1131
+ // asserted at the repository level, which is what the route calls.)
1132
+ const { enqueueIssue } = await import("../repository/queue-entries.js");
1133
+ assert.throws(() => enqueueIssue(id), (err) => {
1134
+ assert.equal(err.code, 409);
1135
+ assert.match(err instanceof Error ? err.message : String(err), /cannot enqueue/);
1136
+ return true;
1137
+ });
1138
+ // A coordinator tick racing the close finds nothing to start.
1139
+ assert.equal(await admitNext(), null);
1140
+ assert.equal(listWorkflowInstancesForIssue(id).length, 0);
1141
+ await app.close();
1142
+ });
1143
+ test("NOT-239: closed issues leave the default paginated list but stay findable by status filter and direct URL", async () => {
1144
+ const app = await buildApp();
1145
+ const closedId = await createClosableIssue(app, "Retired work");
1146
+ const activeId = await createClosableIssue(app, "Live work");
1147
+ assert.equal((await app.inject({ method: "POST", url: `/api/issues/${closedId}/close` })).statusCode, 200);
1148
+ const def = (await app.inject({ method: "GET", url: "/api/issues?page=1" })).json();
1149
+ assert.ok(def.issues.some((i) => i.id === activeId), "active issue stays in the default view");
1150
+ assert.ok(!def.issues.some((i) => i.id === closedId), "closed issue leaves the default view");
1151
+ const filtered = (await app.inject({ method: "GET", url: "/api/issues?status=closed&page=1" })).json();
1152
+ assert.ok(filtered.issues.some((i) => i.id === closedId), "status filter still finds closed work");
1153
+ const direct = await app.inject({ method: "GET", url: `/api/issues/${closedId}` });
1154
+ assert.equal(direct.statusCode, 200);
1155
+ assert.equal(direct.json().issue.status, "closed");
1156
+ // Legacy unpaginated list (CLI contract) is untouched.
1157
+ const legacy = (await app.inject({ method: "GET", url: "/api/issues" })).json();
1158
+ assert.ok(legacy.some((i) => i.id === closedId));
1159
+ await app.close();
1160
+ });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-dealer/server",
3
- "version": "1.1.7",
3
+ "version": "1.1.8",
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.1.7",
25
+ "@agent-dealer/shared": "1.1.8",
26
26
  "@fastify/cors": "^11.0.1",
27
27
  "@fastify/static": "^8.2.0",
28
28
  "@modelcontextprotocol/sdk": "^1.29.0",