@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
@@ -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
+ }
@@ -3,7 +3,7 @@
3
3
  // the merge-exclusion graph. Force the token transport and stub `globalThis.fetch`.
4
4
  import { test } from "node:test";
5
5
  import { assertEquals, assertRejects } from "#test-assert";
6
- import { fetchPrFiles } from "./github.ts";
6
+ import { BaseBranchMustExistError, ensureBaseBranch, fetchPrFiles } from "./github.ts";
7
7
 
8
8
  // A fake `fetch` that serves `pages` of file batches; each page N (1-based) returns `pages[N-1]`
9
9
  // files (named `f{index}`), setting a `Link: rel="next"` header whenever a later page exists.
@@ -58,3 +58,181 @@ test("fetchPrFiles: throws when the cap genuinely truncates (full last page + ne
58
58
  "truncated",
59
59
  );
60
60
  });
61
+
62
+ // ── ensureBaseBranch (ADR 0003 rule 2) ──────────────────────────────────────
63
+ // Force the token transport and stub `globalThis.fetch` so the create-if-missing primitive is
64
+ // exercised end-to-end without touching the network: git-ref lookups, default-branch resolution,
65
+ // and ref creation are all served from an in-memory repo model that records every create call.
66
+ interface FakeRepo {
67
+ repo: string;
68
+ defaultBranch: string;
69
+ branches: Map<string, string>; // branch name → head sha (includes the default branch)
70
+ creates: { ref: string; sha: string }[];
71
+ }
72
+
73
+ function jsonResponse(obj: unknown, status = 200): Response {
74
+ return new Response(JSON.stringify(obj), {
75
+ status,
76
+ headers: { "content-type": "application/json" },
77
+ });
78
+ }
79
+
80
+ function githubFetch(state: FakeRepo) {
81
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
82
+ const u = new URL(String(url));
83
+ const method = (init?.method ?? "GET").toUpperCase();
84
+ const path = u.pathname;
85
+ // Repo metadata → default branch (fetchDefaultBranch token mode).
86
+ if (method === "GET" && path === `/repos/${state.repo}`) {
87
+ return Promise.resolve(jsonResponse({ default_branch: state.defaultBranch }));
88
+ }
89
+ // Git ref lookup → head sha or 404.
90
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
91
+ if (method === "GET" && path.startsWith(refPrefix)) {
92
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
93
+ const sha = state.branches.get(branch);
94
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
95
+ return Promise.resolve(jsonResponse({ ref: `refs/heads/${branch}`, object: { sha } }));
96
+ }
97
+ // Create ref.
98
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
99
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
100
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
101
+ const ref = String(body.ref ?? "");
102
+ const sha = String(body.sha ?? "");
103
+ const branch = ref.replace(/^refs\/heads\//, "");
104
+ if (state.branches.has(branch)) {
105
+ return Promise.resolve(jsonResponse({ message: "Reference already exists" }, 422));
106
+ }
107
+ state.creates.push({ ref, sha });
108
+ state.branches.set(branch, sha);
109
+ return Promise.resolve(jsonResponse({ ref }, 201));
110
+ }
111
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
112
+ };
113
+ }
114
+
115
+ async function withGithub<T>(state: FakeRepo, fn: () => Promise<T>): Promise<T> {
116
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
117
+ const prevFetch = globalThis.fetch;
118
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
119
+ globalThis.fetch = githubFetch(state) as typeof fetch;
120
+ try {
121
+ return await fn();
122
+ } finally {
123
+ globalThis.fetch = prevFetch;
124
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
125
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
126
+ }
127
+ }
128
+
129
+ test("ensureBaseBranch: existing branch is a no-op (never creates/resets the ref)", async () => {
130
+ const state: FakeRepo = {
131
+ repo: "o/exists",
132
+ defaultBranch: "main",
133
+ branches: new Map([
134
+ ["main", "mainsha"],
135
+ ["epic/already-there", "existingsha"],
136
+ ]),
137
+ creates: [],
138
+ };
139
+ const result = await withGithub(state, () =>
140
+ ensureBaseBranch(state.repo, "epic/already-there", "tok"),
141
+ );
142
+ assertEquals(result, "exists");
143
+ assertEquals(state.creates.length, 0);
144
+ // The ref must be left untouched.
145
+ assertEquals(state.branches.get("epic/already-there"), "existingsha");
146
+ });
147
+
148
+ test("ensureBaseBranch: missing epic/* branch is created off the default branch HEAD", async () => {
149
+ const state: FakeRepo = {
150
+ repo: "o/create-epic",
151
+ defaultBranch: "main",
152
+ branches: new Map([["main", "defaulthead"]]),
153
+ creates: [],
154
+ };
155
+ const result = await withGithub(state, () =>
156
+ ensureBaseBranch(state.repo, "epic/new-feature", "tok"),
157
+ );
158
+ assertEquals(result, "created");
159
+ assertEquals(state.creates, [{ ref: "refs/heads/epic/new-feature", sha: "defaulthead" }]);
160
+ });
161
+
162
+ test("ensureBaseBranch: idempotent — a second call once the branch exists is a no-op", async () => {
163
+ const state: FakeRepo = {
164
+ repo: "o/idempotent",
165
+ defaultBranch: "main",
166
+ branches: new Map([["main", "defaulthead"]]),
167
+ creates: [],
168
+ };
169
+ const first = await withGithub(state, () => ensureBaseBranch(state.repo, "epic/twice", "tok"));
170
+ assertEquals(first, "created");
171
+ assertEquals(state.creates.length, 1);
172
+ // Re-plan / durable head-task re-run: the branch now exists → clean no-op, no second create.
173
+ const second = await withGithub(state, () => ensureBaseBranch(state.repo, "epic/twice", "tok"));
174
+ assertEquals(second, "exists");
175
+ assertEquals(state.creates.length, 1);
176
+ });
177
+
178
+ test(
179
+ 'ensureBaseBranch: concurrent create race (GET 404 then POST 422) reports "exists", not "created"',
180
+ async () => {
181
+ // Simulate losing the create race: the ref lookup 404s (so we attempt a create), but by the
182
+ // time our POST lands another actor has already created the ref → GitHub answers 422. The
183
+ // 422 is idempotent, and the outcome must be the honest "exists" (we did not create it), not
184
+ // a misleading "created". This locks in the retriable semantics for a concurrent create.
185
+ const state: FakeRepo = {
186
+ repo: "o/race",
187
+ defaultBranch: "main",
188
+ branches: new Map([["main", "defaulthead"]]),
189
+ creates: [],
190
+ };
191
+ // The git-ref lookup for the epic branch always 404s (as if it does not exist yet), while a
192
+ // concurrent actor has "already created" it so our POST sees a 422.
193
+ const base = githubFetch(state);
194
+ const racingFetch = (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
195
+ const u = new URL(String(url));
196
+ const method = (init?.method ?? "GET").toUpperCase();
197
+ const path = u.pathname;
198
+ if (method === "GET" && path === `/repos/${state.repo}/git/ref/heads/epic/raced`) {
199
+ return Promise.resolve(new Response("Not Found", { status: 404 }));
200
+ }
201
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
202
+ return Promise.resolve(jsonResponse({ message: "Reference already exists" }, 422));
203
+ }
204
+ return base(url, init);
205
+ };
206
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
207
+ const prevFetch = globalThis.fetch;
208
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
209
+ globalThis.fetch = racingFetch as typeof fetch;
210
+ try {
211
+ const result = await ensureBaseBranch(state.repo, "epic/raced", "tok");
212
+ assertEquals(result, "exists");
213
+ } finally {
214
+ globalThis.fetch = prevFetch;
215
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
216
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
217
+ }
218
+ },
219
+ );
220
+
221
+ test("ensureBaseBranch: missing non-epic/* branch throws BaseBranchMustExistError", async () => {
222
+ const state: FakeRepo = {
223
+ repo: "o/typo",
224
+ defaultBranch: "main",
225
+ branches: new Map([["main", "defaulthead"]]),
226
+ creates: [],
227
+ };
228
+ const err = await withGithub(state, () =>
229
+ assertRejects(
230
+ () => ensureBaseBranch(state.repo, "feature/typo", "tok"),
231
+ BaseBranchMustExistError,
232
+ "feature/typo",
233
+ ),
234
+ );
235
+ assertEquals(err instanceof BaseBranchMustExistError, true);
236
+ // A rejected non-epic/* base must never spawn a wrong-rooted branch.
237
+ assertEquals(state.creates.length, 0);
238
+ });