@nanobpm/nano-workforce 0.206.4 → 0.208.0

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 (35) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/SPEC.md +18 -0
  3. package/app/deliveryGraphReadModel.test.ts +4 -2
  4. package/app/deliveryGraphRun.ts +12 -0
  5. package/app/github.ts +29 -0
  6. package/app/incidentStatus.test.ts +175 -0
  7. package/app/mcpExclusions.test.ts +4 -3
  8. package/app/mcpToolSurface.ts +11 -2
  9. package/app/mergeLoopBehaviour.test.ts +136 -1
  10. package/app/pullRequestReadModel.test.ts +1 -1
  11. package/app/service.test.ts +266 -0
  12. package/app/service.ts +311 -96
  13. package/build-info.json +4 -4
  14. package/db/migrations/126_pull_requests_incident_type_since.sql +8 -0
  15. package/db/migrations/127_delivery_graph_run_incidents.sql +60 -0
  16. package/db/migrations/128_pull_requests_read_model_incidents.sql +58 -0
  17. package/db/migrations/129_delivery_graph_read_model_incidents.sql +58 -0
  18. package/docs/mcp-runbook.md +2 -2
  19. package/nano.app.json +8 -0
  20. package/openapi.yaml +238 -0
  21. package/operations/listIncidents.test.ts +70 -0
  22. package/operations/listIncidents.ts +78 -0
  23. package/operations/retryIncident.test.ts +280 -0
  24. package/operations/retryIncident.ts +201 -0
  25. package/package.json +1 -1
  26. package/pages/delivery-graphs.page.json +16 -3
  27. package/pages/home.page.json +19 -0
  28. package/pages/overview.page.json +9 -0
  29. package/resources/processes/merge-loop.bpmn +401 -213
  30. package/workers/merge/worker.test.ts +30 -9
  31. package/workers/merge/worker.ts +23 -12
  32. package/workers/reconverge/worker.test.ts +110 -0
  33. package/workers/reconverge/worker.ts +81 -0
  34. package/workers/request-review/worker.test.ts +54 -0
  35. package/workers/request-review/worker.ts +68 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.208.0](https://github.com/nanobpm/nano-workforce/compare/v0.207.0...v0.208.0) (2026-11-10)
2
+
3
+ ### Features
4
+
5
+ * **merge:** request the head-current Copilot review after a loop-authored head move ([#941](https://github.com/nanobpm/nano-workforce/issues/941)) ([0e2d50e](https://github.com/nanobpm/nano-workforce/commit/0e2d50e8610beb5e939cd3a143d28e62d690f272)), closes [#939](https://github.com/nanobpm/nano-workforce/issues/939)
6
+
7
+ ## [0.207.0](https://github.com/nanobpm/nano-workforce/compare/v0.206.4...v0.207.0) (2026-11-09)
8
+
9
+ ### Features
10
+
11
+ * **status:** surface engine incidents on loop and delivery rows with a retry action ([#938](https://github.com/nanobpm/nano-workforce/issues/938)) ([e3955c8](https://github.com/nanobpm/nano-workforce/commit/e3955c8145a966a29806400872b7a9c362d1d992)), closes [#931](https://github.com/nanobpm/nano-workforce/issues/931) [#931](https://github.com/nanobpm/nano-workforce/issues/931)
12
+
1
13
  ## [0.206.4](https://github.com/nanobpm/nano-workforce/compare/v0.206.3...v0.206.4) (2026-11-09)
2
14
 
3
15
  ### Bug Fixes
package/SPEC.md CHANGED
@@ -855,6 +855,24 @@ start ─► wait: deps merged ─► arm merge ─► wait: mergeable ─┬─
855
855
  sets `ciBlockedReconciled`), and escalates only if the PR is **still** blocked on the
856
856
  re-derived state.
857
857
 
858
+ - **Head-current review after a loop-authored head move — request it, don't escalate**
859
+ (issue #939) — `pr.merge` refuses to land without a Copilot review of the PR's
860
+ current HEAD (#923 backstop). When the loop's own rebase / merge-from-base / fix push
861
+ moved the head, the only remedy is mechanical, so instead of escalating the worker
862
+ returns `mergeStatus: "review-pending"` and the model **requests the review itself**
863
+ (`pr.request-review` — REST `requested_reviewers` POST of
864
+ `copilot-pull-request-reviewer[bot]`; on a re-request whose prior ask is still pending,
865
+ i.e. silently ignored, it withdraws then re-adds the reviewer), waits on a timer
866
+ (`reviewRequestWait`, derived from the review-nudge window) and re-arms the poller so
867
+ the backstop re-checks the new head. It repeats up to `reviewRequestMax` (3) requests;
868
+ only when no review arrives in that budget — or Copilot is not an assignable reviewer —
869
+ does it escalate, with a question stating what was tried. A head-current review whose
870
+ summary reports open findings returns `mergeStatus: "review-findings"`: `pr.reconverge`
871
+ hands the PR back to the convergence loop through the canonical `submitPr` (declaring
872
+ the owning merge-loop instance as `handoffFrom`) and the merge loop ends — the fresh
873
+ convergence run fixes, pushes, re-reviews and starts a new merge loop on convergence.
874
+ An *unreadable* review/head still fails closed to a human (#927).
875
+
858
876
  - **Discovered dependency** — a `senior:fix-ci` or `senior:rebase` agent may find that
859
877
  the PR cannot land because **another PR must merge first** (a required linked-issue
860
878
  gate a sibling PR will close, a stacked base PR, or a `Depends-on:` the agent read
@@ -39,7 +39,7 @@ import { applyMigrationSet, readMigrationSetFromDisk } from "../test/migrations.
39
39
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
40
40
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
41
41
 
42
- const READ_MODEL_MIGRATION = "121_delivery_graph_read_model_auto_resolve.sql";
42
+ const READ_MODEL_MIGRATION = "129_delivery_graph_read_model_incidents.sql";
43
43
 
44
44
  // A minimal in-memory DB carrying the base `delivery_graph_runs` / `pull_requests` shapes the VIEW
45
45
  // reads, plus stand-ins for the managed `<table>__tracking` derived VIEWs urban provisions at mount
@@ -53,7 +53,9 @@ function viewDb(): DatabaseSync {
53
53
  run_key TEXT PRIMARY KEY, process_key TEXT, process_definition_id TEXT, digest TEXT,
54
54
  status TEXT, side_effecting INTEGER, node_count INTEGER, human_node_count INTEGER,
55
55
  side_effect_count INTEGER, title TEXT, phase TEXT, phase_node_id TEXT, human_labels TEXT,
56
- created_at TEXT, updated_at TEXT, acknowledged_at TEXT, auto_resolve_escalations INTEGER, derived_status_override TEXT);
56
+ created_at TEXT, updated_at TEXT, acknowledged_at TEXT, auto_resolve_escalations INTEGER,
57
+ incident_key TEXT, incident_type TEXT, incident_message TEXT, incident_since TEXT,
58
+ derived_status_override TEXT);
57
59
  CREATE TABLE pull_requests (pr_key TEXT PRIMARY KEY, root_request_key TEXT, status TEXT,
58
60
  derived_status_override TEXT);`,
59
61
  );
@@ -56,6 +56,14 @@ export interface DeliveryGraphRun {
56
56
  /** Opt-in escalation steward (120_escalation_auto_resolutions.sql, issue #889): 1 ⇒ this run's
57
57
  * escalations are eligible for steward auto-resolution. Default 0. */
58
58
  auto_resolve_escalations: number;
59
+ /** Technical-incident mirror (127_delivery_graph_run_incidents.sql, issue #931), written by the
60
+ * poller's `pollIncidents` pass — the delivery-side twin of the PR incident columns (017/126).
61
+ * All NULL when the run's instance has no active incident; `/status` derives the `incident`
62
+ * overlay from these columns (see `activeDeliveries` in app/service.ts). */
63
+ incident_key: string | null;
64
+ incident_type: string | null;
65
+ incident_message: string | null;
66
+ incident_since: string | null;
59
67
  }
60
68
 
61
69
  /** The run lifecycle. `awaiting-approval` is RESERVED but no longer produced (issue #460 moved dispatch
@@ -530,5 +538,9 @@ export function buildDeliveryGraphRunRow(input: {
530
538
  created_at: input.createdAt ?? at,
531
539
  updated_at: at,
532
540
  acknowledged_at: null,
541
+ incident_key: null,
542
+ incident_type: null,
543
+ incident_message: null,
544
+ incident_since: null,
533
545
  };
534
546
  }
package/app/github.ts CHANGED
@@ -743,6 +743,35 @@ export async function requestCopilotReview(
743
743
  throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
744
744
  }
745
745
 
746
+ /** Withdraw a pending Copilot review request (REST reviewers DELETE). Used by the merge loop's
747
+ * head-current review request (issue #939) when a prior request was silently ignored: Copilot stays
748
+ * listed as pending but never reviews, and a second POST is a no-op — removing and re-adding it is
749
+ * the equivalent that re-triggers the review. `null` when no transport is usable; throws on a
750
+ * genuine transport failure. */
751
+ export async function removeCopilotReviewer(
752
+ repo: string,
753
+ number: number | string,
754
+ token: string,
755
+ ): Promise<"removed" | null> {
756
+ const path = `repos/${repo}/pulls/${number}/requested_reviewers`;
757
+ if (await useGh()) {
758
+ await runGh(["api", path, "-X", "DELETE", "-f", `reviewers[]=${COPILOT_REVIEWER}`]);
759
+ return "removed";
760
+ }
761
+ if (!token) return null;
762
+ const r = await fetch(`https://api.github.com/${path}`, {
763
+ method: "DELETE",
764
+ headers: {
765
+ authorization: `Bearer ${token}`,
766
+ accept: "application/vnd.github+json",
767
+ "content-type": "application/json",
768
+ },
769
+ body: JSON.stringify({ reviewers: [COPILOT_REVIEWER] }),
770
+ });
771
+ if (r.ok) return "removed";
772
+ throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
773
+ }
774
+
746
775
  // ── Merge stage (SPEC §11) ──────────────────────────────────────────────────
747
776
  // The same two-transport model (gh | token) backs the merge stage: read a PR's merge state to
748
777
  // decide when it is landable, and perform the merge (directly or via the repo's merge queue).
@@ -0,0 +1,175 @@
1
+ // Red/green for issue #931 — an engine incident parked on a convergence loop's job must be VISIBLE
2
+ // on the `/status` row, not hide behind a healthy-looking `status: "converging"`.
3
+ //
4
+ // The defect (observed on merlin, 2026-11-09): a loop whose `senior:pr-review` job exhausted its
5
+ // retries (`JOB_NO_RETRIES`, the agent idle-timeout) sat dead for hours while `/status` reported
6
+ // `status: "converging"`, `openEscalation: null`, `activeWorker: null` — indistinguishable from a
7
+ // healthy queued run. `pollIncidents` mirrored the incident onto `incident_key`/`incident_message`
8
+ // (issue #94), but the `/status` projection never READ those columns, so the only discovery path
9
+ // was a hand-run `POST /v2/incidents/search`.
10
+ //
11
+ // The fix surfaces the mirrored incident on every ActivePr/ActiveDelivery row:
12
+ // status — overlaid to "incident" while the row carries an ACTIVE incident (the base
13
+ // `pull_requests.status` workflow transient is untouched; the overlay is derived at
14
+ // read time so it can never wedge the status machine), and
15
+ // incident — { key, type, message, since }: the engine incidentKey, errorType, errorMessage and
16
+ // creationTime the poller mirrored, so an operator sees WHAT broke and WHEN without
17
+ // opening the engine.
18
+ import { test } from "node:test";
19
+ import { assertEquals } from "#test-assert";
20
+ import { withTrackingViews } from "../test/trackingViews.ts";
21
+ import { activeDeliveries, activePrs } from "./service.ts";
22
+
23
+ function memTable(rows: any[], key: string) {
24
+ return {
25
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
26
+ all: () => Promise.resolve([...rows]),
27
+ find: (q: any) =>
28
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
29
+ findOne: (q: any) =>
30
+ Promise.resolve(rows.find((r) => Object.entries(q).every(([f, v]) => r[f] === v)) ?? null),
31
+ insert: (r: any) => {
32
+ rows.push(r);
33
+ return Promise.resolve(r);
34
+ },
35
+ update: (k: any, patch: any) => {
36
+ const r = rows.find((x) => x[key] === k);
37
+ if (r) Object.assign(r, patch);
38
+ return Promise.resolve(r);
39
+ },
40
+ delete: (k: any) => {
41
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
42
+ return Promise.resolve();
43
+ },
44
+ };
45
+ }
46
+
47
+ type Stores = Record<string, { rows: any[]; key: string }>;
48
+
49
+ function memData(stores: Stores) {
50
+ return {
51
+ table: withTrackingViews((name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
52
+ } as any;
53
+ }
54
+
55
+ /** A fake engine whose `searchVariables` serves the given per-instance variables (none here — the
56
+ * merge policy degrades to its historical default, which these tests do not exercise). */
57
+ function fakeEngine() {
58
+ return {
59
+ searchVariables: () => Promise.resolve([]),
60
+ } as any;
61
+ }
62
+
63
+ function prRow(over: Record<string, unknown>) {
64
+ return {
65
+ pr_key: "owner/repo#1",
66
+ repo: "owner/repo",
67
+ number: 1,
68
+ url: "https://github.com/owner/repo/pull/1",
69
+ title: "t",
70
+ status: "converging",
71
+ current_round: 1,
72
+ process_key: "PI-1",
73
+ updated_at: "2024-01-01",
74
+ ...over,
75
+ };
76
+ }
77
+
78
+ async function statusOf(rowOver: Record<string, unknown> = {}) {
79
+ const stores: Stores = {
80
+ pull_requests: { rows: [prRow(rowOver)], key: "pr_key" },
81
+ user_tasks: { rows: [], key: "id" },
82
+ };
83
+ const [pr] = await activePrs(memData(stores), fakeEngine());
84
+ return pr;
85
+ }
86
+
87
+ test("incident surfacing: a PR row carrying an ACTIVE incident reads status 'incident', not 'converging' (the #931 defect)", async () => {
88
+ const pr = await statusOf({
89
+ incident_key: "598235",
90
+ incident_type: "JOB_NO_RETRIES",
91
+ incident_message: 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)',
92
+ incident_since: "2026-11-09T01:00:00.000Z",
93
+ });
94
+ assertEquals(pr.status, "incident");
95
+ assertEquals(pr.incident, {
96
+ key: "598235",
97
+ type: "JOB_NO_RETRIES",
98
+ message: 'agent "nano-coder" failed: no output and no subtree progress for >= 300000ms (idle)',
99
+ since: "2026-11-09T01:00:00.000Z",
100
+ });
101
+ });
102
+
103
+ test("incident surfacing: a healthy PR row keeps its workflow status and a null incident", async () => {
104
+ const pr = await statusOf({
105
+ incident_key: null,
106
+ incident_type: null,
107
+ incident_message: null,
108
+ incident_since: null,
109
+ });
110
+ assertEquals(pr.status, "converging");
111
+ assertEquals(pr.incident, null);
112
+ });
113
+
114
+ test("incident surfacing: the overlay applies to any live workflow status (e.g. waiting_merge), never just converging", async () => {
115
+ const pr = await statusOf({
116
+ status: "waiting_merge",
117
+ incident_key: "INC-9",
118
+ incident_type: "EXTRACT_VALUE_ERROR",
119
+ incident_message: "failed to evaluate expression",
120
+ incident_since: "2026-11-09T02:00:00.000Z",
121
+ });
122
+ assertEquals(pr.status, "incident");
123
+ assertEquals(pr.incident?.key, "INC-9");
124
+ });
125
+
126
+ test("incident surfacing: a delivery run carrying an ACTIVE incident reads status 'incident' with the incident block", async () => {
127
+ const run = {
128
+ run_key: "dg-1",
129
+ title: "ship it",
130
+ status: "running",
131
+ phase: "Running",
132
+ process_key: "PI-DG-1",
133
+ updated_at: "2024-01-01",
134
+ auto_resolve_escalations: 0,
135
+ incident_key: "INC-DG-1",
136
+ incident_type: "JOB_NO_RETRIES",
137
+ incident_message: "delivery node job exhausted retries",
138
+ incident_since: "2026-11-09T03:00:00.000Z",
139
+ };
140
+ const stores: Stores = {
141
+ delivery_graph_runs: { rows: [run], key: "run_key" },
142
+ user_tasks: { rows: [], key: "id" },
143
+ };
144
+ const [delivery] = await activeDeliveries(memData(stores));
145
+ assertEquals(delivery.status, "incident");
146
+ assertEquals(delivery.incident, {
147
+ key: "INC-DG-1",
148
+ type: "JOB_NO_RETRIES",
149
+ message: "delivery node job exhausted retries",
150
+ since: "2026-11-09T03:00:00.000Z",
151
+ });
152
+ });
153
+
154
+ test("incident surfacing: a healthy delivery run keeps its status and a null incident", async () => {
155
+ const run = {
156
+ run_key: "dg-2",
157
+ title: "ship it",
158
+ status: "running",
159
+ phase: "Running",
160
+ process_key: "PI-DG-2",
161
+ updated_at: "2024-01-01",
162
+ auto_resolve_escalations: 0,
163
+ incident_key: null,
164
+ incident_type: null,
165
+ incident_message: null,
166
+ incident_since: null,
167
+ };
168
+ const stores: Stores = {
169
+ delivery_graph_runs: { rows: [run], key: "run_key" },
170
+ user_tasks: { rows: [], key: "id" },
171
+ };
172
+ const [delivery] = await activeDeliveries(memData(stores));
173
+ assertEquals(delivery.status, "running");
174
+ assertEquals(delivery.incident, null);
175
+ });
@@ -31,9 +31,10 @@ function projectedOperations() {
31
31
  }
32
32
 
33
33
  // The operator-only cockpit doors: the staged delivery-graph lifecycle (stage -> dispatch ->
34
- // dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7). These — and ONLY
35
- // these — must be excluded from the projected MCP tool surface.
36
- const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal"];
34
+ // dismiss) whose approval is a human click in the cockpit (ADR 0005 Decision 7), plus the
35
+ // incident-retry remediation door (issue #931) — the human unstick action on an incident-parked
36
+ // run. These — and ONLY these — must be excluded from the projected MCP tool surface.
37
+ const EXPECTED_EXCLUDED = ["stageDeliveryGraph", "dispatchDeliveryGraph", "dismissProposal", "retryIncident"];
37
38
 
38
39
  // A representative set of agent-facing operations that MUST stay exposed as tools — the drive doors
39
40
  // (submit PR / epic / delivery-graph set), the pure compile/preview doors, and the read/orientation
@@ -87,8 +87,12 @@ export const FRAMEWORK_TOOL_PREFIX = "urban_debug_";
87
87
  * regression guard, not the reduction itself: the reduction an agent actually experiences comes from
88
88
  * importing {@link CURATED_MCP_TOOLS} rather than `["*"]`, and the transport-level shrink of the
89
89
  * framework tool family is tracked upstream (nano-ide#488).
90
+ *
91
+ * RAISE PROVENANCE — 60 → 61 (#931): the read-only `listIncidents` door (sibling of
92
+ * `listEscalations`) stays on the surface per the `x-mcp` convention that read/orient doors are
93
+ * exposed; its operator-only remedy `retryIncident` is excluded. Deliberate, documented growth.
90
94
  */
91
- export const MCP_TOOL_COUNT_BUDGET = 60;
95
+ export const MCP_TOOL_COUNT_BUDGET = 61;
92
96
 
93
97
  /**
94
98
  * Hard CEILING on the serialized byte size of the full `tools/list` payload (the schema bytes a
@@ -138,8 +142,13 @@ export const MCP_TOOL_COUNT_BUDGET = 60;
138
142
  * intended description text (a correctness fix, not new surface). Measured 90,420 bytes serialized
139
143
  * (over the old ceiling's 920-byte headroom). Deliberate, documented growth of restored description
140
144
  * text — not schema fat.
145
+ *
146
+ * RAISE PROVENANCE — 91_000 → 92_000 (#931): the read-only `listIncidents` tool (engine-incident
147
+ * liveness sibling of `listEscalations`) plus the `incident` field on the status row schemas.
148
+ * Measured 91,069 bytes serialized (over the old ceiling's headroom). Deliberate, documented growth
149
+ * of a real new read surface — not schema fat.
141
150
  */
142
- export const MCP_SURFACE_BYTES_BUDGET = 91_000;
151
+ export const MCP_SURFACE_BYTES_BUDGET = 92_000;
143
152
 
144
153
  /**
145
154
  * The eagerly-loaded curated subset MUST stay materially smaller than the full surface — otherwise it
@@ -43,7 +43,8 @@ const MODEL = readFileSync("resources/processes/merge-loop.bpmn", "utf8");
43
43
  const AGENT_SLA_MS = 30 * 60 * 1000; // matches the PT30M we start instances with
44
44
  const LANDED_WAIT_MS = 30 * 60 * 1000; // matches the PT30M landedWaitTimeout we start instances with
45
45
  const MERGEABLE_WAIT_MS = 30 * 60 * 1000; // matches the PT30M mergeableWaitTimeout we start instances with
46
- const MERGEABLE_REPOLL_MS = 2 * 60 * 1000; // matches the PT2M mergeableRepollInterval we start instances with
46
+ const MERGEABLE_REPOLL_MS = 2 * 60 * 1000;
47
+ const REVIEW_REQUEST_WAIT_MS = 10 * 60 * 1000; // matches the PT10M reviewRequestWait we start instances with // matches the PT2M mergeableRepollInterval we start instances with
47
48
 
48
49
  type Output = Record<string, unknown>;
49
50
  type Responder = Output | Output[] | ((job: { variables: Record<string, unknown> }) => Output);
@@ -59,6 +60,8 @@ const ALL_JOB_TYPES = [
59
60
  "pr.persist-escalation",
60
61
  "pr.answer-escalation",
61
62
  "pr.record-dependency",
63
+ "pr.request-review",
64
+ "pr.reconverge",
62
65
  ] as const;
63
66
 
64
67
  const DEFAULT_RESPONSES: Record<string, Responder> = {
@@ -68,6 +71,8 @@ const DEFAULT_RESPONSES: Record<string, Responder> = {
68
71
  "pr.answer-escalation": {},
69
72
  "pr.persist-escalation": { escalated: true },
70
73
  "pr.landed-probe": { landedVerdict: "waiting" },
74
+ "pr.request-review": { reviewRequest: "requested" },
75
+ "pr.reconverge": {},
71
76
  };
72
77
 
73
78
  // Every FEEL expression in the model references these; start them defined (null,
@@ -91,6 +96,10 @@ const DEFAULT_VARS: Record<string, unknown> = {
91
96
  mergeableRepollInterval: "PT2M",
92
97
  mergeStallRounds: 0,
93
98
  mergeStallMax: 3,
99
+ reviewRequestRound: 0,
100
+ reviewRequestMax: 3,
101
+ reviewRequestWait: "PT10M",
102
+ reviewRequest: null,
94
103
  abandonBrief: "\n\n---\nAbort if cancelled: curl …",
95
104
  failingChecksList: null,
96
105
  status: null,
@@ -413,6 +422,59 @@ test("a fix-ci agent echoing abandonBrief cannot corrupt the seeded abort brief
413
422
  .hasVariable("abandonBrief", "\n\n---\nAbort if cancelled: curl …");
414
423
  });
415
424
 
425
+ test("a fix-ci agent echoing review-request state cannot clobber the seeded budget or timer (#941 round 4)", async () => {
426
+ // Same guard class as the #887 echoes: `reviewRequestRound`/`reviewRequestMax` bound the
427
+ // head-current review re-request loop (`reviewRequestRound < reviewRequestMax` gates a retry) and
428
+ // `reviewRequestWait` arms its timer. An agent completion echoing `reviewRequestMax: 0` would send
429
+ // the FIRST missing-review verdict straight to a human escalation, and a clobbered
430
+ // `reviewRequestWait` would reshape the wait. The snapshot/restore guard must pin all four.
431
+ const engine = await boot({
432
+ responses: {
433
+ "pr.merge-stall-probe": { mergeState: "blocked", failingChecks: 1, failingChecksList: "build" },
434
+ "senior:fix-ci": {
435
+ status: "fixed",
436
+ reviewRequestRound: 99,
437
+ reviewRequestMax: 0,
438
+ reviewRequestWait: "PT1S",
439
+ reviewRequest: "unavailable",
440
+ },
441
+ },
442
+ });
443
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
444
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
445
+ assertThatInstance(engine, byProcessId("merge-loop"))
446
+ .isActive()
447
+ .hasCompletedElements("merge-stall-probe", "fix-ci")
448
+ .hasVariable("reviewRequestRound", 0)
449
+ .hasVariable("reviewRequestMax", 3)
450
+ .hasVariable("reviewRequestWait", "PT10M");
451
+ assert(instanceVars(engine).reviewRequest !== "unavailable", "a fix-ci agent must not forge reviewRequest:unavailable");
452
+ });
453
+
454
+ test("a rebase agent echoing review-request state cannot clobber the seeded budget or timer (#941 round 4)", async () => {
455
+ const engine = await boot({
456
+ responses: {
457
+ "pr.merge-stall-probe": { mergeState: "conflict", failingChecks: 0, failingChecksList: "" },
458
+ "senior:rebase": {
459
+ status: "rebased",
460
+ reviewRequestRound: 99,
461
+ reviewRequestMax: 0,
462
+ reviewRequestWait: "PT1S",
463
+ reviewRequest: "unavailable",
464
+ },
465
+ },
466
+ });
467
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
468
+ await engine.advanceTime(MERGEABLE_WAIT_MS + 1);
469
+ assertThatInstance(engine, byProcessId("merge-loop"))
470
+ .isActive()
471
+ .hasCompletedElements("rebase")
472
+ .hasVariable("reviewRequestRound", 0)
473
+ .hasVariable("reviewRequestMax", 3)
474
+ .hasVariable("reviewRequestWait", "PT10M");
475
+ assert(instanceVars(engine).reviewRequest !== "unavailable", "a rebase agent must not forge reviewRequest:unavailable");
476
+ });
477
+
416
478
  test("a blocked PR probed on timeout reaches the CI-fix arm via the timeout path (#636)", async () => {
417
479
  const engine = await boot({
418
480
  responses: {
@@ -908,6 +970,79 @@ test("a worker-supplied block question (review backstop #923) reaches the human
908
970
  assert(!q.includes("Investigate why GitHub refused"), "must not be overwritten by the generic refusal text");
909
971
  });
910
972
 
973
+ // ---------------------------------------------------------------------------
974
+ // Head-current review after a loop-authored head move (issue #939)
975
+ // ---------------------------------------------------------------------------
976
+
977
+ async function driveToAttempt(engine: WasmEngineClient): Promise<void> {
978
+ await engine.publishMessage({ name: "deps-cleared", correlationKey: "pr-1" });
979
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
980
+ }
981
+
982
+ test("#939: a missing head-current review requests one itself, waits, and merges on the re-check — no human", async () => {
983
+ const engine = await boot({ responses: { "pr.merge": [{ mergeStatus: "review-pending" }, { mergeStatus: "merged" }] } });
984
+ await driveToAttempt(engine);
985
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-review-requested");
986
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompletedElements("request-review");
987
+ assertEquals(instanceVars(engine).reviewRequestRound, 1);
988
+ await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
989
+ assertThatInstance(engine, byProcessId("merge-loop")).isActive().hasActiveElement("wait-mergeable");
990
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
991
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("mark-merged");
992
+ assert(!completedElementIds(engine).has("merge-esc-attempt"), "an automatable review gap must never escalate");
993
+ });
994
+
995
+ test("#939 defect-class guard: no automatable merge-gate refusal escalates to a human on its first occurrence", async () => {
996
+ for (const mergeStatus of ["review-pending", "review-findings"]) {
997
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus } } });
998
+ await driveToAttempt(engine);
999
+ const done = completedElementIds(engine);
1000
+ assert(!done.has("merge-esc-attempt") && !done.has("merge-esc-conflict"), `${mergeStatus}: must not escalate`);
1001
+ const open = await engine.searchUserTasks({});
1002
+ assertEquals(open.length, 0, `${mergeStatus}: no human task on the first occurrence`);
1003
+ }
1004
+ });
1005
+
1006
+ test("#939: a head-current review with findings re-enters convergence and ends the merge loop", async () => {
1007
+ const engine = await boot({ responses: { "pr.merge": { mergeStatus: "review-findings" } } });
1008
+ await driveToAttempt(engine);
1009
+ assertThatInstance(engine, byProcessId("merge-loop")).hasCompleted().hasCompletedElements("reconverge", "MergeReconverged");
1010
+ assert(!completedElementIds(engine).has("mark-merged"), "findings must not merge");
1011
+ });
1012
+
1013
+ test("#939: a review that never arrives escalates after the bounded request budget, stating what was tried", async () => {
1014
+ const engine = await boot({
1015
+ responses: { "pr.merge": { mergeStatus: "review-pending" } },
1016
+ vars: { reviewRequestMax: 1 },
1017
+ });
1018
+ await driveToAttempt(engine);
1019
+ await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
1020
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
1021
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
1022
+ const q = String(escalation(engine).question ?? "");
1023
+ assertStringIncludes(q, "requested a Copilot review of the current HEAD 1 time(s)");
1024
+ assertStringIncludes(q, "PT10M");
1025
+ });
1026
+
1027
+ test("#939: a clean re-check resets the request budget so a later head move gets a fresh budget", async () => {
1028
+ const engine = await boot({
1029
+ responses: { "pr.merge": [{ mergeStatus: "review-pending" }, { mergeStatus: "retry" }, { mergeStatus: "merged" }] },
1030
+ });
1031
+ await driveToAttempt(engine);
1032
+ await engine.advanceTime(REVIEW_REQUEST_WAIT_MS + 1);
1033
+ await engine.publishMessage({ name: "merge-ready", correlationKey: "pr-1", variables: { mergeState: "ready" } });
1034
+ assertEquals(instanceVars(engine).reviewRequestRound, 0);
1035
+ });
1036
+
1037
+ test("#939: Copilot not assignable escalates immediately, naming the cause", async () => {
1038
+ const engine = await boot({
1039
+ responses: { "pr.merge": { mergeStatus: "review-pending" }, "pr.request-review": { reviewRequest: "unavailable" } },
1040
+ });
1041
+ await driveToAttempt(engine);
1042
+ await assertThatUserTask(engine, { instance: byProcessId("merge-loop"), elementId: "wait-merge-answer" }).isCreated();
1043
+ assertStringIncludes(String(escalation(engine).question ?? ""), "not an assignable reviewer");
1044
+ });
1045
+
911
1046
  // ---------------------------------------------------------------------------
912
1047
  // CI-fix sub-process (SP_cifix) — mergeCiReattempt (#134), #329
913
1048
  // ---------------------------------------------------------------------------
@@ -33,7 +33,7 @@ import {
33
33
  const MIG = (name: string) => readFileSync(fileURLToPath(new URL(`../db/migrations/${name}`, import.meta.url)), "utf8");
34
34
  const PAGE = (name: string) => JSON.parse(readFileSync(fileURLToPath(new URL(`../pages/${name}`, import.meta.url)), "utf8"));
35
35
 
36
- const READ_MODEL_MIGRATION = "122_pull_requests_read_model_auto_resolve.sql";
36
+ const READ_MODEL_MIGRATION = "128_pull_requests_read_model_incidents.sql";
37
37
 
38
38
  // The real base `pull_requests` columns, in schema order — DERIVED from the migration chain (not a
39
39
  // hand-kept list that could silently omit one), used by both the drift guard and the e2e stand-in.