@nanobpm/nano-workforce 0.57.0 → 0.58.1

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 +14 -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 +5 -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,219 @@
1
+ // Attributed escalation completion (epic #156, slice U6; ADR 0046) — the SINGLE place an escalation
2
+ // user task is completed on the host side, so an AGENT assignee can answer the exact same `.form` a
3
+ // human would without forking a second lane or a second completion implementation.
4
+ //
5
+ // Every escalation the epic migrated (task, plan-review, trial-merge, PR review-loop) parks on a
6
+ // native `userTask` bearing a linked `.form`. Whoever holds the assignment completes it with the
7
+ // form's typed variables, and the engine resumes the process. This module wraps that one canonical
8
+ // primitive (`engine.completeUserTask`) with two cross-cutting concerns ADR 0046 requires of an
9
+ // agent-answerable escalation:
10
+ //
11
+ // • Attribution — record WHO completed the task (an agent identity vs a human) and the exact
12
+ // typed variables they submitted, in the `task_completions` ledger, so the audit trail can tell
13
+ // an agent answer apart from a human one.
14
+ // • Reversibility — a completed user task cannot be un-completed in the engine, so an AGENT answer
15
+ // must not be a silent irreversible commit: it is recorded `reversible`, and a human can mark it
16
+ // reverted/overridden (recording who + when). Host-side consumers read the ledger to see whether
17
+ // the latest completion is still authoritative.
18
+ //
19
+ // Derivation over duplication: the human out-of-band answer paths (app/plan.ts) route through the
20
+ // SAME `completeUserTaskAttributed` (as `human`), so there is exactly one implementation of "complete
21
+ // an escalation user task" — the agent path is an extension of it, not a parallel copy.
22
+
23
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
24
+
25
+ const now = () => new Date().toISOString();
26
+
27
+ /** Who completed an escalation user task. `agent` completions are reversible (a human may override);
28
+ * `human` completions are already the authority. */
29
+ export type ActorKind = "agent" | "human";
30
+
31
+ /** The completing identity: an agent (per ADR 0046) or a human operator. `id` is the audit handle
32
+ * (agent id / operator name). */
33
+ export interface Actor {
34
+ kind: ActorKind;
35
+ id: string;
36
+ }
37
+
38
+ /** One escalation user-task completion, recorded for attribution + reversibility. */
39
+ export interface TaskCompletion {
40
+ id: number;
41
+ user_task_key: string;
42
+ process_instance_key: string | null;
43
+ element_id: string | null;
44
+ actor_kind: ActorKind;
45
+ actor_id: string;
46
+ /** The typed form variables submitted, as JSON — the same shape a human submits. */
47
+ variables_json: string;
48
+ /** 1 when a human may still override this completion (agent completions). */
49
+ reversible: number;
50
+ /** 1 once a human has reverted/overridden it. */
51
+ reverted: number;
52
+ reverted_by: string | null;
53
+ /** The human's corrective guidance that overrides the agent answer (when supplied on revert). */
54
+ reverted_note: string | null;
55
+ reverted_at: string | null;
56
+ created_at: string;
57
+ }
58
+
59
+ export const taskCompletions = (data: DataLayer) =>
60
+ data.table<TaskCompletion>("task_completions", "id");
61
+
62
+ /** The escalation user-task `elementId`s an agent assignee may answer (the four kinds the epic
63
+ * migrated to `userTask` + `.form`). The agent completer refuses any other parked task, so the
64
+ * agent path is scoped to escalations — it can never complete an arbitrary internal user task. */
65
+ export const ESCALATION_TASK_ELEMENTS: ReadonlySet<string> = new Set([
66
+ "feature-escalation",
67
+ "plan-review-decision",
68
+ "trial-merge-decision",
69
+ "wait-answer", // PR review-loop escalation (convergence-loop.bpmn, U3)
70
+ ]);
71
+
72
+ /** The canonical attributed completer. Records an attribution row in `task_completions` (reversible
73
+ * iff the actor is an agent) and THEN completes the user task with the exact typed `variables` — so
74
+ * the ledger row can never be lost by a resume that fires before the write. If the engine
75
+ * completion throws (a failed/rejected completion, or a lost race), the just-written row is rolled
76
+ * back so the ledger never claims a completion that did not happen, and the error is re-raised so
77
+ * the caller can retry. Returns the new completion id. This is the ONE host-side implementation of
78
+ * "complete an escalation user task"; both the agent path and the human out-of-band answer paths
79
+ * route through it. */
80
+ export async function completeUserTaskAttributed(
81
+ data: DataLayer,
82
+ engine: EngineClient,
83
+ target: {
84
+ userTaskKey: string;
85
+ processInstanceKey?: string | null;
86
+ elementId?: string | null;
87
+ variables: Record<string, unknown>;
88
+ },
89
+ actor: Actor,
90
+ ): Promise<{ completionId: number }> {
91
+ // Normalize + validate the attribution keys upfront so the ledger can never record a row with
92
+ // blank attribution or whitespace-mismatched keys.
93
+ const userTaskKey = target.userTaskKey.trim();
94
+ if (!userTaskKey) throw new Error("userTaskKey is required");
95
+ const actorId = actor.id.trim();
96
+ if (!actorId) throw new Error("actor id is required");
97
+
98
+ const reversible = actor.kind === "agent";
99
+ const id = await taskCompletions(data).insert({
100
+ user_task_key: userTaskKey,
101
+ process_instance_key: target.processInstanceKey ?? null,
102
+ element_id: target.elementId ?? null,
103
+ actor_kind: actor.kind,
104
+ actor_id: actorId,
105
+ variables_json: JSON.stringify(target.variables ?? {}),
106
+ reversible: reversible ? 1 : 0,
107
+ reverted: 0,
108
+ reverted_by: null,
109
+ reverted_note: null,
110
+ reverted_at: null,
111
+ created_at: now(),
112
+ });
113
+ const completionId = Number(id);
114
+ try {
115
+ await engine.completeUserTask(userTaskKey, target.variables);
116
+ } catch (err) {
117
+ // The completion did not take — roll the attribution row back so the ledger reflects only
118
+ // completions that actually happened, and let the caller retry. The rollback is best-effort:
119
+ // if the delete itself throws, the engine failure remains the primary signal we re-raise.
120
+ try {
121
+ await taskCompletions(data).delete(completionId);
122
+ } catch {
123
+ // swallow — never let a rollback failure mask the original engine error
124
+ }
125
+ throw err;
126
+ }
127
+ return { completionId };
128
+ }
129
+
130
+ /** The newest recorded completion for a user-task key, or undefined. */
131
+ export async function latestCompletion(
132
+ data: DataLayer,
133
+ userTaskKey: string,
134
+ ): Promise<TaskCompletion | undefined> {
135
+ const rows = await taskCompletions(data).find({ user_task_key: userTaskKey });
136
+ let newest: TaskCompletion | undefined;
137
+ for (const row of rows) {
138
+ if (!newest || row.id > newest.id) newest = row;
139
+ }
140
+ return newest;
141
+ }
142
+
143
+ export interface AgentCompleteResult {
144
+ ok: boolean;
145
+ reason?: string;
146
+ completionId?: number;
147
+ userTaskKey?: string;
148
+ elementId?: string;
149
+ }
150
+
151
+ /** Complete an escalation user task AS AN AGENT (ADR 0046). Resolves the parked task by its key,
152
+ * refuses anything that is not one of the migrated escalation tasks, and routes the typed form
153
+ * variables through the shared attributed completer with the agent's identity. Reuses the exact
154
+ * form contract + resume path a human uses — no parallel completion. A key with no matching open
155
+ * escalation task is a 404-style no-op. */
156
+ export async function completeEscalationAsAgent(
157
+ data: DataLayer,
158
+ engine: EngineClient,
159
+ input: { userTaskKey: string; variables: Record<string, unknown>; agentId: string },
160
+ ): Promise<AgentCompleteResult> {
161
+ const userTaskKey = input.userTaskKey.trim();
162
+ if (!userTaskKey) return { ok: false, reason: "userTaskKey is required" };
163
+ const agentId = input.agentId.trim();
164
+ if (!agentId) return { ok: false, reason: "agentId is required" };
165
+
166
+ const open = await engine.searchUserTasks();
167
+ const match = open.find((t) => t.userTaskKey === userTaskKey);
168
+ if (!match) return { ok: false, reason: "no open escalation task" };
169
+ if (!match.elementId || !ESCALATION_TASK_ELEMENTS.has(match.elementId)) {
170
+ return { ok: false, reason: "not an escalation task" };
171
+ }
172
+
173
+ const { completionId } = await completeUserTaskAttributed(
174
+ data,
175
+ engine,
176
+ {
177
+ userTaskKey,
178
+ elementId: match.elementId,
179
+ variables: input.variables,
180
+ },
181
+ { kind: "agent", id: agentId },
182
+ );
183
+ return { ok: true, completionId, userTaskKey, elementId: match.elementId };
184
+ }
185
+
186
+ export interface RevertResult {
187
+ ok: boolean;
188
+ reason?: string;
189
+ completionId?: number;
190
+ }
191
+
192
+ /** Revert/override an AGENT escalation completion (the reversibility guarantee of ADR 0046). A human
193
+ * marks a reversible, not-yet-reverted agent completion reverted, recording who did it, when, and
194
+ * optionally the corrective guidance that overrides the agent's answer — so the agent's answer is no
195
+ * longer treated as authoritative and the human correction is captured. Human completions are not
196
+ * reversible (they are already the authority), and a completion can only be reverted once. */
197
+ export async function revertAgentCompletion(
198
+ data: DataLayer,
199
+ completionId: number,
200
+ reverter: Actor,
201
+ note?: string,
202
+ ): Promise<RevertResult> {
203
+ const row = await taskCompletions(data).get(completionId);
204
+ if (!row) return { ok: false, reason: "no such completion" };
205
+ if (reverter.kind !== "human") return { ok: false, reason: "only a human may revert a completion" };
206
+ if (!row.reversible) return { ok: false, reason: "completion is not reversible" };
207
+ if (row.reverted) return { ok: false, reason: "completion already reverted" };
208
+ const reverterId = reverter.id.trim();
209
+ if (!reverterId) return { ok: false, reason: "reverter id is required" };
210
+
211
+ const correction = typeof note === "string" ? note.trim() : "";
212
+ await taskCompletions(data).update(completionId, {
213
+ reverted: 1,
214
+ reverted_by: reverterId,
215
+ reverted_note: correction || null,
216
+ reverted_at: now(),
217
+ });
218
+ return { ok: true, completionId };
219
+ }
@@ -0,0 +1,106 @@
1
+ // Red/green regression for pr.answer-escalation (Copilot review of PR #180).
2
+ //
3
+ // The review-loop escalation migrated from a message catch to a native `wait-answer` userTask.
4
+ // Completing the task resumes the token, but nothing retired the durable `escalations` audit row —
5
+ // so it stayed `status="open"` with a null `answer`/`answered_at` forever, both losing the Q&A
6
+ // trail and (since `activePrs` derives `openEscalation` from that row) surfacing a phantom open
7
+ // escalation on `/status` after it was answered. `pr.answer-escalation` runs on `wait-answer`
8
+ // completion and must transition the latest open row to `answered`, recording the submitted answer.
9
+ //
10
+ // It must ALSO move the `pull_requests` row off `status="escalated"` back to `"converging"`, exactly
11
+ // as the merge-loop's `answerEscalation` message path does. Otherwise the PR stays `escalated` (with
12
+ // a now-null `openEscalation`) until the re-entered round's `persist-round` runs — an inconsistent
13
+ // `/status` window and a divergence from the merge loop the two paths are meant to share.
14
+ import { test } from "node:test";
15
+ import { assertEquals } from "#test-assert";
16
+ import handler from "../workers/answer-escalation/worker.ts";
17
+
18
+ function fakeApp(escalationRows: Record<string, unknown>[]) {
19
+ const updates: { key: unknown; patch: Record<string, unknown> }[] = [];
20
+ const prUpdates: { key: unknown; patch: Record<string, unknown> }[] = [];
21
+ const app = {
22
+ data: {
23
+ table(name: string, _key: string) {
24
+ if (name === "pull_requests") {
25
+ return {
26
+ async update(key: unknown, patch: Record<string, unknown>) {
27
+ prUpdates.push({ key, patch });
28
+ },
29
+ };
30
+ }
31
+ if (name !== "escalations") throw new Error(`unexpected table ${name}`);
32
+ return {
33
+ async find(where: Record<string, unknown>) {
34
+ return escalationRows.filter((r) =>
35
+ Object.entries(where).every(([k, v]) => r[k] === v)
36
+ );
37
+ },
38
+ async update(key: unknown, patch: Record<string, unknown>) {
39
+ updates.push({ key, patch });
40
+ },
41
+ };
42
+ },
43
+ },
44
+ };
45
+ return { app, updates, prUpdates };
46
+ }
47
+
48
+ test("retires the latest open escalation to answered with the submitted answer", async () => {
49
+ const rows = [
50
+ { id: 5, pr_key: "o/r#1", status: "answered", question: "old" },
51
+ { id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" },
52
+ ];
53
+ const { app, updates, prUpdates } = fakeApp(rows);
54
+ const job = { variables: { prKey: "o/r#1", answer: " Cap at 5. " } };
55
+ await handler(job as any, app as any);
56
+ assertEquals(updates.length, 1, "exactly the one open row is retired");
57
+ assertEquals(updates[0].key, 7, "the newest open row (not the already-answered one) is retired");
58
+ assertEquals(updates[0].patch.status, "answered");
59
+ assertEquals(updates[0].patch.answer, "Cap at 5.", "the answer is trimmed and recorded");
60
+ assertEquals(typeof updates[0].patch.answered_at, "string", "answered_at is stamped");
61
+ assertEquals(prUpdates.length, 1, "the PR row is moved off `escalated`");
62
+ assertEquals(prUpdates[0].key, "o/r#1", "the PR keyed by prKey is updated");
63
+ assertEquals(prUpdates[0].patch.status, "converging", "mirrors answerEscalation: back to converging");
64
+ assertEquals(typeof prUpdates[0].patch.updated_at, "string", "updated_at is stamped");
65
+ });
66
+
67
+ test("retires ALL open rows: newest answered, any duplicate open rows marked stale", async () => {
68
+ // `pr.persist-escalation` always INSERTs a new open row, so a retry/duplicate activation can
69
+ // leave more than one `status="open"` row for the same PR. Answering only the newest would leave
70
+ // an older open row behind — a phantom that keeps `activePrs` deriving an open escalation while
71
+ // the PR is still `escalated`. Every open row for the PR must leave `open` in this completion.
72
+ const rows = [
73
+ { id: 3, pr_key: "o/r#1", status: "open", question: "stale dup A" },
74
+ { id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" },
75
+ { id: 9, pr_key: "o/r#2", status: "open", question: "other PR — untouched" },
76
+ ];
77
+ const { app, updates, prUpdates } = fakeApp(rows);
78
+ const job = { variables: { prKey: "o/r#1", answer: "Cap at 5." } };
79
+ await handler(job as any, app as any);
80
+ assertEquals(updates.length, 2, "both open rows for this PR are retired; the other PR is untouched");
81
+ const answered = updates.find((u) => u.key === 7);
82
+ const stale = updates.find((u) => u.key === 3);
83
+ assertEquals(answered?.patch.status, "answered", "the newest open row is answered");
84
+ assertEquals(answered?.patch.answer, "Cap at 5.", "the answer is recorded on the newest row");
85
+ assertEquals(stale?.patch.status, "stale", "the older duplicate open row is marked stale");
86
+ assertEquals(prUpdates.length, 1, "the PR row is moved off `escalated` exactly once");
87
+ assertEquals(prUpdates[0].patch.status, "converging");
88
+ });
89
+
90
+ test("no open row is a no-op (idempotent re-completion)", async () => {
91
+ const rows = [{ id: 7, pr_key: "o/r#1", status: "answered", question: "Which retry cap?" }];
92
+ const { app, updates, prUpdates } = fakeApp(rows);
93
+ const job = { variables: { prKey: "o/r#1", answer: "again" } };
94
+ await handler(job as any, app as any);
95
+ assertEquals(updates.length, 0, "nothing to retire when no escalation is open");
96
+ assertEquals(prUpdates.length, 0, "no open row → PR status is left untouched");
97
+ });
98
+
99
+ test("a blank answer is recorded as NULL, not an empty string", async () => {
100
+ const rows = [{ id: 7, pr_key: "o/r#1", status: "open", question: "Which retry cap?" }];
101
+ const { app, updates } = fakeApp(rows);
102
+ const job = { variables: { prKey: "o/r#1", answer: " " } };
103
+ await handler(job as any, app as any);
104
+ assertEquals(updates[0].patch.answer, null);
105
+ assertEquals(updates[0].patch.status, "answered");
106
+ });
@@ -0,0 +1,67 @@
1
+ // Red/green regression for answerEscalation (merge-loop message path) — Copilot review of PR #180.
2
+ //
3
+ // `pr.persist-escalation` always INSERTs a fresh `escalations` row with `status="open"`, so a
4
+ // retry/duplicate activation can leave more than one open row for the same PR. answerEscalation
5
+ // only closed the NEWEST open row, leaving older duplicates `open` — a phantom that keeps
6
+ // `activePrs` deriving an open escalation while the PR is still `escalated`. Every open row for the
7
+ // PR must leave `open` in this completion (newest answered, the rest marked `stale`), exactly as
8
+ // `submitPr`'s resubmit cleanup already does. Mirrors the review loop's `pr.answer-escalation`.
9
+ import { test } from "node:test";
10
+ import { assertEquals } from "#test-assert";
11
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
12
+ import { answerEscalation } from "./service.ts";
13
+
14
+ function memData(escalations: any[], prs: any[]): DataLayer {
15
+ const table = (name: string, key: string) => ({
16
+ async find(where: Record<string, unknown>) {
17
+ const src = name === "escalations" ? escalations : prs;
18
+ return src.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v));
19
+ },
20
+ async update(k: unknown, patch: Record<string, unknown>) {
21
+ const src = name === "escalations" ? escalations : prs;
22
+ const r = src.find((x) => x[key] === k);
23
+ if (r) Object.assign(r, patch);
24
+ return r;
25
+ },
26
+ });
27
+ return { table } as any as DataLayer;
28
+ }
29
+
30
+ function fakeEngine(published: any[]): EngineClient {
31
+ return {
32
+ async publishMessage(m: any) {
33
+ published.push(m);
34
+ },
35
+ } as any as EngineClient;
36
+ }
37
+
38
+ test("answerEscalation answers the newest open row and marks duplicate open rows stale", async () => {
39
+ const escalations = [
40
+ { id: 3, pr_key: "o/r#1", status: "open", answer: null, answered_at: null },
41
+ { id: 7, pr_key: "o/r#1", status: "open", answer: null, answered_at: null },
42
+ { id: 9, pr_key: "o/r#2", status: "open", answer: null, answered_at: null },
43
+ ];
44
+ const prs = [{ pr_key: "o/r#1", status: "escalated", updated_at: "t0" }];
45
+ const published: any[] = [];
46
+ const res = await answerEscalation(memData(escalations, prs), fakeEngine(published), "o/r#1", "Cap at 5.");
47
+
48
+ assertEquals(res, { ok: true, escalationId: 7 }, "the newest open row is the one answered");
49
+ const answered = escalations.find((e) => e.id === 7);
50
+ const stale = escalations.find((e) => e.id === 3);
51
+ const other = escalations.find((e) => e.id === 9);
52
+ assertEquals(answered?.status, "answered");
53
+ assertEquals(answered?.answer, "Cap at 5.");
54
+ assertEquals(stale?.status, "stale", "the older duplicate open row is retired to stale");
55
+ assertEquals(other?.status, "open", "the other PR's open row is untouched");
56
+ assertEquals(prs[0].status, "converging", "the PR row is moved off escalated");
57
+ assertEquals(published.length, 1, "the resume message is published once");
58
+ assertEquals(published[0].variables.escalationId, 7);
59
+ });
60
+
61
+ test("answerEscalation is a no-op when no row is open", async () => {
62
+ const escalations = [{ id: 7, pr_key: "o/r#1", status: "answered", answer: "x", answered_at: "t" }];
63
+ const published: any[] = [];
64
+ const res = await answerEscalation(memData(escalations, []), fakeEngine(published), "o/r#1", "again");
65
+ assertEquals(res, { ok: false, reason: "no open escalation" });
66
+ assertEquals(published.length, 0);
67
+ });
@@ -1,7 +1,7 @@
1
1
  // Unit tests for the dead-end-base guard decision (#60).
2
2
  import { test } from "node:test";
3
3
  import { assertEquals } from "#test-assert";
4
- import { type BaseTarget, isDeadEndBase } from "./baseGuard.ts";
4
+ import { type BaseTarget, classifyBaseGuard, isDeadEndBase } from "./baseGuard.ts";
5
5
 
6
6
  const t = (base: string, defaultBranch: string, landed: BaseTarget["landed"]): BaseTarget => ({
7
7
  base,
@@ -34,3 +34,11 @@ test("unknown-safe: blank base or blank default is never a dead-end", () => {
34
34
  assertEquals(isDeadEndBase(t("", "main", "landed")), false);
35
35
  assertEquals(isDeadEndBase(t("feat/x", "", "landed")), false);
36
36
  });
37
+
38
+ // The dead-end base is classified against the canonical escalation taxonomy: a confirmed dead end
39
+ // is a decision-required escalation (a human must retarget); anything else is not an escalation, so
40
+ // the merge worker never blocks a valid merge on ambiguity.
41
+ test("classifyBaseGuard: a confirmed dead end is decision-required, else none", () => {
42
+ assertEquals(classifyBaseGuard({ deadEnd: true }), "decision-required");
43
+ assertEquals(classifyBaseGuard({ deadEnd: false }), "none");
44
+ });
package/app/baseGuard.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  //
10
10
  // This module detects that case so the merge worker can escalate (retarget) instead of landing.
11
11
 
12
+ import { classifyEscalation, type EscalationDisposition } from "./escalationTaxonomy.ts";
12
13
  import { baseBranchLanded, fetchDefaultBranch, fetchPrBase } from "./github.ts";
13
14
 
14
15
  /** The landing-target facts the dead-end decision is made from. */
@@ -60,3 +61,13 @@ export async function checkBaseTarget(
60
61
  const target: BaseTarget = { base, defaultBranch, landed };
61
62
  return { deadEnd: isDeadEndBase(target), base, defaultBranch, landed };
62
63
  }
64
+
65
+ /** Classify a base-guard result against the canonical escalation taxonomy. A confirmed
66
+ * dead-end base is a `decision-required` escalation (a human must retarget the PR); anything
67
+ * else — a legitimately-stacked open base, an ambiguous signal, or a transport hiccup — is not
68
+ * an escalation (`none`), so the guard never blocks a valid merge. Delegates to
69
+ * {@link classifyEscalation} so the merge worker shares one source of truth with every other
70
+ * raise site. */
71
+ export function classifyBaseGuard(result: Pick<BaseGuardResult, "deadEnd">): EscalationDisposition {
72
+ return classifyEscalation({ kind: "dead-end-base", deadEnd: result.deadEnd });
73
+ }
@@ -0,0 +1,39 @@
1
+ // Unit coverage for the escalation-of-the-escalation SLA duration policy (U5, #156). The value is
2
+ // baked into every plan-fanout instance's `escalationSlaTimeout` process variable and evaluated by
3
+ // each escalation user task's interrupting timer boundary, so a malformed operator env must never
4
+ // deploy an uninterpretable `<bpmn:timeDuration>` — it falls back to the default instead. Run with
5
+ // `node --test`.
6
+
7
+ import assert from "node:assert/strict";
8
+ import { test } from "node:test";
9
+ import { DEFAULT_ESCALATION_SLA_TIMEOUT, escalationSlaTimeout } from "./escalationSla.ts";
10
+
11
+ test("escalationSlaTimeout: blank / absent / malformed → default", () => {
12
+ assert.equal(escalationSlaTimeout(undefined), DEFAULT_ESCALATION_SLA_TIMEOUT);
13
+ assert.equal(escalationSlaTimeout(""), DEFAULT_ESCALATION_SLA_TIMEOUT);
14
+ assert.equal(escalationSlaTimeout(" "), DEFAULT_ESCALATION_SLA_TIMEOUT);
15
+ assert.equal(escalationSlaTimeout("2h"), DEFAULT_ESCALATION_SLA_TIMEOUT); // missing leading P/T
16
+ assert.equal(escalationSlaTimeout("P"), DEFAULT_ESCALATION_SLA_TIMEOUT); // no component
17
+ assert.equal(escalationSlaTimeout("PT"), DEFAULT_ESCALATION_SLA_TIMEOUT); // T with no time part
18
+ assert.equal(escalationSlaTimeout("garbage"), DEFAULT_ESCALATION_SLA_TIMEOUT);
19
+ });
20
+
21
+ test("escalationSlaTimeout: a valid ISO-8601 duration is honoured and upper-cased", () => {
22
+ assert.equal(escalationSlaTimeout("PT30M"), "PT30M");
23
+ assert.equal(escalationSlaTimeout("pt2h"), "PT2H");
24
+ assert.equal(escalationSlaTimeout("P1D"), "P1D");
25
+ assert.equal(escalationSlaTimeout(" pt15m "), "PT15M");
26
+ });
27
+
28
+ test("escalationSlaTimeout: an explicit fallback is honoured for a bad value", () => {
29
+ assert.equal(escalationSlaTimeout("nope", "PT10M"), "PT10M");
30
+ assert.equal(escalationSlaTimeout("PT45M", "PT10M"), "PT45M");
31
+ });
32
+
33
+ test("the default is itself a well-formed ISO-8601 duration (never an uninterpretable timer)", () => {
34
+ // Validate the default against the grammar with a *distinct* fallback: if the default were
35
+ // malformed it would fall through to the sentinel, so equality to itself proves it parses.
36
+ const sentinel = "PT1S";
37
+ assert.notEqual(DEFAULT_ESCALATION_SLA_TIMEOUT, sentinel);
38
+ assert.equal(escalationSlaTimeout(DEFAULT_ESCALATION_SLA_TIMEOUT, sentinel), DEFAULT_ESCALATION_SLA_TIMEOUT);
39
+ });
@@ -0,0 +1,28 @@
1
+ // Escalation-of-the-escalation SLA policy (epic #156, slice U5), kept as a pure module (no env, no
2
+ // I/O) so it is trivially testable — mirrors app/reviewWait.ts. `app/plan.ts` seeds the validated
3
+ // `escalationSlaTimeout` process variable at submit; the escalation user tasks in
4
+ // plan-fanout.bpmn carry an interrupting timer boundary whose `<bpmn:timeDuration>=escalationSlaTimeout`
5
+ // evaluates it at timer creation (FEEL-expression timer durations, engine-native).
6
+ //
7
+ // This models liveness IN the process: an escalation user task a human never answers can no longer
8
+ // hang forever — when the SLA elapses the boundary fires and the token auto-proceeds down each
9
+ // gateway's existing safe-default arm. It is the durable, in-process replacement for a poller-side
10
+ // watchdog: no external actor is required and forward progress is always possible.
11
+
12
+ import { isoDuration } from "./reviewWait.ts";
13
+
14
+ /** Default escalation SLA (ISO-8601 duration): how long an escalation user task may sit unanswered
15
+ * before its timer boundary fires and the process auto-proceeds down the safe-default arm. A day is
16
+ * generous for a human decision while still bounding the wait. */
17
+ export const DEFAULT_ESCALATION_SLA_TIMEOUT = "PT24H";
18
+
19
+ /** Validate the operator-supplied escalation SLA (env `NANO_ESCALATION_SLA_TIMEOUT`, ISO-8601
20
+ * duration), falling back to {@link DEFAULT_ESCALATION_SLA_TIMEOUT} when absent, blank, or
21
+ * malformed — a bad env value must never deploy an uninterpretable timer expression. Derives its
22
+ * validation from the single canonical {@link isoDuration}. */
23
+ export function escalationSlaTimeout(
24
+ raw: string | undefined,
25
+ def: string = DEFAULT_ESCALATION_SLA_TIMEOUT,
26
+ ): string {
27
+ return isoDuration(raw, def);
28
+ }
@@ -0,0 +1,115 @@
1
+ // Unit coverage for the canonical escalation taxonomy (app/escalationTaxonomy.ts) — the single
2
+ // source of truth every raise site and escalation-conversion slice routes through (ADR 0002 §1).
3
+ import { test } from "node:test";
4
+ import { assert, assertEquals } from "#test-assert";
5
+ import {
6
+ classifyEscalation,
7
+ type EscalationSignal,
8
+ hasAnswerableQuestion,
9
+ shouldRaiseTask,
10
+ } from "./escalationTaxonomy.ts";
11
+
12
+ test("hasAnswerableQuestion: only a non-blank string is answerable", () => {
13
+ assertEquals(hasAnswerableQuestion("decide this"), true);
14
+ assertEquals(hasAnswerableQuestion(" trimmed "), true);
15
+ for (const blank of [undefined, null, "", " ", "\n\t"]) {
16
+ assertEquals(hasAnswerableQuestion(blank), false, `blank ${JSON.stringify(blank)} is not answerable`);
17
+ }
18
+ });
19
+
20
+ // --- review-round (roundResultDefault / gw-status) ---
21
+
22
+ test("review-round: an unknown/empty/converged status is transient (the empty-status backstop)", () => {
23
+ for (const status of [undefined, "", "unknown", "converged", "addressed", "waiting"]) {
24
+ assertEquals(
25
+ classifyEscalation({ kind: "review-round", status, question: "ignored" }),
26
+ "transient",
27
+ `status ${JSON.stringify(status)} re-enters the loop`,
28
+ );
29
+ }
30
+ });
31
+
32
+ test("review-round: a human-blocking status WITH an answerable question is decision-required", () => {
33
+ for (const status of ["needs_input", "blocked"]) {
34
+ assertEquals(classifyEscalation({ kind: "review-round", status, question: "please decide" }), "decision-required");
35
+ }
36
+ });
37
+
38
+ test("review-round: status is matched exactly (untrimmed), mirroring the gw-status conditions", () => {
39
+ // The gw-status gateway does not trim `status`, so a whitespace-padded token is NOT the enum
40
+ // value and stays transient. Pins the no-drift contract between this taxonomy and the model.
41
+ for (const status of ["needs_input ", " needs_input", "blocked ", "converged "]) {
42
+ assertEquals(
43
+ classifyEscalation({ kind: "review-round", status, question: "please decide" }),
44
+ "transient",
45
+ `whitespace-padded status ${JSON.stringify(status)} is not the enum → transient`,
46
+ );
47
+ }
48
+ });
49
+
50
+ test("review-round: a human-blocking status with a BLANK question is a non-escalation", () => {
51
+ for (const status of ["needs_input", "blocked"]) {
52
+ for (const question of [undefined, "", " "]) {
53
+ assertEquals(
54
+ classifyEscalation({ kind: "review-round", status, question }),
55
+ "none",
56
+ `blank-question ${status} cannot fabricate an escalation`,
57
+ );
58
+ }
59
+ }
60
+ });
61
+
62
+ // --- dead-end-base (baseGuard) ---
63
+
64
+ test("dead-end-base: a confirmed dead end is decision-required; ambiguity is none", () => {
65
+ assertEquals(classifyEscalation({ kind: "dead-end-base", deadEnd: true }), "decision-required");
66
+ assertEquals(classifyEscalation({ kind: "dead-end-base", deadEnd: false }), "none");
67
+ assertEquals(classifyEscalation({ kind: "dead-end-base" }), "none");
68
+ });
69
+
70
+ // --- merge-protocol (mergeProtocol) ---
71
+
72
+ test("merge-protocol: only a `ui` land method is decision-required; the rest are transient", () => {
73
+ assertEquals(classifyEscalation({ kind: "merge-protocol", landMethod: "ui" }), "decision-required");
74
+ for (const landMethod of ["gh-merge", "admin", "mergify-queue", undefined]) {
75
+ assertEquals(
76
+ classifyEscalation({ kind: "merge-protocol", landMethod }),
77
+ "transient",
78
+ `land method ${JSON.stringify(landMethod)} lands in-process`,
79
+ );
80
+ }
81
+ });
82
+
83
+ // --- task (plan-fanout w_gw "escalated?") ---
84
+
85
+ test("task: status=escalated with an answerable question is decision-required; blank is none", () => {
86
+ assertEquals(classifyEscalation({ kind: "task", question: "which approach?" }), "decision-required");
87
+ for (const question of [undefined, "", " "]) {
88
+ assertEquals(
89
+ classifyEscalation({ kind: "task", question }),
90
+ "none",
91
+ "a blank-question escalated task is a non-escalation",
92
+ );
93
+ }
94
+ });
95
+
96
+ // --- the shared guard ---
97
+
98
+ test("shouldRaiseTask: true only for the decision-required tier", () => {
99
+ const raised: EscalationSignal[] = [
100
+ { kind: "review-round", status: "needs_input", question: "q" },
101
+ { kind: "dead-end-base", deadEnd: true },
102
+ { kind: "merge-protocol", landMethod: "ui" },
103
+ { kind: "task", question: "q" },
104
+ ];
105
+ for (const s of raised) assert(shouldRaiseTask(s), `${s.kind} should raise a task`);
106
+
107
+ const notRaised: EscalationSignal[] = [
108
+ { kind: "review-round", status: "needs_input", question: "" }, // blank question
109
+ { kind: "review-round", status: "waiting", question: "q" }, // transient
110
+ { kind: "dead-end-base", deadEnd: false }, // ambiguous
111
+ { kind: "merge-protocol", landMethod: "gh-merge" }, // transient
112
+ { kind: "task", question: " " }, // blank question
113
+ ];
114
+ for (const s of notRaised) assertEquals(shouldRaiseTask(s), false, `${s.kind} should NOT raise a task`);
115
+ });