agent-dealer 1.1.6 → 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.
- package/bundle/server/dist/coordinator/admission.test.js +35 -0
- package/bundle/server/dist/coordinator/commands.js +89 -4
- package/bundle/server/dist/routes/issues.js +37 -7
- package/bundle/server/dist/routes/issues.test.js +333 -1
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-Ct2CCoYn.js +60 -0
- package/bundle/server/static-ui/assets/index-DlKwApOA.css +1 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/workflow.js +3 -2
- package/bundle/shared/package.json +1 -1
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-BTqSHauT.js +0 -60
- package/bundle/server/static-ui/assets/index-BrC-jEt_.css +0 -1
|
@@ -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-
|
|
1610
|
-
*
|
|
1611
|
-
*
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
|
290
|
-
// comparing against the freshly read row so a concurrent edit
|
|
291
|
-
|
|
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.
|
|
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.
|
|
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",
|