@nanobpm/nano-workforce 0.56.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 (90) hide show
  1. package/.github/workflows/ci.yml +7 -0
  2. package/AGENTS.md +83 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +10 -2
  5. package/SPEC.md +27 -24
  6. package/app/agentCompletion.test.ts +337 -0
  7. package/app/agentCompletion.ts +219 -0
  8. package/app/agentGuide.ts +1 -1
  9. package/app/answer-escalation.test.ts +106 -0
  10. package/app/answerEscalation.test.ts +67 -0
  11. package/app/baseGuard.test.ts +9 -1
  12. package/app/baseGuard.ts +11 -0
  13. package/app/escalationSla.test.ts +39 -0
  14. package/app/escalationSla.ts +28 -0
  15. package/app/escalationTaxonomy.test.ts +115 -0
  16. package/app/escalationTaxonomy.ts +115 -0
  17. package/app/feature.test.ts +161 -0
  18. package/app/feature.ts +173 -0
  19. package/app/github.test.ts +179 -1
  20. package/app/github.ts +132 -0
  21. package/app/mergeProtocol.test.ts +15 -0
  22. package/app/mergeProtocol.ts +10 -0
  23. package/app/persist-escalation.test.ts +34 -36
  24. package/app/plan.test.ts +267 -313
  25. package/app/plan.ts +173 -231
  26. package/app/reviewWait.ts +12 -4
  27. package/app/roundResultDefault.test.ts +111 -2
  28. package/app/roundResultDefault.ts +35 -0
  29. package/app/service.test.ts +6 -7
  30. package/app/service.ts +52 -35
  31. package/db/migrations/026_agent_completion.sql +36 -0
  32. package/db/migrations/027_retire_escalation_subsystem.sql +43 -0
  33. package/db/migrations/028_feature_runs.sql +28 -0
  34. package/e2e/agent-answerable.e2e.ts +185 -0
  35. package/e2e/convergence-escalation.e2e.ts +180 -0
  36. package/e2e/convergence-loop.e2e.ts +1 -1
  37. package/e2e/feature-run.e2e.ts +231 -0
  38. package/e2e/plan-fanout-sla.e2e.ts +238 -0
  39. package/e2e/plan-fanout.e2e.ts +303 -0
  40. package/e2e/retire-escalation-subsystem.e2e.ts +223 -0
  41. package/e2e/support/github-admit.ts +99 -0
  42. package/e2e/user-task-spine.e2e.ts +155 -0
  43. package/nano.app.json +41 -11
  44. package/openapi.yaml +237 -84
  45. package/operations/agentCompleteEscalation.ts +53 -0
  46. package/operations/listActivePrs.test.ts +39 -6
  47. package/operations/postMessage.ts +10 -41
  48. package/operations/revertEscalationCompletion.ts +44 -0
  49. package/operations/startAndMessage.test.ts +62 -60
  50. package/operations/startFeature.ts +127 -0
  51. package/operations/startPlanFanout.admission.integration.test.ts +263 -0
  52. package/operations/startPlanFanout.ts +70 -11
  53. package/package.json +4 -1
  54. package/pages/cockpit.page.json +1 -0
  55. package/pages/epic-detail.page.json +11 -37
  56. package/pages/epic.page.json +5 -2
  57. package/pages/feature.page.json +82 -0
  58. package/pages/home.page.json +6 -18
  59. package/resources/agent-guide.md +90 -26
  60. package/resources/forms/feature-escalation.form +27 -0
  61. package/resources/forms/plan-review-decision.form +27 -0
  62. package/resources/forms/pr-escalation.form +23 -0
  63. package/resources/forms/spine-demo.form +15 -0
  64. package/resources/forms/trial-merge-decision.form +25 -0
  65. package/resources/processes/convergence-loop.bpmn +127 -75
  66. package/resources/processes/feature.bpmn +240 -0
  67. package/resources/processes/plan-fanout.bpmn +322 -222
  68. package/resources/processes/spine-demo.bpmn +72 -0
  69. package/scripts/check-migrations.ts +68 -0
  70. package/workers/answer-escalation/worker.ts +78 -0
  71. package/workers/converge-feature/worker.ts +51 -0
  72. package/workers/ensure-base-branch/head-task.integration.test.ts +126 -0
  73. package/workers/ensure-base-branch/worker.test.ts +104 -0
  74. package/workers/ensure-base-branch/worker.ts +31 -0
  75. package/workers/finalize/worker.ts +0 -2
  76. package/workers/mark-merged/worker.ts +0 -2
  77. package/workers/merge/worker.ts +6 -5
  78. package/workers/persist-escalation/worker.ts +28 -32
  79. package/workers/record-feature/worker.ts +61 -0
  80. package/workers/record-plan-review/worker.test.ts +9 -10
  81. package/workers/record-plan-review/worker.ts +15 -5
  82. package/workers/resolve-trial-attention/worker.test.ts +77 -0
  83. package/workers/resolve-trial-attention/worker.ts +43 -0
  84. package/operations/answerFeatureEscalation.test.ts +0 -112
  85. package/operations/answerFeatureEscalation.ts +0 -58
  86. package/operations/answerPlanEscalation.test.ts +0 -115
  87. package/operations/answerPlanEscalation.ts +0 -41
  88. package/workers/persist-plan-escalation/worker.test.ts +0 -80
  89. package/workers/persist-plan-escalation/worker.ts +0 -73
  90. package/workers/persist-task-escalation/worker.ts +0 -120
@@ -15,8 +15,9 @@
15
15
  // repo's lightweight model-guard style (see mergeRebaseArm.test.ts).
16
16
 
17
17
  import { test } from "node:test";
18
- import { assert, assertStringIncludes } from "#test-assert";
18
+ import { assert, assertEquals, assertStringIncludes } from "#test-assert";
19
19
  import { readFileSync } from "node:fs";
20
+ import { routeRoundResult } from "./roundResultDefault.ts";
20
21
 
21
22
  const bpmn = readFileSync("resources/processes/convergence-loop.bpmn", "utf8");
22
23
 
@@ -59,12 +60,18 @@ test("gw-status defaults to the addressed arm, not escalation", () => {
59
60
  );
60
61
  });
61
62
 
62
- test("escalation is an explicit needs_input/blocked arm", () => {
63
+ test("escalation is an explicit needs_input/blocked arm gated on a non-blank question", () => {
63
64
  const esc = flowElement("f_escalate");
64
65
  assert(esc, "f_escalate flow missing");
65
66
  assertStringIncludes(esc, 'targetRef="persist-escalation"');
66
67
  // Escalation now only fires on an explicit human-blocking status.
67
68
  assertStringIncludes(esc, 'status = "needs_input" or status = "blocked"');
69
+ // ...AND only when the round carries an answerable question. A blank/absent/whitespace-only
70
+ // question can no longer route to escalation (retires the blank-question fabrication failure
71
+ // mode); it falls through to the addressed default and re-enters the review wait. The guard
72
+ // trims so a whitespace-only question (" ") is treated as blank too.
73
+ assertStringIncludes(esc, 'question != null');
74
+ assertStringIncludes(esc, 'trim(question) != ""');
68
75
  });
69
76
 
70
77
  test("the default (addressed) arm carries no condition and re-enters the guard", () => {
@@ -92,3 +99,105 @@ test("regression: an empty/unknown status no longer routes to persist-escalation
92
99
  const addressed = flowElement("f_addressed");
93
100
  assert(addressed && /targetRef="gw-guard"/.test(addressed), "default arm must re-enter gw-guard");
94
101
  });
102
+
103
+ // The canonical router (app/roundResultDefault.ts) mirrors the gw-status routing above, with the
104
+ // escalation decision delegated to the single taxonomy. These unit tests pin its behaviour — the
105
+ // same rules the structural BPMN assertions above enforce on the committed model.
106
+
107
+ test("routeRoundResult: a converged round converges", () => {
108
+ assertEquals(routeRoundResult("converged", null), "converged");
109
+ assertEquals(routeRoundResult("converged", "ignored"), "converged");
110
+ });
111
+
112
+ test("routeRoundResult: a human-blocking status with a question escalates", () => {
113
+ assertEquals(routeRoundResult("needs_input", "please decide"), "escalate");
114
+ assertEquals(routeRoundResult("blocked", "please decide"), "escalate");
115
+ });
116
+
117
+ test("routeRoundResult: a blank-question human-blocking status re-enters the loop (no fabrication)", () => {
118
+ for (const status of ["needs_input", "blocked"]) {
119
+ for (const question of [undefined, null, "", " "]) {
120
+ assertEquals(
121
+ routeRoundResult(status, question),
122
+ "reenter",
123
+ `blank-question ${status} must re-enter the review wait, not escalate`,
124
+ );
125
+ }
126
+ }
127
+ });
128
+
129
+ test("routeRoundResult: an addressed/unknown/empty status re-enters the loop", () => {
130
+ for (const status of [undefined, "", "addressed", "waiting", "in_progress"]) {
131
+ assertEquals(routeRoundResult(status, "q"), "reenter", `status ${JSON.stringify(status)} re-enters`);
132
+ }
133
+ });
134
+
135
+ test("routeRoundResult: status is matched exactly, mirroring the untrimmed gw-status conditions", () => {
136
+ // The gw-status gateway conditions do NOT trim `status` (unlike `question`, which trims for
137
+ // blank-detection). `status` is a machine enum, so a whitespace-padded token is NOT the enum
138
+ // value: it must route exactly as the deployed model does — never converge/escalate in code
139
+ // while the model re-enters. This pins the no-drift contract.
140
+ assertEquals(routeRoundResult("converged ", null), "reenter", "'converged ' is not the enum → re-enter");
141
+ assertEquals(routeRoundResult(" converged", null), "reenter", "' converged' is not the enum → re-enter");
142
+ assertEquals(routeRoundResult("needs_input ", "q"), "reenter", "'needs_input ' is not the enum → re-enter");
143
+ assertEquals(routeRoundResult("blocked ", "q"), "reenter", "'blocked ' is not the enum → re-enter");
144
+ });
145
+
146
+ // --- Liveness guard: a non-escalation early return must not wedge on the durable answer-wait. ---
147
+ // `persist-escalation` may complete WITHOUT opening an escalation (worker returns
148
+ // `escalated:false` for a blank-question / non-decision-required job — defence-in-depth). The
149
+ // model must branch on that output so such a token re-enters the loop instead of flowing into
150
+ // `wait-answer`, which would block forever with no escalation for a human to answer.
151
+
152
+ test("persist-escalation routes through the gw-escalated liveness gateway, not straight to wait-answer", () => {
153
+ // The only flow out of persist-escalation goes to the gateway.
154
+ const escOut = allFlows().filter((f) => /sourceRef="persist-escalation"/.test(f));
155
+ assertEquals(escOut.length, 1, "persist-escalation must have exactly one outgoing flow");
156
+ assertStringIncludes(escOut[0], 'targetRef="gw-escalated"');
157
+ // No flow leaves persist-escalation directly for wait-answer.
158
+ assert(
159
+ !escOut.some((f) => /targetRef="wait-answer"/.test(f)),
160
+ "persist-escalation must not flow directly into wait-answer",
161
+ );
162
+ });
163
+
164
+ test("gw-escalated waits only when an escalation was opened, else re-enters the guard", () => {
165
+ const gw = flat.match(/<bpmn:exclusiveGateway\b[^>]*\bid="gw-escalated"[^>]*>/);
166
+ assert(gw, "gw-escalated gateway missing");
167
+ // Its default arm must be the re-enter (no-escalation) arm, never the answer-wait.
168
+ assertStringIncludes(gw[0], 'default="f_escReenter"');
169
+
170
+ // The wait arm is guarded on the worker's `escalated` output — a token only reaches the
171
+ // durable answer-wait when an escalation actually exists.
172
+ const wait = flowElement("f_escWait");
173
+ assert(wait, "f_escWait flow missing");
174
+ assertStringIncludes(wait, 'sourceRef="gw-escalated"');
175
+ assertStringIncludes(wait, 'targetRef="wait-answer"');
176
+ assertStringIncludes(wait, "escalated = true");
177
+
178
+ // The default (no-escalation) arm carries no condition and re-enters gw-guard (forward
179
+ // progress), so a non-escalation early return can never wedge on wait-answer.
180
+ const reenter = flowElement("f_escReenter");
181
+ assert(reenter, "f_escReenter flow missing");
182
+ assertStringIncludes(reenter, 'sourceRef="gw-escalated"');
183
+ assertStringIncludes(reenter, 'targetRef="gw-guard"');
184
+ assert(
185
+ !/conditionExpression/.test(reenter),
186
+ "f_escReenter is the default arm and must not carry a conditionExpression",
187
+ );
188
+ });
189
+
190
+ test("regression: every flow into wait-answer is either escalated-gated or a fixed-question escalation", () => {
191
+ // wait-answer must never be reachable by an unconditional edge from the agent-raised
192
+ // persist-escalation (whose escalation is conditional). The agent path reaches it only via
193
+ // gw-escalated's `escalated = true` arm; the max-rounds / review-stalled paths set a fixed
194
+ // non-blank question and so always escalate.
195
+ const intoWait = allFlows().filter((f) => /targetRef="wait-answer"/.test(f));
196
+ assert(intoWait.length > 0, "no flow targets wait-answer");
197
+ for (const f of intoWait) {
198
+ assert(
199
+ !/sourceRef="persist-escalation"/.test(f),
200
+ "wait-answer must not be entered directly from persist-escalation",
201
+ );
202
+ }
203
+ });
@@ -0,0 +1,35 @@
1
+ // Review-round "safe default" routing — the canonical, testable mirror of the convergence-loop
2
+ // `gw-status` exclusive gateway (see resources/processes/convergence-loop.bpmn and the structural
3
+ // guard in roundResultDefault.test.ts).
4
+ //
5
+ // A review round that exits without a machine-readable result (empty/unknown status) must NOT
6
+ // escalate: the round-cap gate and the review-wait timeout already provide the human-escalation
7
+ // safety nets, and `persist-round` defaults an absent status to `addressed`. Escalation is an
8
+ // EXPLICIT decision, delegated here to the single canonical taxonomy so this routing can never
9
+ // drift from the tier logic every other raise site uses.
10
+ //
11
+ // Crucially, a human-blocking status (`needs_input` / `blocked`) with a BLANK question is a
12
+ // NON-escalation — it re-enters the durable review wait rather than fabricating an answerable
13
+ // escalation. The gateway enforces the same rule via its `f_escalate` condition.
14
+
15
+ import { classifyEscalation } from "./escalationTaxonomy.ts";
16
+
17
+ /** Where a review-round result routes:
18
+ * • `converged` — the PR is done (success path).
19
+ * • `escalate` — a decision-required escalation (human-blocking status + answerable question).
20
+ * • `reenter` — everything else re-enters the durable review wait / round-cap guard: an
21
+ * addressed/waiting/unknown/empty status, OR a human-blocking status whose
22
+ * question is blank (the retired blank-question fabrication path). */
23
+ export type RoundRouting = "converged" | "escalate" | "reenter";
24
+
25
+ /** Route a review-round result exactly as `gw-status` does, delegating the escalation decision
26
+ * to {@link classifyEscalation}. A round only escalates when the taxonomy classifies it as
27
+ * `decision-required`; otherwise it converges or re-enters the loop. */
28
+ export function routeRoundResult(
29
+ status: string | null | undefined,
30
+ question?: string | null,
31
+ ): RoundRouting {
32
+ if ((status ?? "") === "converged") return "converged";
33
+ const disposition = classifyEscalation({ kind: "review-round", status, question });
34
+ return disposition === "decision-required" ? "escalate" : "reenter";
35
+ }
@@ -43,7 +43,7 @@ function withGithubOff(run: () => Promise<void>): Promise<void> {
43
43
  });
44
44
  }
45
45
 
46
- test("re-submit of a cancelled PR clears stale open escalations + the denormalised pointer", async () => {
46
+ test("re-submit of a cancelled PR marks stale open escalations", async () => {
47
47
  await withGithubOff(async () => {
48
48
  const PR_KEY = "owner/repo#42";
49
49
  const stores: Record<string, { rows: unknown[]; key: string }> = {
@@ -56,8 +56,6 @@ test("re-submit of a cancelled PR clears stale open escalations + the denormalis
56
56
  title: "old title",
57
57
  status: "abandoned", // terminal -> re-open path
58
58
  current_round: 3,
59
- open_escalation_id: 5,
60
- open_escalation_question: "(no question provided)",
61
59
  }],
62
60
  key: "pr_key",
63
61
  },
@@ -81,15 +79,16 @@ test("re-submit of a cancelled PR clears stale open escalations + the denormalis
81
79
  prKey: PR_KEY,
82
80
  });
83
81
 
84
- // The prior run's open escalation is retired (not left "open" to resurface a dead form)
82
+ // The prior run's open escalation is retired (not left "open" to resurface a dead form on the
83
+ // re-opened PR). The review-loop escalation is now a native userTask (open state derived from
84
+ // the canonical `escalations` row status), so there is no denormalised PR-row pointer to clear.
85
85
  const esc = stores.escalations.rows[0] as Record<string, unknown>;
86
86
  assertEquals(esc.status, "stale");
87
- // … and the PR row is re-opened with the denormalised escalation pointer cleared.
88
87
  const pr = stores.pull_requests.rows[0] as Record<string, unknown>;
89
88
  assertEquals(pr.status, "converging");
90
89
  assertEquals(pr.current_round, 1);
91
- assertEquals(pr.open_escalation_id, null);
92
- assertEquals(pr.open_escalation_question, null);
90
+ assertEquals(pr.open_escalation_id, undefined);
91
+ assertEquals(pr.open_escalation_question, undefined);
93
92
  assertEquals(pr.process_key, "PI-9");
94
93
  });
95
94
  });
package/app/service.ts CHANGED
@@ -115,8 +115,6 @@ interface PullRequest {
115
115
  updated_at: string;
116
116
  converged_at: string | null;
117
117
  merged_at: string | null;
118
- open_escalation_id: number | null;
119
- open_escalation_question: string | null;
120
118
  // Job-activation visibility (005_job_activation.sql), written by the poller's
121
119
  // `pollJobActivation` pass. `active_worker` is the leasing worker's name while an
122
120
  // agent is actively working the `senior:pr-review` round; NULL means the job is
@@ -353,10 +351,11 @@ export async function submitPr(
353
351
  const abandonToken = existing?.abandon_token ?? mintAbandonToken();
354
352
  if (existing) {
355
353
  // A prior run (cancelled, converged, or otherwise superseded) may have left an OPEN
356
- // escalation row plus the denormalised pointer on the PR. A fresh convergence run must not
357
- // inherit that stale answer form — the "(no question provided)" bleed-through on resubmit
358
- // (Magikcraft/nano-bpm #597/#599). Mark any still-open escalations `stale` and clear the
359
- // pointer below, mirroring the plan re-plan cleanup (issue #25 in plan.ts).
354
+ // escalation row. A fresh convergence run must not inherit that stale answer — the
355
+ // "(no question provided)" bleed-through on resubmit (Magikcraft/nano-bpm #597/#599).
356
+ // Mark any still-open escalations `stale`, mirroring the plan re-plan cleanup (issue #25
357
+ // in plan.ts). The review-loop escalation is now a native userTask; its open state is derived
358
+ // from the canonical `escalations` row status, so there is no denormalised PR-row pointer to clear.
360
359
  for (const e of await escs(data).find({ pr_key: parsed.prKey, status: "open" })) {
361
360
  await escs(data).update(e.id, { status: "stale" });
362
361
  }
@@ -372,10 +371,6 @@ export async function submitPr(
372
371
  outcome: null,
373
372
  converged_at: null,
374
373
  merged_at: null,
375
- // Drop any denormalised open-escalation pointer from the prior run so the answer form
376
- // does not resurface a dead/stale question on the re-opened PR.
377
- open_escalation_id: null,
378
- open_escalation_question: null,
379
374
  abandon_token: abandonToken,
380
375
  updated_at: ts,
381
376
  });
@@ -486,22 +481,26 @@ export async function answerEscalation(
486
481
  prKey: string,
487
482
  answer: string,
488
483
  ) {
489
- const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id)[0];
490
- if (!open) return { ok: false, reason: "no open escalation" };
484
+ const open = (await escs(data).find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
485
+ if (open.length === 0) return { ok: false, reason: "no open escalation" };
491
486
  const ts = now();
492
- await escs(data).update(open.id, { answer, status: "answered", answered_at: ts });
487
+ await escs(data).update(open[0].id, { answer, status: "answered", answered_at: ts });
488
+ // `pr.persist-escalation` always INSERTs a new open row, so a retry can leave duplicate open rows
489
+ // for this PR. Retire any older ones to `stale` so none is left `open` to phantom-surface on
490
+ // /status (mirrors `submitPr`'s resubmit cleanup and the review loop's `pr.answer-escalation`).
491
+ for (const dup of open.slice(1)) {
492
+ await escs(data).update(dup.id, { status: "stale" });
493
+ }
493
494
  await prs(data).update(prKey, {
494
495
  status: "converging",
495
496
  updated_at: ts,
496
- open_escalation_id: null,
497
- open_escalation_question: null,
498
497
  });
499
498
  await engine.publishMessage({
500
499
  name: "escalation-answered",
501
500
  correlationKey: prKey,
502
- variables: { answer, escalationId: open.id },
501
+ variables: { answer, escalationId: open[0].id },
503
502
  });
504
- return { ok: true, escalationId: open.id };
503
+ return { ok: true, escalationId: open[0].id };
505
504
  }
506
505
 
507
506
  /** A PR currently in flight, as reported by the status endpoint. */
@@ -526,27 +525,45 @@ export interface ActivePr {
526
525
 
527
526
  /** Every tracked PR not in a terminal state (converged/abandoned), newest-updated first. Backs
528
527
  * the GET status endpoint so an operator or an external harness can see what is in flight
529
- * without reading the datasource directly. */
528
+ * without reading the datasource directly. The open-escalation question is derived from the
529
+ * canonical `escalations` audit row — the single source of truth (no denormalised PR-row
530
+ * pointer). A PR reads `status="escalated"` only while a token is parked awaiting a human answer,
531
+ * and the row it raised carries `status="open"` until that answer is recorded — by the review
532
+ * loop's `pr.answer-escalation` step on `wait-answer` completion, or the merge loop's
533
+ * `answerEscalation` message path. Deriving from the row (not a per-loop wait mechanism) surfaces
534
+ * BOTH loops' escalations: the merge loop parks on a message catch with no user task, so a
535
+ * user-task probe would silently hide it. Once answered the row leaves `open`, so `openEscalation`
536
+ * derives back to null. */
530
537
  export async function activePrs(data: DataLayer): Promise<ActivePr[]> {
531
538
  const all = await prs(data).all();
532
- return all
539
+ const active = all
533
540
  .filter((p) => !TERMINAL_STATUSES.includes(p.status))
534
- .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0))
535
- .map((p) => ({
536
- prKey: p.pr_key,
537
- repo: p.repo,
538
- number: p.number,
539
- url: p.url,
540
- title: p.title ?? null,
541
- status: p.status,
542
- round: p.current_round,
543
- processKey: p.process_key ?? null,
544
- waitingSince: p.waiting_since ?? null,
545
- openEscalation: p.open_escalation_question ?? null,
546
- updatedAt: p.updated_at,
547
- activeWorker: p.active_worker ?? null,
548
- leaseUntil: p.lease_until ?? null,
549
- }));
541
+ .sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : 0));
542
+ // Only an `escalated` PR is parked awaiting a human answer (either loop). Surface the question
543
+ // from its latest still-open `escalations` row; a resubmit retires stale rows and finalize/merge
544
+ // move the PR off `escalated`, so an open row on an escalated PR is a genuinely live escalation.
545
+ // Fetch every open row in one query (avoids an N+1 over escalated PRs), then keep the newest per PR.
546
+ const openEscByPr = new Map<string, string>();
547
+ const escalatedPrs = new Set(active.filter((p) => p.status === "escalated").map((p) => p.pr_key));
548
+ for (const e of (await escs(data).find({ status: "open" })).sort((a, b) => b.id - a.id)) {
549
+ if (!escalatedPrs.has(e.pr_key) || openEscByPr.has(e.pr_key)) continue;
550
+ if (e.question) openEscByPr.set(e.pr_key, e.question);
551
+ }
552
+ return active.map((p) => ({
553
+ prKey: p.pr_key,
554
+ repo: p.repo,
555
+ number: p.number,
556
+ url: p.url,
557
+ title: p.title ?? null,
558
+ status: p.status,
559
+ round: p.current_round,
560
+ processKey: p.process_key ?? null,
561
+ waitingSince: p.waiting_since ?? null,
562
+ openEscalation: openEscByPr.get(p.pr_key) ?? null,
563
+ updatedAt: p.updated_at,
564
+ activeWorker: p.active_worker ?? null,
565
+ leaseUntil: p.lease_until ?? null,
566
+ }));
550
567
  }
551
568
 
552
569
  /** One review-ready poll pass (SPEC §10): for every PR waiting on a review, fetch its GitHub
@@ -0,0 +1,36 @@
1
+ -- Agent-answerable escalations (epic #156, slice U6; ADR 0046). The escalation user tasks landed by
2
+ -- U0–U3 are completed by whoever holds the assignment — a human via the task inbox, or an AGENT
3
+ -- assignee via the host-side completer. Both drive the SAME `.form` contract and the SAME engine
4
+ -- `completeUserTask` resume path (no parallel lane); this ledger is the attribution + reversibility
5
+ -- surface layered over that one completion.
6
+ --
7
+ -- One row per escalation user-task completion routed through the canonical attributed completer
8
+ -- (app/agentCompletion.ts). It records WHO completed the task (agent vs human, and their id), the
9
+ -- exact typed form variables submitted, and — for an AGENT completion — whether a human has since
10
+ -- reverted/overridden it. A completed user task cannot be un-completed in the engine, so
11
+ -- reversibility is modelled here: an agent answer is never a silent irreversible commit — a human
12
+ -- can mark it reverted (recording who + when), and any host-side consumer reads this ledger to see
13
+ -- whether the latest completion is still authoritative.
14
+ --
15
+ -- Expand-only / additive (AGENTS.md forward-only migrations): a new table, no existing shape
16
+ -- touched. `actor_kind` is agent | human; `reversible` is 1 for agent completions (a human may
17
+ -- override) and 0 for human completions (already the authority).
18
+ CREATE TABLE task_completions (
19
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
20
+ user_task_key TEXT NOT NULL, -- the engine user-task key that was completed
21
+ process_instance_key TEXT, -- owning process instance, when known
22
+ element_id TEXT, -- the escalation task's BPMN elementId
23
+ actor_kind TEXT NOT NULL CHECK (actor_kind IN ('agent', 'human')), -- agent | human
24
+ actor_id TEXT NOT NULL, -- the completing identity (agent id / operator)
25
+ variables_json TEXT NOT NULL, -- the typed form variables submitted (JSON)
26
+ reversible INTEGER NOT NULL DEFAULT 0, -- 1 = a human may override (agent completions)
27
+ reverted INTEGER NOT NULL DEFAULT 0, -- 1 once a human has reverted/overridden it
28
+ reverted_by TEXT, -- the human identity that reverted it
29
+ reverted_note TEXT, -- the human's corrective guidance overriding the agent answer
30
+ reverted_at TEXT,
31
+ created_at TEXT NOT NULL
32
+ );
33
+
34
+ -- The completer and the revert path look a completion up by its user-task key (newest first);
35
+ -- index it to avoid a scan.
36
+ CREATE INDEX idx_task_completions_key ON task_completions(user_task_key);
@@ -0,0 +1,43 @@
1
+ -- Retire the bespoke escalation subsystem — the destructive CONTRACT phase of the
2
+ -- expand-and-contract migration to native BPMN `userTask` + `.form` (ADR 0002, epic
3
+ -- #156). The escalation-conversion slices already stopped WRITING/reading these
4
+ -- surfaces (U2 vacated the plan/task/plan-review mirrors; U3 vacated the PR
5
+ -- review-loop `open_escalation_*` mirror), deriving open-escalation state from parked
6
+ -- user tasks (`searchUserTasks`) instead. Nothing reads these tables/columns any more,
7
+ -- so drop them.
8
+ --
9
+ -- Forward-only and numbered after the current highest prefix (026); migrations apply
10
+ -- in order and are auto-applied on boot. The migration runner wraps each file in its
11
+ -- own transaction — this file must NOT contain BEGIN/COMMIT.
12
+ --
13
+ -- NB: the merge-loop escalation (out of scope for #156) still uses the shared
14
+ -- `escalations` audit table + the `escalation-answered` message path — those are
15
+ -- deliberately KEPT here.
16
+
17
+ -- 1. The per-task implementation-phase escalation audit table (006_task_escalation.sql).
18
+ -- Superseded by the `feature-escalation` user task; its answer now resumes the
19
+ -- process directly. DROP TABLE also drops its indexes.
20
+ DROP TABLE IF EXISTS plan_escalations;
21
+
22
+ -- 2. The plan-review cap escalation audit table (020_plan_review_escalation.sql).
23
+ -- Superseded by the `plan-review-decision` user task; the review epoch is now the
24
+ -- durable process variable `planReviewEpoch`, not the count of answered rows.
25
+ DROP TABLE IF EXISTS plan_review_escalations;
26
+
27
+ -- 3. The denormalised "currently-open escalation" pointers on `pull_requests`
28
+ -- (003_open_escalation.sql). The open-escalation question is derived from the
29
+ -- canonical `escalations` audit row + parked user tasks — no denormalised pointer.
30
+ ALTER TABLE pull_requests DROP COLUMN open_escalation_id;
31
+ ALTER TABLE pull_requests DROP COLUMN open_escalation_question;
32
+
33
+ -- 4. The denormalised per-plan "oldest open task escalation" pointer
34
+ -- (006_task_escalation.sql) and the "open plan-review escalation" pointer
35
+ -- (020_plan_review_escalation.sql) on `plans`. Both are surfaced from the task
36
+ -- inbox (`searchUserTasks`) now, with no denormalised mirror on the plan row.
37
+ ALTER TABLE plans DROP COLUMN open_task_escalation_id;
38
+ ALTER TABLE plans DROP COLUMN open_task_question;
39
+ ALTER TABLE plans DROP COLUMN open_task_corr_key;
40
+ ALTER TABLE plans DROP COLUMN open_task_id;
41
+ ALTER TABLE plans DROP COLUMN open_plan_escalation_id;
42
+ ALTER TABLE plans DROP COLUMN open_plan_findings;
43
+ ALTER TABLE plans DROP COLUMN open_plan_round;
@@ -0,0 +1,28 @@
1
+ -- Single-issue feature run (issue #172): the "missing middle" between Epics
2
+ -- (plan-fanout: one issue → many PRs) and PR convergence (an already-open PR →
3
+ -- review → merge). One row per issue handed to a single implementation agent
4
+ -- that raises exactly ONE PR, then OPTIONALLY hands that PR to the convergence
5
+ -- loop (and, with auto-merge, the merge-loop).
6
+ --
7
+ -- The downstream PR lifecycle (review rounds, escalations, merge) is NOT
8
+ -- duplicated here: once `converge` hands the opened PR to `submitPr`, its live
9
+ -- state lives on the `pull_requests` row keyed by `pr_key`. `feature_runs` only
10
+ -- tracks the feature run's own lifecycle up to the hand-off.
11
+
12
+ CREATE TABLE feature_runs (
13
+ feature_key TEXT PRIMARY KEY, -- "<owner>/<repo>#<issue-number>"
14
+ repo TEXT NOT NULL, -- "<owner>/<repo>"
15
+ issue_number INTEGER NOT NULL,
16
+ issue_url TEXT NOT NULL,
17
+ base_branch TEXT NOT NULL, -- the branch the agent branches off / opens its PR against
18
+ status TEXT NOT NULL, -- running | opened | converging | blocked | skipped | failed | abandoned
19
+ process_key TEXT, -- engine process-instance key (feature.bpmn)
20
+ pr_key TEXT, -- the PR this run produced ("<owner>/<repo>#<n>") → pull_requests
21
+ converge INTEGER NOT NULL DEFAULT 0, -- 0|1 — hand the opened PR to the convergence loop
22
+ auto_merge INTEGER NOT NULL DEFAULT 0, -- 0|1 — drive the merge-loop after convergence (converge=0 ⇒ moot)
23
+ outcome TEXT, -- final summary / note from the agent
24
+ created_at TEXT NOT NULL,
25
+ updated_at TEXT NOT NULL
26
+ );
27
+
28
+ CREATE INDEX idx_feature_runs_status ON feature_runs(status);
@@ -0,0 +1,185 @@
1
+ // End-to-end proof for agent-answerable escalations (epic #156, slice U6; ADR 0046). Boots the whole
2
+ // app against the WASM engine and drives the REAL plan-fanout.bpmn to the implementation-phase task
3
+ // escalation — the native `feature-escalation` userTask + form the human path (U2) completes — then
4
+ // completes it through the HOST-SIDE AGENT COMPLETER (`completeEscalationAsAgent`) instead of a raw
5
+ // human completion. It proves the three things U6 promises:
6
+ //
7
+ // 1. an AGENT assignee completing the SAME `.form` resumes the process with typed vars IDENTICAL to
8
+ // a human completion — asserted on the cumulative taken sequence flows: `{resolution:"answer"}`
9
+ // routes `w_gw_answer -> implement-task`, exactly as the U2 human test asserts (an empty/wrong
10
+ // completion would take the abandon default);
11
+ // 2. attribution is recorded — the `task_completions` ledger row is actor_kind=agent + the agent id
12
+ // + the submitted variables;
13
+ // 3. the completion is reversible — a human reverts it, and the ledger records who + when.
14
+ //
15
+ // No `.bpmn` is touched by this slice: the agent uses the same process + form the human does, only
16
+ // the completion CALLER differs. Network isolation mirrors the sibling e2e suites (github forced to
17
+ // offline `token` mode). Run with `npm run e2e`.
18
+
19
+ import assert from "node:assert/strict";
20
+ import { mkdtempSync, rmSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { dirname, join, resolve } from "node:path";
23
+ import { after, before, describe, test } from "node:test";
24
+ import { fileURLToPath } from "node:url";
25
+ import type { EngineJob } from "@nanobpm/urban/runtime";
26
+ import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
27
+ import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
28
+ import {
29
+ completeEscalationAsAgent,
30
+ latestCompletion,
31
+ revertAgentCompletion,
32
+ type TaskCompletion,
33
+ } from "../app/agentCompletion.ts";
34
+
35
+ const APP_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
36
+
37
+ const GITHUB_ENV_OVERRIDES: Record<string, string> = {
38
+ NANO_PR_GITHUB_TRANSPORT: "token",
39
+ GITHUB_TOKEN: "",
40
+ };
41
+
42
+ interface InboxTask {
43
+ userTaskKey: string;
44
+ elementId?: string;
45
+ variables?: Record<string, unknown>;
46
+ }
47
+
48
+ interface TakenFlow {
49
+ from: string;
50
+ to: string;
51
+ }
52
+
53
+ function takenFlows(app: TestApp): string[] {
54
+ const snapshot = app.snapshot();
55
+ const flows = Array.isArray(snapshot.takenSequenceFlows) ? snapshot.takenSequenceFlows : [];
56
+ return flows
57
+ .filter((f): f is TakenFlow => typeof f === "object" && f !== null && "from" in f && "to" in f)
58
+ .map((f) => `${f.from}->${f.to}`);
59
+ }
60
+
61
+ type Stub = (job: EngineJob) => Record<string, unknown> | void;
62
+
63
+ describe("agent-answerable escalations (U6 — same form, agent completer, attribution + reversibility)", () => {
64
+ const savedEnv = new Map<string, string | undefined>();
65
+ let restoreGithub: (() => void) | undefined;
66
+
67
+ before(() => {
68
+ for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
69
+ savedEnv.set(k, process.env[k]);
70
+ process.env[k] = v;
71
+ }
72
+ // ADR 0003: `startPlanFanout` + the `pr.ensure-base-branch` head task now pass through base
73
+ // admission, which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
74
+ restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
75
+ });
76
+
77
+ after(() => {
78
+ restoreGithub?.();
79
+ for (const [k, v] of savedEnv) {
80
+ if (v === undefined) delete process.env[k];
81
+ else process.env[k] = v;
82
+ }
83
+ });
84
+
85
+ async function withApp(
86
+ stubs: Record<string, Stub>,
87
+ body: (ctx: { app: TestApp; planKey: string; processKey: string }) => Promise<void>,
88
+ ): Promise<void> {
89
+ const dbDir = mkdtempSync(join(tmpdir(), "nwf-u6-"));
90
+ const app = await bootTestApp(APP_ROOT, {
91
+ env: { NANO_APP_DB_URL: `file:${join(dbDir, "app.db")}` },
92
+ });
93
+ try {
94
+ for (const [jobType, stub] of Object.entries(stubs)) {
95
+ await app.engine.registerWorker(jobType, async (job) => stub(job) ?? undefined);
96
+ }
97
+ const planKey = "owner/repo#1";
98
+ const started = await app.api?.call("startPlanFanout", { body: { issue: planKey, baseBranch: "epic/e2e" } });
99
+ assert.equal(started?.status, 202, "startPlanFanout accepted the issue");
100
+ await app.settle();
101
+ const plan = await app.db
102
+ .table<{ plan_key: string; process_key: string | null }>("plans", "plan_key")
103
+ .findOne({ plan_key: planKey });
104
+ assert.ok(plan?.process_key, "the plan row carries the engine process-instance key");
105
+ await body({ app, planKey, processKey: plan!.process_key! });
106
+ } finally {
107
+ await app.stop();
108
+ rmSync(dbDir, { recursive: true, force: true });
109
+ }
110
+ }
111
+
112
+ async function openTask(app: TestApp, processKey: string, elementId: string): Promise<InboxTask> {
113
+ const tasks = await app.engine.searchUserTasks({ processInstanceKey: processKey });
114
+ const match = tasks.find((t) => t.elementId === elementId);
115
+ assert.ok(match, `expected an open ${elementId} user task (open: ${tasks.map((t) => t.elementId).join(", ")})`);
116
+ return match!;
117
+ }
118
+
119
+ const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
120
+ const approveReview: Stub = () => ({ approved: true, findings: "" });
121
+
122
+ test("an agent completes the SAME feature-escalation form a human would → process resumes with the identical typed vars, attributed, and reversible", async () => {
123
+ let featureCalls = 0;
124
+ await withApp(
125
+ {
126
+ "senior:plan": singleTaskPlan,
127
+ "senior:plan-review": approveReview,
128
+ "senior:feature": () => {
129
+ featureCalls += 1;
130
+ return featureCalls === 1
131
+ ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
132
+ : { status: "blocked", summary: "resumed after answer" };
133
+ },
134
+ },
135
+ async ({ app, processKey }) => {
136
+ const task = await openTask(app, processKey, "feature-escalation");
137
+ assert.ok(task.userTaskKey, "the feature escalation carries a completable userTaskKey");
138
+
139
+ // Complete AS AN AGENT through the host-side completer — the same typed `{resolution, answer}`
140
+ // a human submits through the inbox, only the caller differs.
141
+ const r = await completeEscalationAsAgent(app.db, app.engine, {
142
+ userTaskKey: task.userTaskKey,
143
+ agentId: "senior:answer-bot",
144
+ variables: { resolution: "answer", answer: "use v2" },
145
+ });
146
+ assert.equal(r.ok, true, "the agent completer accepted the escalation completion");
147
+ assert.equal(r.elementId, "feature-escalation");
148
+ await app.settle();
149
+
150
+ // IDENTICAL resume to the human path (mirrors U2's human test): the typed resolution loops
151
+ // the child back to re-dispatch the SAME task — NOT the abandon default.
152
+ const flows = takenFlows(app);
153
+ assert.ok(
154
+ flows.includes("w_gw_answer->implement-task"),
155
+ `agent answer routed back to implement-task (flows: ${flows.join(", ")})`,
156
+ );
157
+ assert.ok(
158
+ !flows.includes("w_gw_answer->w_end"),
159
+ "the abandon (default) flow was NOT taken",
160
+ );
161
+
162
+ // Attribution: the ledger records an AGENT completion with the agent id + the exact vars.
163
+ const completions = await app.db
164
+ .table<TaskCompletion>("task_completions", "id")
165
+ .find({ user_task_key: task.userTaskKey });
166
+ assert.equal(completions.length, 1, "exactly one attribution row for the completion");
167
+ const row = completions[0];
168
+ assert.equal(row.actor_kind, "agent");
169
+ assert.equal(row.actor_id, "senior:answer-bot");
170
+ assert.equal(row.element_id, "feature-escalation");
171
+ assert.deepEqual(JSON.parse(row.variables_json), { resolution: "answer", answer: "use v2" });
172
+ assert.equal(row.reversible, 1, "an agent completion is reversible");
173
+ assert.equal(row.reverted, 0);
174
+
175
+ // Reversibility: a human overrides the agent's answer; the ledger records who + when.
176
+ const rev = await revertAgentCompletion(app.db, row.id, { kind: "human", id: "alice" });
177
+ assert.equal(rev.ok, true, "a human can revert the agent completion");
178
+ const after = (await latestCompletion(app.db, task.userTaskKey))!;
179
+ assert.equal(after.reverted, 1);
180
+ assert.equal(after.reverted_by, "alice");
181
+ assert.ok(after.reverted_at, "reverted_at is stamped");
182
+ },
183
+ );
184
+ });
185
+ });