@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 +14 -0
- package/app/abandon.test.ts +16 -0
- package/app/abandon.ts +15 -0
- package/app/ensure-pr.test.ts +117 -0
- package/app/instance-tracking.test.ts +73 -0
- package/app/persist-escalation.test.ts +71 -3
- package/app/persist-round.test.ts +70 -2
- package/app/service.test.ts +75 -1
- package/app/service.ts +81 -5
- package/deno.json +1 -1
- package/deno.lock +7 -7
- package/nano.app.json +37 -0
- package/package.json +2 -2
- package/workers/finalize/worker.ts +16 -2
- package/workers/merge/worker.ts +22 -2
- package/workers/persist-escalation/worker.ts +30 -1
- package/workers/persist-round/worker.ts +30 -1
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
|
|
package/app/abandon.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
59
|
-
//
|
|
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
|
|
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
|
+
});
|
package/app/service.test.ts
CHANGED
|
@@ -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
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
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
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.
|
|
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.
|
|
71
|
-
"integrity": "sha512
|
|
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.
|
|
86
|
-
"integrity": "sha512-
|
|
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.
|
|
1760
|
+
"npm:@nanobpm/urban@0.29"
|
|
1761
1761
|
],
|
|
1762
1762
|
"packageJson": {
|
|
1763
1763
|
"dependencies": [
|
|
1764
|
-
"npm:@nanobpm/urban@0.
|
|
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.
|
|
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.
|
|
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,
|
package/workers/merge/worker.ts
CHANGED
|
@@ -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,
|