agent-dealer 1.3.3 → 1.3.5
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/commands.js +79 -1
- package/bundle/server/dist/coordinator/commands.test.js +2 -0
- package/bundle/server/dist/coordinator/developer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/operator-verification.integration.test.js +20 -2
- package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +2 -0
- package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +44 -4
- package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +129 -1
- package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
- package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
- package/bundle/server/dist/db/index.js +4 -2
- package/bundle/server/dist/db/schema.sql +1 -1
- package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
- package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
- package/bundle/server/dist/routes/human-actions.test.js +4 -4
- package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
- package/bundle/server/dist/routes/issues.js +32 -2
- package/bundle/server/dist/routes/issues.test.js +25 -2
- package/bundle/server/package.json +2 -2
- package/bundle/server/static-ui/assets/index-CHCiztRI.css +1 -0
- package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
- package/bundle/server/static-ui/index.html +2 -2
- package/bundle/shared/dist/issues.d.ts +8 -5
- package/bundle/shared/dist/issues.js +7 -4
- package/bundle/shared/dist/issues.test.js +11 -1
- package/bundle/shared/package.json +1 -1
- package/dist/index.js +1 -1
- package/dist/issue.d.ts +1 -0
- package/dist/issue.js +3 -2
- package/dist/issue.test.js +37 -0
- package/package.json +1 -1
- package/bundle/server/static-ui/assets/index-B_pNUDRY.js +0 -64
- package/bundle/server/static-ui/assets/index-DL8mEXzN.css +0 -1
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// NOT-490: auto-merge policy changes on queued and in-flight issues — the policy-only
|
|
2
|
+
// PATCH path, its audit event, the frozen-input guard for mixed patches, and the
|
|
3
|
+
// linearization against reviewer-approval routing.
|
|
4
|
+
import { test, before, beforeEach, afterEach } from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
10
|
+
import Fastify from "fastify";
|
|
11
|
+
process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not490-"));
|
|
12
|
+
const { migrate, getDb } = await import("../db/index.js");
|
|
13
|
+
const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CODEX_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
|
|
14
|
+
const { registerIssueRoutes } = await import("./issues.js");
|
|
15
|
+
const { createIssue, getIssue } = await import("../repository/issues.js");
|
|
16
|
+
const { listHumanActionsForIssue } = await import("../repository/human-actions.js");
|
|
17
|
+
const { listWorkflowEventsForIssue } = await import("../repository/workflow-events.js");
|
|
18
|
+
const { claimWorkItem } = await import("../repository/work-items.js");
|
|
19
|
+
const { enqueueIssue, getQueuedEntryForIssue, listQueuedEntries } = await import("../repository/queue-entries.js");
|
|
20
|
+
const { queueStatusForIssue, setAdmissionHealthCheckerForTests } = await import("../coordinator/admission.js");
|
|
21
|
+
const { startWorkflow, applyCompletion, getTaskSnapshot } = await import("../coordinator/commands.js");
|
|
22
|
+
const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
|
|
23
|
+
const { setMergePrForTests, clearFinalizeInflightForTests } = await import("../coordinator/auto-merge.js");
|
|
24
|
+
let fixtureRepo = "";
|
|
25
|
+
before(() => {
|
|
26
|
+
migrate();
|
|
27
|
+
setAdmissionHealthCheckerForTests(async () => ({ ok: true }));
|
|
28
|
+
// A real local checkout so the auto-merge finalizer resolves its cwd.
|
|
29
|
+
fixtureRepo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-not490-repo-"));
|
|
30
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: fixtureRepo });
|
|
31
|
+
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: fixtureRepo });
|
|
32
|
+
execFileSync("git", ["config", "user.name", "Test"], { cwd: fixtureRepo });
|
|
33
|
+
fs.writeFileSync(path.join(fixtureRepo, "README.md"), "hi\n");
|
|
34
|
+
execFileSync("git", ["add", "."], { cwd: fixtureRepo });
|
|
35
|
+
execFileSync("git", ["commit", "-m", "init"], { cwd: fixtureRepo });
|
|
36
|
+
});
|
|
37
|
+
let mergeCalls = 0;
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
getDb().exec(`
|
|
40
|
+
DELETE FROM work_items;
|
|
41
|
+
DELETE FROM human_actions;
|
|
42
|
+
DELETE FROM workflow_events;
|
|
43
|
+
DELETE FROM findings;
|
|
44
|
+
DELETE FROM review_publications;
|
|
45
|
+
DELETE FROM worker_sessions;
|
|
46
|
+
DELETE FROM artifacts;
|
|
47
|
+
DELETE FROM usage_events;
|
|
48
|
+
DELETE FROM workflow_instances;
|
|
49
|
+
DELETE FROM queue_entries;
|
|
50
|
+
DELETE FROM issues;
|
|
51
|
+
`);
|
|
52
|
+
mergeCalls = 0;
|
|
53
|
+
clearFinalizeInflightForTests();
|
|
54
|
+
setMergePrForTests(async () => {
|
|
55
|
+
mergeCalls++;
|
|
56
|
+
return { ok: true };
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
setMergePrForTests(null);
|
|
61
|
+
clearFinalizeInflightForTests();
|
|
62
|
+
});
|
|
63
|
+
async function buildApp() {
|
|
64
|
+
const app = Fastify();
|
|
65
|
+
await registerIssueRoutes(app);
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
68
|
+
function newIssue(opts) {
|
|
69
|
+
return createIssue({
|
|
70
|
+
title: opts.title ?? "Policy target",
|
|
71
|
+
description: "d",
|
|
72
|
+
acceptanceCriteria: opts.acceptanceCriteria ?? "It works",
|
|
73
|
+
repo: fixtureRepo,
|
|
74
|
+
developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
|
|
75
|
+
reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
|
|
76
|
+
baseBranch: "main",
|
|
77
|
+
maxReviewRounds: opts.maxReviewRounds ?? 3,
|
|
78
|
+
maxInfraAttempts: 3,
|
|
79
|
+
source: "manual",
|
|
80
|
+
autoMerge: opts.autoMerge,
|
|
81
|
+
}).id;
|
|
82
|
+
}
|
|
83
|
+
async function complete(issueId, outcome) {
|
|
84
|
+
const item = claimWorkItem(`test-${issueId}`, { leaseMs: 60_000 });
|
|
85
|
+
assert.ok(item && item.issueId === issueId, "expected to lease this issue's work item");
|
|
86
|
+
return applyCompletion(item.id, item.leaseToken, outcome);
|
|
87
|
+
}
|
|
88
|
+
const review = (verdict) => ReviewerResult.parse({
|
|
89
|
+
verdict,
|
|
90
|
+
baseSha: "b",
|
|
91
|
+
headSha: "h",
|
|
92
|
+
acceptanceCriteriaAssessment: "ok",
|
|
93
|
+
evidenceAssessment: "ok",
|
|
94
|
+
findings: [],
|
|
95
|
+
risks: [],
|
|
96
|
+
});
|
|
97
|
+
const cleanHandoff = {
|
|
98
|
+
kind: "clean_handoff",
|
|
99
|
+
branch: "issue-490",
|
|
100
|
+
headSha: "abc123",
|
|
101
|
+
baseSha: "base1",
|
|
102
|
+
prNumber: 42,
|
|
103
|
+
prUrl: "https://gh/pr/42",
|
|
104
|
+
};
|
|
105
|
+
function policyEvents(issueId) {
|
|
106
|
+
return listWorkflowEventsForIssue(issueId).filter((e) => e.type === "issue.reassigned");
|
|
107
|
+
}
|
|
108
|
+
/** Every issue column except the policy itself and the write timestamp. */
|
|
109
|
+
function frozenView(issueId) {
|
|
110
|
+
const { autoMerge: _a, updatedAt: _u, ...rest } = getIssue(issueId);
|
|
111
|
+
return rest;
|
|
112
|
+
}
|
|
113
|
+
/** Drives a fresh autoMerge issue into each eligible pre-decision state. */
|
|
114
|
+
const ELIGIBLE = [
|
|
115
|
+
{
|
|
116
|
+
status: "developing",
|
|
117
|
+
setup: async (autoMerge) => {
|
|
118
|
+
const id = newIssue({ autoMerge });
|
|
119
|
+
assert.equal(startWorkflow(id).ok, true);
|
|
120
|
+
return id;
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
status: "reviewing",
|
|
125
|
+
setup: async (autoMerge) => {
|
|
126
|
+
const id = newIssue({ autoMerge });
|
|
127
|
+
startWorkflow(id);
|
|
128
|
+
await complete(id, cleanHandoff);
|
|
129
|
+
return id;
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
status: "repairing",
|
|
134
|
+
setup: async (autoMerge) => {
|
|
135
|
+
const id = newIssue({ autoMerge });
|
|
136
|
+
startWorkflow(id);
|
|
137
|
+
await complete(id, cleanHandoff);
|
|
138
|
+
await complete(id, { kind: "verdict", result: review("changes_requested") });
|
|
139
|
+
return id;
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
status: "needs_human",
|
|
144
|
+
setup: async (autoMerge) => {
|
|
145
|
+
// Review-round budget of 1: changes_requested parks at attempts_exhausted, which a
|
|
146
|
+
// human resume continues in the same workflow.
|
|
147
|
+
const id = newIssue({ autoMerge, maxReviewRounds: 1 });
|
|
148
|
+
startWorkflow(id);
|
|
149
|
+
await complete(id, cleanHandoff);
|
|
150
|
+
await complete(id, { kind: "verdict", result: review("changes_requested") });
|
|
151
|
+
return id;
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
];
|
|
155
|
+
test("NOT-490: a queued ready issue accepts an autoMerge-only update with queue entry, wait reason and other fields untouched", async () => {
|
|
156
|
+
const app = await buildApp();
|
|
157
|
+
const first = newIssue({ autoMerge: true, title: "Ahead" });
|
|
158
|
+
const id = newIssue({ autoMerge: false });
|
|
159
|
+
const behind = newIssue({ autoMerge: true, title: "Behind" });
|
|
160
|
+
for (const issueId of [first, id, behind])
|
|
161
|
+
enqueueIssue(issueId);
|
|
162
|
+
const queueBefore = listQueuedEntries();
|
|
163
|
+
const statusBefore = queueStatusForIssue(id);
|
|
164
|
+
const entryBefore = getQueuedEntryForIssue(id);
|
|
165
|
+
const issueBefore = frozenView(id);
|
|
166
|
+
assert.ok(entryBefore, "precondition: queued");
|
|
167
|
+
const detailBefore = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
168
|
+
assert.deepEqual(detailBefore.autoMergePolicy, { editable: true, reason: null });
|
|
169
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true, updatedBy: "operator-1" } });
|
|
170
|
+
assert.equal(res.statusCode, 200);
|
|
171
|
+
assert.equal(res.json().autoMerge, true);
|
|
172
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
173
|
+
assert.equal(getIssue(id).status, "ready");
|
|
174
|
+
assert.deepEqual(frozenView(id), issueBefore, "every other issue field is unchanged");
|
|
175
|
+
assert.deepEqual(getQueuedEntryForIssue(id), entryBefore, "same queue entry");
|
|
176
|
+
assert.deepEqual(listQueuedEntries(), queueBefore, "queue order and entries are untouched");
|
|
177
|
+
assert.deepEqual(queueStatusForIssue(id), statusBefore, "same position and wait reason");
|
|
178
|
+
const events = policyEvents(id);
|
|
179
|
+
assert.equal(events.length, 1);
|
|
180
|
+
assert.equal(events[0].actorType, "human");
|
|
181
|
+
assert.deepEqual(JSON.parse(events[0].payloadJson), { fromAutoMerge: false, toAutoMerge: true, actor: "operator-1" });
|
|
182
|
+
// A queued issue also refuses coordinator-owned fields smuggled beside the policy.
|
|
183
|
+
for (const mixed of [{ autoMerge: false, branch: "x" }, { autoMerge: false, status: "done" }, { autoMerge: false, headSha: "s" }]) {
|
|
184
|
+
const refused = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: mixed });
|
|
185
|
+
assert.equal(refused.statusCode, 409, `mixed ${JSON.stringify(mixed)} must be refused`);
|
|
186
|
+
assert.equal(getIssue(id).autoMerge, true, "no partial write");
|
|
187
|
+
assert.equal(policyEvents(id).length, 1);
|
|
188
|
+
assert.deepEqual(listQueuedEntries(), queueBefore);
|
|
189
|
+
}
|
|
190
|
+
// Idempotent no-op: 200, no event.
|
|
191
|
+
const again = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
192
|
+
assert.equal(again.statusCode, 200);
|
|
193
|
+
assert.equal(policyEvents(id).length, 1, "a no-op update records nothing");
|
|
194
|
+
// And back off, attributed to the default actor.
|
|
195
|
+
await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } });
|
|
196
|
+
const after = policyEvents(id);
|
|
197
|
+
assert.equal(after.length, 2);
|
|
198
|
+
assert.deepEqual(JSON.parse(after[1].payloadJson), { fromAutoMerge: true, toAutoMerge: false, actor: "human" });
|
|
199
|
+
assert.deepEqual(listQueuedEntries(), queueBefore);
|
|
200
|
+
await app.close();
|
|
201
|
+
});
|
|
202
|
+
for (const { status, setup } of ELIGIBLE) {
|
|
203
|
+
test(`NOT-490: an active ${status} issue accepts an autoMerge-only update; a mixed patch is 409 with no partial write`, async () => {
|
|
204
|
+
const app = await buildApp();
|
|
205
|
+
const id = await setup(false);
|
|
206
|
+
assert.equal(getIssue(id).status, status, "precondition");
|
|
207
|
+
const snapshotBefore = JSON.stringify(getTaskSnapshot(getIssue(id)));
|
|
208
|
+
const issueBefore = frozenView(id);
|
|
209
|
+
const eventsBefore = listWorkflowEventsForIssue(id).length;
|
|
210
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
211
|
+
assert.equal(detail.autoMergePolicy.editable, true);
|
|
212
|
+
// Mixed patches that carry a frozen field fail closed and write nothing.
|
|
213
|
+
for (const mixed of [
|
|
214
|
+
{ autoMerge: true, title: "New title" },
|
|
215
|
+
{ autoMerge: true, repo: "acme/other" },
|
|
216
|
+
{ autoMerge: true, developerAgentId: BUILTIN_AGENT_CODEX_ID },
|
|
217
|
+
{ autoMerge: true, maxReviewRounds: 9 },
|
|
218
|
+
// Coordinator-owned fields the schema does not declare: a non-strict parse would
|
|
219
|
+
// strip them, so the route must refuse on the raw body keys.
|
|
220
|
+
{ autoMerge: true, branch: "other" },
|
|
221
|
+
{ autoMerge: true, status: "done" },
|
|
222
|
+
{ autoMerge: true, headSha: "deadbeef" },
|
|
223
|
+
{ autoMerge: true, prNumber: 7 },
|
|
224
|
+
{ autoMerge: true, prUrl: "https://gh/pr/7" },
|
|
225
|
+
{ autoMerge: true, queuePosition: 0 },
|
|
226
|
+
]) {
|
|
227
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: mixed });
|
|
228
|
+
assert.equal(res.statusCode, 409, `mixed ${JSON.stringify(mixed)} must be refused`);
|
|
229
|
+
assert.equal(getIssue(id).autoMerge, false, "no partial write of the policy");
|
|
230
|
+
assert.deepEqual(frozenView(id), issueBefore);
|
|
231
|
+
assert.equal(listWorkflowEventsForIssue(id).length, eventsBefore);
|
|
232
|
+
}
|
|
233
|
+
const ok = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
234
|
+
assert.equal(ok.statusCode, 200);
|
|
235
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
236
|
+
assert.equal(getIssue(id).status, status, "status is untouched");
|
|
237
|
+
assert.deepEqual(frozenView(id), issueBefore, "frozen inputs, branch, PR, rounds untouched");
|
|
238
|
+
assert.equal(JSON.stringify(getTaskSnapshot(getIssue(id))), snapshotBefore, "frozen task snapshot untouched");
|
|
239
|
+
const events = policyEvents(id);
|
|
240
|
+
assert.equal(events.length, 1);
|
|
241
|
+
assert.deepEqual(JSON.parse(events[0].payloadJson), { fromAutoMerge: false, toAutoMerge: true, actor: "human" });
|
|
242
|
+
assert.ok(events[0].workflowInstanceId, "the event is scoped to the live workflow");
|
|
243
|
+
const noop = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
244
|
+
assert.equal(noop.statusCode, 200);
|
|
245
|
+
assert.equal(policyEvents(id).length, 1);
|
|
246
|
+
await app.close();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
test("NOT-490: a policy update committed before reviewer routing turns approval into the guarded auto-merge", async () => {
|
|
250
|
+
const app = await buildApp();
|
|
251
|
+
const id = newIssue({ autoMerge: false });
|
|
252
|
+
startWorkflow(id);
|
|
253
|
+
await complete(id, cleanHandoff);
|
|
254
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } })).statusCode, 200);
|
|
255
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
256
|
+
assert.equal(getIssue(id).status, "done");
|
|
257
|
+
assert.equal(mergeCalls, 1, "exactly one merge");
|
|
258
|
+
assert.equal(listHumanActionsForIssue(id).filter((a) => a.actionType === "final_review").length, 0, "no final_review action");
|
|
259
|
+
assert.equal(listWorkflowEventsForIssue(id).filter((e) => e.type === "final_review.requested").length, 0);
|
|
260
|
+
await app.close();
|
|
261
|
+
});
|
|
262
|
+
test("NOT-490: a committed false policy routes approval to final_review and never merges", async () => {
|
|
263
|
+
const app = await buildApp();
|
|
264
|
+
const id = newIssue({ autoMerge: true });
|
|
265
|
+
startWorkflow(id);
|
|
266
|
+
await complete(id, cleanHandoff);
|
|
267
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } })).statusCode, 200);
|
|
268
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
269
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
270
|
+
assert.equal(getIssue(id).currentOwner, "human");
|
|
271
|
+
assert.equal(mergeCalls, 0);
|
|
272
|
+
assert.equal(listHumanActionsForIssue(id).filter((a) => a.actionType === "final_review").length, 1);
|
|
273
|
+
await app.close();
|
|
274
|
+
});
|
|
275
|
+
test("NOT-490: a reviewer decision committed first wins — the later update is 409 and creates no second effect", async () => {
|
|
276
|
+
const app = await buildApp();
|
|
277
|
+
const id = newIssue({ autoMerge: false });
|
|
278
|
+
startWorkflow(id);
|
|
279
|
+
await complete(id, cleanHandoff);
|
|
280
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
281
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
282
|
+
const eventsBefore = listWorkflowEventsForIssue(id).length;
|
|
283
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
284
|
+
assert.equal(detail.autoMergePolicy.editable, false);
|
|
285
|
+
assert.match(detail.autoMergePolicy.reason ?? "", /Merge/);
|
|
286
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: true } });
|
|
287
|
+
assert.equal(res.statusCode, 409);
|
|
288
|
+
assert.match(res.json().error, /no longer be changed/);
|
|
289
|
+
assert.equal(getIssue(id).autoMerge, false);
|
|
290
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
291
|
+
assert.equal(mergeCalls, 0, "no auto-merge effect");
|
|
292
|
+
assert.equal(listHumanActionsForIssue(id).filter((a) => a.actionType === "final_review").length, 1, "one final_review action");
|
|
293
|
+
assert.equal(listWorkflowEventsForIssue(id).length, eventsBefore, "no audit event for the losing update");
|
|
294
|
+
await app.close();
|
|
295
|
+
});
|
|
296
|
+
test("NOT-490: while auto-merge finalization is in flight the policy is locked; the merge completes once", async () => {
|
|
297
|
+
const app = await buildApp();
|
|
298
|
+
let releaseMerge;
|
|
299
|
+
const mergeGate = new Promise((resolve) => (releaseMerge = resolve));
|
|
300
|
+
setMergePrForTests(async () => {
|
|
301
|
+
mergeCalls++;
|
|
302
|
+
await mergeGate;
|
|
303
|
+
return { ok: true };
|
|
304
|
+
});
|
|
305
|
+
const id = newIssue({ autoMerge: true });
|
|
306
|
+
startWorkflow(id);
|
|
307
|
+
await complete(id, cleanHandoff);
|
|
308
|
+
const approval = complete(id, { kind: "verdict", result: review("approved") });
|
|
309
|
+
// The routing transaction commits before the async merge — wait for the park.
|
|
310
|
+
for (let i = 0; i < 200 && getIssue(id).status !== "final_review"; i++) {
|
|
311
|
+
await new Promise((r) => setImmediate(r));
|
|
312
|
+
}
|
|
313
|
+
assert.equal(getIssue(id).status, "final_review");
|
|
314
|
+
assert.equal(getIssue(id).currentOwner, "system");
|
|
315
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
316
|
+
assert.equal(detail.autoMergePolicy.editable, false);
|
|
317
|
+
assert.match(detail.autoMergePolicy.reason ?? "", /in flight/);
|
|
318
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } });
|
|
319
|
+
assert.equal(res.statusCode, 409);
|
|
320
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
321
|
+
releaseMerge();
|
|
322
|
+
await approval;
|
|
323
|
+
assert.equal(getIssue(id).status, "done");
|
|
324
|
+
assert.equal(mergeCalls, 1);
|
|
325
|
+
assert.equal(listHumanActionsForIssue(id).length, 0, "no human action created");
|
|
326
|
+
assert.equal(policyEvents(id).length, 0);
|
|
327
|
+
await app.close();
|
|
328
|
+
});
|
|
329
|
+
test("NOT-490: an operator-verification park after approval keeps the committed policy locked", async () => {
|
|
330
|
+
const app = await buildApp();
|
|
331
|
+
const id = newIssue({
|
|
332
|
+
autoMerge: true,
|
|
333
|
+
acceptanceCriteria: "- [ ] Operator signs in with SSO [operator]",
|
|
334
|
+
});
|
|
335
|
+
startWorkflow(id);
|
|
336
|
+
await complete(id, cleanHandoff);
|
|
337
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
338
|
+
assert.equal(getIssue(id).status, "needs_human");
|
|
339
|
+
const gate = listHumanActionsForIssue(id).filter((a) => a.status === "open");
|
|
340
|
+
assert.deepEqual(gate.map((a) => a.actionType), ["operator_verification"], "precondition: parked at the gate");
|
|
341
|
+
assert.equal(mergeCalls, 0);
|
|
342
|
+
const eventsBefore = listWorkflowEventsForIssue(id).length;
|
|
343
|
+
const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
344
|
+
assert.equal(detail.autoMergePolicy.editable, false);
|
|
345
|
+
assert.match(detail.autoMergePolicy.reason ?? "", /operator verification/);
|
|
346
|
+
const res = await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } });
|
|
347
|
+
assert.equal(res.statusCode, 409);
|
|
348
|
+
assert.match(res.json().error, /no longer be changed/);
|
|
349
|
+
assert.equal(getIssue(id).autoMerge, true, "committed policy is unchanged");
|
|
350
|
+
assert.equal(listWorkflowEventsForIssue(id).length, eventsBefore, "no audit event for the refused update");
|
|
351
|
+
assert.equal(policyEvents(id).length, 0);
|
|
352
|
+
await app.close();
|
|
353
|
+
});
|
|
354
|
+
test("NOT-490: a merge-failure park and terminal issues are read-only with an explanation", async () => {
|
|
355
|
+
const app = await buildApp();
|
|
356
|
+
setMergePrForTests(async () => ({ ok: false, reason: "protected branch" }));
|
|
357
|
+
const id = newIssue({ autoMerge: true });
|
|
358
|
+
startWorkflow(id);
|
|
359
|
+
await complete(id, cleanHandoff);
|
|
360
|
+
await complete(id, { kind: "verdict", result: review("approved") });
|
|
361
|
+
assert.equal(getIssue(id).status, "needs_human");
|
|
362
|
+
const parked = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
|
|
363
|
+
assert.equal(parked.autoMergePolicy.editable, false);
|
|
364
|
+
assert.match(parked.autoMergePolicy.reason ?? "", /merge/);
|
|
365
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload: { autoMerge: false } })).statusCode, 409);
|
|
366
|
+
assert.equal(getIssue(id).autoMerge, true);
|
|
367
|
+
setMergePrForTests(async () => ({ ok: true }));
|
|
368
|
+
const done = newIssue({ autoMerge: true, title: "Done" });
|
|
369
|
+
startWorkflow(done);
|
|
370
|
+
await complete(done, cleanHandoff);
|
|
371
|
+
await complete(done, { kind: "verdict", result: review("approved") });
|
|
372
|
+
assert.equal(getIssue(done).status, "done");
|
|
373
|
+
const doneDetail = (await app.inject({ method: "GET", url: `/api/issues/${done}` })).json();
|
|
374
|
+
assert.deepEqual(doneDetail.autoMergePolicy, { editable: false, reason: "The issue is done." });
|
|
375
|
+
assert.equal((await app.inject({ method: "PATCH", url: `/api/issues/${done}`, payload: { autoMerge: false } })).statusCode, 409);
|
|
376
|
+
assert.equal(policyEvents(done).length, 0);
|
|
377
|
+
await app.close();
|
|
378
|
+
});
|
|
@@ -36,7 +36,7 @@ async function buildApp() {
|
|
|
36
36
|
return app;
|
|
37
37
|
}
|
|
38
38
|
function seedIssueAwaitingFinalReview() {
|
|
39
|
-
const issue = createIssue({ title: "Awaiting review", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
39
|
+
const issue = createIssue({ title: "Awaiting review", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
40
40
|
transitionIssue(issue.id, "developing");
|
|
41
41
|
transitionIssue(issue.id, "reviewing");
|
|
42
42
|
transitionIssue(issue.id, "final_review", { currentOwner: "human" });
|
|
@@ -48,7 +48,7 @@ function seedIssueAwaitingFinalReview() {
|
|
|
48
48
|
* (unlike seedIssueAwaitingFinalReview's hand-crafted transitions) — needed to exercise
|
|
49
49
|
* resolveHumanActionAndAdvance's actual transactional path through the route. */
|
|
50
50
|
async function seedRealIssueAwaitingFinalReview() {
|
|
51
|
-
const issue = createIssue({ title: "Real workflow", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
51
|
+
const issue = createIssue({ title: "Real workflow", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
52
52
|
const start = startWorkflow(issue.id);
|
|
53
53
|
assert.equal(start.ok, true);
|
|
54
54
|
const devItem = claimWorkItem(`route-test-${issue.id}-dev`, { leaseMs: 60_000 });
|
|
@@ -286,7 +286,7 @@ test("POST resolve 400s on a choice not valid for reflection_interaction_require
|
|
|
286
286
|
});
|
|
287
287
|
// NOT-272: seeds.
|
|
288
288
|
function seedPreStartScopeDecision() {
|
|
289
|
-
const issue = createIssue({ title: "Scope gated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
289
|
+
const issue = createIssue({ title: "Scope gated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
290
290
|
const action = createHumanAction({
|
|
291
291
|
issueId: issue.id,
|
|
292
292
|
actionType: "product_scope_decision",
|
|
@@ -299,7 +299,7 @@ function seedPreStartScopeDecision() {
|
|
|
299
299
|
/** Drives a real issue to a mid-workflow product_scope_decision via a reviewer
|
|
300
300
|
* escalate verdict carrying a productScopeQuestion (NOT-150 routing). */
|
|
301
301
|
async function seedMidWorkflowScopeDecision() {
|
|
302
|
-
const issue = createIssue({ title: "Scope escalated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
|
|
302
|
+
const issue = createIssue({ title: "Scope escalated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, autoMerge: false, source: "manual" });
|
|
303
303
|
const start = startWorkflow(issue.id);
|
|
304
304
|
assert.equal(start.ok, true);
|
|
305
305
|
const devItem = claimWorkItem(`scope-note-test-${issue.id}-dev`, { leaseMs: 60_000 });
|
|
@@ -379,8 +379,8 @@ const LINEAR_ISSUE_PAYLOAD = {
|
|
|
379
379
|
externalUrl: "https://linear.app/not-so-fat/issue/NOT-123/stale",
|
|
380
380
|
...BASE_AGENTS,
|
|
381
381
|
};
|
|
382
|
-
async function createLinearIssue(app) {
|
|
383
|
-
const res = await app.inject({ method: "POST", url: "/api/issues", payload: LINEAR_ISSUE_PAYLOAD });
|
|
382
|
+
async function createLinearIssue(app, overrides = {}) {
|
|
383
|
+
const res = await app.inject({ method: "POST", url: "/api/issues", payload: { ...LINEAR_ISSUE_PAYLOAD, ...overrides } });
|
|
384
384
|
assert.equal(res.statusCode, 200, res.body);
|
|
385
385
|
return res.json();
|
|
386
386
|
}
|
|
@@ -743,7 +743,8 @@ test("NOT-422: PATCH at a scope pause fails closed on pending/leased work, runni
|
|
|
743
743
|
assert.equal(swapRes.statusCode, 409);
|
|
744
744
|
assert.match(swapRes.json().error, /Only title, description and acceptanceCriteria/);
|
|
745
745
|
// A final_review pause is a different park — still 409 on an active workflow.
|
|
746
|
-
|
|
746
|
+
// NOT-489: auto-merge is the default; opt out so approval parks at final_review.
|
|
747
|
+
const { id: finalId } = await createLinearIssue(app, { autoMerge: false });
|
|
747
748
|
assert.equal(startWorkflow(finalId).ok, true);
|
|
748
749
|
const finalDev = claimWorkItem("route-test", { leaseMs: 60_000 });
|
|
749
750
|
await applyCompletion(finalDev.id, finalDev.leaseToken, {
|
|
@@ -10,7 +10,7 @@ import { listUsageEventsForIssue, summarizeIssueUsage } from "../repository/usag
|
|
|
10
10
|
import { listWorkflowEventsForIssue, appendWorkflowEvent, listWorkflowInstancesForIssue, getActiveWorkflowInstance, } from "../repository/workflow-events.js";
|
|
11
11
|
import { listHumanActionsForIssue, listOpenHumanActions } from "../repository/human-actions.js";
|
|
12
12
|
import { listFindingsForIssue } from "../repository/findings.js";
|
|
13
|
-
import { abortIssueAsync, applyParkedEdit, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, diffTaskSnapshotFromLive, parkCapWait, parkedEditableFields, parkedEditableFieldsLabel } from "../coordinator/commands.js";
|
|
13
|
+
import { abortIssueAsync, applyAutoMergePolicyUpdate, applyParkedEdit, autoMergePolicyEditability, canEditParkedIssue, capWaitForIssue, checkIssueReadiness, closeReadyIssue, diffTaskSnapshotFromLive, parkCapWait, parkedEditableFields, parkedEditableFieldsLabel } from "../coordinator/commands.js";
|
|
14
14
|
import { deleteDealerIssue } from "../coordinator/delete-issue.js";
|
|
15
15
|
import { getAgent } from "../repository/agents.js";
|
|
16
16
|
import { executeIssueNow, isStartable, queueStatusForIssue, refreshQueueWaitReasonForIssue, startIssueViaQueue, } from "../coordinator/admission.js";
|
|
@@ -163,6 +163,9 @@ export async function registerIssueRoutes(app) {
|
|
|
163
163
|
// re-freeze into a new task snapshot (empty = in sync, or pre-freeze).
|
|
164
164
|
// Issue Detail renders the pending-snapshot notice off this.
|
|
165
165
|
pendingSnapshotChangedFields: diffTaskSnapshotFromLive(issue),
|
|
166
|
+
// NOT-490: whether the auto-merge policy may still change (queued or active,
|
|
167
|
+
// before the reviewer decision commits) — Issue Detail gates its toggle on this.
|
|
168
|
+
autoMergePolicy: autoMergePolicyEditability(issue),
|
|
166
169
|
};
|
|
167
170
|
});
|
|
168
171
|
/** NOT-173: stable issue-level execution read model. The existing
|
|
@@ -338,6 +341,29 @@ export async function registerIssueRoutes(app) {
|
|
|
338
341
|
const parsed = UpdateIssueInput.safeParse(req.body);
|
|
339
342
|
if (!parsed.success)
|
|
340
343
|
return reply.status(400).send({ error: parsed.error.message });
|
|
344
|
+
// NOT-490: an `autoMerge`-only patch is the policy command — allowed while queued or
|
|
345
|
+
// active until the reviewer decision commits, race-safe against `applyReviewer`.
|
|
346
|
+
// Any other key (even alongside autoMerge) falls through to the frozen-input guard
|
|
347
|
+
// below, so a mixed patch on a live issue still fails closed with no partial write.
|
|
348
|
+
//
|
|
349
|
+
// The discriminator reads the RAW body keys, not `parsed.data`: the schema is
|
|
350
|
+
// non-strict and strips unknown properties, so `{autoMerge, branch}` would otherwise
|
|
351
|
+
// parse to `{autoMerge}` and slip into the policy path. Any key outside the editable
|
|
352
|
+
// schema (branch, status, SHA, PR, queue fields, …) — other than the `updatedBy`
|
|
353
|
+
// actor metadata — names a coordinator-owned field and fails closed before any write.
|
|
354
|
+
const body = (req.body ?? {});
|
|
355
|
+
const rawKeys = Object.keys(body).filter((k) => k !== "updatedBy");
|
|
356
|
+
const ownedKeys = rawKeys.filter((k) => !Object.prototype.hasOwnProperty.call(UpdateIssueInput.shape, k));
|
|
357
|
+
if (ownedKeys.length > 0) {
|
|
358
|
+
return reply.status(409).send({ error: `Cannot edit coordinator-owned field(s): ${ownedKeys.join(", ")}` });
|
|
359
|
+
}
|
|
360
|
+
if (rawKeys.length === 1 && rawKeys[0] === "autoMerge" && parsed.data.autoMerge !== undefined) {
|
|
361
|
+
const actor = typeof body.updatedBy === "string" && body.updatedBy.trim() ? body.updatedBy.trim() : "human";
|
|
362
|
+
const result = applyAutoMergePolicyUpdate(id, parsed.data.autoMerge, actor);
|
|
363
|
+
if (!result.ok)
|
|
364
|
+
return reply.status(result.code).send({ error: result.error });
|
|
365
|
+
return result.issue;
|
|
366
|
+
}
|
|
341
367
|
// Editable only pre-start or parked back on needs_human — the same statuses
|
|
342
368
|
// startWorkflow itself accepts (commands.ts). A running workflow owns the frozen
|
|
343
369
|
// task snapshot (freezeTaskSnapshot), so an edit must never race or diverge from
|
|
@@ -419,9 +445,12 @@ export async function registerIssueRoutes(app) {
|
|
|
419
445
|
// is attributed exactly. Repository changes ride the same `issue.reassigned`
|
|
420
446
|
// event (extended with from/to repo) rather than silently changing where the
|
|
421
447
|
// issue will run.
|
|
448
|
+
// NOT-490: a pre-start auto-merge change in a mixed edit rides the same event.
|
|
449
|
+
const autoMergeChanged = fresh.autoMerge !== next.autoMerge;
|
|
422
450
|
if (fresh.repo !== next.repo ||
|
|
423
451
|
fresh.developerAgentId !== next.developerAgentId ||
|
|
424
|
-
fresh.reviewerAgentId !== next.reviewerAgentId
|
|
452
|
+
fresh.reviewerAgentId !== next.reviewerAgentId ||
|
|
453
|
+
autoMergeChanged) {
|
|
425
454
|
appendWorkflowEvent({
|
|
426
455
|
issueId: id,
|
|
427
456
|
type: "issue.reassigned",
|
|
@@ -434,6 +463,7 @@ export async function registerIssueRoutes(app) {
|
|
|
434
463
|
toDeveloperAgentId: next.developerAgentId,
|
|
435
464
|
fromReviewerAgentId: fresh.reviewerAgentId,
|
|
436
465
|
toReviewerAgentId: next.reviewerAgentId,
|
|
466
|
+
...(autoMergeChanged ? { fromAutoMerge: fresh.autoMerge, toAutoMerge: next.autoMerge } : {}),
|
|
437
467
|
},
|
|
438
468
|
});
|
|
439
469
|
}
|
|
@@ -109,6 +109,27 @@ test("NOT-118: POST /api/issues with enqueue:false creates a draft that is not q
|
|
|
109
109
|
assert.equal(listQueuedEntries().length, 0);
|
|
110
110
|
await app.close();
|
|
111
111
|
});
|
|
112
|
+
test("NOT-489: POST /api/issues without autoMerge persists true; an explicit false persists false; both still enqueue", async () => {
|
|
113
|
+
const app = await buildApp();
|
|
114
|
+
const payload = { title: "Agent-created", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "It works" };
|
|
115
|
+
const omittedRes = await app.inject({ method: "POST", url: "/api/issues", payload });
|
|
116
|
+
assert.equal(omittedRes.statusCode, 200);
|
|
117
|
+
const omitted = omittedRes.json();
|
|
118
|
+
assert.equal(omitted.autoMerge, true);
|
|
119
|
+
assert.equal(getIssue(omitted.id).autoMerge, true);
|
|
120
|
+
assert.equal(getDb().prepare("SELECT auto_merge FROM issues WHERE id = ?").get(omitted.id).auto_merge, 1);
|
|
121
|
+
assert.equal(omitted.queue, "enqueued");
|
|
122
|
+
assert.equal(getQueuedEntryForIssue(omitted.id)?.state, "queued");
|
|
123
|
+
const optedOutRes = await app.inject({ method: "POST", url: "/api/issues", payload: { ...payload, title: "Final review please", autoMerge: false } });
|
|
124
|
+
assert.equal(optedOutRes.statusCode, 200);
|
|
125
|
+
const optedOut = optedOutRes.json();
|
|
126
|
+
assert.equal(optedOut.autoMerge, false);
|
|
127
|
+
assert.equal(getIssue(optedOut.id).autoMerge, false);
|
|
128
|
+
assert.equal(getDb().prepare("SELECT auto_merge FROM issues WHERE id = ?").get(optedOut.id).auto_merge, 0);
|
|
129
|
+
assert.equal(optedOut.queue, "enqueued");
|
|
130
|
+
assert.equal(getQueuedEntryForIssue(optedOut.id)?.state, "queued");
|
|
131
|
+
await app.close();
|
|
132
|
+
});
|
|
112
133
|
test("POST /api/issues matches a live (source, externalId) instead of duplicating it, and reports the queue it did not change", async () => {
|
|
113
134
|
const app = await buildApp();
|
|
114
135
|
const payload = { title: "Linear task", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, source: "linear", externalId: "LIN-1" };
|
|
@@ -284,13 +305,15 @@ test("NOT-185: PATCH /api/issues/:id succeeds while parked at attempts_exhausted
|
|
|
284
305
|
});
|
|
285
306
|
// Non-task, non-agent fields stay frozen at the park — notably the review budget
|
|
286
307
|
// (a non-goal). Agent profiles are swappable at the park (NOT-358, covered below),
|
|
287
|
-
// so they are no longer in this blocked list.
|
|
308
|
+
// so they are no longer in this blocked list. NOT-490: an autoMerge-only patch is the
|
|
309
|
+
// policy command (allowed until the review decision), but mixed into a parked edit it
|
|
310
|
+
// is still refused.
|
|
288
311
|
for (const payload of [
|
|
289
312
|
{ maxReviewRounds: 5 },
|
|
290
313
|
{ maxInfraAttempts: 3 },
|
|
291
314
|
{ repo: "acme/other" },
|
|
292
315
|
{ baseBranch: "develop" },
|
|
293
|
-
{ autoMerge: true },
|
|
316
|
+
{ autoMerge: true, title: "Mixed" },
|
|
294
317
|
{ title: "Mixed", maxReviewRounds: 5 },
|
|
295
318
|
]) {
|
|
296
319
|
assert.equal((await patch(payload)).statusCode, 409, JSON.stringify(payload));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-dealer/server",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"files": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 npm run build -w @agent-dealer/shared && AGENT_DEALER_SKIP_GITHUB_HEALTH=1 AGENT_DEALER_SKIP_AGENT_HEALTH=1 node ../../scripts/run-workspace-tests.mjs"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@agent-dealer/shared": "1.3.
|
|
26
|
+
"@agent-dealer/shared": "1.3.5",
|
|
27
27
|
"@fastify/cors": "^11.0.1",
|
|
28
28
|
"@fastify/static": "^8.2.0",
|
|
29
29
|
"@modelcontextprotocol/sdk": "^1.29.0",
|