agent-dealer 1.3.0 → 1.3.2

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 (44) hide show
  1. package/bundle/server/dist/adapters/git-worktree.js +38 -1
  2. package/bundle/server/dist/adapters/github.js +50 -0
  3. package/bundle/server/dist/adapters/github.test.js +89 -1
  4. package/bundle/server/dist/coordinator/admission.test.js +115 -0
  5. package/bundle/server/dist/coordinator/auto-merge.js +46 -9
  6. package/bundle/server/dist/coordinator/commands.js +89 -9
  7. package/bundle/server/dist/coordinator/commands.test.js +228 -2
  8. package/bundle/server/dist/coordinator/developer-effect.js +36 -0
  9. package/bundle/server/dist/coordinator/operator-verification.integration.test.js +102 -1
  10. package/bundle/server/dist/coordinator/product-scope-contract.test.js +249 -0
  11. package/bundle/server/dist/coordinator/prompts.js +57 -2
  12. package/bundle/server/dist/coordinator/prompts.test.js +236 -2
  13. package/bundle/server/dist/coordinator/reviewer-browser-probe.js +1083 -0
  14. package/bundle/server/dist/coordinator/reviewer-browser-probe.test.js +780 -0
  15. package/bundle/server/dist/coordinator/reviewer-effect.js +30 -1
  16. package/bundle/server/dist/coordinator/reviewer-effect.test.js +54 -0
  17. package/bundle/server/dist/coordinator/reviewer-visual-evidence.js +594 -0
  18. package/bundle/server/dist/coordinator/reviewer-visual-evidence.test.js +496 -0
  19. package/bundle/server/dist/coordinator/source-reload.js +44 -9
  20. package/bundle/server/dist/coordinator/visual-qa-collection.test.js +619 -0
  21. package/bundle/server/dist/coordinator/visual-qa.js +630 -0
  22. package/bundle/server/dist/coordinator/visual-qa.test.js +493 -0
  23. package/bundle/server/dist/coordinator/worker-loop.js +1 -1
  24. package/bundle/server/dist/paths.js +24 -0
  25. package/bundle/server/dist/repository/admission-settings.js +5 -5
  26. package/bundle/server/dist/repository/admission-settings.test.js +71 -15
  27. package/bundle/server/dist/repository/issues.js +8 -1
  28. package/bundle/server/dist/repository/issues.test.js +34 -0
  29. package/bundle/server/dist/routes/issues-execution-contract.test.js +293 -0
  30. package/bundle/server/dist/routes/issues-source-attachments.test.js +277 -0
  31. package/bundle/server/dist/routes/issues.js +23 -8
  32. package/bundle/server/dist/routes/issues.test.js +80 -0
  33. package/bundle/server/dist/routes/queue.js +1 -1
  34. package/bundle/server/dist/routes/queue.test.js +55 -20
  35. package/bundle/server/dist/runners/process-registry.js +1 -1
  36. package/bundle/server/package.json +2 -2
  37. package/bundle/server/static-ui/assets/index-DQ5ivcyg.js +64 -0
  38. package/bundle/server/static-ui/index.html +1 -1
  39. package/bundle/shared/dist/queue-entries.d.ts +5 -4
  40. package/bundle/shared/dist/queue-entries.js +5 -4
  41. package/bundle/shared/package.json +1 -1
  42. package/package.json +1 -1
  43. package/templates/prod.env.example +5 -2
  44. package/bundle/server/static-ui/assets/index-Bm74Vq_X.js +0 -64
@@ -1,6 +1,7 @@
1
1
  // packages/server/src/repository/admission-settings.test.ts
2
2
  //
3
3
  // NOT-215: validation, persistence, and ceiling clamping of `maxActiveIssues`.
4
+ // NOT-378: the configurable range widens to 1–5 with a five-process default ceiling.
4
5
  import { test, before, beforeEach, afterEach } from "node:test";
5
6
  import assert from "node:assert/strict";
6
7
  import fs from "node:fs";
@@ -8,7 +9,9 @@ import os from "node:os";
8
9
  import path from "node:path";
9
10
  process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-admission-settings-"));
10
11
  const { migrate, getDb } = await import("../db/index.js");
11
- const { ADMISSION_MAX_ACTIVE_ISSUES_KEY, getMaxActiveIssues, setMaxActiveIssues, getEffectiveMaxActiveIssues, admissionLimitOptions, workerSpawnCeiling, } = await import("./admission-settings.js");
12
+ const { ADMISSION_MAX_ACTIVE_ISSUES_KEY, DEFAULT_MAX_ACTIVE_ISSUES, MAX_ACTIVE_ISSUES_HARD_MAX, getMaxActiveIssues, setMaxActiveIssues, getEffectiveMaxActiveIssues, admissionLimitOptions, workerSpawnCeiling, } = await import("./admission-settings.js");
13
+ const { coordinatorConfig } = await import("../coordinator/worker-loop.js");
14
+ const { maxConcurrentSpawns } = await import("../runners/process-registry.js");
12
15
  before(() => migrate());
13
16
  const savedEnv = {
14
17
  MAX_COORDINATOR_CONCURRENCY: process.env.MAX_COORDINATOR_CONCURRENCY,
@@ -28,18 +31,35 @@ afterEach(() => {
28
31
  process.env[key] = value;
29
32
  }
30
33
  });
31
- test("default is 1 for fresh installations (no stored row)", () => {
34
+ test("NOT-378: the default stays 1 while the hard maximum widens to 5", () => {
35
+ assert.equal(DEFAULT_MAX_ACTIVE_ISSUES, 1);
36
+ assert.equal(MAX_ACTIVE_ISSUES_HARD_MAX, 5);
32
37
  assert.equal(getMaxActiveIssues(), 1);
33
38
  assert.equal(getEffectiveMaxActiveIssues(), 1);
34
39
  });
35
40
  test("persisted value survives re-read (page reload / server restart read the same DB row)", () => {
36
- assert.equal(setMaxActiveIssues(2), 2);
37
- assert.equal(getMaxActiveIssues(), 2);
38
- assert.equal(getEffectiveMaxActiveIssues(), 2);
41
+ assert.equal(setMaxActiveIssues(5), 5);
42
+ assert.equal(getMaxActiveIssues(), 5);
43
+ assert.equal(getEffectiveMaxActiveIssues(), 5);
39
44
  const row = getDb()
40
45
  .prepare("SELECT value_json FROM intake_settings WHERE key = ?")
41
46
  .get(ADMISSION_MAX_ACTIVE_ISSUES_KEY);
42
- assert.equal(row.value_json, "2");
47
+ assert.equal(row.value_json, "5");
48
+ });
49
+ test("every integer 1–5 persists and reads back", () => {
50
+ for (const value of [1, 2, 3, 4, 5]) {
51
+ assert.equal(setMaxActiveIssues(value), value);
52
+ assert.equal(getMaxActiveIssues(), value);
53
+ assert.equal(getEffectiveMaxActiveIssues(), value);
54
+ }
55
+ });
56
+ test("an existing persisted value (e.g. 2) is unchanged after upgrade", () => {
57
+ getDb()
58
+ .prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json")
59
+ .run(ADMISSION_MAX_ACTIVE_ISSUES_KEY, "2");
60
+ assert.equal(getMaxActiveIssues(), 2);
61
+ assert.equal(getEffectiveMaxActiveIssues(), 2);
62
+ assert.deepEqual(admissionLimitOptions(), [1, 2, 3, 4, 5]);
43
63
  });
44
64
  test("corrupt or out-of-range stored values fall back to the default", () => {
45
65
  const upsert = getDb().prepare("INSERT INTO intake_settings (key, value_json) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json");
@@ -49,30 +69,66 @@ test("corrupt or out-of-range stored values fall back to the default", () => {
49
69
  assert.equal(getMaxActiveIssues(), 1);
50
70
  upsert.run(ADMISSION_MAX_ACTIVE_ISSUES_KEY, "0");
51
71
  assert.equal(getMaxActiveIssues(), 1);
72
+ upsert.run(ADMISSION_MAX_ACTIVE_ISSUES_KEY, "6");
73
+ assert.equal(getMaxActiveIssues(), 1);
52
74
  });
53
- test("validation rejects non-integers and values outside 1..2", () => {
54
- for (const bad of [0, 3, 99, 1.5, NaN, "x", null, undefined, {}]) {
75
+ test("validation rejects non-integers and values outside 1..5", () => {
76
+ for (const bad of [0, 6, 99, 1.5, NaN, "x", null, undefined, {}]) {
55
77
  assert.throws(() => setMaxActiveIssues(bad), /maxActiveIssues/, `rejects ${String(bad)}`);
56
78
  }
57
79
  assert.equal(getMaxActiveIssues(), 1, "a rejected write leaves the stored value alone");
58
80
  });
81
+ test("range rejection names the exact 1–5 bounds", () => {
82
+ assert.throws(() => setMaxActiveIssues(0), /maxActiveIssues must be between 1 and 5/);
83
+ assert.throws(() => setMaxActiveIssues(6), /maxActiveIssues must be between 1 and 5/);
84
+ assert.throws(() => setMaxActiveIssues(1.5), /maxActiveIssues must be an integer/);
85
+ });
59
86
  test("values above the worker/spawn ceiling are rejected, never silently stored", () => {
60
87
  process.env.MAX_COORDINATOR_CONCURRENCY = "1";
61
88
  assert.equal(workerSpawnCeiling(), 1);
62
89
  assert.deepEqual(admissionLimitOptions(), [1]);
63
- assert.throws(() => setMaxActiveIssues(2), /ceiling/);
90
+ assert.throws(() => setMaxActiveIssues(2), /maxActiveIssues 2 exceeds the effective worker\/spawn ceiling of 1/);
64
91
  assert.equal(getMaxActiveIssues(), 1);
65
92
  });
66
- test("the ceiling is the lower of the coordinator and spawn bounds", () => {
93
+ test("NOT-378: the default ceiling is 5 with options 1–5 and no overrides", () => {
94
+ assert.equal(workerSpawnCeiling(), 5);
95
+ assert.deepEqual(admissionLimitOptions(), [1, 2, 3, 4, 5]);
96
+ });
97
+ test("NOT-378: a lower coordinator bound clamps the ceiling and options to 3", () => {
98
+ process.env.MAX_COORDINATOR_CONCURRENCY = "3";
99
+ assert.equal(workerSpawnCeiling(), 3);
100
+ assert.deepEqual(admissionLimitOptions(), [1, 2, 3]);
101
+ assert.equal(setMaxActiveIssues(3), 3);
102
+ assert.throws(() => setMaxActiveIssues(4), /maxActiveIssues 4 exceeds the effective worker\/spawn ceiling of 3/);
103
+ assert.equal(getMaxActiveIssues(), 3, "a rejected write leaves the stored value alone");
104
+ });
105
+ test("NOT-378: a lower spawn bound clamps the ceiling and options to 2", () => {
106
+ process.env.MAX_CONCURRENT_RUNS = "2";
67
107
  assert.equal(workerSpawnCeiling(), 2);
68
108
  assert.deepEqual(admissionLimitOptions(), [1, 2]);
69
- process.env.MAX_CONCURRENT_RUNS = "1";
70
- assert.equal(workerSpawnCeiling(), 1);
71
- assert.deepEqual(admissionLimitOptions(), [1]);
109
+ assert.equal(setMaxActiveIssues(2), 2);
110
+ assert.throws(() => setMaxActiveIssues(3), /maxActiveIssues 3 exceeds the effective worker\/spawn ceiling of 2/);
111
+ assert.equal(getMaxActiveIssues(), 2, "a rejected write leaves the stored value alone");
112
+ });
113
+ test("the ceiling is the lower of the coordinator and spawn bounds", () => {
114
+ assert.equal(workerSpawnCeiling(), 5);
115
+ assert.deepEqual(admissionLimitOptions(), [1, 2, 3, 4, 5]);
116
+ process.env.MAX_COORDINATOR_CONCURRENCY = "3";
117
+ process.env.MAX_CONCURRENT_RUNS = "2";
118
+ assert.equal(workerSpawnCeiling(), 2, "the minimum of the two bounds wins");
119
+ assert.deepEqual(admissionLimitOptions(), [1, 2]);
120
+ process.env.MAX_CONCURRENT_RUNS = "4";
121
+ assert.equal(workerSpawnCeiling(), 3);
122
+ assert.deepEqual(admissionLimitOptions(), [1, 2, 3]);
72
123
  });
73
124
  test("the effective limit clamps a persisted value when the ceiling drops below it", () => {
74
- assert.equal(setMaxActiveIssues(2), 2);
125
+ assert.equal(setMaxActiveIssues(5), 5);
75
126
  process.env.MAX_CONCURRENT_RUNS = "1";
76
- assert.equal(getMaxActiveIssues(), 2, "persisted setting is untouched");
127
+ assert.equal(getMaxActiveIssues(), 5, "persisted setting is untouched");
77
128
  assert.equal(getEffectiveMaxActiveIssues(), 1, "admission uses the clamped value");
78
129
  });
130
+ test("NOT-378: authoritative runtime defaults agree on the five-process ceiling", () => {
131
+ assert.equal(workerSpawnCeiling(), 5);
132
+ assert.equal(coordinatorConfig.maxConcurrency, 5);
133
+ assert.equal(maxConcurrentSpawns(), 5);
134
+ });
@@ -137,6 +137,7 @@ export function listIssues(status) {
137
137
  /** NOT-228: page-size bounds for the Issues list (matches the report default/max). */
138
138
  export const ISSUES_LIST_DEFAULT_LIMIT = 25;
139
139
  export const ISSUES_LIST_MAX_LIMIT = 100;
140
+ export const DEFAULT_ISSUES_LIST_DIRECTION = "desc";
140
141
  /** Escape the LIKE metacharacters in a user query so `%`/`_` match literally. */
141
142
  function escapeLikePattern(raw) {
142
143
  return raw.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
@@ -146,6 +147,9 @@ function escapeLikePattern(raw) {
146
147
  * Ordering is `updated_at DESC, rowid DESC` so pages are repeatable when many
147
148
  * rows share a timestamp; the filter applies before pagination so `total` and
148
149
  * `totalPages` describe the full matching cohort.
150
+ * NOT-385: `direction: "asc"` reverses both to `updated_at ASC, rowid ASC` —
151
+ * the tie-breaker always follows the timestamp direction so equal-timestamp
152
+ * rows paginate without duplicates or skips in either direction.
149
153
  */
150
154
  export function queryIssues(query = {}) {
151
155
  const db = getDb();
@@ -183,8 +187,11 @@ export function queryIssues(query = {}) {
183
187
  .get(...params);
184
188
  const totalPages = total === 0 ? 0 : Math.ceil(total / limit);
185
189
  const offset = (page - 1) * limit;
190
+ // NOT-385: the direction is a ternary over literals, never interpolated —
191
+ // anything but an explicit "asc" orders newest-first.
192
+ const dir = query.direction === "asc" ? "ASC" : "DESC";
186
193
  const rows = db
187
- .prepare(`SELECT * FROM issues ${where} ORDER BY updated_at DESC, rowid DESC LIMIT ? OFFSET ?`)
194
+ .prepare(`SELECT * FROM issues ${where} ORDER BY updated_at ${dir}, rowid ${dir} LIMIT ? OFFSET ?`)
188
195
  .all(...params, limit, offset);
189
196
  return { rows: rows.map(rowToIssue), page, limit, total, totalPages };
190
197
  }
@@ -139,6 +139,40 @@ test("NOT-228: page/limit bounds clamp instead of erroring", () => {
139
139
  assert.equal(empty.total, 0);
140
140
  assert.equal(empty.totalPages, 0);
141
141
  });
142
+ test("NOT-385: the default direction is newest-first by updated_at", () => {
143
+ const oldId = createIssue(makeInput("NOT385 default old")).id;
144
+ const newId = createIssue(makeInput("NOT385 default new")).id;
145
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-08-01T00:00:00.000Z", oldId);
146
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run("2026-09-01T00:00:00.000Z", newId);
147
+ for (const query of [{ search: "NOT385 default" }, { search: "NOT385 default", direction: "desc" }]) {
148
+ const res = queryIssues(query);
149
+ assert.deepEqual(res.rows.map((i) => i.id), [newId, oldId], `newest updated_at first (${JSON.stringify(query)})`);
150
+ }
151
+ // Anything but an explicit "asc" falls back to newest-first.
152
+ const fallback = queryIssues({ search: "NOT385 default", direction: "sideways" });
153
+ assert.deepEqual(fallback.rows.map((i) => i.id), [newId, oldId]);
154
+ });
155
+ test("NOT-385: ascending reverses to oldest-first with an ascending rowid tiebreaker", () => {
156
+ const stamp = "2026-09-01T00:00:00.000Z";
157
+ const ids = ["NOT385 tie one", "NOT385 tie two", "NOT385 tie three"].map((t) => createIssue(makeInput(t)).id);
158
+ for (const id of ids) {
159
+ getDb().prepare("UPDATE issues SET updated_at = ? WHERE id = ?").run(stamp, id);
160
+ }
161
+ const first = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 2, page: 1 });
162
+ const second = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 2, page: 2 });
163
+ assert.equal(first.total, 3);
164
+ assert.equal(first.totalPages, 2);
165
+ // Oldest rowid first: creation order was one, two, three.
166
+ assert.deepEqual(first.rows.map((i) => i.id), [ids[0], ids[1]]);
167
+ assert.deepEqual(second.rows.map((i) => i.id), [ids[2]]);
168
+ const again = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 2, page: 1 });
169
+ assert.deepEqual(again.rows.map((i) => i.id), first.rows.map((i) => i.id), "repeatable");
170
+ assert.deepEqual(new Set([...first.rows, ...second.rows].map((i) => i.id)).size, 3, "pages neither duplicate nor skip rows");
171
+ // The reverse is the exact mirror of the default across the same cohort.
172
+ const descAll = queryIssues({ search: "NOT385 tie", limit: 10 });
173
+ const ascAll = queryIssues({ search: "NOT385 tie", direction: "asc", limit: 10 });
174
+ assert.deepEqual(ascAll.rows.map((i) => i.id), [...descAll.rows.map((i) => i.id)].reverse());
175
+ });
142
176
  test("NOT-228: needsAttention returns only issues with an open human action", () => {
143
177
  const flagged = createIssue(makeInput("NOT228 flagged attention"));
144
178
  const quiet = createIssue(makeInput("NOT228 quiet no-action"));
@@ -588,6 +588,299 @@ test("NOT-363: a contract-validation failure on refreshed text leaves everything
588
588
  assert.ok(!listWorkflowEventsForIssue(id).some((e) => e.type === "issue.source_reloaded"));
589
589
  await app.close();
590
590
  });
591
+ // NOT-422: product-scope pause contract adoption — a `needs_human` issue with an
592
+ // active workflow and an open `product_scope_decision` accepts PATCH task edits
593
+ // and Linear source reload while nothing runs and nothing is pending/leased.
594
+ const { startWorkflow } = await import("../coordinator/commands.js");
595
+ const { enqueueWorkItem, cancelWorkItem } = await import("../repository/work-items.js");
596
+ const { getActiveWorkflowInstance } = await import("../repository/workflow-events.js");
597
+ const { getHumanAction } = await import("../repository/human-actions.js");
598
+ let scopeParkCounter = 0;
599
+ async function parkLinearAtProductScope(app) {
600
+ // Unique external ids per park: several tests park more than one issue, and
601
+ // a repeated import of a live ticket is a 409, not a second row (NOT-141).
602
+ scopeParkCounter += 1;
603
+ const suffix = scopeParkCounter;
604
+ const res = await app.inject({
605
+ method: "POST",
606
+ url: "/api/issues",
607
+ payload: { ...LINEAR_ISSUE_PAYLOAD, externalId: `linear-uuid-scope-${suffix}` },
608
+ });
609
+ assert.equal(res.statusCode, 200, res.body);
610
+ const { id } = res.json();
611
+ assert.equal(startWorkflow(id).ok, true);
612
+ const dev = claimWorkItem("route-test", { leaseMs: 60_000 });
613
+ await applyCompletion(dev.id, dev.leaseToken, {
614
+ kind: "clean_handoff",
615
+ branch: "b",
616
+ headSha: "abc",
617
+ baseSha: "base",
618
+ prNumber: 1,
619
+ prUrl: "https://gh/pr/1",
620
+ });
621
+ const rev = claimWorkItem("route-test", { leaseMs: 60_000 });
622
+ await applyCompletion(rev.id, rev.leaseToken, {
623
+ kind: "verdict",
624
+ result: ReviewerResult.parse({
625
+ verdict: "escalated",
626
+ productScopeQuestion: "Should deleted users retain sessions?",
627
+ baseSha: "b",
628
+ headSha: "h",
629
+ acceptanceCriteriaAssessment: "ok",
630
+ evidenceAssessment: "ok",
631
+ findings: [],
632
+ risks: [],
633
+ }),
634
+ });
635
+ assert.equal(getIssue(id).status, "needs_human");
636
+ return id;
637
+ }
638
+ test("NOT-422: a product-scope pause accepts PATCH task edits and reports them pending for the next snapshot", async () => {
639
+ const app = await buildApp();
640
+ const id = await parkLinearAtProductScope(app);
641
+ const actionId = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision").id;
642
+ const patchRes = await app.inject({
643
+ method: "PATCH",
644
+ url: `/api/issues/${id}`,
645
+ payload: { description: RELOADED_DESCRIPTION },
646
+ });
647
+ assert.equal(patchRes.statusCode, 200, patchRes.body);
648
+ const patched = patchRes.json();
649
+ assert.equal(patched.description, RELOADED_DESCRIPTION);
650
+ assert.deepStrictEqual(patched.executionContract, EXPECTED_RELOADED_CONTRACT);
651
+ // The scope action stays open and the frozen snapshot stays stale until Resume.
652
+ assert.equal(getHumanAction(actionId).status, "open");
653
+ assert.equal(getTaskSnapshot(getIssue(id)).description, CONTRACT_DESCRIPTION);
654
+ const detail = (await app.inject({ method: "GET", url: `/api/issues/${id}` })).json();
655
+ assert.ok(detail.pendingSnapshotChangedFields.includes("description"));
656
+ assert.ok(detail.pendingSnapshotChangedFields.includes("executionContract"));
657
+ await app.close();
658
+ });
659
+ test("NOT-422: a product-scope pause accepts Linear source reload and preserves the workflow", async () => {
660
+ resetLinearMock(linearNodeForTest(RELOADED_DESCRIPTION));
661
+ const app = await buildApp();
662
+ const id = await parkLinearAtProductScope(app);
663
+ const before = getIssue(id);
664
+ const instanceBefore = getActiveWorkflowInstance(id);
665
+ const actionId = listHumanActionsForIssue(id).find((a) => a.actionType === "product_scope_decision").id;
666
+ const res = await app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
667
+ assert.equal(res.statusCode, 200, res.body);
668
+ const reloaded = res.json();
669
+ assert.equal(reloaded.title, "NOT-123: Refreshed title from Linear");
670
+ assert.equal(reloaded.description, RELOADED_DESCRIPTION);
671
+ assert.deepStrictEqual(reloaded.executionContract, EXPECTED_RELOADED_CONTRACT);
672
+ assert.ok(reloaded.acceptanceCriteria.includes("- [ ] Reloaded ticket compiles into the refreshed structured schema"));
673
+ assert.ok(!reloaded.acceptanceCriteria.includes("Operator-typed override"));
674
+ const after = getIssue(id);
675
+ assert.equal(after.status, "needs_human");
676
+ assert.equal(after.repo, before.repo);
677
+ assert.equal(after.baseBranch, before.baseBranch);
678
+ assert.equal(after.developerAgentId, before.developerAgentId);
679
+ assert.equal(after.reviewerAgentId, before.reviewerAgentId);
680
+ assert.equal(after.maxReviewRounds, before.maxReviewRounds);
681
+ assert.equal(after.maxInfraAttempts, before.maxInfraAttempts);
682
+ assert.equal(after.autoMerge, before.autoMerge);
683
+ assert.equal(after.currentRound, before.currentRound);
684
+ assert.equal(after.infraAttempts, before.infraAttempts);
685
+ assert.equal(after.branch, before.branch);
686
+ assert.equal(after.baseSha, before.baseSha);
687
+ assert.equal(after.headSha, before.headSha);
688
+ assert.equal(after.prNumber, before.prNumber);
689
+ assert.equal(after.prUrl, before.prUrl);
690
+ assert.equal(getActiveWorkflowInstance(id).id, instanceBefore.id);
691
+ assert.equal(getHumanAction(actionId).status, "open");
692
+ const reloadEvents = listWorkflowEventsForIssue(id).filter((e) => e.type === "issue.source_reloaded");
693
+ assert.equal(reloadEvents.length, 1);
694
+ assert.equal(reloadEvents[0].stage, "needs_human");
695
+ // Still stale until the explicit Resume re-freezes.
696
+ assert.equal(getTaskSnapshot(after).description, CONTRACT_DESCRIPTION);
697
+ await app.close();
698
+ });
699
+ test("NOT-422: PATCH at a scope pause fails closed on pending/leased work, running sessions, and other parks", async () => {
700
+ const app = await buildApp();
701
+ const patch = (id, payload) => app.inject({ method: "PATCH", url: `/api/issues/${id}`, payload });
702
+ // Pending work blocks the edit with no row or event change.
703
+ const pendingId = await parkLinearAtProductScope(app);
704
+ const pendingInstance = getActiveWorkflowInstance(pendingId);
705
+ const item = enqueueWorkItem({
706
+ issueId: pendingId,
707
+ workflowInstanceId: pendingInstance.id,
708
+ kind: "developer",
709
+ round: 1,
710
+ payload: {},
711
+ idempotencyKey: `${pendingInstance.id}:test:n422-route-pending`,
712
+ });
713
+ const beforePending = JSON.stringify(getIssue(pendingId));
714
+ const eventsPending = listWorkflowEventsForIssue(pendingId).length;
715
+ assert.equal((await patch(pendingId, { title: "Racing edit" })).statusCode, 409);
716
+ // Leased work blocks it too.
717
+ const leased = claimWorkItem("route-test", { leaseMs: 60_000 });
718
+ assert.equal(leased.id, item.id);
719
+ assert.equal((await patch(pendingId, { title: "Racing edit" })).statusCode, 409);
720
+ cancelWorkItem(item.id);
721
+ assert.equal(JSON.stringify(getIssue(pendingId)), beforePending);
722
+ assert.equal(listWorkflowEventsForIssue(pendingId).length, eventsPending);
723
+ // A running session blocks the edit.
724
+ const runningId = await parkLinearAtProductScope(app);
725
+ const session = createWorkerSession({
726
+ issueId: runningId,
727
+ role: "developer",
728
+ round: 1,
729
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
730
+ runtime: "claude_code",
731
+ });
732
+ startSession(session.id);
733
+ const beforeRunning = JSON.stringify(getIssue(runningId));
734
+ const eventsRunning = listWorkflowEventsForIssue(runningId).length;
735
+ const runningRes = await patch(runningId, { title: "Racing session" });
736
+ assert.equal(runningRes.statusCode, 409);
737
+ assert.match(runningRes.json().error, /actively running session/);
738
+ assert.equal(JSON.stringify(getIssue(runningId)), beforeRunning);
739
+ assert.equal(listWorkflowEventsForIssue(runningId).length, eventsRunning);
740
+ // An agent swap at a scope pause is a 409, not a silent accept.
741
+ const swapId = await parkLinearAtProductScope(app);
742
+ const swapRes = await patch(swapId, { developerAgentId: BUILTIN_AGENT_CURSOR_ID });
743
+ assert.equal(swapRes.statusCode, 409);
744
+ assert.match(swapRes.json().error, /Only title, description and acceptanceCriteria/);
745
+ // A final_review pause is a different park — still 409 on an active workflow.
746
+ const { id: finalId } = await createLinearIssue(app);
747
+ assert.equal(startWorkflow(finalId).ok, true);
748
+ const finalDev = claimWorkItem("route-test", { leaseMs: 60_000 });
749
+ await applyCompletion(finalDev.id, finalDev.leaseToken, {
750
+ kind: "clean_handoff",
751
+ branch: "b",
752
+ headSha: "abc",
753
+ baseSha: "base",
754
+ prNumber: 1,
755
+ prUrl: "https://gh/pr/1",
756
+ });
757
+ const finalRev = claimWorkItem("route-test", { leaseMs: 60_000 });
758
+ await applyCompletion(finalRev.id, finalRev.leaseToken, {
759
+ kind: "verdict",
760
+ result: ReviewerResult.parse({
761
+ verdict: "approved",
762
+ baseSha: "b",
763
+ headSha: "h",
764
+ acceptanceCriteriaAssessment: "ok",
765
+ evidenceAssessment: "ok",
766
+ findings: [],
767
+ risks: [],
768
+ }),
769
+ });
770
+ assert.equal(getIssue(finalId).status, "final_review");
771
+ const beforeFinal = JSON.stringify(getIssue(finalId));
772
+ const eventsFinal = listWorkflowEventsForIssue(finalId).length;
773
+ assert.equal((await patch(finalId, { title: "Wrong park" })).statusCode, 409);
774
+ assert.equal(JSON.stringify(getIssue(finalId)), beforeFinal);
775
+ assert.equal(listWorkflowEventsForIssue(finalId).length, eventsFinal);
776
+ await app.close();
777
+ });
778
+ test("NOT-422: reload at a scope pause fails closed on pending/leased work, running sessions, and other parks", async () => {
779
+ resetLinearMock(linearNodeForTest(RELOADED_DESCRIPTION));
780
+ const app = await buildApp();
781
+ const reload = (id) => app.inject({ method: "POST", url: `/api/issues/${id}/reload-source` });
782
+ const pendingId = await parkLinearAtProductScope(app);
783
+ const pendingInstance = getActiveWorkflowInstance(pendingId);
784
+ const item = enqueueWorkItem({
785
+ issueId: pendingId,
786
+ workflowInstanceId: pendingInstance.id,
787
+ kind: "developer",
788
+ round: 1,
789
+ payload: {},
790
+ idempotencyKey: `${pendingInstance.id}:test:n422-route-reload-pending`,
791
+ });
792
+ const beforePending = JSON.stringify(getIssue(pendingId));
793
+ const eventsPending = listWorkflowEventsForIssue(pendingId).length;
794
+ assert.equal((await reload(pendingId)).statusCode, 409);
795
+ const leased = claimWorkItem("route-test", { leaseMs: 60_000 });
796
+ assert.equal(leased.id, item.id);
797
+ assert.equal((await reload(pendingId)).statusCode, 409);
798
+ cancelWorkItem(item.id);
799
+ assert.equal(JSON.stringify(getIssue(pendingId)), beforePending);
800
+ assert.equal(listWorkflowEventsForIssue(pendingId).length, eventsPending);
801
+ assert.ok(!listWorkflowEventsForIssue(pendingId).some((e) => e.type === "issue.source_reloaded"));
802
+ const runningId = await parkLinearAtProductScope(app);
803
+ const session = createWorkerSession({
804
+ issueId: runningId,
805
+ role: "developer",
806
+ round: 1,
807
+ agentId: BUILTIN_AGENT_CLAUDE_ID,
808
+ runtime: "claude_code",
809
+ });
810
+ startSession(session.id);
811
+ const beforeRunning = JSON.stringify(getIssue(runningId));
812
+ const runningRes = await reload(runningId);
813
+ assert.equal(runningRes.statusCode, 409);
814
+ assert.match(runningRes.json().error, /actively running session/);
815
+ assert.equal(JSON.stringify(getIssue(runningId)), beforeRunning);
816
+ // An attempts-exhausted park accepts PATCH but never a source reload.
817
+ const exhausted = (await app.inject({
818
+ method: "POST",
819
+ url: "/api/issues",
820
+ payload: {
821
+ ...LINEAR_ISSUE_PAYLOAD,
822
+ externalId: "linear-uuid-exhausted",
823
+ title: "NOT-123: exhausted",
824
+ maxReviewRounds: 1,
825
+ },
826
+ })).json();
827
+ assert.equal(startWorkflow(exhausted.id).ok, true);
828
+ const exhaustedDev = claimWorkItem("route-test", { leaseMs: 60_000 });
829
+ await applyCompletion(exhaustedDev.id, exhaustedDev.leaseToken, {
830
+ kind: "clean_handoff",
831
+ branch: "b",
832
+ headSha: "abc",
833
+ baseSha: "base",
834
+ prNumber: 1,
835
+ prUrl: "https://gh/pr/1",
836
+ });
837
+ const exhaustedRev = claimWorkItem("route-test", { leaseMs: 60_000 });
838
+ await applyCompletion(exhaustedRev.id, exhaustedRev.leaseToken, {
839
+ kind: "verdict",
840
+ result: ReviewerResult.parse({
841
+ verdict: "changes_requested",
842
+ baseSha: "b",
843
+ headSha: "h",
844
+ acceptanceCriteriaAssessment: "ok",
845
+ evidenceAssessment: "ok",
846
+ findings: [],
847
+ risks: [],
848
+ }),
849
+ });
850
+ assert.ok(listHumanActionsForIssue(exhausted.id).some((a) => a.actionType === "attempts_exhausted" && a.status === "open"));
851
+ const beforeExhausted = JSON.stringify(getIssue(exhausted.id));
852
+ const eventsExhausted = listWorkflowEventsForIssue(exhausted.id).length;
853
+ assert.equal((await reload(exhausted.id)).statusCode, 409);
854
+ assert.equal(JSON.stringify(getIssue(exhausted.id)), beforeExhausted);
855
+ assert.equal(listWorkflowEventsForIssue(exhausted.id).length, eventsExhausted);
856
+ await app.close();
857
+ });
858
+ test("NOT-422: a malformed amended contract at a scope pause is a 400 that changes nothing", async () => {
859
+ resetLinearMock(linearNodeForTest(CONTRACT_DESCRIPTION.replace("feature", "teleport")));
860
+ const app = await buildApp();
861
+ const patchId = await parkLinearAtProductScope(app);
862
+ const beforePatch = JSON.stringify(getIssue(patchId));
863
+ const eventsPatch = listWorkflowEventsForIssue(patchId).length;
864
+ const badPatch = await app.inject({
865
+ method: "PATCH",
866
+ url: `/api/issues/${patchId}`,
867
+ payload: { description: CONTRACT_DESCRIPTION.replace("feature", "teleport") },
868
+ });
869
+ assert.equal(badPatch.statusCode, 400, badPatch.body);
870
+ assert.match(badPatch.json().error, /unknown execution mode/);
871
+ assert.equal(JSON.stringify(getIssue(patchId)), beforePatch);
872
+ assert.equal(listWorkflowEventsForIssue(patchId).length, eventsPatch);
873
+ const reloadId = await parkLinearAtProductScope(app);
874
+ const beforeReload = JSON.stringify(getIssue(reloadId));
875
+ const eventsReload = listWorkflowEventsForIssue(reloadId).length;
876
+ const badReload = await app.inject({ method: "POST", url: `/api/issues/${reloadId}/reload-source` });
877
+ assert.equal(badReload.statusCode, 400, badReload.body);
878
+ assert.match(badReload.json().error, /unknown execution mode/);
879
+ assert.equal(JSON.stringify(getIssue(reloadId)), beforeReload);
880
+ assert.equal(listWorkflowEventsForIssue(reloadId).length, eventsReload);
881
+ assert.ok(!listWorkflowEventsForIssue(reloadId).some((e) => e.type === "issue.source_reloaded"));
882
+ await app.close();
883
+ });
591
884
  test("NOT-363: the frozen snapshot after reload contains exactly the refreshed text", async () => {
592
885
  resetLinearMock(linearNodeForTest(RELOADED_DESCRIPTION));
593
886
  const app = await buildApp();