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.
Files changed (33) hide show
  1. package/bundle/server/dist/coordinator/commands.js +79 -1
  2. package/bundle/server/dist/coordinator/commands.test.js +2 -0
  3. package/bundle/server/dist/coordinator/developer-effect.test.js +2 -0
  4. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +20 -2
  5. package/bundle/server/dist/coordinator/repair-cycle.integration.test.js +2 -0
  6. package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
  7. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +44 -4
  8. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +129 -1
  9. package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
  10. package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
  11. package/bundle/server/dist/db/index.js +4 -2
  12. package/bundle/server/dist/db/schema.sql +1 -1
  13. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
  14. package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
  15. package/bundle/server/dist/routes/human-actions.test.js +4 -4
  16. package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
  17. package/bundle/server/dist/routes/issues.js +32 -2
  18. package/bundle/server/dist/routes/issues.test.js +25 -2
  19. package/bundle/server/package.json +2 -2
  20. package/bundle/server/static-ui/assets/index-CHCiztRI.css +1 -0
  21. package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
  22. package/bundle/server/static-ui/index.html +2 -2
  23. package/bundle/shared/dist/issues.d.ts +8 -5
  24. package/bundle/shared/dist/issues.js +7 -4
  25. package/bundle/shared/dist/issues.test.js +11 -1
  26. package/bundle/shared/package.json +1 -1
  27. package/dist/index.js +1 -1
  28. package/dist/issue.d.ts +1 -0
  29. package/dist/issue.js +3 -2
  30. package/dist/issue.test.js +37 -0
  31. package/package.json +1 -1
  32. package/bundle/server/static-ui/assets/index-B_pNUDRY.js +0 -64
  33. 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
- const { id: finalId } = await createLinearIssue(app);
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",
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.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",