@nanobpm/nano-workforce 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +7 -0
  4. package/README.md +1 -1
  5. package/SPEC.md +21 -22
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/answer-escalation.test.ts +106 -0
  9. package/app/answerEscalation.test.ts +67 -0
  10. package/app/baseGuard.test.ts +9 -1
  11. package/app/baseGuard.ts +11 -0
  12. package/app/escalationSla.test.ts +39 -0
  13. package/app/escalationSla.ts +28 -0
  14. package/app/escalationTaxonomy.test.ts +115 -0
  15. package/app/escalationTaxonomy.ts +115 -0
  16. package/app/feature.test.ts +161 -0
  17. package/app/feature.ts +173 -0
  18. package/app/mergeProtocol.test.ts +15 -0
  19. package/app/mergeProtocol.ts +10 -0
  20. package/app/persist-escalation.test.ts +34 -36
  21. package/app/plan.test.ts +0 -294
  22. package/app/plan.ts +26 -216
  23. package/app/reviewWait.ts +12 -4
  24. package/app/roundResultDefault.test.ts +111 -2
  25. package/app/roundResultDefault.ts +35 -0
  26. package/app/service.test.ts +6 -7
  27. package/app/service.ts +52 -35
  28. package/db/migrations/026_agent_completion.sql +36 -0
  29. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  30. package/db/migrations/028_feature_runs.sql +28 -0
  31. package/e2e/agent-answerable.e2e.ts +185 -0
  32. package/e2e/convergence-escalation.e2e.ts +180 -0
  33. package/e2e/convergence-loop.e2e.ts +1 -1
  34. package/e2e/feature-run.e2e.ts +231 -0
  35. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  36. package/e2e/plan-fanout.e2e.ts +303 -0
  37. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  38. package/e2e/support/github-admit.ts +99 -0
  39. package/e2e/user-task-spine.e2e.ts +155 -0
  40. package/nano.app.json +37 -11
  41. package/openapi.yaml +181 -73
  42. package/operations/agentCompleteEscalation.ts +53 -0
  43. package/operations/listActivePrs.test.ts +39 -6
  44. package/operations/postMessage.ts +10 -41
  45. package/operations/revertEscalationCompletion.ts +44 -0
  46. package/operations/startAndMessage.test.ts +0 -58
  47. package/operations/startFeature.ts +127 -0
  48. package/package.json +4 -1
  49. package/pages/cockpit.page.json +1 -0
  50. package/pages/epic-detail.page.json +11 -37
  51. package/pages/epic.page.json +1 -1
  52. package/pages/feature.page.json +82 -0
  53. package/pages/home.page.json +6 -18
  54. package/resources/agent-guide.md +52 -24
  55. package/resources/forms/feature-escalation.form +27 -0
  56. package/resources/forms/plan-review-decision.form +27 -0
  57. package/resources/forms/pr-escalation.form +23 -0
  58. package/resources/forms/spine-demo.form +15 -0
  59. package/resources/forms/trial-merge-decision.form +25 -0
  60. package/resources/processes/convergence-loop.bpmn +127 -75
  61. package/resources/processes/feature.bpmn +240 -0
  62. package/resources/processes/plan-fanout.bpmn +304 -223
  63. package/resources/processes/spine-demo.bpmn +72 -0
  64. package/scripts/check-migrations.ts +68 -0
  65. package/workers/answer-escalation/worker.ts +78 -0
  66. package/workers/converge-feature/worker.ts +51 -0
  67. package/workers/finalize/worker.ts +0 -2
  68. package/workers/mark-merged/worker.ts +0 -2
  69. package/workers/merge/worker.ts +6 -5
  70. package/workers/persist-escalation/worker.ts +28 -32
  71. package/workers/record-feature/worker.ts +61 -0
  72. package/workers/record-plan-review/worker.test.ts +9 -10
  73. package/workers/record-plan-review/worker.ts +15 -5
  74. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  75. package/workers/resolve-trial-attention/worker.ts +43 -0
  76. package/operations/answerFeatureEscalation.test.ts +0 -112
  77. package/operations/answerFeatureEscalation.ts +0 -58
  78. package/operations/answerPlanEscalation.test.ts +0 -115
  79. package/operations/answerPlanEscalation.ts +0 -41
  80. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  81. package/workers/persist-plan-escalation/worker.ts +0 -73
  82. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -0,0 +1,303 @@
1
+ // End-to-end proof for the plan-fanout escalations migrated to native user tasks + forms
2
+ // (epic #156, slice U2). Boots the whole app against the WASM engine and drives the REAL
3
+ // plan-fanout.bpmn to each of its three human-decision points — the implementation-phase task
4
+ // escalation, the plan-review cap escalation, and the trial-merge escalation — by registering stub
5
+ // `senior:*` agent workers that steer the process. At each park it asserts the escalation is a
6
+ // native `userTask`, then completes it via the engine's user-task API and asserts the TYPED
7
+ // completion variables routed the downstream decision gateway.
8
+ //
9
+ // The gateway assertions are the falsifiable core (mirroring the U0 spine): the WASM engine folds a
10
+ // completed instance's variables away, so we assert on the cumulative taken sequence flows instead
11
+ // — an empty/wrong completion would take a different (default) branch. Each scenario boots its own
12
+ // app so `takenSequenceFlows` (engine-global, cumulative) reflects exactly one instance's routing.
13
+ //
14
+ // Run with `npm run e2e`.
15
+
16
+ import assert from "node:assert/strict";
17
+ import { mkdtempSync, rmSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { after, before, describe, test } from "node:test";
21
+ import { fileURLToPath } from "node:url";
22
+ import type { EngineJob } from "@nanobpm/urban/runtime";
23
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
+ import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
+
26
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
27
+
28
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
29
+ NANO_PR_GITHUB_TRANSPORT: "token",
30
+ GITHUB_TOKEN: "",
31
+ };
32
+
33
+ interface InboxTask {
34
+ userTaskKey: string;
35
+ elementId?: string;
36
+ variables?: Record<string, unknown>;
37
+ }
38
+
39
+ interface TakenFlow {
40
+ from: string;
41
+ to: string;
42
+ }
43
+
44
+ function takenFlows(app: TestApp): string[] {
45
+ const snapshot = app.snapshot();
46
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
47
+ return flows
48
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
49
+ .map((f) => `${f.from}->${f.to}`);
50
+ }
51
+
52
+ /** A stub `senior:*` agent worker. Handlers are keyed by job type; each returns the process
53
+ * variables the real agent would emit. */
54
+ type Stub = (job: EngineJob) => Record<string, unknown> | void;
55
+
56
+ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → userTask + form)", () => {
57
+ const savedEnv = new Map<string, string | undefined>();
58
+ let restoreGithub: (() => void) | undefined;
59
+
60
+ before(() => {
61
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
62
+ savedEnv.set(k, process.env[k]);
63
+ process.env[k] = v;
64
+ }
65
+ // ADR 0003: `startPlanFanout` + the `pr.ensure-base-branch` head task now pass through base
66
+ // admission, which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
67
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
68
+ });
69
+
70
+ after(() => {
71
+ restoreGithub?.();
72
+ for (const [k, v] of savedEnv) {
73
+ if (v === undefined) delete process.env[k];
74
+ else process.env[k] = v;
75
+ }
76
+ });
77
+
78
+ /** Boot a fresh app (isolated engine + DB), register the given `senior:*` stubs, run `body`, and
79
+ * tear down. Each scenario gets its own engine so `takenSequenceFlows` is that run's alone. */
80
+ async function withApp(
81
+ stubs: Record<string, Stub>,
82
+ body: (ctx: { app: TestApp; planKey: string; processKey: string }) => Promise<void>,
83
+ ): Promise<void> {
84
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-u2-"));
85
+ const app = await bootTestApp(APP_ROOT, {
86
+ env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
87
+ });
88
+ try {
89
+ for (const [jobType, stub] of Object.entries(stubs)) {
90
+ await app.engine.registerWorker(jobType, async (job) => stub(job) ?? undefined);
91
+ }
92
+ const planKey = "owner/repo#1";
93
+ const started = await app.api?.call("startPlanFanout", { body: { issue: planKey, baseBranch: "epic/e2e" } });
94
+ assert.equal(started?.status, 202, "startPlanFanout accepted the issue");
95
+ await app.settle();
96
+ const plan = await app.db
97
+ .table<{ plan_key: string; process_key: string | null }>("plans", "plan_key")
98
+ .findOne({ plan_key: planKey });
99
+ assert.ok(plan?.process_key, "the plan row carries the engine process-instance key");
100
+ await body({ app, planKey, processKey: plan!.process_key! });
101
+ } finally {
102
+ await app.stop();
103
+ rmSync(dbDir, { recursive: true, force: true });
104
+ }
105
+ }
106
+
107
+ async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
108
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
109
+ const match = tasks.find((t) => t.elementId === elementId);
110
+ assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
111
+ return match!;
112
+ }
113
+
114
+ // A planner that emits a single independent task, and a reviewer that approves — the shortest
115
+ // path to the implementation phase where the task escalation lives.
116
+ const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
117
+ const approveReview: Stub = () => ({ approved: true, findings: "" });
118
+
119
+ test("task escalation: a native userTask parks the child; answering routes back to implement-task", async () => {
120
+ let featureCalls = 0;
121
+ await withApp(
122
+ {
123
+ "senior:plan": singleTaskPlan,
124
+ "senior:plan-review": approveReview,
125
+ "senior:feature": () => {
126
+ featureCalls += 1;
127
+ return featureCalls === 1
128
+ ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
129
+ : { status: "blocked", summary: "resumed after answer" };
130
+ },
131
+ },
132
+ async ({ app, processKey }) => {
133
+ const task = await openTask(app, processKey, "feature-escalation");
134
+ assert.ok(task.userTaskKey, "the feature escalation carries a completable userTaskKey");
135
+
136
+ // Answer it: the typed resolution loops the child back to re-dispatch the SAME task.
137
+ await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
138
+ await app.settle();
139
+
140
+ const flows = takenFlows(app);
141
+ assert.ok(
142
+ flows.includes("w_gw_answer->implement-task"),
143
+ `answer routed back to implement-task (flows: ${flows.join(", ")})`,
144
+ );
145
+ assert.ok(
146
+ !flows.includes("w_gw_answer->w_end"),
147
+ "the abandon (default) flow was NOT taken",
148
+ );
149
+ },
150
+ );
151
+ });
152
+
153
+ test("task escalation: abandoning routes to the task-done end (default flow)", async () => {
154
+ await withApp(
155
+ {
156
+ "senior:plan": singleTaskPlan,
157
+ "senior:plan-review": approveReview,
158
+ "senior:feature": () => ({
159
+ status: "escalated",
160
+ question: "Blocked — abandon?",
161
+ summary: "parked",
162
+ }),
163
+ },
164
+ async ({ app, processKey }) => {
165
+ const task = await openTask(app, processKey, "feature-escalation");
166
+ await app.engine.completeUserTask(task.userTaskKey, { resolution: "abandon" });
167
+ await app.settle();
168
+
169
+ const flows = takenFlows(app);
170
+ assert.ok(
171
+ flows.includes("w_gw_answer->w_end"),
172
+ `abandon routed to the task-done end (flows: ${flows.join(", ")})`,
173
+ );
174
+ assert.ok(
175
+ !flows.includes("w_gw_answer->implement-task"),
176
+ "the answer loop was NOT taken",
177
+ );
178
+ },
179
+ );
180
+ });
181
+
182
+ test("plan-review escalation: proceed overrides the gate and routes to select-wave", async () => {
183
+ await withApp(
184
+ {
185
+ "senior:plan": singleTaskPlan,
186
+ "senior:plan-review": () => ({ approved: false, findings: "not good enough" }),
187
+ "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
188
+ },
189
+ async ({ app, processKey }) => {
190
+ const task = await openTask(app, processKey, "plan-review-decision");
191
+ await app.engine.completeUserTask(task.userTaskKey, { directive: "proceed", notes: "ship it" });
192
+ await app.settle();
193
+
194
+ const flows = takenFlows(app);
195
+ assert.ok(
196
+ flows.includes("gw-plan-answer->select-wave"),
197
+ `proceed routed to select-wave (flows: ${flows.join(", ")})`,
198
+ );
199
+ assert.ok(
200
+ !flows.includes("gw-plan-answer->plan"),
201
+ "the revise (default) flow was NOT taken",
202
+ );
203
+ },
204
+ );
205
+ });
206
+
207
+ test("plan-review escalation: revise loops back to plan and resets the review epoch", async () => {
208
+ await withApp(
209
+ {
210
+ "senior:plan": singleTaskPlan,
211
+ "senior:plan-review": () => ({ approved: false, findings: "still not good" }),
212
+ "senior:feature": () => ({ status: "blocked", summary: "n/a" }),
213
+ },
214
+ async ({ app, planKey, processKey }) => {
215
+ const task = await openTask(app, processKey, "plan-review-decision");
216
+ await app.engine.completeUserTask(task.userTaskKey, { directive: "revise", notes: "narrow scope" });
217
+ await app.settle();
218
+
219
+ const flows = takenFlows(app);
220
+ assert.ok(
221
+ flows.includes("gw-plan-answer->plan"),
222
+ `revise routed back to plan (flows: ${flows.join(", ")})`,
223
+ );
224
+
225
+ // The user task bumped `planReviewEpoch`; record-plan-review derived the fresh epoch and
226
+ // reset the round budget — proving the epoch is computed from completed plan-review tasks
227
+ // across rounds (epoch 0's rounds, then a fresh epoch 1 round 0 after the human revise).
228
+ const reviews = await app.db
229
+ .table<{ plan_key: string; epoch: number; round: number }>("plan_reviews", "plan_key")
230
+ .find({ plan_key: planKey });
231
+ assert.ok(
232
+ reviews.some((r) => r.epoch === 0),
233
+ "the first review epoch (0) was recorded",
234
+ );
235
+ assert.ok(
236
+ reviews.some((r) => r.epoch === 1 && r.round === 0),
237
+ `a fresh epoch 1 round 0 was recorded after revise (epochs: ${reviews.map((r) => `${r.epoch}.${r.round}`).join(", ")})`,
238
+ );
239
+ },
240
+ );
241
+ });
242
+
243
+ // A two-task wave whose PRs both open triggers the D3 trial-merge gate; a suite-failed trial
244
+ // parks on the trial-merge decision user task.
245
+ const twoTaskPlan: Stub = () => ({
246
+ tasks: [
247
+ { id: "t1", title: "T1", prompt: "do t1" },
248
+ { id: "t2", title: "T2", prompt: "do t2" },
249
+ ],
250
+ });
251
+ const openBothPrs: Stub = (job) => {
252
+ const taskId = (job.variables as { task?: { id?: string } }).task?.id;
253
+ return taskId === "t2"
254
+ ? { status: "opened", pr: "owner/repo#102", summary: "opened t2" }
255
+ : { status: "opened", pr: "owner/repo#101", summary: "opened t1" };
256
+ };
257
+ const trialSuiteFailed: Stub = () => ({ result: "suite-failed", failing: "combined suite red" });
258
+
259
+ async function driveToTrialDecision(
260
+ action: string,
261
+ ): Promise<string[]> {
262
+ let flows: string[] = [];
263
+ await withApp(
264
+ {
265
+ "senior:plan": twoTaskPlan,
266
+ "senior:plan-review": approveReview,
267
+ "senior:feature": openBothPrs,
268
+ "senior:trial-merge": trialSuiteFailed,
269
+ },
270
+ async ({ app, processKey }) => {
271
+ const task = await openTask(app, processKey, "trial-merge-decision");
272
+ await app.engine.completeUserTask(task.userTaskKey, { action, notes: `chose ${action}` });
273
+ await app.settle();
274
+ flows = takenFlows(app);
275
+ },
276
+ );
277
+ return flows;
278
+ }
279
+
280
+ test("trial-merge escalation: proceed routes to the more-waves gate", async () => {
281
+ const flows = await driveToTrialDecision("proceed");
282
+ assert.ok(
283
+ flows.includes("gw-trial-answer->gw-more"),
284
+ `proceed routed to gw-more (flows: ${flows.join(", ")})`,
285
+ );
286
+ });
287
+
288
+ test("trial-merge escalation: rebase re-runs the trial merge (default flow)", async () => {
289
+ const flows = await driveToTrialDecision("rebase");
290
+ assert.ok(
291
+ flows.includes("gw-trial-answer->trial-merge"),
292
+ `rebase routed back to trial-merge (flows: ${flows.join(", ")})`,
293
+ );
294
+ });
295
+
296
+ test("trial-merge escalation: abandon finalizes the plan", async () => {
297
+ const flows = await driveToTrialDecision("abandon");
298
+ assert.ok(
299
+ flows.includes("gw-trial-answer->record-results"),
300
+ `abandon routed to record-results (flows: ${flows.join(", ")})`,
301
+ );
302
+ });
303
+ });
@@ -0,0 +1,223 @@
1
+ // Migration-path regression proof for retiring the bespoke escalation subsystem (epic #156, slice
2
+ // U7 — the destructive CONTRACT phase). Boots the whole app against the WASM engine so migration
3
+ // `027_retire_escalation_subsystem.sql` is applied on top of the full ledger, then proves three
4
+ // things about the retired surface:
5
+ //
6
+ // 1. Schema contract — the superseded tables (`plan_escalations`, `plan_review_escalations`) and
7
+ // the denormalised pointer columns (`pull_requests.open_escalation_*`, `plans.open_task_*` /
8
+ // `open_plan_*`) are GONE, while the kept audit surface (`escalations`) survives intact. This
9
+ // is the falsifiable core of the contract phase — an over-drop or an un-applied migration
10
+ // fails here.
11
+ // 2. No resurrected answer surface — the retired out-of-band answer webhooks
12
+ // (`/app/api/hooks/feature-answer`, `/app/api/hooks/plan-answer`) 404. The task inbox is now
13
+ // the single answer place; a stray bespoke route must not linger.
14
+ // 3. Post-migration round-trip — an escalation still round-trips through a native `userTask` + the
15
+ // task inbox with the drained-old / re-issued-new path intact: the open escalation is DERIVED
16
+ // from the `escalations` audit row (no denormalised pointer resurfaces), answering it via the
17
+ // inbox resumes the loop, and no addressed escalation lingers afterwards.
18
+ //
19
+ // Run with `npm run e2e`.
20
+
21
+ import assert from "node:assert/strict";
22
+ import { mkdtempSync, rmSync } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { after, before, describe, test } from "node:test";
26
+ import { fileURLToPath } from "node:url";
27
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
28
+
29
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
30
+ const DB_DIR = mkdtempSync(join(tmpdir(), "nwf-u7-"));
31
+
32
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
33
+ NANO_PR_GITHUB_TRANSPORT: "token",
34
+ GITHUB_TOKEN: "",
35
+ };
36
+ const savedEnv = new Map<string, string | undefined>();
37
+
38
+ const HARNESS_ENV = {
39
+ NANO_APP_DB_URL: `file:${join(DB_DIR, "app.db")}`,
40
+ } as const;
41
+
42
+ interface InboxTask {
43
+ userTaskKey: string;
44
+ elementId?: string;
45
+ variables?: Record<string, unknown>;
46
+ }
47
+
48
+ interface StatusBody {
49
+ prs: Array<{ prKey: string; status: string; openEscalation: string | null }>;
50
+ }
51
+
52
+ interface TableInfoRow {
53
+ name: string;
54
+ }
55
+
56
+ interface MasterRow {
57
+ name: string;
58
+ }
59
+
60
+ async function columnNames(app: TestApp, table: string): Promise<string[]> {
61
+ const rows = await app.db.open().query<TableInfoRow>(`PRAGMA table_info(${table})`);
62
+ return rows.map((r) => r.name);
63
+ }
64
+
65
+ async function tableExists(app: TestApp, table: string): Promise<boolean> {
66
+ const rows = await app.db
67
+ .open()
68
+ .query<MasterRow>("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", [table]);
69
+ return rows.length > 0;
70
+ }
71
+
72
+ describe("retire escalation subsystem (U7 — destructive contract phase)", () => {
73
+ let app: TestApp;
74
+ let reviewCalls = 0;
75
+ let capturedAnswer: unknown;
76
+
77
+ before(async () => {
78
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
79
+ savedEnv.set(k, process.env[k]);
80
+ process.env[k] = v;
81
+ }
82
+ app = await bootTestApp(APP_ROOT, { env: HARNESS_ENV });
83
+ await app.engine.registerWorker("senior:pr-review", (job) => {
84
+ reviewCalls += 1;
85
+ if (reviewCalls === 1) {
86
+ return { status: "needs_input", summary: "need a human decision", question: "Which retry cap?" };
87
+ }
88
+ capturedAnswer = (job.variables as Record<string, unknown>).answer;
89
+ return { status: "converged", summary: "resolved after the human answer" };
90
+ });
91
+ });
92
+
93
+ after(async () => {
94
+ await app?.stop();
95
+ for (const [k, v] of savedEnv) {
96
+ if (v === undefined) delete process.env[k];
97
+ else process.env[k] = v;
98
+ }
99
+ rmSync(DB_DIR, { recursive: true, force: true });
100
+ });
101
+
102
+ test("migration 023 drops the superseded tables and denormalised columns but keeps the audit surface", async () => {
103
+ // The retirement migration is recorded in the ledger (it actually ran on top of the full stack).
104
+ const applied = app.db.source().migrationsApplied;
105
+ assert.ok(
106
+ applied.some((m) => m.includes("027_retire_escalation_subsystem")),
107
+ `migration 023 is in the applied ledger (applied: ${applied.join(", ")})`,
108
+ );
109
+
110
+ // Superseded escalation tables are gone.
111
+ assert.equal(await tableExists(app, "plan_escalations"), false, "plan_escalations table dropped");
112
+ assert.equal(
113
+ await tableExists(app, "plan_review_escalations"),
114
+ false,
115
+ "plan_review_escalations table dropped",
116
+ );
117
+
118
+ // Denormalised pointer columns on pull_requests are gone (the open escalation is derived, not stored).
119
+ const prCols = await columnNames(app, "pull_requests");
120
+ assert.ok(prCols.length > 0, "pull_requests table still exists");
121
+ for (const dropped of ["open_escalation_id", "open_escalation_question"]) {
122
+ assert.ok(!prCols.includes(dropped), `pull_requests.${dropped} column dropped`);
123
+ }
124
+
125
+ // Denormalised pointer columns on plans are gone.
126
+ const planCols = await columnNames(app, "plans");
127
+ assert.ok(planCols.length > 0, "plans table still exists");
128
+ for (const dropped of [
129
+ "open_task_escalation_id",
130
+ "open_task_question",
131
+ "open_task_corr_key",
132
+ "open_task_id",
133
+ "open_plan_escalation_id",
134
+ "open_plan_findings",
135
+ "open_plan_round",
136
+ ]) {
137
+ assert.ok(!planCols.includes(dropped), `plans.${dropped} column dropped`);
138
+ }
139
+
140
+ // The kept audit surface survives — over-dropping it would break the merge-loop escalation.
141
+ assert.equal(await tableExists(app, "escalations"), true, "escalations audit table is retained");
142
+ const escCols = await columnNames(app, "escalations");
143
+ assert.ok(escCols.includes("pr_key"), "escalations retains its pr_key column");
144
+ assert.ok(escCols.includes("question"), "escalations retains its question column");
145
+ });
146
+
147
+ test("the retired out-of-band answer webhooks are gone — the task inbox is the single answer place", async () => {
148
+ for (const path of ["/app/api/hooks/feature-answer", "/app/api/hooks/plan-answer"]) {
149
+ const res = await app.callRoute({
150
+ method: "POST",
151
+ path,
152
+ body: JSON.stringify({ answer: "x" }),
153
+ });
154
+ assert.equal(res.status, 404, `retired webhook ${path} is unmounted (404)`);
155
+ }
156
+ });
157
+
158
+ test("an escalation still round-trips via userTask + inbox with no denormalised pointer or dead form", async () => {
159
+ const api = app.api;
160
+ assert.ok(api, "the OpenAPI driver is available");
161
+
162
+ const prKey = "acme/widgets#701";
163
+ const started = await api.call<{ prKey: string }>("startConvergenceLoop", {
164
+ body: { pr: prKey, convergeOnly: true },
165
+ });
166
+ assert.equal(started.status, 202, "start returns 202 Accepted");
167
+
168
+ const prs = app.db.table<{ pr_key: string; status: string; process_key: string | null }>(
169
+ "pull_requests",
170
+ "pr_key",
171
+ );
172
+ const row = await prs.findOne({ pr_key: prKey });
173
+ assert.ok(row?.process_key, "the PR row carries the engine process-instance key");
174
+ const processInstanceKey = row!.process_key!;
175
+
176
+ // Drain the first review round: it parks on the native `wait-answer` userTask.
177
+ await app.settle();
178
+
179
+ // The escalation is a native userTask surfaced through the inbox — not a bespoke answer form.
180
+ const listed = await app.callRoute<InboxTask[]>({
181
+ method: "GET",
182
+ path: "/tasks/api/tasks",
183
+ query: { processInstanceKey },
184
+ });
185
+ assert.equal(listed.status, 200, "the taskInbox surface serves the task list");
186
+ assert.equal(listed.body.length, 1, "exactly one escalation userTask is open");
187
+ const task = listed.body[0];
188
+ assert.equal(task.elementId, "wait-answer", "the open task is the review-loop escalation userTask");
189
+ assert.ok(task.userTaskKey, "the task carries a completable userTaskKey");
190
+
191
+ // The open escalation is DERIVED from the durable audit row — no denormalised pointer is written.
192
+ const status = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
193
+ const statusRow = status.body.prs.find((p) => p.prKey === prKey);
194
+ assert.equal(statusRow?.status, "escalated", "the PR reads as escalated");
195
+ assert.equal(
196
+ statusRow?.openEscalation,
197
+ "Which retry cap?",
198
+ "the open escalation question is derived from the escalations audit row",
199
+ );
200
+
201
+ // Answer through the inbox completion route with the typed `answer` (the re-issued-new path).
202
+ const answer = "Cap the retries at 5 and proceed.";
203
+ const completed = await app.callRoute<{ ok: boolean }>({
204
+ method: "POST",
205
+ path: "/tasks/api/complete",
206
+ body: JSON.stringify({ userTaskKey: task.userTaskKey, variables: { answer } }),
207
+ });
208
+ assert.equal(completed.status, 200, "the completion route accepts the typed submission");
209
+ assert.equal(completed.body.ok, true, "the userTask was completed");
210
+
211
+ // The answer resumed the loop and reached the resumed review round (drained-old path).
212
+ await app.settle();
213
+ assert.equal(reviewCalls, 2, "the review agent ran a second round after the answer");
214
+ assert.equal(capturedAnswer, answer, "the typed answer reached the resumed review round");
215
+
216
+ // No addressed escalation lingers — the audit row is the single source of truth.
217
+ const afterStatus = await app.callRoute<StatusBody>({ method: "GET", path: "/app/api/status" });
218
+ const afterRow = afterStatus.body.prs.find((p) => p.prKey === prKey);
219
+ if (afterRow) {
220
+ assert.equal(afterRow.openEscalation, null, "no open escalation lingers after the answer");
221
+ }
222
+ });
223
+ });
@@ -0,0 +1,99 @@
1
+ // Shared hermetic GitHub stub for the ADR-0003 base-branch admission (`admitPlan` +
2
+ // the `pr.ensure-base-branch` head task). The e2e suites that drive `startPlanFanout` now
3
+ // pass through admission, which reads the default branch, checks the base ref, and creates a
4
+ // missing `epic/*` base off default HEAD. There is no network in e2e, so we pin the `token`
5
+ // transport (`useGh()` is always false in `token` mode → every call routes through `fetch`,
6
+ // never the `gh` subprocess) and intercept `globalThis.fetch` for the three admission
7
+ // endpoints. This mirrors `workers/ensure-base-branch/head-task.integration.test.ts`.
8
+ //
9
+ // The live nightly exercises the real `gh` transport against GitHub; this stub deliberately
10
+ // only covers the hermetic `token` path.
11
+ import { resetDefaultBranchCache } from "../../app/github.ts";
12
+
13
+ export interface AdmitGithubState {
14
+ repo: string;
15
+ defaultBranch: string;
16
+ branches: Map<string, string>; // branch → head sha
17
+ creates: { ref: string; sha: string }[];
18
+ resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
19
+ }
20
+
21
+ /** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
22
+ * `epic/*` base can be created off it. */
23
+ export function admitGithubState(
24
+ repo = "owner/repo",
25
+ defaultBranch = "main",
26
+ ): AdmitGithubState {
27
+ return {
28
+ repo,
29
+ defaultBranch,
30
+ branches: new Map([[defaultBranch, "0".repeat(40)]]),
31
+ creates: [],
32
+ resets: [],
33
+ };
34
+ }
35
+
36
+ function admitFetch(state: AdmitGithubState) {
37
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
38
+ const u = new URL(String(url));
39
+ const method = (init?.method ?? "GET").toUpperCase();
40
+ const path = u.pathname;
41
+ const json = (obj: unknown, status = 200) =>
42
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
43
+
44
+ // GET /repos/{repo} → default branch (fetchDefaultBranch).
45
+ if (method === "GET" && path === `/repos/${state.repo}`) {
46
+ return Promise.resolve(json({ default_branch: state.defaultBranch }));
47
+ }
48
+ // GET /repos/{repo}/git/ref/heads/{branch} → head sha or 404 (branchHeadSha).
49
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
50
+ if (method === "GET" && path.startsWith(refPrefix)) {
51
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
52
+ const sha = state.branches.get(branch);
53
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
54
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha } }));
55
+ }
56
+ // POST /repos/{repo}/git/refs → create a ref (createBranchRef); 422 if it already exists.
57
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
58
+ // biome-ignore lint/plugin: runtime contract boundary for parsed JSON
59
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
60
+ const ref = String(body.ref ?? "");
61
+ const sha = String(body.sha ?? "");
62
+ const branch = ref.replace(/^refs\/heads\//, "");
63
+ if (state.branches.has(branch)) {
64
+ return Promise.resolve(json({ message: "Reference already exists" }, 422));
65
+ }
66
+ state.creates.push({ ref, sha });
67
+ state.branches.set(branch, sha);
68
+ return Promise.resolve(json({ ref }, 201));
69
+ }
70
+ // A ref force-update (reset) would be a PATCH; the idempotent head task must NEVER issue one.
71
+ if (method === "PATCH" && path.startsWith(`/repos/${state.repo}/git/refs/heads/`)) {
72
+ state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
73
+ return Promise.resolve(json({ ok: true }));
74
+ }
75
+ // Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
76
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
77
+ };
78
+ }
79
+
80
+ /** Install the hermetic admit-github stub: pin the `token` transport with a dummy token and swap
81
+ * `globalThis.fetch`. Returns a restore function that reverts env + fetch and clears the
82
+ * default-branch cache so no state leaks between suites. */
83
+ export function installAdmitGithub(state: AdmitGithubState): () => void {
84
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
85
+ const prevTok = process.env["GITHUB_TOKEN"];
86
+ const prevFetch = globalThis.fetch;
87
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
88
+ process.env["GITHUB_TOKEN"] = "e2e-admit-token";
89
+ resetDefaultBranchCache();
90
+ globalThis.fetch = admitFetch(state) as typeof fetch;
91
+ return () => {
92
+ globalThis.fetch = prevFetch;
93
+ resetDefaultBranchCache();
94
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
95
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
96
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
97
+ else process.env["GITHUB_TOKEN"] = prevTok;
98
+ };
99
+ }