agent-dealer 1.3.3 → 1.3.4

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 (30) 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/repair-cycle.integration.test.js +2 -0
  5. package/bundle/server/dist/coordinator/reviewer-effect.test.js +2 -0
  6. package/bundle/server/dist/coordinator/worker-loop.test.js +2 -0
  7. package/bundle/server/dist/db/auto-merge-default-migration.test.js +52 -0
  8. package/bundle/server/dist/db/index.js +4 -2
  9. package/bundle/server/dist/db/schema.sql +1 -1
  10. package/bundle/server/dist/dev-review-cli-happy-path.integration.test.js +2 -0
  11. package/bundle/server/dist/routes/auto-merge-policy.test.js +378 -0
  12. package/bundle/server/dist/routes/human-actions.test.js +4 -4
  13. package/bundle/server/dist/routes/issues-execution-contract.test.js +4 -3
  14. package/bundle/server/dist/routes/issues.js +32 -2
  15. package/bundle/server/dist/routes/issues.test.js +25 -2
  16. package/bundle/server/package.json +2 -2
  17. package/bundle/server/static-ui/assets/index-CHCiztRI.css +1 -0
  18. package/bundle/server/static-ui/assets/index-CrLQFFpw.js +64 -0
  19. package/bundle/server/static-ui/index.html +2 -2
  20. package/bundle/shared/dist/issues.d.ts +8 -5
  21. package/bundle/shared/dist/issues.js +7 -4
  22. package/bundle/shared/dist/issues.test.js +11 -1
  23. package/bundle/shared/package.json +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/issue.d.ts +1 -0
  26. package/dist/issue.js +3 -2
  27. package/dist/issue.test.js +37 -0
  28. package/package.json +1 -1
  29. package/bundle/server/static-ui/assets/index-B_pNUDRY.js +0 -64
  30. package/bundle/server/static-ui/assets/index-DL8mEXzN.css +0 -1
@@ -32,7 +32,7 @@ import { routeDeveloperOutcome, routeReviewerOutcome, } from "./routing.js";
32
32
  import { projectDeveloperRoute, projectReviewerRoute } from "./projection.js";
33
33
  import { MERGE_CONFLICT_FILES_EVIDENCE_KEY, MERGE_FAILURE_EVIDENCE_KEY, MERGE_FAILURE_RESPONSE_OPTIONS, PUSH_DIVERGENCE_EVIDENCE_KEY, PUSH_DIVERGENCE_RESPONSE_OPTIONS, WORKTREE_BLOCKER_EVIDENCE_KEY, normalizeResolutionNote, parseHumanResolution, resolveHumanActionOutcome, } from "./human-resolution.js";
34
34
  import { AUTH_TRANSIENT_RETRY_PAYLOAD_KEY, RUNTIME_AUTH_PARK_EVIDENCE_KEY, authProbeConfirmsFailure, defaultRemediationForRuntime, gateRuntimeAuthParkResume, parseRuntimeAuthParkEvidence, remediationFromProbe, } from "./runtime-auth-park.js";
35
- import { AUTO_MERGE_INTENT, finalizeAutoMerge } from "./auto-merge.js";
35
+ import { AUTO_MERGE_INTENT, OPERATOR_VERIFICATION_INTENT, finalizeAutoMerge } from "./auto-merge.js";
36
36
  import { triggerLinearPostMerge } from "./linear-merge-verify.js";
37
37
  import { conflictRepairSpent, queueConflictRepairRound } from "./merge-conflict-sync.js";
38
38
  import { OPERATOR_VERIFICATION_ARTIFACT_KIND, OPERATOR_VERIFICATION_RESPONSE_OPTIONS, } from "./operator-criteria.js";
@@ -261,6 +261,84 @@ export function applyParkedEdit(issueId, patch) {
261
261
  throw err;
262
262
  }
263
263
  }
264
+ export function autoMergePolicyEditability(issue) {
265
+ switch (issue.status) {
266
+ case "ready":
267
+ return { editable: true, reason: null };
268
+ case "developing":
269
+ case "reviewing":
270
+ case "repairing":
271
+ return getActiveWorkflowInstance(issue.id)
272
+ ? { editable: true, reason: null }
273
+ : { editable: false, reason: "No active workflow is running for this issue." };
274
+ case "needs_human": {
275
+ const open = listHumanActionsForIssue(issue.id).filter((a) => a.status === "open");
276
+ // The operator-verification gate runs after approval routing: resolving it continues
277
+ // the already-committed merge path, so the policy is locked while it is open.
278
+ if (issue.currentIntent === OPERATOR_VERIFICATION_INTENT ||
279
+ open.some((a) => a.actionType === "operator_verification")) {
280
+ return {
281
+ editable: false,
282
+ reason: "The reviewer approved and the merge decision is committed — it waits only on operator verification.",
283
+ };
284
+ }
285
+ // Merge-failure parks, including the no-head operator-verification escalation.
286
+ if (open.some((a) => a.actionType === "policy_escalation" && isMergeFailureAction(a))) {
287
+ return {
288
+ editable: false,
289
+ reason: "The reviewer approved and the merge was already attempted — use the merge-failure action instead.",
290
+ };
291
+ }
292
+ return { editable: true, reason: null };
293
+ }
294
+ case "final_review":
295
+ return {
296
+ editable: false,
297
+ reason: issue.currentOwner === "system" && issue.currentIntent === AUTO_MERGE_INTENT
298
+ ? "The reviewer approved and auto-merge is already in flight."
299
+ : "The reviewer approved and the merge decision is committed — use Merge on the final review action.",
300
+ };
301
+ case "done":
302
+ case "closed":
303
+ return { editable: false, reason: `The issue is ${issue.status}.` };
304
+ }
305
+ }
306
+ /**
307
+ * NOT-490: an `autoMerge`-only update — the one input that may change during a live
308
+ * round, because nothing reads it until approved-review routing. Guard re-check, row
309
+ * write and audit run in one SQLite transaction, which serializes against the
310
+ * reviewer-completion transaction: an update committed first is the value
311
+ * `applyReviewer` routes on; a reviewer decision committed first moves the issue to
312
+ * `final_review` and this answers 409, so one approval can never yield both an
313
+ * auto-merge and a `final_review` action. Queue entry, frozen snapshot, agents,
314
+ * budgets, branch and PR are untouched (`updateIssue` writes only the policy column
315
+ * here). A real change appends one `issue.reassigned` event with before/after values
316
+ * and the actor; a no-op appends nothing.
317
+ */
318
+ export function applyAutoMergePolicyUpdate(issueId, autoMerge, actor) {
319
+ return getDb().transaction(() => {
320
+ const fresh = getIssue(issueId);
321
+ if (!fresh)
322
+ return { ok: false, code: 404, error: "Not found" };
323
+ const editability = autoMergePolicyEditability(fresh);
324
+ if (!editability.editable) {
325
+ return { ok: false, code: 409, error: `Auto-merge can no longer be changed: ${editability.reason}` };
326
+ }
327
+ if (fresh.autoMerge === autoMerge)
328
+ return { ok: true, issue: fresh, changed: false };
329
+ const next = updateIssue(issueId, { autoMerge });
330
+ appendWorkflowEvent({
331
+ issueId,
332
+ workflowInstanceId: getActiveWorkflowInstance(issueId)?.id ?? null,
333
+ type: "issue.reassigned",
334
+ actorType: "human",
335
+ stage: next.status,
336
+ round: next.currentRound,
337
+ payload: { fromAutoMerge: fresh.autoMerge, toAutoMerge: next.autoMerge, actor },
338
+ });
339
+ return { ok: true, issue: next, changed: true };
340
+ })();
341
+ }
264
342
  /**
265
343
  * Stable identity-plus-bytes key for one attachment manifest entry. blobPath is
266
344
  * excluded: it is content-addressed from the compared fields under an
@@ -42,6 +42,8 @@ function newIssue(opts = {}) {
42
42
  baseBranch: "main",
43
43
  maxReviewRounds: opts.maxReviewRounds ?? 3,
44
44
  maxInfraAttempts: opts.maxInfraAttempts ?? 3,
45
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
46
+ autoMerge: false,
45
47
  source: "manual"
46
48
  }).id;
47
49
  }
@@ -89,6 +89,8 @@ async function makeIssue(opts = {}) {
89
89
  reviewerAgentId: rev.id,
90
90
  maxReviewRounds: 3,
91
91
  maxInfraAttempts: opts.maxInfraAttempts ?? 3,
92
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
93
+ autoMerge: false,
92
94
  source: "manual"
93
95
  }).id;
94
96
  }
@@ -84,6 +84,8 @@ async function makeIssue() {
84
84
  reviewerAgentId: rev.id,
85
85
  maxReviewRounds: 3,
86
86
  maxInfraAttempts: 3,
87
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
88
+ autoMerge: false,
87
89
  source: "manual"
88
90
  }).id;
89
91
  }
@@ -104,6 +104,8 @@ async function makeIssue(opts = {}) {
104
104
  reviewerAgentId: rev.id,
105
105
  maxReviewRounds: 3,
106
106
  maxInfraAttempts: opts.maxInfraAttempts ?? 3,
107
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
108
+ autoMerge: false,
107
109
  source: "manual"
108
110
  }).id;
109
111
  }
@@ -51,6 +51,8 @@ function newIssue(maxReviewRounds = 3) {
51
51
  baseBranch: "main",
52
52
  maxReviewRounds,
53
53
  maxInfraAttempts: 3,
54
+ // NOT-489: omitted now means auto-merge; this suite exercises final human review.
55
+ autoMerge: false,
54
56
  source: "manual"
55
57
  }).id;
56
58
  }
@@ -0,0 +1,52 @@
1
+ // NOT-489: a clean install's `issues.auto_merge` column defaults on, while an upgrade
2
+ // never rewrites an existing issue's stored choice — and new creates on the upgraded
3
+ // database still take the shared `CreateIssueInput` default (true), not the column's.
4
+ import { test } from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import Database from "better-sqlite3";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-auto-merge-migrate-"));
12
+ const { migrate, getDb } = await import("./index.js");
13
+ const { BUILTIN_AGENT_CLAUDE_ID, BUILTIN_AGENT_CURSOR_ID } = await import("@agent-dealer/shared");
14
+ const { createIssue, getIssue } = await import("../repository/issues.js");
15
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
+ const autoMergeDefault = (db) => db.prepare("PRAGMA table_info(issues)").all().find((c) => c.name === "auto_merge").dflt_value;
17
+ const storedAutoMerge = (id) => getDb().prepare("SELECT auto_merge FROM issues WHERE id = ?").get(id).auto_merge;
18
+ function newIssueId(autoMerge) {
19
+ return createIssue({
20
+ title: "T",
21
+ repo: "github.com/acme/app",
22
+ baseBranch: "main",
23
+ developerAgentId: BUILTIN_AGENT_CLAUDE_ID,
24
+ reviewerAgentId: BUILTIN_AGENT_CURSOR_ID,
25
+ maxReviewRounds: 3,
26
+ maxInfraAttempts: 3,
27
+ source: "manual",
28
+ ...(autoMerge === undefined ? {} : { autoMerge }),
29
+ }).id;
30
+ }
31
+ test("a clean schema defaults auto_merge on", () => {
32
+ const db = new Database(":memory:");
33
+ db.exec(fs.readFileSync(path.join(__dirname, "schema.sql"), "utf8"));
34
+ assert.equal(autoMergeDefault(db), "1");
35
+ });
36
+ test("upgrading preserves every stored auto_merge value; post-upgrade omitted-field creates still get true", () => {
37
+ migrate();
38
+ const optedOut = newIssueId(false);
39
+ const optedIn = newIssueId(true);
40
+ // An upgraded database whose column predates NOT-489 (still DEFAULT 0).
41
+ getDb().exec("ALTER TABLE issues DROP COLUMN auto_merge");
42
+ getDb().exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
43
+ getDb().prepare("UPDATE issues SET auto_merge = 1 WHERE id = ?").run(optedIn);
44
+ assert.equal(autoMergeDefault(getDb()), "0");
45
+ migrate();
46
+ assert.equal(storedAutoMerge(optedOut), 0, "a pre-existing false row is not rewritten");
47
+ assert.equal(getIssue(optedOut).autoMerge, false);
48
+ assert.equal(storedAutoMerge(optedIn), 1);
49
+ const fresh = newIssueId();
50
+ assert.equal(storedAutoMerge(fresh), 1, "an omitted-field create on the upgraded DB stores true");
51
+ assert.equal(getIssue(fresh).autoMerge, true);
52
+ });
@@ -264,8 +264,10 @@ export function migrate() {
264
264
  if (!issueColsForCi.some((c) => c.name === "ci_attempts")) {
265
265
  db.exec("ALTER TABLE issues ADD COLUMN ci_attempts INTEGER NOT NULL DEFAULT 0");
266
266
  }
267
- // NOT-102: per-issue auto-merge after reviewer approve (kick UI defaults on; existing
268
- // rows stay off so in-flight workflows keep the human final_review path).
267
+ // NOT-102: per-issue auto-merge after reviewer approve. Rows that predate the column
268
+ // backfill off so in-flight workflows keep the human final_review path. NOT-489: new
269
+ // creates write the shared `CreateIssueInput` default (true) explicitly, so this
270
+ // backfill DEFAULT never decides a new issue's policy and stays 0 to preserve old rows.
269
271
  const issueColsForAutoMerge = db.prepare("PRAGMA table_info(issues)").all();
270
272
  if (!issueColsForAutoMerge.some((c) => c.name === "auto_merge")) {
271
273
  db.exec("ALTER TABLE issues ADD COLUMN auto_merge INTEGER NOT NULL DEFAULT 0");
@@ -121,7 +121,7 @@ CREATE TABLE IF NOT EXISTS issues (
121
121
  head_sha TEXT,
122
122
  pr_number INTEGER,
123
123
  pr_url TEXT,
124
- auto_merge INTEGER NOT NULL DEFAULT 0,
124
+ auto_merge INTEGER NOT NULL DEFAULT 1,
125
125
  created_at TEXT NOT NULL,
126
126
  updated_at TEXT NOT NULL
127
127
  );
@@ -280,6 +280,8 @@ test("agent-operated CLI: discover profiles, create + start a Dev-review issue,
280
280
  "Widget renders.",
281
281
  "--base-branch",
282
282
  "main",
283
+ // NOT-489: auto-merge is the default; this walk drives the final-review action.
284
+ "--no-auto-merge",
283
285
  ]));
284
286
  const issueId = created.id;
285
287
  assert.equal(created.status, "ready");
@@ -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 });