@nanobpm/nano-workforce 0.32.0 → 0.32.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.32.2](https://github.com/nanobpm/nano-workforce/compare/v0.32.1...v0.32.2) (2026-08-09)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * heal missing pull_requests FK parent before child writes ([#93](https://github.com/nanobpm/nano-workforce/issues/93)) ([896c1f7](https://github.com/nanobpm/nano-workforce/commit/896c1f7a71098463c581816ffc2821af196f779e)), closes [owner/repo#N](https://github.com/owner/repo/issues/N)
7
+
8
+ ## [0.32.1](https://github.com/nanobpm/nano-workforce/compare/v0.32.0...v0.32.1) (2026-08-09)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **cancel:** reconcile terminated Epic/plan runs via instanceTracking ([#96](https://github.com/nanobpm/nano-workforce/issues/96)) ([3d0c5ba](https://github.com/nanobpm/nano-workforce/commit/3d0c5ba1b8bb9bd0c28432323b1867a7c86d1835))
14
+
1
15
  # [0.32.0](https://github.com/nanobpm/nano-workforce/compare/v0.31.0...v0.32.0) (2026-08-09)
2
16
 
3
17
 
@@ -3,6 +3,7 @@ import { assertEquals, assertNotEquals } from "jsr:@std/assert@1";
3
3
  import type { DataLayer } from "@nanobpm/urban";
4
4
  import {
5
5
  abandonStatusForToken,
6
+ abandonTokenFromUrl,
6
7
  abandonUrl,
7
8
  isAbandoned,
8
9
  mintAbandonToken,
@@ -61,6 +62,21 @@ Deno.test("abandonUrl carries the token on the query string (url-encoded)", () =
61
62
  );
62
63
  });
63
64
 
65
+ Deno.test("abandonTokenFromUrl round-trips the token minted into an abandonUrl", () => {
66
+ const tok = mintAbandonToken();
67
+ assertEquals(abandonTokenFromUrl(abandonUrl(tok, "https://host")), tok);
68
+ // url-special tokens survive the encode/decode round-trip too.
69
+ assertEquals(abandonTokenFromUrl(abandonUrl("tok+/=", "https://host")), "tok+/=");
70
+ });
71
+
72
+ Deno.test("abandonTokenFromUrl returns undefined for absent/tokenless/garbage input", () => {
73
+ assertEquals(abandonTokenFromUrl(undefined), undefined);
74
+ assertEquals(abandonTokenFromUrl(null), undefined);
75
+ assertEquals(abandonTokenFromUrl(""), undefined);
76
+ assertEquals(abandonTokenFromUrl("https://host/hooks/abandon"), undefined, "no token param");
77
+ assertEquals(abandonTokenFromUrl("not a url"), undefined, "unparseable input never throws");
78
+ });
79
+
64
80
  Deno.test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
65
81
  const brief = renderAbandonBrief("https://host/hooks/abandon?token=tok");
66
82
  assertEquals(brief.includes("https://host/hooks/abandon?token=tok"), true);
package/app/abandon.ts CHANGED
@@ -44,6 +44,21 @@ export function abandonUrl(token: string, base: string = publicBaseUrl()): strin
44
44
  return `${base}/hooks/abandon?token=${encodeURIComponent(token)}`;
45
45
  }
46
46
 
47
+ /** Recover the capability token from an abandon URL — the inverse of `abandonUrl`. Returns
48
+ * undefined when the input is absent or carries no `token` query param. A desync-heal uses this to
49
+ * reconstruct a missing `pull_requests` row with the SAME token the running agent was already
50
+ * handed (via the `abandonUrl` process variable), so its `curl -f "…/hooks/abandon?token=…"` abort
51
+ * check keeps resolving instead of 404-ing on a freshly-minted token and aborting a live run. */
52
+ export function abandonTokenFromUrl(url: string | null | undefined): string | undefined {
53
+ if (!url) return undefined;
54
+ try {
55
+ const t = new URL(url).searchParams.get("token");
56
+ return t || undefined;
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
47
62
  /** Resolve an abandon token back to its PR key, or undefined when the token is unknown. */
48
63
  export async function prKeyForAbandonToken(
49
64
  data: DataLayer,
@@ -0,0 +1,117 @@
1
+ // Tests for ensurePr — the idempotent, race-safe heal that reconstructs a missing
2
+ // `pull_requests` FK parent before a child (`rounds`/`escalations`/`merges`) insert, so an
3
+ // engine/app.db store desync never parks an opaque `FOREIGN KEY constraint failed` incident
4
+ // (observed on convergence-loop instance 94).
5
+ import { assert, assertEquals } from "jsr:@std/assert@1";
6
+ import type { DataLayer } from "@nanobpm/urban";
7
+ import { canonicalPrUrl, ensurePr } from "./service.ts";
8
+
9
+ // A tiny in-memory DataLayer backing only the `.get`/`.insert` surface ensurePr uses. `insert`
10
+ // can be made to throw to exercise the race guard, optionally after seeding the row so the
11
+ // post-throw `.get` sees it.
12
+ function memData(opts: { throwOnInsert?: boolean; seedOnThrow?: boolean } = {}): {
13
+ data: DataLayer;
14
+ rows: Map<string, Record<string, unknown>>;
15
+ insertCalls: number;
16
+ } {
17
+ const rows = new Map<string, Record<string, unknown>>();
18
+ let insertCalls = 0;
19
+ function tbl(name: string, key: string) {
20
+ return {
21
+ // deno-lint-ignore require-await
22
+ async get(id: string) {
23
+ return rows.get(id);
24
+ },
25
+ // deno-lint-ignore no-explicit-any require-await
26
+ async insert(row: any) {
27
+ insertCalls++;
28
+ if (opts.throwOnInsert) {
29
+ if (opts.seedOnThrow) rows.set(row[key], { ...row });
30
+ throw new Error("FOREIGN KEY constraint failed");
31
+ }
32
+ rows.set(row[key], { ...row });
33
+ return row[key];
34
+ },
35
+ };
36
+ }
37
+ // deno-lint-ignore no-explicit-any
38
+ const data = { table: (n: string, k: string) => tbl(n, k) } as any as DataLayer;
39
+ return { data, rows, get insertCalls() {
40
+ return insertCalls;
41
+ } };
42
+ }
43
+
44
+ Deno.test("ensurePr is a no-op when the parent already exists", async () => {
45
+ const mem = memData();
46
+ const { data, rows } = mem;
47
+ rows.set("o/r#1", { pr_key: "o/r#1", status: "converging" });
48
+ const before = { ...rows.get("o/r#1") };
49
+ await ensurePr(data, { prKey: "o/r#1", repo: "o/r", number: 1 });
50
+ assertEquals(rows.size, 1, "no new row is written");
51
+ assertEquals(rows.get("o/r#1"), before, "the existing row is untouched");
52
+ assertEquals(mem.insertCalls, 0, "insert is never attempted — the no-write guarantee holds");
53
+ });
54
+
55
+ Deno.test("ensurePr reconstructs a minimal converging row when the parent is absent", async () => {
56
+ const { data, rows } = memData();
57
+ await ensurePr(data, { prKey: "o/r#2", repo: "o/r", number: 2, round: 3 });
58
+ const row = rows.get("o/r#2")!;
59
+ assertEquals(row.pr_key, "o/r#2");
60
+ assertEquals(row.repo, "o/r");
61
+ assertEquals(row.number, 2);
62
+ assertEquals(row.status, "converging");
63
+ assertEquals(row.current_round, 3, "the round is carried through so the aggregate isn't behind");
64
+ assertEquals(row.url, canonicalPrUrl("o/r", 2), "URL is derived canonically when none is passed");
65
+ assert(typeof row.abandon_token === "string" && (row.abandon_token as string).length > 0);
66
+ });
67
+
68
+ Deno.test("ensurePr defaults current_round to 1 (rounds are 1-based) when none is passed", async () => {
69
+ const { data, rows } = memData();
70
+ await ensurePr(data, { prKey: "o/r#5", repo: "o/r", number: 5 });
71
+ assertEquals(
72
+ rows.get("o/r#5")!.current_round,
73
+ 1,
74
+ "an unknown round heals to 1, not 0, matching submitPr's 1-based invariant",
75
+ );
76
+ });
77
+
78
+ Deno.test("ensurePr reuses a supplied abandon token instead of minting a new one", async () => {
79
+ const { data, rows } = memData();
80
+ await ensurePr(data, { prKey: "o/r#7", repo: "o/r", number: 7, abandonToken: "TOK-en_123" });
81
+ assertEquals(
82
+ rows.get("o/r#7")!.abandon_token,
83
+ "TOK-en_123",
84
+ "the running agent's existing token is preserved so its abort check keeps resolving",
85
+ );
86
+ });
87
+
88
+ Deno.test("ensurePr mints a token when none is supplied", async () => {
89
+ const { data, rows } = memData();
90
+ await ensurePr(data, { prKey: "o/r#8", repo: "o/r", number: 8 });
91
+ const tok = rows.get("o/r#8")!.abandon_token;
92
+ assert(typeof tok === "string" && (tok as string).length > 0, "a fresh token is minted as a fallback");
93
+ });
94
+
95
+ Deno.test("ensurePr prefers an explicit url over the canonical one", async () => {
96
+ const { data, rows } = memData();
97
+ const url = "https://github.com/o/r/pull/9";
98
+ await ensurePr(data, { prKey: "o/r#9", repo: "o/r", number: 9, url });
99
+ assertEquals(rows.get("o/r#9")!.url, url);
100
+ });
101
+
102
+ Deno.test("ensurePr swallows an insert race when the row appears anyway", async () => {
103
+ // insert throws (unique-violation / concurrent writer) but the row is now present → healed.
104
+ const { data } = memData({ throwOnInsert: true, seedOnThrow: true });
105
+ await ensurePr(data, { prKey: "o/r#3", repo: "o/r", number: 3 });
106
+ });
107
+
108
+ Deno.test("ensurePr rethrows when the insert fails and the row is still absent", async () => {
109
+ const { data } = memData({ throwOnInsert: true, seedOnThrow: false });
110
+ let threw = false;
111
+ try {
112
+ await ensurePr(data, { prKey: "o/r#4", repo: "o/r", number: 4 });
113
+ } catch (_e) {
114
+ threw = true;
115
+ }
116
+ assert(threw, "a genuine insert failure must surface, not be silently swallowed");
117
+ });
@@ -0,0 +1,73 @@
1
+ // Guard for the `instanceTracking` manifest bindings (nano.app.json). The reconciler flips a row
2
+ // whose engine instance is TERMINATED only when the row is in one of `activeStatuses`. A status
3
+ // that is genuinely in-flight but missing from that list would leave an operator-terminated (or
4
+ // crashed) run stuck "active" in the UI — the exact drift Copilot flagged on #96. This ties the
5
+ // manifest to the code's single source of truth for "done" (TERMINAL_STATUSES / PLAN_TERMINAL_
6
+ // STATUSES) so the two can't diverge silently.
7
+ import { assert, assertEquals } from "jsr:@std/assert@1";
8
+ import { TERMINAL_STATUSES } from "./service.ts";
9
+ import { PLAN_TERMINAL_STATUSES } from "./plan.ts";
10
+
11
+ interface Binding {
12
+ table: string;
13
+ statusField?: string;
14
+ activeStatuses?: string[];
15
+ onTerminated: { set: Record<string, unknown> };
16
+ }
17
+
18
+ async function bindings(): Promise<Binding[]> {
19
+ const manifest = JSON.parse(await Deno.readTextFile(new URL("../nano.app.json", import.meta.url)));
20
+ return manifest.instanceTracking as Binding[];
21
+ }
22
+
23
+ function bindingFor(all: Binding[], table: string): Binding {
24
+ const b = all.find((x) => x.table === table);
25
+ assert(b, `no instanceTracking binding for ${table}`);
26
+ return b;
27
+ }
28
+
29
+ Deno.test("instanceTracking: pull_requests activeStatuses excludes every terminal status", async () => {
30
+ const b = bindingFor(await bindings(), "pull_requests");
31
+ for (const terminal of TERMINAL_STATUSES) {
32
+ assert(
33
+ !b.activeStatuses?.includes(terminal),
34
+ `terminal status "${terminal}" must not be listed active (it would let the reconciler clobber a settled row)`,
35
+ );
36
+ }
37
+ });
38
+
39
+ // Every in-flight status the merge train keys off must be reconcilable. These are the states a
40
+ // pull_requests row can hold while a live engine instance still backs it (see app/service.ts merge
41
+ // poller: converging/waiting_review/escalated + the merge-stage waiting_deps/waiting_merge/
42
+ // waiting_lane/queued/merging). If a new one is added to the flow, add it here AND to the manifest.
43
+ Deno.test("instanceTracking: pull_requests activeStatuses covers every in-flight status", async () => {
44
+ const inFlight = [
45
+ "converging",
46
+ "waiting_review",
47
+ "escalated",
48
+ "waiting_deps",
49
+ "waiting_merge",
50
+ "waiting_lane",
51
+ "queued",
52
+ "merging",
53
+ ];
54
+ const b = bindingFor(await bindings(), "pull_requests");
55
+ for (const s of inFlight) {
56
+ assert(b.activeStatuses?.includes(s), `in-flight status "${s}" missing from activeStatuses`);
57
+ }
58
+ // No terminal status leaks into the in-flight universe we assert on.
59
+ for (const s of inFlight) assert(!TERMINAL_STATUSES.includes(s));
60
+ });
61
+
62
+ Deno.test("instanceTracking: plans activeStatuses excludes every terminal status", async () => {
63
+ const b = bindingFor(await bindings(), "plans");
64
+ for (const terminal of PLAN_TERMINAL_STATUSES) {
65
+ assert(!b.activeStatuses?.includes(terminal), `terminal status "${terminal}" must not be active`);
66
+ }
67
+ });
68
+
69
+ Deno.test("instanceTracking: plans activeStatuses covers every in-flight status", async () => {
70
+ const inFlight = ["planning", "dispatched"];
71
+ const b = bindingFor(await bindings(), "plans");
72
+ assertEquals([...(b.activeStatuses ?? [])].sort(), [...inFlight].sort());
73
+ });
@@ -12,13 +12,22 @@ import handler from "../workers/persist-escalation/worker.ts";
12
12
  function fakeApp() {
13
13
  const inserts: Record<string, unknown[]> = { rounds: [], escalations: [] };
14
14
  const updates: Record<string, unknown[]> = { pull_requests: [] };
15
+ const rows: Record<string, Map<string, unknown>> = {};
15
16
  const app = {
16
17
  data: {
17
18
  table(name: string, _key: string) {
19
+ const store = (rows[name] ??= new Map());
18
20
  return {
21
+ // deno-lint-ignore require-await
22
+ async get(key: string) {
23
+ return store.get(key);
24
+ },
19
25
  // deno-lint-ignore require-await
20
26
  async insert(row: unknown) {
21
27
  (inserts[name] ??= []).push(row);
28
+ const pk = name === "escalations" ? "id" : name === "rounds" ? "id" : "pr_key";
29
+ // deno-lint-ignore no-explicit-any
30
+ store.set((row as any)[pk], row);
22
31
  return name === "escalations" ? 42 : 1;
23
32
  },
24
33
  // deno-lint-ignore require-await
@@ -29,7 +38,7 @@ function fakeApp() {
29
38
  },
30
39
  },
31
40
  };
32
- return { app, inserts, updates };
41
+ return { app, inserts, updates, rows };
33
42
  }
34
43
 
35
44
  Deno.test("stalled arm (recordRound=false) does not insert a duplicate rounds row", async () => {
@@ -55,8 +64,39 @@ Deno.test("escalation arm without the flag still records the round", async () =>
55
64
  assertEquals((inserts.rounds[0] as any).round_no, 3);
56
65
  });
57
66
 
58
- // A blank question would have to be trimmed away, so a padded-but-non-blank question is stored
59
- // trimmed (matching the sibling `persist-task-escalation`) no whitespace drift into the UI/DB.
67
+ // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
68
+ // desync), persist-escalation reconstructs the `pull_requests` row before the rounds/escalations
69
+ // inserts so opening an escalation never dies with an opaque FOREIGN KEY constraint failure.
70
+ Deno.test("persist-escalation heals a missing pull_requests parent before recording", async () => {
71
+ const { app, inserts, updates } = fakeApp();
72
+ const job = {
73
+ variables: {
74
+ prKey: "o/r#8",
75
+ round: 4,
76
+ status: "blocked",
77
+ question: "max rounds",
78
+ repo: "o/r",
79
+ prNumber: 8,
80
+ },
81
+ };
82
+ // deno-lint-ignore no-explicit-any
83
+ await handler(job as any, app as any);
84
+ assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
85
+ // Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
86
+ // fake update() doesn't apply patches, so the row would otherwise still read the insert's
87
+ // "converging" status and mask the worker's real final state.
88
+ // deno-lint-ignore no-explicit-any
89
+ const healed = inserts.pull_requests![0] as any;
90
+ assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
91
+ assertEquals(inserts.rounds.length, 1, "the round is still recorded");
92
+ assertEquals(inserts.escalations.length, 1, "the escalation is still opened");
93
+ // And the worker still moves the (now-present) PR to escalated as its final state.
94
+ assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
95
+ // deno-lint-ignore no-explicit-any
96
+ assertEquals((updates.pull_requests![0] as any).patch.status, "escalated");
97
+ });
98
+
99
+
60
100
  Deno.test("a padded question is persisted trimmed (no whitespace drift)", async () => {
61
101
  const { app, inserts, updates } = fakeApp();
62
102
  const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
@@ -117,3 +157,31 @@ Deno.test("unclassified status without a question names the status in the fabric
117
157
  assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
118
158
  assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
119
159
  });
160
+
161
+ // When repo/prNumber process variables are absent the heal still runs by parsing the canonical
162
+ // `owner/repo#N` prKey, so the escalation's FK parent is never left unguarded.
163
+ Deno.test("persist-escalation heals from the prKey when repo/prNumber are absent", async () => {
164
+ const { app, inserts } = fakeApp();
165
+ const job = {
166
+ variables: {
167
+ prKey: "o/r#12",
168
+ round: 2,
169
+ status: "needs_input",
170
+ question: "decide",
171
+ abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
172
+ },
173
+ };
174
+ // deno-lint-ignore no-explicit-any
175
+ await handler(job as any, app as any);
176
+ assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
177
+ // deno-lint-ignore no-explicit-any
178
+ const healed = inserts.pull_requests![0] as any;
179
+ assertEquals(healed.repo, "o/r");
180
+ assertEquals(healed.number, 12);
181
+ assertEquals(healed.url, "https://github.com/o/r/pull/12", "URL is derived from the parsed prKey");
182
+ assertEquals(
183
+ healed.abandon_token,
184
+ "TOK-en_123",
185
+ "the running agent's abandon token is preserved from abandonUrl, not re-minted",
186
+ );
187
+ });
@@ -12,13 +12,22 @@ import handler from "../workers/persist-round/worker.ts";
12
12
  function fakeApp() {
13
13
  const inserts: Record<string, unknown[]> = { rounds: [] };
14
14
  const updates: Record<string, unknown[]> = { pull_requests: [] };
15
+ const rows: Record<string, Map<string, unknown>> = {};
15
16
  const app = {
16
17
  data: {
17
18
  table(name: string, _key: string) {
19
+ const store = (rows[name] ??= new Map());
18
20
  return {
21
+ // deno-lint-ignore require-await
22
+ async get(key: string) {
23
+ return store.get(key);
24
+ },
19
25
  // deno-lint-ignore require-await
20
26
  async insert(row: unknown) {
21
27
  (inserts[name] ??= []).push(row);
28
+ const pk = name === "rounds" ? "id" : "pr_key";
29
+ // deno-lint-ignore no-explicit-any
30
+ store.set((row as any)[pk], row);
22
31
  return 1;
23
32
  },
24
33
  // deno-lint-ignore require-await
@@ -29,7 +38,7 @@ function fakeApp() {
29
38
  },
30
39
  },
31
40
  };
32
- return { app, inserts, updates };
41
+ return { app, inserts, updates, rows };
33
42
  }
34
43
 
35
44
  for (const status of ["addressed", "waiting"]) {
@@ -53,7 +62,38 @@ for (const status of ["addressed", "waiting"]) {
53
62
  });
54
63
  }
55
64
 
56
- // When the agent omits `status` (e.g. a fallback), persist-round defaults it to `addressed`
65
+ // When the convergence-loop passes repo/prNumber and the FK parent is missing (engine/app.db
66
+ // desync), persist-round reconstructs the `pull_requests` row before recording the round so the
67
+ // insert never dies with an opaque FOREIGN KEY constraint failure.
68
+ Deno.test("persist-round heals a missing pull_requests parent before recording the round", async () => {
69
+ const { app, inserts, updates } = fakeApp();
70
+ const job = {
71
+ variables: {
72
+ prKey: "o/r#7",
73
+ round: 3,
74
+ status: "addressed",
75
+ repo: "o/r",
76
+ prNumber: 7,
77
+ },
78
+ };
79
+ // deno-lint-ignore no-explicit-any
80
+ await handler(job as any, app as any);
81
+
82
+ assertEquals(inserts.pull_requests?.length, 1, "the missing parent is reconstructed");
83
+ // Assert against the reconstruction insert payload (ensurePr) rather than the stored row: the
84
+ // fake update() doesn't apply patches, so the row would otherwise still read the insert's
85
+ // "converging" status and mask the worker's real final state.
86
+ // deno-lint-ignore no-explicit-any
87
+ const healed = inserts.pull_requests![0] as any;
88
+ assertEquals(healed.status, "converging", "the healed parent starts in the converging aggregate");
89
+ assertEquals(healed.url, "https://github.com/o/r/pull/7", "URL is derived canonically");
90
+ assertEquals(inserts.rounds.length, 1, "the round is still recorded after the heal");
91
+ // And the worker still parks the (now-present) PR in waiting_review as its final state.
92
+ assertEquals(updates.pull_requests!.length, 1, "the PR is updated once after the heal");
93
+ // deno-lint-ignore no-explicit-any
94
+ assertEquals((updates.pull_requests![0] as any).patch.status, "waiting_review");
95
+ });
96
+
57
97
  // rather than writing a NULL status — the round history stays readable.
58
98
  Deno.test("persist-round defaults a missing status to 'addressed'", async () => {
59
99
  const { app, inserts } = fakeApp();
@@ -63,3 +103,31 @@ Deno.test("persist-round defaults a missing status to 'addressed'", async () =>
63
103
  // deno-lint-ignore no-explicit-any
64
104
  assertEquals((inserts.rounds[0] as any).status, "addressed");
65
105
  });
106
+
107
+ // When repo/prNumber process variables are absent (an older in-flight instance, or a regression)
108
+ // the heal still runs by parsing the canonical `owner/repo#N` prKey, so the FK-child insert is
109
+ // never left unguarded.
110
+ Deno.test("persist-round heals from the prKey when repo/prNumber are absent", async () => {
111
+ const { app, inserts } = fakeApp();
112
+ const job = {
113
+ variables: {
114
+ prKey: "o/r#12",
115
+ round: 2,
116
+ status: "addressed",
117
+ abandonUrl: "https://host/hooks/abandon?token=TOK-en_123",
118
+ },
119
+ };
120
+ // deno-lint-ignore no-explicit-any
121
+ await handler(job as any, app as any);
122
+ assertEquals(inserts.pull_requests?.length, 1, "the parent is reconstructed from the prKey");
123
+ // deno-lint-ignore no-explicit-any
124
+ const healed = inserts.pull_requests![0] as any;
125
+ assertEquals(healed.repo, "o/r");
126
+ assertEquals(healed.number, 12);
127
+ assertEquals(healed.url, "https://github.com/o/r/pull/12", "URL is derived from the parsed prKey");
128
+ assertEquals(
129
+ healed.abandon_token,
130
+ "TOK-en_123",
131
+ "the running agent's abandon token is preserved from abandonUrl, not re-minted",
132
+ );
133
+ });
@@ -6,7 +6,7 @@
6
6
  // loop already guards in `startPlan`). Drives `submitPr` against an in-memory data layer with the
7
7
  // GitHub transport forced off so it is hermetic.
8
8
  import { assertEquals } from "jsr:@std/assert@1";
9
- import { pollIncidentsImpl, submitPr } from "./service.ts";
9
+ import { cancelRun, pollIncidentsImpl, submitPr } from "./service.ts";
10
10
 
11
11
  // deno-lint-ignore no-explicit-any
12
12
  function memTable(rows: any[], key: string) {
@@ -251,3 +251,77 @@ Deno.test("pollIncidents picks the oldest incident by creationTime, sorting a mi
251
251
  assertEquals(row.incident_key, "INC-OLD");
252
252
  assertEquals(row.incident_message, "the first fault");
253
253
  });
254
+
255
+
256
+ // Bug: the Epic cancel button (Nano Workforce UI) POSTs the plan row's `process_key` to
257
+ // /app/actions/cancel → cancelRun. cancelRun only knows the `pull_requests` table, so for a plan
258
+ // instance it terminated the engine instance but returned `not_found` (a 404 the UI surfaces as an
259
+ // error), and never reconciled the `plans` row — so "cancel didn't cancel the epic". The instance
260
+ // IS torn down; the declarative instanceTracking reconciler flips the plans row. cancelRun must
261
+ // therefore report success for a raw instance key it terminated.
262
+ Deno.test("cancelRun terminates a non-PR (Epic/plan) instance and reports success", async () => {
263
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
264
+ pull_requests: { rows: [], key: "pr_key" }, // no PR tracks this key — it's a plan instance
265
+ };
266
+ const data = {
267
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
268
+ // deno-lint-ignore no-explicit-any
269
+ } as any;
270
+ const cancelled: string[] = [];
271
+ const engine = {
272
+ // deno-lint-ignore no-explicit-any
273
+ cancelInstance: (input: any) => {
274
+ cancelled.push(String(input.processInstanceKey));
275
+ return Promise.resolve();
276
+ },
277
+ // deno-lint-ignore no-explicit-any
278
+ } as any;
279
+
280
+ const r = await cancelRun(data, engine, { processInstanceKey: "PI-EPIC-1" });
281
+
282
+ assertEquals(cancelled, ["PI-EPIC-1"]); // the engine instance was terminated …
283
+ assertEquals(r.ok, true); // … and cancel is reported successful (no misleading 404).
284
+ });
285
+
286
+ // The tracked-PR path must still flip the row abandoned SYNCHRONOUSLY: app/abandon.ts derives the
287
+ // agent-abort signal straight off pull_requests.status, so a deferred (reconciler-only) write would
288
+ // widen the check-then-push window a side-effecting agent races against.
289
+ Deno.test("cancelRun flips a tracked PR to abandoned immediately and clears the escalation pointer", async () => {
290
+ const PR_KEY = "owner/repo#7";
291
+ const stores: Record<string, { rows: unknown[]; key: string }> = {
292
+ pull_requests: {
293
+ rows: [{
294
+ pr_key: PR_KEY,
295
+ repo: "owner/repo",
296
+ number: 7,
297
+ status: "escalated",
298
+ process_key: "PI-PR-7",
299
+ open_escalation_id: 3,
300
+ open_escalation_question: "why?",
301
+ }],
302
+ key: "pr_key",
303
+ },
304
+ };
305
+ const data = {
306
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
307
+ // deno-lint-ignore no-explicit-any
308
+ } as any;
309
+ const cancelled: string[] = [];
310
+ const engine = {
311
+ // deno-lint-ignore no-explicit-any
312
+ cancelInstance: (input: any) => {
313
+ cancelled.push(String(input.processInstanceKey));
314
+ return Promise.resolve();
315
+ },
316
+ // deno-lint-ignore no-explicit-any
317
+ } as any;
318
+
319
+ const r = await cancelRun(data, engine, { prKey: PR_KEY });
320
+
321
+ assertEquals(r.ok, true);
322
+ assertEquals(cancelled, ["PI-PR-7"]);
323
+ const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
324
+ assertEquals(pr.status, "abandoned");
325
+ assertEquals(pr.open_escalation_id, null);
326
+ assertEquals(pr.open_escalation_question, null);
327
+ });
package/app/service.ts CHANGED
@@ -168,6 +168,64 @@ export interface ParsedPr {
168
168
  prKey: string;
169
169
  }
170
170
 
171
+ /** Canonical GitHub PR URL for a repo + number. Matches the `url` `parsePr` derives, so a
172
+ * reconstructed row is indistinguishable from one registered at submit time. */
173
+ export function canonicalPrUrl(repo: string, number: number): string {
174
+ return `https://github.com/${repo}/pull/${number}`;
175
+ }
176
+
177
+ /**
178
+ * Guarantee the `pull_requests` parent row exists before a FK-child row (`rounds`, `escalations`,
179
+ * `merges`) is written. Idempotent and race-safe.
180
+ *
181
+ * The durable engine and the app's `app.db` are two INDEPENDENT stores. If they ever desync — the
182
+ * DB is rebuilt, restored from a stale copy, or simply lags a live process instance while running
183
+ * from a local checkout — a persist/finalize/merge job would otherwise insert a child row whose
184
+ * FK parent is absent and die with an opaque `FOREIGN KEY constraint failed` incident that a human
185
+ * has to hand-resolve (observed on convergence-loop instance 94). The PR's `repo`/`prNumber` are
186
+ * normally carried as process variables; where they may be absent (older in-flight instances) the
187
+ * call sites derive them from the canonical `owner/repo#N` prKey, so the parent can always be
188
+ * reconstructed deterministically: we heal the missing row (a minimal `converging` aggregate) and
189
+ * let the loop continue instead of parking a dead-end incident. Callers pass the instance's
190
+ * `abandonToken` (recovered from its `abandonUrl` var) so the healed row keeps the token the
191
+ * running agent was handed and its cooperative-abort check keeps resolving. When the row already
192
+ * exists this is a no-op, so the healthy path is unchanged.
193
+ */
194
+ export async function ensurePr(
195
+ data: DataLayer,
196
+ pr: { prKey: string; repo: string; number: number; url?: string; round?: number; abandonToken?: string },
197
+ ): Promise<void> {
198
+ const table = prs(data);
199
+ if (await table.get(pr.prKey)) return;
200
+ const ts = now();
201
+ console.warn(
202
+ `[ensurePr] pull_requests row for ${pr.prKey} was missing — reconstructing it so the FK-child ` +
203
+ `write can proceed (engine/app.db desync heal)`,
204
+ );
205
+ try {
206
+ await table.insert({
207
+ pr_key: pr.prKey,
208
+ repo: pr.repo,
209
+ number: pr.number,
210
+ url: pr.url ?? canonicalPrUrl(pr.repo, pr.number),
211
+ status: "converging",
212
+ current_round: pr.round ?? 1,
213
+ // Reuse the token the running agent was already handed (recovered from the instance's
214
+ // `abandonUrl` var) so its abort check keeps resolving; only mint a fresh one when the caller
215
+ // has no token to preserve (e.g. an old instance predating the `abandonUrl` variable).
216
+ abandon_token: pr.abandonToken ?? mintAbandonToken(),
217
+ created_at: ts,
218
+ updated_at: ts,
219
+ });
220
+ } catch (err) {
221
+ // Lost a race with a concurrent writer (submitPr or another job created the row first): the
222
+ // parent now exists, which is exactly the goal, so swallow. Only a still-absent row is a real
223
+ // failure worth surfacing.
224
+ if (await table.get(pr.prKey)) return;
225
+ throw err;
226
+ }
227
+ }
228
+
171
229
  /** Parse "owner/repo#123" or a canonical PR URL into its parts. */
172
230
  export function parsePr(input: string): ParsedPr | null {
173
231
  const s = input.trim();
@@ -388,11 +446,21 @@ export interface CancelSelector {
388
446
  prKey?: string;
389
447
  }
390
448
 
391
- /** Cancel a PR's running convergence instance and mark it abandoned. Terminating the engine
392
- * instance emits no completion event (no worker runs), so the app-tier flips the PR's status
393
- * here the same place ADR 0040 puts app-owned rest state. Accepts either selector; a PR
394
- * already in a terminal state is left untouched so a stale cancel can't overwrite a `converged`
395
- * outcome with `abandoned`. */
449
+ /** Cancel a run and mark its read-model row abandoned. Two paths converge here:
450
+ *
451
+ * - **Tracked PR** (a `pull_requests` row): the engine instance is terminated (which emits no
452
+ * completion event no worker runs) and this function flips the PR row to `abandoned`
453
+ * *synchronously*. The immediacy matters: the agent-abort capability (`app/abandon.ts`) derives
454
+ * `abandoned` straight off `pull_requests.status`, so a deferred write would widen the
455
+ * check-then-push window a side-effecting agent races against.
456
+ * - **Any other instance** (e.g. the cancel button on an Epic/plan row, which POSTs the row's
457
+ * `process_key`): the instance is terminated here, and the declarative `instanceTracking`
458
+ * reconciler (`nano.app.json`) flips the owning row (`plans`) to abandoned on its next poll.
459
+ * That same reconciler is also the safety net for terminations that never reach this function
460
+ * at all — an operator terminating the instance directly, or a crash.
461
+ *
462
+ * Accepts either selector; a PR already in a terminal state is left untouched so a stale cancel
463
+ * can't overwrite a `converged` outcome with `abandoned`. */
396
464
  export async function cancelRun(data: DataLayer, engine: EngineClient, selector: CancelSelector) {
397
465
  const { processInstanceKey, prKey } = selector;
398
466
  const table = prs(data);
@@ -423,6 +491,14 @@ export async function cancelRun(data: DataLayer, engine: EngineClient, selector:
423
491
  });
424
492
  return { ok: true, prKey: pr.pr_key };
425
493
  }
494
+ // No tracked PR for this key. If we were handed a raw instance key (e.g. the cancel button
495
+ // on an Epic/plan row, which POSTs the row's `process_key`), the instance has still been
496
+ // terminated above — the declarative `instanceTracking` reconciler (nano.app.json) flips the
497
+ // owning row (`plans`) to abandoned on its next poll. Report success so the UI does not
498
+ // surface a misleading 404 for a cancel that actually took effect.
499
+ if (instanceKey) {
500
+ return { ok: true, processInstanceKey: instanceKey };
501
+ }
426
502
  return { ok: false, kind: "not_found", reason: "no PR for that selector" };
427
503
  }
428
504
 
package/deno.json CHANGED
@@ -6,7 +6,7 @@
6
6
  ]
7
7
  },
8
8
  "imports": {
9
- "@nanobpm/urban": "npm:@nanobpm/urban@^0.28.0"
9
+ "@nanobpm/urban": "npm:@nanobpm/urban@^0.29.0"
10
10
  },
11
11
  "tasks": {
12
12
  "start": "deno run --allow-net --allow-read --allow-write --allow-run=gh --allow-env main.ts",
package/deno.lock CHANGED
@@ -3,7 +3,7 @@
3
3
  "specifiers": {
4
4
  "jsr:@std/assert@1": "1.0.19",
5
5
  "jsr:@std/internal@^1.0.12": "1.0.14",
6
- "npm:@nanobpm/urban@0.28": "0.28.0",
6
+ "npm:@nanobpm/urban@0.29": "0.29.0",
7
7
  "npm:@semantic-release/changelog@^6.0.3": "6.0.3_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
8
8
  "npm:@semantic-release/git@^10.0.1": "10.0.1_semantic-release@24.2.9__typescript@5.9.3_typescript@5.9.3",
9
9
  "npm:@semantic-release/npm@^13.1.5": "13.1.5_semantic-release@24.2.9__typescript@5.9.3",
@@ -67,8 +67,8 @@
67
67
  "@colors/colors@1.5.0": {
68
68
  "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="
69
69
  },
70
- "@nanobpm/nano-app-schema@0.3.0": {
71
- "integrity": "sha512-bz7wqYsfetcSMYIMW2e1rmbE2k844TCtXWwBd+uVV/s3iRsi09Ff0WMaBEZWuvBPopwHhD2JATCNCXdEBULjfA==",
70
+ "@nanobpm/nano-app-schema@0.4.0": {
71
+ "integrity": "sha512-/1rXzXVDVglqjNoqrxJ0FjBUKqej3q8R+/3xXXU3cdwRO9QKwDkd3/2K+I0gS1bAiqNI8SWZiH9+pkKiT7I8wQ==",
72
72
  "dependencies": [
73
73
  "bpmn-moddle@9.0.4",
74
74
  "dmn-moddle",
@@ -82,8 +82,8 @@
82
82
  "ws"
83
83
  ]
84
84
  },
85
- "@nanobpm/urban@0.28.0": {
86
- "integrity": "sha512-rhzgOV+Vb1CPuNoVHVX3OtxjHqzSp2gJIV2pi3EBi245ZjDRN0gSoPGd/MEnJdufxgWwDKuvhyljRDHE8T957w==",
85
+ "@nanobpm/urban@0.29.0": {
86
+ "integrity": "sha512-79CMeYpfnPmKSF2x7zRJ6ZcmAimOknCxySFXvg2z0JtS/pTGlUvkzZy87xp5jHB88LZdKsUIuvVnN5hnZKdS+g==",
87
87
  "dependencies": [
88
88
  "@nanobpm/nano-app-schema",
89
89
  "@nanobpm/nano-sdk",
@@ -1757,11 +1757,11 @@
1757
1757
  },
1758
1758
  "workspace": {
1759
1759
  "dependencies": [
1760
- "npm:@nanobpm/urban@0.28"
1760
+ "npm:@nanobpm/urban@0.29"
1761
1761
  ],
1762
1762
  "packageJson": {
1763
1763
  "dependencies": [
1764
- "npm:@nanobpm/urban@0.28",
1764
+ "npm:@nanobpm/urban@0.29",
1765
1765
  "npm:@semantic-release/changelog@^6.0.3",
1766
1766
  "npm:@semantic-release/git@^10.0.1",
1767
1767
  "npm:@semantic-release/npm@^13.1.5",
package/nano.app.json CHANGED
@@ -22,6 +22,43 @@
22
22
  }
23
23
  }
24
24
  },
25
+ "instanceTracking": [
26
+ {
27
+ "table": "pull_requests",
28
+ "keyField": "process_key",
29
+ "statusField": "status",
30
+ "activeStatuses": [
31
+ "converging",
32
+ "waiting_review",
33
+ "escalated",
34
+ "waiting_deps",
35
+ "waiting_merge",
36
+ "waiting_lane",
37
+ "queued",
38
+ "merging"
39
+ ],
40
+ "onTerminated": {
41
+ "set": {
42
+ "status": "abandoned",
43
+ "open_escalation_id": null,
44
+ "open_escalation_question": null
45
+ }
46
+ },
47
+ "pollMs": 5000
48
+ },
49
+ {
50
+ "table": "plans",
51
+ "keyField": "process_key",
52
+ "statusField": "status",
53
+ "activeStatuses": ["planning", "dispatched"],
54
+ "onTerminated": {
55
+ "set": {
56
+ "status": "abandoned"
57
+ }
58
+ },
59
+ "pollMs": 5000
60
+ }
61
+ ],
25
62
  "workers": [
26
63
  {
27
64
  "taskType": "pr.persist-round",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.32.0",
3
+ "version": "0.32.2",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -40,7 +40,7 @@
40
40
  "test": "deno test -A"
41
41
  },
42
42
  "dependencies": {
43
- "@nanobpm/urban": "^0.28.0"
43
+ "@nanobpm/urban": "^0.29.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@semantic-release/changelog": "^6.0.3",
@@ -2,7 +2,8 @@
2
2
  // merge stage (start the `merge-loop` process and park the PR in `waiting_deps`) when auto-merge
3
3
  // is on, or (b) close the PR out as `converged` (review-only mode).
4
4
  import type { AppJobHandler } from "@nanobpm/urban";
5
- import { AUTO_MERGE, startMerge } from "../../app/service.ts";
5
+ import { AUTO_MERGE, ensurePr, startMerge } from "../../app/service.ts";
6
+ import { abandonTokenFromUrl } from "../../app/abandon.ts";
6
7
  import { maybeStartRetro } from "../../app/retro.ts";
7
8
 
8
9
  // Extends Record so the declared fields are typed while the job may still carry
@@ -14,6 +15,9 @@ interface In extends Record<string, unknown> {
14
15
  prUrl: string;
15
16
  round: number;
16
17
  summary?: string;
18
+ // The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
19
+ // the agent's cooperative-abort check keeps resolving (see ensurePr).
20
+ abandonUrl?: string;
17
21
  }
18
22
 
19
23
  const AGENT_RESULT_KEY = "io.nanobpm.agentResult";
@@ -26,9 +30,19 @@ const handler: AppJobHandler<In> = async (job, app) => {
26
30
  // `summary` is left undefined when absent so the write boundary omits it: the
27
31
  // nullable `rounds.summary` stays NULL and `pull_requests.outcome` is untouched
28
32
  // rather than being coerced to "".
29
- const { prKey, repo, prNumber, prUrl, round, summary } = job.variables;
33
+ const { prKey, repo, prNumber, prUrl, round, summary, abandonUrl } = job.variables;
30
34
  const now = new Date().toISOString();
31
35
 
36
+ // Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert.
37
+ await ensurePr(app.data, {
38
+ prKey,
39
+ repo,
40
+ number: prNumber,
41
+ url: prUrl,
42
+ round,
43
+ abandonToken: abandonTokenFromUrl(abandonUrl),
44
+ });
45
+
32
46
  await app.data.table("rounds", "id").insert({
33
47
  pr_key: prKey,
34
48
  round_no: round,
@@ -10,7 +10,8 @@
10
10
  // shapes the escalation payload on a block.
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
12
  import { enqueueViaComment, mergePr } from "../../app/github.ts";
13
- import { MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
13
+ import { MERGE_ADMIN, MERGE_METHOD, ensurePr } from "../../app/service.ts";
14
+ import { abandonTokenFromUrl } from "../../app/abandon.ts";
14
15
  import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
15
16
  import { checkBaseTarget } from "../../app/baseGuard.ts";
16
17
 
@@ -18,6 +19,12 @@ interface In extends Record<string, unknown> {
18
19
  prKey: string;
19
20
  repo: string;
20
21
  prNumber: number;
22
+ // Carried by the merge-loop instance (startMerge sets it to the converged round); passed to
23
+ // the heal so a reconstructed row reflects the real round, not a 1-based default.
24
+ round?: number;
25
+ // The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
26
+ // the agent's cooperative-abort check keeps resolving (see ensurePr).
27
+ abandonUrl?: string;
21
28
  }
22
29
 
23
30
  interface Out extends Record<string, unknown> {
@@ -27,10 +34,23 @@ interface Out extends Record<string, unknown> {
27
34
  }
28
35
 
29
36
  const handler: AppJobHandler<In, Out> = async (job, app) => {
30
- const { prKey, repo, prNumber } = job.variables;
37
+ const { prKey, repo, prNumber, round, abandonUrl } = job.variables;
31
38
  const token = process.env.GITHUB_TOKEN ?? "";
32
39
  const now = new Date().toISOString();
33
40
 
41
+ // Heal a missing FK parent (engine/app.db desync) before any child `merges` insert below so a
42
+ // land attempt never dies with an opaque `FOREIGN KEY constraint failed` incident. The merge-loop
43
+ // instance carries repo+prNumber (and the converged round) but not prUrl; ensurePr derives the
44
+ // canonical URL from them, keeps the healed round faithful, and preserves the agent's abandon
45
+ // token so its cooperative-abort check keeps resolving.
46
+ await ensurePr(app.data, {
47
+ prKey,
48
+ repo,
49
+ number: prNumber,
50
+ round,
51
+ abandonToken: abandonTokenFromUrl(abandonUrl),
52
+ });
53
+
34
54
  // Dead-end-base guard (#60): never land a PR into a base branch that has itself already merged
35
55
  // to the default branch — the merge would land into a dead branch and never reach `main`.
36
56
  // GitHub only auto-retargets a PR when its base is *deleted* on merge; a merged-but-undeleted
@@ -3,6 +3,8 @@
3
3
  // and the MAX_ROUNDS guard (status = blocked, question set by the process). Returns
4
4
  // `escalationId` for the UI.
5
5
  import type { AppJobHandler } from "@nanobpm/urban";
6
+ import { ensurePr, parsePr } from "../../app/service.ts";
7
+ import { abandonTokenFromUrl } from "../../app/abandon.ts";
6
8
 
7
9
  // Extends Record so the declared fields are typed while the job may still carry
8
10
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -12,6 +14,14 @@ interface In extends Record<string, unknown> {
12
14
  status?: string;
13
15
  summary?: string;
14
16
  question?: string;
17
+ // Carried by the convergence-loop instance so a missing `pull_requests` parent can be
18
+ // reconstructed before the FK-child `rounds`/`escalations` inserts.
19
+ repo?: string;
20
+ prNumber?: number;
21
+ prUrl?: string;
22
+ // The per-PR abandon capability URL the agent was handed; its token is preserved on a heal so
23
+ // the agent's cooperative-abort check keeps resolving (see ensurePr).
24
+ abandonUrl?: string;
15
25
  // False on the "review stalled" arm: `persist-round` already recorded this `round` as
16
26
  // `addressed`, so this escalation must not insert a second `rounds` row for the same
17
27
  // `pr_key`/`round_no` (which would record one round as both addressed and blocked). Absent
@@ -52,7 +62,7 @@ function fabricateQuestion(rawStatus: string | undefined, hasTranscript: boolean
52
62
  }
53
63
 
54
64
  const handler: AppJobHandler<In> = async (job, app) => {
55
- const { prKey, round, summary } = job.variables;
65
+ const { prKey, round, summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
56
66
  // `status` drives the escalation kind (control flow); a blank/absent status is an
57
67
  // unclassified escalation -> a question needing input. `question` is denormalised
58
68
  // onto pull_requests below and bound by the UI answer form, so it must be a
@@ -71,6 +81,25 @@ const handler: AppJobHandler<In> = async (job, app) => {
71
81
  const kind = status === "needs_input" ? "question" : "blocker";
72
82
  const now = new Date().toISOString();
73
83
 
84
+ // Heal a missing FK parent (engine/app.db desync) before the child `rounds`/`escalations`
85
+ // inserts so this never dies with an opaque `FOREIGN KEY constraint failed` incident. Prefer
86
+ // the carried repo/prNumber; if either is missing (an older in-flight instance, or a
87
+ // process-variable regression) fall back to parsing them out of the canonical `owner/repo#N`
88
+ // prKey so the heal still runs.
89
+ const parsed = parsePr(prKey);
90
+ const healRepo = repo ?? parsed?.repo;
91
+ const healNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
92
+ if (healRepo && typeof healNumber === "number") {
93
+ await ensurePr(app.data, {
94
+ prKey,
95
+ repo: healRepo,
96
+ number: healNumber,
97
+ url: prUrl,
98
+ round,
99
+ abandonToken: abandonTokenFromUrl(abandonUrl),
100
+ });
101
+ }
102
+
74
103
  // Skip the round insert when the caller already recorded this round (the "review stalled"
75
104
  // arm runs after `persist-round`): re-inserting would duplicate the `pr_key`/`round_no` row.
76
105
  if (job.variables.recordRound !== false) {
@@ -5,6 +5,8 @@
5
5
  // Data access goes through the injected app datasource gateway (`app.data.table<T>`), the RAD
6
6
  // `Table<T>` surface — `rounds.insert(...)` / `pull_requests.update(...)`, not hand-written SQL.
7
7
  import type { AppJobHandler } from "@nanobpm/urban";
8
+ import { ensurePr, parsePr } from "../../app/service.ts";
9
+ import { abandonTokenFromUrl } from "../../app/abandon.ts";
8
10
 
9
11
  // Extends Record so the declared fields are typed while the job may still carry
10
12
  // other process variables (e.g. io.nanobpm.agentResult, read by transcriptOf).
@@ -13,6 +15,14 @@ interface In extends Record<string, unknown> {
13
15
  round: number;
14
16
  status?: string;
15
17
  summary?: string;
18
+ // Carried by the convergence-loop instance (set at createInstance) so a missing
19
+ // `pull_requests` parent can be reconstructed before the FK-child `rounds` insert.
20
+ repo?: string;
21
+ prNumber?: number;
22
+ prUrl?: string;
23
+ // The per-PR abandon capability URL the review agent was handed; its token is preserved on a
24
+ // heal so the agent's cooperative-abort check keeps resolving (see ensurePr).
25
+ abandonUrl?: string;
16
26
  }
17
27
 
18
28
  // The harness records the agent's full (byte-capped) stdout on the result envelope; keep it
@@ -27,9 +37,28 @@ const handler: AppJobHandler<In> = async (job, app) => {
27
37
  // This worker is the "addressed"/"waiting" path, so `status` resolves to one of those
28
38
  // domain values. `summary` is left undefined when absent: the write boundary omits it so the
29
39
  // nullable `rounds.summary` column stays NULL rather than being coerced to "".
30
- const { prKey, round, status = "addressed", summary } = job.variables;
40
+ const { prKey, round, status = "addressed", summary, repo, prNumber, prUrl, abandonUrl } = job.variables;
31
41
  const now = new Date().toISOString();
32
42
 
43
+ // Heal a missing FK parent (engine/app.db desync) before the child `rounds` insert so this
44
+ // never dies with an opaque `FOREIGN KEY constraint failed` incident. Prefer the carried
45
+ // repo/prNumber; if either is missing (an older in-flight instance, or a process-variable
46
+ // regression) fall back to parsing them out of the canonical `owner/repo#N` prKey so the heal
47
+ // still runs.
48
+ const parsed = parsePr(prKey);
49
+ const healRepo = repo ?? parsed?.repo;
50
+ const healNumber = typeof prNumber === "number" ? prNumber : parsed?.number;
51
+ if (healRepo && typeof healNumber === "number") {
52
+ await ensurePr(app.data, {
53
+ prKey,
54
+ repo: healRepo,
55
+ number: healNumber,
56
+ url: prUrl,
57
+ round,
58
+ abandonToken: abandonTokenFromUrl(abandonUrl),
59
+ });
60
+ }
61
+
33
62
  await app.data.table("rounds", "id").insert({
34
63
  pr_key: prKey,
35
64
  round_no: round,