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
@@ -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
  }
@@ -358,13 +358,15 @@ function recordVisualEvidence(issueId, opts) {
358
358
  baseSha: "base1",
359
359
  uiAffecting: true,
360
360
  state: opts.state,
361
- runId: opts.state === "missing" ? null : 42,
361
+ runId: opts.state === "available" || opts.state === "failed" ? 42 : null,
362
362
  artifactNames: opts.state === "available" ? ["ui-screenshots", "ui-baseline", "ui-diff"] : [],
363
363
  reason: opts.state === "available"
364
364
  ? null
365
365
  : opts.state === "failed"
366
366
  ? "Visual run 42 for this head concluded failure — its captures are unusable"
367
- : "no Visual workflow run found for head abc123",
367
+ : opts.state === "not_applicable"
368
+ ? "repository has no Visual workflow (workflow visual.yml not found on the default branch) — nothing to capture"
369
+ : "no Visual workflow run found for head abc123",
368
370
  },
369
371
  author: "system",
370
372
  });
@@ -420,6 +422,22 @@ test("NOT-384: available visual evidence on a UI head merges normally", async ()
420
422
  assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
421
423
  assert.equal(getIssue(issueId).status, "done");
422
424
  });
425
+ test("NOT-538: not_applicable visual evidence (no Visual workflow) on a UI head merges normally", async () => {
426
+ const calls = [];
427
+ setMergePrForTests(async (opts) => {
428
+ calls.push(opts);
429
+ return { ok: true };
430
+ });
431
+ const issueId = newIssue("It works");
432
+ startWorkflow(issueId);
433
+ recordVisualEvidence(issueId, { headSha: "abc123", state: "not_applicable" });
434
+ const result = await approve(issueId);
435
+ assert.equal(result.applied, true);
436
+ if (result.applied)
437
+ assert.equal(result.issueStatus, "done");
438
+ assert.deepEqual(calls, [{ cwd: managedCwd, number: 42 }]);
439
+ assert.equal(getIssue(issueId).status, "done");
440
+ });
423
441
  test("NOT-384: [operator] criteria plus a visual hold raise one action naming both", async () => {
424
442
  let merges = 0;
425
443
  setMergePrForTests(async () => {
@@ -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
  }
@@ -22,8 +22,10 @@
22
22
  // Dealer's issue-artifact storage, stage a read-only copy inside the
23
23
  // reviewer's worktree (outside tracked paths, ignored by `isWorktreeClean`),
24
24
  // and record one `visual_evidence` artifact row. It never throws: every
25
- // failure mode resolves to `missing` with a reason, and a missing or
26
- // failed capture never counts as a visual pass.
25
+ // failure mode resolves to `missing` with a reason — except a lookup 404
26
+ // proving the repository has no `visual.yml`, which resolves to
27
+ // `not_applicable` (nothing to capture) — and a missing or failed capture
28
+ // never counts as a visual pass.
27
29
  // 4. `readVisualEvidenceHold` — the merge-gate reader (auto-merge.ts): a
28
30
  // UI-affecting head whose recorded evidence is `missing` or `failed` holds
29
31
  // for `operator_verification` instead of merging or queuing a repair round.
@@ -334,6 +336,24 @@ function truncateReason(text, maxChars = 300) {
334
336
  const oneLine = text.replace(/\s+/g, " ").trim();
335
337
  return oneLine.length > maxChars ? `${oneLine.slice(0, maxChars).trimEnd()}…` : oneLine;
336
338
  }
339
+ /**
340
+ * NOT-538: true when a Visual run lookup failure means the repository's
341
+ * default branch has no `visual.yml` (`gh run list --workflow visual.yml`
342
+ * answers HTTP 404: "workflow visual.yml not found on the default branch"),
343
+ * rather than a transient or auth failure. Deliberately narrow — all three
344
+ * markers must be present — so auth, network, 5xx, and rate-limit failures
345
+ * keep failing closed to `missing`.
346
+ */
347
+ export function isVisualWorkflowNotFoundError(message) {
348
+ const text = message.toLowerCase();
349
+ return text.includes("404") && text.includes("visual.yml") && text.includes("not found");
350
+ }
351
+ /**
352
+ * NOT-538: the recorded reason when the lookup proves the repository has no
353
+ * Visual workflow. The head still matches the UI paths (`uiAffecting: true`),
354
+ * but there is nothing to capture, so the merge gate must not hold.
355
+ */
356
+ export const VISUAL_WORKFLOW_ABSENT_REASON = "repository has no Visual workflow (workflow visual.yml not found on the default branch) — nothing to capture";
337
357
  function noEvidence(uiAffecting, base, reason) {
338
358
  return {
339
359
  ...base,
@@ -357,7 +377,9 @@ function noEvidence(uiAffecting, base, reason) {
357
377
  *
358
378
  * Never throws and never returns usable evidence it does not have: unknown
359
379
  * changed files fail closed to UI-affecting + `missing`, and every lookup,
360
- * download, stage, or record failure degrades to `missing` with a reason.
380
+ * download, stage, or record failure degrades to `missing` with a reason —
381
+ * except a lookup 404 proving the repository has no Visual workflow, which
382
+ * records `not_applicable` instead (NOT-538).
361
383
  */
362
384
  export async function resolveReviewerVisualEvidence(opts) {
363
385
  const base = { headSha: opts.headSha, baseSha: opts.baseSha };
@@ -411,7 +433,25 @@ export async function resolveReviewerVisualEvidence(opts) {
411
433
  run = await fetcher.findRunForHead({ cwd: opts.worktreePath, headSha: opts.headSha });
412
434
  }
413
435
  catch (err) {
414
- const resolved = noEvidence(true, base, `Visual run lookup failed: ${truncateReason(err instanceof Error ? err.message : String(err))}`);
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ if (isVisualWorkflowNotFoundError(message)) {
438
+ const resolved = {
439
+ ...base,
440
+ uiAffecting: true,
441
+ state: "not_applicable",
442
+ runId: null,
443
+ artifactNames: [],
444
+ stagedDir: null,
445
+ summaryChars: null,
446
+ reason: VISUAL_WORKFLOW_ABSENT_REASON,
447
+ staged: null,
448
+ summary: null,
449
+ files: null,
450
+ };
451
+ record(resolved, null);
452
+ return resolved;
453
+ }
454
+ const resolved = noEvidence(true, base, `Visual run lookup failed: ${truncateReason(message)}`);
415
455
  record(resolved, null);
416
456
  return resolved;
417
457
  }
@@ -17,7 +17,7 @@ const { createIssue } = await import("../repository/issues.js");
17
17
  const { createIssueArtifact } = await import("../repository/artifacts.js");
18
18
  const { listArtifactsForIssueByKind } = await import("../repository/artifacts-for-issue.js");
19
19
  const { isWorktreeClean, REVIEWER_VISUAL_STAGING_DIR_NAME } = await import("../adapters/git-worktree.js");
20
- const { VISUAL_WORKFLOW_PATHS, VISUAL_ARTIFACT_NAMES, VISUAL_EVIDENCE_ARTIFACT_KIND, isUiAffectingChange, classifyVisualEvidence, githubVisualEvidenceFetcher, resolveReviewerVisualEvidence, stageVisualArtifacts, cleanupStagedVisualArtifacts, readVisualDiffSummary, reviewerVisualStagingDir, toReviewerVisualEvidenceInput, readVisualEvidenceHold, } = await import("./reviewer-visual-evidence.js");
20
+ const { VISUAL_WORKFLOW_PATHS, VISUAL_ARTIFACT_NAMES, VISUAL_EVIDENCE_ARTIFACT_KIND, VISUAL_WORKFLOW_ABSENT_REASON, isUiAffectingChange, classifyVisualEvidence, githubVisualEvidenceFetcher, resolveReviewerVisualEvidence, stageVisualArtifacts, cleanupStagedVisualArtifacts, readVisualDiffSummary, reviewerVisualStagingDir, toReviewerVisualEvidenceInput, readVisualEvidenceHold, } = await import("./reviewer-visual-evidence.js");
21
21
  before(() => migrate());
22
22
  beforeEach(() => {
23
23
  getDb().exec(`
@@ -285,6 +285,8 @@ function fixtureFetcher(opts) {
285
285
  return {
286
286
  calls,
287
287
  async findRunForHead() {
288
+ if (opts.failLookupError !== undefined)
289
+ throw new Error(opts.failLookupError);
288
290
  if (opts.failLookup)
289
291
  throw new Error("gh exploded");
290
292
  return opts.run === undefined
@@ -494,3 +496,129 @@ test("NOT-384: gate reader holds only UI heads with missing/failed evidence at t
494
496
  });
495
497
  assert.equal(readVisualEvidenceHold(nonUiIssue, HEAD), null);
496
498
  });
499
+ // NOT-538: a repository whose default branch has no visual.yml has nothing to
500
+ // capture — the lookup's "workflow not found" 404 resolves to not_applicable
501
+ // (the head still matches the UI paths) instead of holding the PR.
502
+ test("NOT-538: workflow-not-found 404 records not_applicable, never missing", async () => {
503
+ const issueId = makeIssue();
504
+ const worktree = initRepo();
505
+ try {
506
+ const resolved = await resolveReviewerVisualEvidence({
507
+ issueId,
508
+ worktreePath: worktree,
509
+ baseSha: "c".repeat(40),
510
+ headSha: HEAD,
511
+ changedFiles: ["packages/shared/src/types.ts"],
512
+ github: {},
513
+ fetcher: fixtureFetcher({
514
+ failLookupError: "gh run list failed: HTTP 404: workflow visual.yml not found on the default branch",
515
+ }),
516
+ });
517
+ assert.equal(resolved.state, "not_applicable");
518
+ assert.equal(resolved.uiAffecting, true);
519
+ assert.equal(resolved.runId, null);
520
+ assert.equal(resolved.staged, null);
521
+ assert.equal(resolved.reason, VISUAL_WORKFLOW_ABSENT_REASON);
522
+ assert.match(resolved.reason, /repository has no Visual workflow/);
523
+ assert.equal(toReviewerVisualEvidenceInput(resolved), undefined);
524
+ const rows = listArtifactsForIssueByKind(issueId, VISUAL_EVIDENCE_ARTIFACT_KIND);
525
+ assert.equal(rows.length, 1);
526
+ const content = JSON.parse(rows[0].contentJson);
527
+ assert.equal(content.state, "not_applicable");
528
+ assert.equal(content.uiAffecting, true);
529
+ // The recorded workflow-absent record releases the merge gate.
530
+ assert.equal(readVisualEvidenceHold(issueId, HEAD), null);
531
+ }
532
+ finally {
533
+ fs.rmSync(worktree, { recursive: true, force: true });
534
+ }
535
+ });
536
+ // NOT-538: every other lookup failure still fails closed to missing with the
537
+ // existing reason — only the workflow-not-found 404 skips the gate.
538
+ test("NOT-538: auth, network, 5xx, and rate-limit lookup failures still record missing", async () => {
539
+ const worktree = initRepo();
540
+ try {
541
+ const failures = {
542
+ auth: "gh run list failed: HTTP 401: Bad credentials",
543
+ network: "gh run list failed: Could not resolve host: api.github.com",
544
+ server: "gh run list failed: HTTP 502: Bad Gateway",
545
+ rateLimit: "gh run list failed: HTTP 403: API rate limit exceeded for user ID 123",
546
+ };
547
+ for (const [kind, message] of Object.entries(failures)) {
548
+ const resolved = await resolveReviewerVisualEvidence({
549
+ issueId: makeIssue(),
550
+ worktreePath: worktree,
551
+ baseSha: "c".repeat(40),
552
+ headSha: HEAD,
553
+ changedFiles: ["packages/shared/src/types.ts"],
554
+ github: {},
555
+ fetcher: fixtureFetcher({ failLookupError: message }),
556
+ });
557
+ assert.equal(resolved.state, "missing", `${kind} failure must stay missing`);
558
+ assert.equal(resolved.uiAffecting, true);
559
+ assert.match(resolved.reason, /^Visual run lookup failed: /, `${kind} failure keeps the lookup-failed reason`);
560
+ }
561
+ // Near-miss 404s that do not name the missing workflow also stay missing.
562
+ for (const message of [
563
+ "gh run list failed: HTTP 404: Not Found",
564
+ "gh run list failed: HTTP 404: repository not found",
565
+ ]) {
566
+ const resolved = await resolveReviewerVisualEvidence({
567
+ issueId: makeIssue(),
568
+ worktreePath: worktree,
569
+ baseSha: "c".repeat(40),
570
+ headSha: HEAD,
571
+ changedFiles: ["packages/shared/src/types.ts"],
572
+ github: {},
573
+ fetcher: fixtureFetcher({ failLookupError: message }),
574
+ });
575
+ assert.equal(resolved.state, "missing", `${message} must stay missing`);
576
+ }
577
+ }
578
+ finally {
579
+ fs.rmSync(worktree, { recursive: true, force: true });
580
+ }
581
+ });
582
+ // NOT-538: the gate reader releases a UI-affecting head whose record is
583
+ // not_applicable because the repository has no Visual workflow, and still
584
+ // holds missing and failed.
585
+ test("NOT-538: gate reader releases workflow-absent not_applicable, holds missing and failed", () => {
586
+ const absentIssue = makeIssue();
587
+ createIssueArtifact({
588
+ issueId: absentIssue,
589
+ kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
590
+ content: {
591
+ headSha: HEAD,
592
+ uiAffecting: true,
593
+ state: "not_applicable",
594
+ reason: VISUAL_WORKFLOW_ABSENT_REASON,
595
+ runId: null,
596
+ artifactNames: [],
597
+ },
598
+ author: "system",
599
+ });
600
+ assert.equal(readVisualEvidenceHold(absentIssue, HEAD), null);
601
+ const missingIssue = makeIssue();
602
+ createIssueArtifact({
603
+ issueId: missingIssue,
604
+ kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
605
+ content: {
606
+ headSha: HEAD,
607
+ uiAffecting: true,
608
+ state: "missing",
609
+ reason: "Visual run lookup failed: HTTP 502: Bad Gateway",
610
+ runId: null,
611
+ artifactNames: [],
612
+ },
613
+ author: "system",
614
+ });
615
+ assert.equal(readVisualEvidenceHold(missingIssue, HEAD).state, "missing");
616
+ const failedIssue = makeIssue();
617
+ createIssueArtifact({
618
+ issueId: failedIssue,
619
+ kind: VISUAL_EVIDENCE_ARTIFACT_KIND,
620
+ content: { headSha: HEAD, uiAffecting: true, state: "failed", reason: "boom", runId: 9, artifactNames: [] },
621
+ author: "system",
622
+ });
623
+ assert.equal(readVisualEvidenceHold(failedIssue, HEAD).state, "failed");
624
+ });
@@ -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");