@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,115 @@
1
+ // Escalation taxonomy — the single canonical source of truth for classifying an
2
+ // escalation raise site into a tier (nano-workforce ADR 0002 §1).
3
+ //
4
+ // The app used to page a human from several independent raise sites, each with its own
5
+ // ad-hoc "should this become an escalation?" logic, and — worst of all — a blank/absent
6
+ // question could FABRICATE an answerable escalation (or, on the plan-fanout arm, wedge an
7
+ // un-remediable incident). This module replaces that scattered logic with ONE classifier so
8
+ // every raise site, and every downstream escalation-conversion slice (U2–U7), asks the same
9
+ // question in the same place.
10
+ //
11
+ // ADR 0002 §1 defines three tiers:
12
+ // • transient — handled in-process (retry / re-enter a durable wait / re-request a
13
+ // review). Never becomes a task. The empty-status backstop and the
14
+ // re-request-review nudge are transient.
15
+ // • advisory — recorded on the coordination blackboard for humans/siblings to read.
16
+ // Never becomes a task and never blocks a token.
17
+ // • decision-required — a human (or, per ADR 0046, an agent assignee) must make a call.
18
+ // ONLY this tier proceeds toward a user task.
19
+ //
20
+ // A fourth disposition, `none`, means "not an escalation at all": the signal was raised but,
21
+ // on inspection, there is nothing to escalate — most importantly a decision-required kind
22
+ // whose question is blank. A blank question can no longer fabricate an answerable escalation:
23
+ // it is a NON-escalation (no task, no wait).
24
+
25
+ /** The three escalation tiers of ADR 0002 §1. */
26
+ export type EscalationTier = "transient" | "advisory" | "decision-required";
27
+
28
+ /** A raise site's disposition: either it is not an escalation at all (`none`), or it falls
29
+ * into one of the three tiers. Only `decision-required` proceeds toward a user task. */
30
+ export type EscalationDisposition = "none" | EscalationTier;
31
+
32
+ /** Every escalation raise site in the codebase, tagged by origin so the classifier can apply
33
+ * that site's policy. */
34
+ export type EscalationKind =
35
+ // convergence-loop `gw-status` gateway — the review-round "safe default" routing that
36
+ // `roundResultDefault` mirrors.
37
+ | "review-round"
38
+ // `baseGuard` (app/baseGuard.ts) — a PR that targets a dead-end (already-landed) base.
39
+ | "dead-end-base"
40
+ // `mergeProtocol` (app/mergeProtocol.ts) — the repo's declared land method.
41
+ | "merge-protocol"
42
+ // plan-fanout `w_gw` "escalated?" gateway — an implementation agent reported
43
+ // `status = "escalated"` with a question.
44
+ | "task";
45
+
46
+ /** Everything the classifier may need from any raise site. Each field is consumed only by the
47
+ * kind(s) it applies to; the rest are ignored. */
48
+ export interface EscalationSignal {
49
+ kind: EscalationKind;
50
+ /** The human-facing question / reason. Blank (absent, empty, or whitespace-only) means the
51
+ * signal can never become a task — see {@link hasAnswerableQuestion}. */
52
+ question?: string | null;
53
+ /** `review-round` only: the machine-readable status the review agent reported. */
54
+ status?: string | null;
55
+ /** `dead-end-base` only: whether the base is a CONFIRMED dead end (ambiguity is never a
56
+ * dead end — see {@link import("./baseGuard.ts").isDeadEndBase}). */
57
+ deadEnd?: boolean;
58
+ /** `merge-protocol` only: the repo's declared land method (`ui` needs a human). */
59
+ landMethod?: string | null;
60
+ }
61
+
62
+ /** True iff `question` is a concrete, human-answerable string — a non-blank value after
63
+ * trimming. This is the canonical blank-question rule the whole taxonomy shares: an
64
+ * empty / whitespace-only / absent question is NOT answerable, so it can never fabricate an
65
+ * escalation. */
66
+ export function hasAnswerableQuestion(question: string | null | undefined): boolean {
67
+ return typeof question === "string" && question.trim() !== "";
68
+ }
69
+
70
+ /** The review-round statuses that DEMAND a human decision. Any other status (converged,
71
+ * addressed, waiting, or an unknown/empty one — the empty-status backstop) is transient: it
72
+ * re-enters the durable review wait rather than paging a human. */
73
+ const DECISION_STATUSES: ReadonlySet<string> = new Set(["needs_input", "blocked"]);
74
+
75
+ /** Classify an escalation signal into its disposition. The one place the tiered taxonomy of
76
+ * ADR 0002 §1 is decided — every raise site and every escalation-conversion slice routes
77
+ * through here rather than re-deriving tier logic per kind. */
78
+ export function classifyEscalation(signal: EscalationSignal): EscalationDisposition {
79
+ switch (signal.kind) {
80
+ case "review-round": {
81
+ // Exact-token match, mirroring the convergence-loop `gw-status` gateway (whose
82
+ // status conditions do NOT trim) so this canonical router can never drift from the
83
+ // deployed model. `status` is a machine-produced enum, not free-form prose, so it is
84
+ // compared exactly — unlike `question`, whose blank-detection trims (hasAnswerableQuestion).
85
+ const status = signal.status ?? "";
86
+ // Only an explicit human-blocking status is even a candidate; everything else is
87
+ // transient (re-enter the review wait — this is the empty-status backstop).
88
+ if (!DECISION_STATUSES.has(status)) return "transient";
89
+ // A human-blocking status with no answerable question is a NON-escalation: a blank
90
+ // question can no longer fabricate an answerable escalation.
91
+ return hasAnswerableQuestion(signal.question) ? "decision-required" : "none";
92
+ }
93
+ case "dead-end-base":
94
+ // A confirmed dead-end base needs a human retarget; ambiguity is never raised, so a
95
+ // non-dead-end signal is simply not an escalation.
96
+ return signal.deadEnd ? "decision-required" : "none";
97
+ case "merge-protocol":
98
+ // Only a `ui` land method needs a human (to click Merge). Every machine-landable method
99
+ // (gh-merge / admin / mergify-queue) stays in-process — transient.
100
+ return signal.landMethod === "ui" ? "decision-required" : "transient";
101
+ case "task":
102
+ // The agent declared `status = "escalated"`; a blank question is a NON-escalation. This
103
+ // retires the blank-question fabrication failure mode — no task, no wait.
104
+ return hasAnswerableQuestion(signal.question) ? "decision-required" : "none";
105
+ default:
106
+ return "none";
107
+ }
108
+ }
109
+
110
+ /** The guard every raise site and escalation-conversion slice shares: does this signal proceed
111
+ * toward a user task? Only the `decision-required` tier does — transient and advisory signals,
112
+ * and non-escalations (`none`, including blank-question signals), never raise a task. */
113
+ export function shouldRaiseTask(signal: EscalationSignal): boolean {
114
+ return classifyEscalation(signal) === "decision-required";
115
+ }
@@ -0,0 +1,161 @@
1
+ // Unit coverage for the single-issue feature run domain (issue #172).
2
+ //
3
+ // `startFeature` mirrors `startPlan`: it registers/refreshes the `feature_runs` aggregate
4
+ // (idempotent on `feature_key`), starts `feature.bpmn`, and persists the process key. These tests
5
+ // drive it against an in-memory data layer + a stub engine and assert the row shape, the
6
+ // short-circuit on an already-running run, the in-place restart of a settled run, and the seeded
7
+ // process variables (the single `task` slice + the base-branch brief).
8
+ import { test } from "node:test";
9
+ import { assertEquals } from "#test-assert";
10
+ import { FEATURE_PROCESS_ID, featureTaskId, startFeature } from "./feature.ts";
11
+
12
+ function memTable(rows: any[], key: string) {
13
+ return {
14
+ get: (k: any) => Promise.resolve(rows.find((r) => r[key] === k) ?? null),
15
+ find: (q: any) =>
16
+ Promise.resolve(rows.filter((r) => Object.entries(q).every(([f, v]) => r[f] === v))),
17
+ insert: (r: any) => {
18
+ rows.push(r);
19
+ return Promise.resolve(r);
20
+ },
21
+ update: (k: any, patch: any) => {
22
+ const r = rows.find((x) => x[key] === k);
23
+ if (r) Object.assign(r, patch);
24
+ return Promise.resolve(r);
25
+ },
26
+ delete: (k: any) => {
27
+ for (let i = rows.length - 1; i >= 0; i--) if (rows[i][key] === k) rows.splice(i, 1);
28
+ return Promise.resolve();
29
+ },
30
+ };
31
+ }
32
+
33
+ function memData(stores: Record<string, { rows: any[]; key: string }>) {
34
+ return {
35
+ table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
36
+ } as any;
37
+ }
38
+
39
+ const PARSED = {
40
+ repo: "owner/repo",
41
+ number: 42,
42
+ url: "https://github.com/owner/repo/issues/42",
43
+ planKey: "owner/repo#42",
44
+ };
45
+
46
+ test("featureTaskId: deterministic branch slug derivable from the issue number alone", () => {
47
+ assertEquals(featureTaskId(42), "issue-42");
48
+ assertEquals(featureTaskId(1), "issue-1");
49
+ });
50
+
51
+ test("startFeature: inserts a running feature_runs row and persists the process key", async () => {
52
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
53
+ const data = memData(stores);
54
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-9" }) } as any;
55
+
56
+ const result = await startFeature(data, engine, PARSED, "main", false, false);
57
+
58
+ assertEquals(result.featureKey, "owner/repo#42");
59
+ assertEquals(result.processKey, "PI-9");
60
+ const row = stores.feature_runs.rows[0];
61
+ assertEquals(row.feature_key, "owner/repo#42");
62
+ assertEquals(row.repo, "owner/repo");
63
+ assertEquals(row.issue_number, 42);
64
+ assertEquals(row.base_branch, "main");
65
+ assertEquals(row.status, "running");
66
+ assertEquals(row.process_key, "PI-9");
67
+ assertEquals(row.converge, 0);
68
+ assertEquals(row.auto_merge, 0);
69
+ assertEquals(row.pr_key, null);
70
+ });
71
+
72
+ test("startFeature: converge/autoMerge flags are persisted as 0/1", async () => {
73
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
74
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-1" }) } as any;
75
+ await startFeature(memData(stores), engine, PARSED, "main", true, true);
76
+ const row = stores.feature_runs.rows[0];
77
+ assertEquals(row.converge, 1);
78
+ assertEquals(row.auto_merge, 1);
79
+ });
80
+
81
+ test("startFeature: seeds the single task slice + base-branch brief onto the instance", async () => {
82
+ let captured: any = null;
83
+ const engine = {
84
+ createInstance: (req: any) => {
85
+ captured = req;
86
+ return Promise.resolve({ processInstanceKey: "PI-2" });
87
+ },
88
+ } as any;
89
+ await startFeature(
90
+ memData({ feature_runs: { rows: [], key: "feature_key" } }),
91
+ engine,
92
+ PARSED,
93
+ "epic/x",
94
+ true,
95
+ false,
96
+ );
97
+ assertEquals(captured.processDefinitionId, FEATURE_PROCESS_ID);
98
+ const v = captured.variables;
99
+ assertEquals(v.featureKey, "owner/repo#42");
100
+ assertEquals(v.issue, "owner/repo#42");
101
+ assertEquals(v.task.id, "issue-42");
102
+ assertEquals(v.task.title, "owner/repo#42");
103
+ assertEquals(typeof v.task.prompt, "string");
104
+ assertEquals(v.task.prompt.includes("owner/repo#42"), true);
105
+ assertEquals(v.converge, true);
106
+ assertEquals(v.autoMerge, false);
107
+ assertEquals(v.baseBranch, "epic/x");
108
+ // The brief is the authoritative base-branch override the agent gets via appendPrompt.
109
+ assertEquals(v.baseBranchBrief.includes("epic/x"), true);
110
+ // Agent-result variables are pre-seeded so the escalation loop + record worker can reference them.
111
+ assertEquals(v.pr, null);
112
+ assertEquals(v.status, null);
113
+ });
114
+
115
+ test("startFeature: an already-running run short-circuits (no new instance)", async () => {
116
+ const stores = {
117
+ feature_runs: {
118
+ rows: [{ feature_key: "owner/repo#42", status: "running", process_key: "PI-OLD" }],
119
+ key: "feature_key",
120
+ },
121
+ };
122
+ let created = 0;
123
+ const engine = {
124
+ createInstance: () => {
125
+ created += 1;
126
+ return Promise.resolve({ processInstanceKey: "PI-NEW" });
127
+ },
128
+ } as any;
129
+ const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
130
+ assertEquals(created, 0);
131
+ assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true);
132
+ assertEquals(result.processKey, "PI-OLD");
133
+ });
134
+
135
+ test("startFeature: a settled run is restarted in place (status reset, pr/outcome cleared)", async () => {
136
+ const stores = {
137
+ feature_runs: {
138
+ rows: [
139
+ {
140
+ feature_key: "owner/repo#42",
141
+ status: "opened",
142
+ process_key: "PI-OLD",
143
+ pr_key: "owner/repo#100",
144
+ outcome: "prior run",
145
+ converge: 0,
146
+ auto_merge: 0,
147
+ },
148
+ ],
149
+ key: "feature_key",
150
+ },
151
+ };
152
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: "PI-2" }) } as any;
153
+ await startFeature(memData(stores), engine, PARSED, "main", true, true);
154
+ const row = stores.feature_runs.rows[0];
155
+ assertEquals(row.status, "running");
156
+ assertEquals(row.pr_key, null);
157
+ assertEquals(row.outcome, null);
158
+ assertEquals(row.converge, 1);
159
+ assertEquals(row.auto_merge, 1);
160
+ assertEquals(row.process_key, "PI-2");
161
+ });
package/app/feature.ts ADDED
@@ -0,0 +1,173 @@
1
+ // nano-workforce — single-issue feature run (issue #172).
2
+ //
3
+ // The "missing middle" dispatch surface, between Epics (plan-fanout: one issue →
4
+ // many PRs) and PR convergence (an already-open PR → review → merge): hand ONE
5
+ // issue to a single implementation agent (`senior:feature`), which raises exactly
6
+ // ONE PR, then OPTIONALLY hand that PR to the convergence loop (and, with
7
+ // auto-merge, the merge-loop).
8
+ //
9
+ // This module is the seam the `startFeature` action and the `feature.bpmn` record
10
+ // workers call: it owns the `feature_runs` row shape, the per-run task derivation,
11
+ // and starting the process. It deliberately REUSES the epic primitives rather than
12
+ // forking them — `parseIssue`/`renderBaseBranchBrief`/`normalizeBaseBranch` and the
13
+ // `ESCALATION_SLA_TIMEOUT` come from app/plan.ts, and the downstream PR lifecycle is
14
+ // the existing convergence loop (`submitPr`), keyed on `pr_key` → `pull_requests`.
15
+ // Data access goes through the record gateway (`data.table`), never hand-written
16
+ // SQL — matching app/plan.ts and app/service.ts.
17
+ import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
+ import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
19
+
20
+ /** The BPMN process this module drives (resources/processes/feature.bpmn). */
21
+ export const FEATURE_PROCESS_ID = "feature";
22
+
23
+ const now = () => new Date().toISOString();
24
+
25
+ /** One row per issue handed to a single implementation agent. Keyed on `feature_key`
26
+ * (`<owner>/<repo>#<issue-number>`, the same string `parseIssue` returns as `planKey`).
27
+ * The downstream PR lifecycle (review/merge) is NOT duplicated here — once `converge`
28
+ * hands the opened PR to `submitPr`, its live state lives on the `pull_requests` row
29
+ * keyed by `pr_key`. */
30
+ export interface FeatureRun {
31
+ feature_key: string;
32
+ repo: string;
33
+ issue_number: number;
34
+ issue_url: string;
35
+ base_branch: string;
36
+ status: FeatureRunStatus;
37
+ process_key: string | null;
38
+ pr_key: string | null;
39
+ converge: number;
40
+ auto_merge: number;
41
+ outcome: string | null;
42
+ created_at: string;
43
+ updated_at: string;
44
+ }
45
+
46
+ export const FEATURE_RUN_STATUSES = [
47
+ "running", // the agent is implementing (including while parked at an escalation user task)
48
+ "opened", // a PR was raised and the run ends here (converge was not requested)
49
+ "converging", // the opened PR was handed to the convergence loop (live state via pr_key → pull_requests)
50
+ "blocked", // the agent could not open a PR (gave up / escalation abandoned)
51
+ "skipped", // nothing to do
52
+ "failed", // an unexpected failure
53
+ "abandoned", // the process instance was cancelled (set by instanceTracking.onTerminated)
54
+ ] as const;
55
+ export type FeatureRunStatus = typeof FEATURE_RUN_STATUSES[number];
56
+
57
+ /** A feature run is finished once it leaves `running`. Mirrors PLAN_TERMINAL_STATUSES: a
58
+ * re-dispatch of the same issue restarts only when the prior run has settled. */
59
+ export const FEATURE_TERMINAL_STATUSES: readonly FeatureRunStatus[] = [
60
+ "opened",
61
+ "converging",
62
+ "blocked",
63
+ "skipped",
64
+ "failed",
65
+ "abandoned",
66
+ ];
67
+
68
+ export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
69
+
70
+ /** The deterministic task id for a single-issue run — the implementation agent branches
71
+ * `feat/<task.id>` (see prompts/feature.md), so it MUST be derivable from the issue alone
72
+ * and stable across a resume. The PR is opened on the target repo, so the issue number
73
+ * alone is unambiguous within it. */
74
+ export function featureTaskId(issueNumber: number): string {
75
+ return `issue-${issueNumber}`;
76
+ }
77
+
78
+ /** Register/refresh the feature-run aggregate (idempotent on `feature_key`) and start
79
+ * `feature.bpmn`. Mirrors startPlan: an already-running run for the same issue
80
+ * short-circuits; a settled prior run is restarted in place. `baseBranch` is admitted at
81
+ * the operation edge (`admitPlan`, ADR 0003) exactly as the epic path — this seam only
82
+ * normalizes it again defensively before seeding the brief. */
83
+ export async function startFeature(
84
+ data: DataLayer,
85
+ engine: EngineClient,
86
+ parsed: ParsedIssue,
87
+ baseBranch: string,
88
+ converge: boolean,
89
+ autoMerge: boolean,
90
+ ) {
91
+ const table = featureRuns(data);
92
+ const existing = await table.get(parsed.planKey);
93
+ if (existing && !FEATURE_TERMINAL_STATUSES.includes(existing.status)) {
94
+ return { featureKey: parsed.planKey, alreadyRunning: true, processKey: existing.process_key };
95
+ }
96
+ const base = normalizeBaseBranch(baseBranch);
97
+ const ts = now();
98
+ if (existing) {
99
+ await table.update(parsed.planKey, {
100
+ status: "running",
101
+ base_branch: base,
102
+ issue_url: parsed.url,
103
+ pr_key: null,
104
+ converge: converge ? 1 : 0,
105
+ auto_merge: autoMerge ? 1 : 0,
106
+ outcome: null,
107
+ updated_at: ts,
108
+ });
109
+ } else {
110
+ await table.insert({
111
+ feature_key: parsed.planKey,
112
+ repo: parsed.repo,
113
+ issue_number: parsed.number,
114
+ issue_url: parsed.url,
115
+ base_branch: base,
116
+ status: "running",
117
+ process_key: null,
118
+ pr_key: null,
119
+ converge: converge ? 1 : 0,
120
+ auto_merge: autoMerge ? 1 : 0,
121
+ outcome: null,
122
+ created_at: ts,
123
+ updated_at: ts,
124
+ });
125
+ }
126
+ const { processInstanceKey } = await engine.createInstance({
127
+ processDefinitionId: FEATURE_PROCESS_ID,
128
+ variables: {
129
+ featureKey: parsed.planKey,
130
+ repo: parsed.repo,
131
+ // The parent issue reference (`owner/repo#123`) the agent reads for context (`gh issue view`).
132
+ issue: parsed.planKey,
133
+ issueNumber: parsed.number,
134
+ issueUrl: parsed.url,
135
+ // The single slice the implementation agent builds. `task.prompt` is its primary instruction
136
+ // (prompts/feature.md); `task.id` fixes its deterministic branch `feat/<task.id>` across a
137
+ // resume. Unlike an epic, there is no planner — the whole issue IS the slice.
138
+ task: {
139
+ id: featureTaskId(parsed.number),
140
+ title: parsed.planKey,
141
+ prompt:
142
+ `Implement the GitHub issue ${parsed.planKey} end to end. Read it in full first ` +
143
+ `(\`gh issue view ${parsed.number} -R ${parsed.repo}\`), implement it completely, ` +
144
+ `and open exactly one pull request that closes it.`,
145
+ },
146
+ // Follow-on knobs, read by the record/converge workers. `autoMerge` maps to submitPr's
147
+ // `convergeOnly` (inverted): converge-only stops at `converged`; auto-merge drives the
148
+ // merge-loop. `converge=false` ⇒ merge is moot.
149
+ converge,
150
+ autoMerge,
151
+ // Seed the agent-result variables so the escalation loop + record worker can reference them
152
+ // before the first `senior:feature` job completes (the harness merges the real values in).
153
+ answer: null,
154
+ status: null,
155
+ question: null,
156
+ summary: null,
157
+ pr: null,
158
+ // Escalation SLA + optional assignee — identical to the epic path (plan-fanout), read by the
159
+ // `feature-escalation` user task's interrupting timer boundary and assignment definition.
160
+ escalationSlaTimeout: ESCALATION_SLA_TIMEOUT,
161
+ escalationAssignee: null,
162
+ // Base branch (ADR 0003): the branch the agent branches off and opens its PR against. The
163
+ // brief rides `appendPrompt` in the implement task, exactly like the epic implementer.
164
+ baseBranch: base,
165
+ baseBranchBrief: renderBaseBranchBrief(base),
166
+ },
167
+ });
168
+ const processKey = processInstanceKey == null ? null : String(processInstanceKey);
169
+ if (processKey != null) {
170
+ await table.update(parsed.planKey, { process_key: processKey, updated_at: now() });
171
+ }
172
+ return { featureKey: parsed.planKey, processKey };
173
+ }
@@ -7,6 +7,7 @@
7
7
  import { test } from "node:test";
8
8
  import { assertEquals } from "#test-assert";
9
9
  import {
10
+ classifyMergeLanding,
10
11
  DEFAULT_MERGE_PROTOCOL,
11
12
  extractProtocolBlock,
12
13
  freshHeadRunAction,
@@ -195,3 +196,17 @@ test("headRunPresenceCount: token mode (totalChecks < 0) stays conservative (-1)
195
196
  assertEquals(headRunPresenceCount(NANO_REQ, { totalChecks: -1, presentCheckNames: [] }), -1);
196
197
  assertEquals(freshHeadRunAction(NANO_REQ, "waiting", -1, false), null);
197
198
  });
199
+
200
+ // The land method is classified against the canonical escalation taxonomy: only a `ui` method is a
201
+ // decision-required escalation (a human must click Merge); every machine-landable method is
202
+ // transient (the merge stage lands it in-process).
203
+ test("classifyMergeLanding: only `ui` is decision-required", () => {
204
+ const withMethod = (method: MergeProtocol["land"]["method"]): MergeProtocol => ({
205
+ ...DEFAULT_MERGE_PROTOCOL,
206
+ land: { method },
207
+ });
208
+ assertEquals(classifyMergeLanding(withMethod("ui")), "decision-required");
209
+ assertEquals(classifyMergeLanding(withMethod("gh-merge")), "transient");
210
+ assertEquals(classifyMergeLanding(withMethod("admin")), "transient");
211
+ assertEquals(classifyMergeLanding(withMethod("mergify-queue")), "transient");
212
+ });
@@ -14,6 +14,7 @@
14
14
  // 2. `.github/merge-protocol.json`.
15
15
  // A repo that publishes neither keeps today's behaviour (DEFAULT_MERGE_PROTOCOL).
16
16
 
17
+ import { classifyEscalation, type EscalationDisposition } from "./escalationTaxonomy.ts";
17
18
  import { fetchRepoFile } from "./github.ts";
18
19
 
19
20
  /** How to give branch protection a fresh head `pull_request` run before landing. `none` = the
@@ -66,6 +67,15 @@ export const DEFAULT_MERGE_PROTOCOL: MergeProtocol = {
66
67
  requiredChecks: [],
67
68
  };
68
69
 
70
+ /** Classify a repo's landing method against the canonical escalation taxonomy. A `ui` land
71
+ * method is a `decision-required` escalation (only a human can click Merge); every
72
+ * machine-landable method (gh-merge / admin / mergify-queue) is `transient` — the merge stage
73
+ * lands it in-process without paging a human. Delegates to {@link classifyEscalation} so the
74
+ * merge worker shares one source of truth with every other raise site. */
75
+ export function classifyMergeLanding(protocol: MergeProtocol): EscalationDisposition {
76
+ return classifyEscalation({ kind: "merge-protocol", landMethod: protocol.land.method });
77
+ }
78
+
69
79
  const FRESH_HEAD_RUNS: ReadonlySet<string> = new Set(["none", "ready", "reopen", "ready-or-reopen"]);
70
80
  const LAND_METHODS: ReadonlySet<string> = new Set(["gh-merge", "admin", "mergify-queue", "ui"]);
71
81
 
@@ -7,7 +7,7 @@
7
7
  // escalation. The agent-raised / max-rounds arms omit the flag (no prior round row) and must
8
8
  // still record the round.
9
9
  import { test } from "node:test";
10
- import { assert, assertEquals } from "#test-assert";
10
+ import { assertEquals } from "#test-assert";
11
11
  import handler from "../workers/persist-escalation/worker.ts";
12
12
 
13
13
  function fakeApp() {
@@ -99,62 +99,60 @@ test("persist-escalation heals a missing pull_requests parent before recording",
99
99
  });
100
100
 
101
101
 
102
- test("a padded question is persisted trimmed (no whitespace drift)", async () => {
102
+ test("a padded question is persisted trimmed and returned trimmed (no whitespace drift)", async () => {
103
103
  const { app, inserts, updates } = fakeApp();
104
104
  const job = { variables: { prKey: "o/r#1", round: 4, status: "needs_input", question: " needs a decision " } };
105
- await handler(job as any, app as any);
105
+ const out = await handler(job as any, app as any);
106
106
  assertEquals((inserts.escalations[0] as any).question, "needs a decision", "escalation stores the trimmed question");
107
- assertEquals((updates.pull_requests![0] as any).patch.open_escalation_question, "needs a decision", "denormalised question is trimmed too");
107
+ // The trimmed question is returned as a process variable so the downstream `wait-answer`
108
+ // userTask + `pr-escalation.form` can display it (there is no denormalised PR-row pointer).
109
+ assertEquals((out as any).question, "needs a decision", "the returned question is trimmed too");
110
+ const patch = (updates.pull_requests![0] as any).patch;
111
+ assertEquals(patch.open_escalation_question, undefined, "no denormalised question pointer is written");
112
+ assertEquals(patch.open_escalation_id, undefined, "no denormalised id pointer is written");
108
113
  });
109
114
 
110
- // Defence-in-depth for the persist-escalation worker: if the gateway ever DOES route a
111
- // blank-status / blank-question job here (an agent-raised `needs_input`/`blocked` with no
112
- // question, or the max-rounds / review-stalled arms), the worker must NOT throw — throwing
113
- // parked an un-remediable JobNoRetries incident (the empty "(no question provided)" escalations
114
- // on Magikcraft/nano-bpm #597/#599). It opens an *answerable* escalation with a fabricated,
115
- // concrete question and the agent's transcript attached, so a human can unblock the loop from
116
- // the UI.
115
+ // REGRESSION (nano-workforce ADR 0002 §1 retire the blank-question fabrication failure mode).
117
116
  //
118
- // NOTE: the `gw-status` gateway no longer routes an empty/unknown status here that now
119
- // defaults to `f_addressed` and re-enters the review wait (see roundResultDefault.test.ts).
120
- // This fabrication path stays as a worker-level backstop for the explicit escalation arms.
121
- test("blank question fabricates an answerable escalation (no throw, no incident)", async () => {
117
+ // A blank/absent question must be treated as a NON-escalation: no escalation row, no PR status
118
+ // flip to `escalated`, no wait. The worker previously FABRICATED a concrete question from the
119
+ // transcript and opened an answerable escalation that is exactly the failure mode this slice
120
+ // retires. This test reproduces that defect (it fails against the old fabricating worker, which
121
+ // opened an escalation with `escalationId:42`) and pins the new non-escalation behaviour.
122
+ //
123
+ // The `gw-status` gateway is the primary guard (its `f_escalate` arm now requires a non-blank
124
+ // question — see roundResultDefault.test.ts), so a blank-question round never reaches this worker
125
+ // in practice; this asserts the worker's defence-in-depth via the canonical taxonomy.
126
+ test("blank question is a non-escalation (no row fabricated, no status flip)", async () => {
122
127
  for (const question of [undefined, "", " "]) {
123
128
  const { app, inserts, updates } = fakeApp();
124
129
  const job = {
125
130
  variables: {
126
131
  prKey: "o/r#1",
127
132
  round: 2,
133
+ status: "needs_input",
128
134
  ...(question === undefined ? {} : { question }),
129
135
  "io.nanobpm.agentResult": { output: "the agent's prose review, no result file" },
130
136
  },
131
137
  };
132
138
  const out = await handler(job as any, app as any);
133
- assertEquals((out as any).escalationId, 42, "an escalation is opened, not refused");
134
- assertEquals(inserts.escalations.length, 1, "escalation row written");
135
- const esc = inserts.escalations[0] as any;
136
- assert(esc.question.trim().length > 0, "fabricated question is concrete/non-blank");
137
- assert(
138
- esc.question.includes("machine-readable result"),
139
- "no-result rounds explain the missing status",
140
- );
141
- assertEquals(esc.transcript, "the agent's prose review, no result file", "transcript attached");
142
- // Default status for an unclassified escalation is a question needing input.
143
- assertEquals(esc.kind, "question");
144
- const pr = updates.pull_requests![0] as any;
145
- assertEquals(pr.patch.open_escalation_question, esc.question, "denormalised question set");
139
+ assertEquals((out as any).escalated, false, "the job reports no escalation");
140
+ assertEquals((out as any).escalationId, null, "no escalation id is minted");
141
+ assertEquals(inserts.escalations.length, 0, "no escalation row is fabricated");
142
+ assertEquals(inserts.rounds.length, 0, "no round row is written for a non-escalation");
143
+ assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is never flipped to escalated");
146
144
  }
147
145
  });
148
146
 
149
- // When a non-empty-but-unclassified status arrives with no question, the fabricated question
150
- // names the status so the human sees what the agent reported.
151
- test("unclassified status without a question names the status in the fabricated question", async () => {
152
- const { app, inserts } = fakeApp();
147
+ // A non-human-blocking status (never routed here by `gw-status`, but defensively handled) is a
148
+ // transient signal, not a decision-required escalation: it opens nothing.
149
+ test("a non-decision status is a non-escalation even with no question", async () => {
150
+ const { app, inserts, updates } = fakeApp();
153
151
  const job = { variables: { prKey: "o/r#1", round: 3, status: "in_progress" } };
154
- await handler(job as any, app as any);
155
- const esc = inserts.escalations[0] as any;
156
- assert(esc.question.includes("in_progress"), "fabricated question references the raw status");
157
- assertEquals(esc.kind, "blocker", "a non needs_input status is a blocker escalation");
152
+ const out = await handler(job as any, app as any);
153
+ assertEquals((out as any).escalated, false);
154
+ assertEquals(inserts.escalations.length, 0, "no escalation is opened for a transient status");
155
+ assertEquals(updates.pull_requests?.length ?? 0, 0, "the PR is not flipped to escalated");
158
156
  });
159
157
 
160
158
  // When repo/prNumber process variables are absent the heal still runs by parsing the canonical