@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,72 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI" xmlns:dc="http://www.omg.org/spec/DD/20100524/DC" xmlns:di="http://www.omg.org/spec/DD/20100524/DI" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:zeebe="http://camunda.org/schema/zeebe/1.0" xmlns:nano="https://nanobpm.io/schema/shapes/1.0" id="Definitions_nano_workforce_spine_demo" targetNamespace="http://nanobpm.io/nano-workforce">
3
+ <bpmn:process id="spine-demo" name="User-task Spine Demo" isExecutable="true">
4
+ <bpmn:startEvent id="start">
5
+ <bpmn:outgoing>flow-to-decide</bpmn:outgoing>
6
+ </bpmn:startEvent>
7
+ <bpmn:userTask id="decide" name="Decide">
8
+ <bpmn:extensionElements>
9
+ <zeebe:formDefinition formId="spine-demo" />
10
+ <zeebe:userTask />
11
+ </bpmn:extensionElements>
12
+ <bpmn:incoming>flow-to-decide</bpmn:incoming>
13
+ <bpmn:outgoing>flow-to-gateway</bpmn:outgoing>
14
+ </bpmn:userTask>
15
+ <bpmn:exclusiveGateway id="gw-decision" name="decision?" default="flow-rejected">
16
+ <bpmn:incoming>flow-to-gateway</bpmn:incoming>
17
+ <bpmn:outgoing>flow-approved</bpmn:outgoing>
18
+ <bpmn:outgoing>flow-rejected</bpmn:outgoing>
19
+ </bpmn:exclusiveGateway>
20
+ <bpmn:endEvent id="end-approved">
21
+ <bpmn:incoming>flow-approved</bpmn:incoming>
22
+ </bpmn:endEvent>
23
+ <bpmn:endEvent id="end-rejected">
24
+ <bpmn:incoming>flow-rejected</bpmn:incoming>
25
+ </bpmn:endEvent>
26
+ <bpmn:sequenceFlow id="flow-to-decide" sourceRef="start" targetRef="decide" />
27
+ <bpmn:sequenceFlow id="flow-to-gateway" sourceRef="decide" targetRef="gw-decision" />
28
+ <bpmn:sequenceFlow id="flow-approved" sourceRef="gw-decision" targetRef="end-approved">
29
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=decision = "approve"</bpmn:conditionExpression>
30
+ </bpmn:sequenceFlow>
31
+ <bpmn:sequenceFlow id="flow-rejected" sourceRef="gw-decision" targetRef="end-rejected" />
32
+ </bpmn:process>
33
+ <bpmndi:BPMNDiagram id="BPMNDiagram_spine-demo">
34
+ <bpmndi:BPMNPlane id="BPMNPlane_spine-demo" bpmnElement="spine-demo">
35
+ <bpmndi:BPMNShape id="BPMNShape_start" bpmnElement="start">
36
+ <dc:Bounds x="80" y="102" width="36" height="36" />
37
+ </bpmndi:BPMNShape>
38
+ <bpmndi:BPMNShape id="BPMNShape_decide" bpmnElement="decide">
39
+ <dc:Bounds x="216" y="80" width="100" height="80" />
40
+ </bpmndi:BPMNShape>
41
+ <bpmndi:BPMNShape id="BPMNShape_gw-decision" bpmnElement="gw-decision" isMarkerVisible="true">
42
+ <dc:Bounds x="416" y="95" width="50" height="50" />
43
+ <bpmndi:BPMNLabel>
44
+ <dc:Bounds x="408" y="76" width="67" height="14" />
45
+ </bpmndi:BPMNLabel>
46
+ </bpmndi:BPMNShape>
47
+ <bpmndi:BPMNShape id="BPMNShape_end-approved" bpmnElement="end-approved">
48
+ <dc:Bounds x="566" y="262" width="36" height="36" />
49
+ </bpmndi:BPMNShape>
50
+ <bpmndi:BPMNShape id="BPMNShape_end-rejected" bpmnElement="end-rejected">
51
+ <dc:Bounds x="566" y="102" width="36" height="36" />
52
+ </bpmndi:BPMNShape>
53
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-to-decide" bpmnElement="flow-to-decide">
54
+ <di:waypoint x="116" y="120" />
55
+ <di:waypoint x="216" y="120" />
56
+ </bpmndi:BPMNEdge>
57
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-to-gateway" bpmnElement="flow-to-gateway">
58
+ <di:waypoint x="316" y="120" />
59
+ <di:waypoint x="416" y="120" />
60
+ </bpmndi:BPMNEdge>
61
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-rejected" bpmnElement="flow-rejected">
62
+ <di:waypoint x="466" y="120" />
63
+ <di:waypoint x="566" y="120" />
64
+ </bpmndi:BPMNEdge>
65
+ <bpmndi:BPMNEdge id="BPMNEdge_flow-approved" bpmnElement="flow-approved">
66
+ <di:waypoint x="441" y="145" />
67
+ <di:waypoint x="441" y="280" />
68
+ <di:waypoint x="566" y="280" />
69
+ </bpmndi:BPMNEdge>
70
+ </bpmndi:BPMNPlane>
71
+ </bpmndi:BPMNDiagram>
72
+ </bpmn:definitions>
@@ -0,0 +1,68 @@
1
+ // check-migrations — merge-safety gate for the forward-only SQLite migrations under db/migrations/.
2
+ //
3
+ // Migrations are numbered by a leading 3-digit prefix and applied in prefix order (nano.app.json
4
+ // data.sources.app.migrations). AGENTS.md tells every author to "number a new migration after the
5
+ // current highest prefix" — but that number is computed independently in each worktree. When an
6
+ // epic fans a fleet out across parallel branches, two siblings both take "the next" free prefix
7
+ // and emit e.g. 023_agentic_presence.sql and 023_agentic_transcript.sql. Those filenames don't
8
+ // textually conflict, so git merges both cleanly and the collision is SILENT: two migrations now
9
+ // share an apply-order slot. This exact failure mode already scarred the repo (the historical
10
+ // 004/005/006/007 duplicate pairs) and recurred on epic #142 despite the doc note added by the
11
+ // #160 retro (PR #178) — so we escalate the lesson from a doc line to a mechanical gate.
12
+ //
13
+ // The rule: no two migration files may share a numeric prefix. The pre-existing historical
14
+ // duplicates are forward-only and already applied, so they cannot be renamed — they are
15
+ // grandfathered in GRANDFATHERED_DUPES. Any NEW duplicate prefix fails the build.
16
+ import { readdirSync } from "node:fs";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const MIGRATIONS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "db", "migrations");
21
+
22
+ // Historical collisions that predate this gate. Forward-only + already applied ⇒ cannot be
23
+ // renumbered. New duplicates are NOT allowed here — fix them before merge.
24
+ const GRANDFATHERED_DUPES: ReadonlySet<string> = new Set(["004", "005", "006", "007"]);
25
+
26
+ const PREFIX = /^(\d{3})_[^/]*\.sql$/;
27
+
28
+ function main(): void {
29
+ const files = readdirSync(MIGRATIONS_DIR)
30
+ .filter((f) => f.endsWith(".sql"))
31
+ .sort();
32
+
33
+ const errors: string[] = [];
34
+ const byPrefix = new Map<string, string[]>();
35
+
36
+ for (const file of files) {
37
+ const m = PREFIX.exec(file);
38
+ if (!m) {
39
+ errors.push(
40
+ ` ${file}: does not match the required NNN_name.sql shape (3-digit prefix, underscore, name).`,
41
+ );
42
+ continue;
43
+ }
44
+ const prefix = m[1];
45
+ const group = byPrefix.get(prefix) ?? [];
46
+ group.push(file);
47
+ byPrefix.set(prefix, group);
48
+ }
49
+
50
+ for (const [prefix, group] of byPrefix) {
51
+ if (group.length > 1 && !GRANDFATHERED_DUPES.has(prefix)) {
52
+ errors.push(
53
+ ` prefix ${prefix} is used by ${group.length} files: ${group.join(", ")} — ` +
54
+ `two migrations cannot share an apply-order slot. Renumber the newer one to the next ` +
55
+ `free prefix (check origin/main, not your branch point).`,
56
+ );
57
+ }
58
+ }
59
+
60
+ if (errors.length > 0) {
61
+ console.error(`check-migrations: db/migrations has colliding prefixes:\n${errors.join("\n")}`);
62
+ process.exit(1);
63
+ }
64
+
65
+ console.log(`check-migrations: OK (${files.length} migrations, no colliding prefixes).`);
66
+ }
67
+
68
+ main();
@@ -0,0 +1,78 @@
1
+ // pr.answer-escalation — retires the review-loop escalation the operator just answered.
2
+ //
3
+ // The review-loop escalation is a native `wait-answer` userTask (backed by `pr-escalation.form`).
4
+ // Completing that task resumes the token, but the engine folds the completed instance's variables
5
+ // away — so without this step the durable `escalations` audit row raised by `pr.persist-escalation`
6
+ // would stay `status="open"` forever, its `answer`/`answered_at` never recorded. That both loses the
7
+ // Q&A audit trail and (because `activePrs` derives `openEscalation` from the open-row status — the
8
+ // single source of truth, ADR "derivation over duplication") would keep surfacing a phantom open
9
+ // escalation on `/status` after it was answered.
10
+ //
11
+ // This mirrors the merge-loop's message-catch answer path (`answerEscalation` in app/service.ts):
12
+ // both answer the newest still-open `escalations` row, mark any duplicate open rows `stale` (a
13
+ // retry of `pr.persist-escalation` can leave more than one open), AND move the `pull_requests` row
14
+ // off `status="escalated"` back to `"converging"`, so an answered escalation is never left dangling
15
+ // and `/status` never shows an escalated PR with a null question or a phantom open row. The token
16
+ // resume itself is owned by the engine (userTask completion), so this worker only reconciles the
17
+ // durable rows; it returns no variables, leaving the submitted `answer` untouched so it flows on to
18
+ // the next review round.
19
+ import type { AppJobHandler } from "@nanobpm/urban";
20
+
21
+ interface Escalation extends Record<string, unknown> {
22
+ id: number;
23
+ pr_key: string;
24
+ status: string;
25
+ answer: string | null;
26
+ answered_at: string | null;
27
+ }
28
+
29
+ // The PR-row fields this worker reconciles when an escalation is answered. Only `status`/`updated_at`
30
+ // are written (mirroring `answerEscalation`); the rest of the row is untouched.
31
+ interface PullRequest extends Record<string, unknown> {
32
+ pr_key: string;
33
+ status: string;
34
+ updated_at: string;
35
+ }
36
+
37
+ interface In extends Record<string, unknown> {
38
+ prKey: string;
39
+ answer?: string;
40
+ }
41
+
42
+ // A string variable, or undefined when it is absent/blank. The form marks `answer` required, so a
43
+ // blank here would be an out-of-band completion; record it as NULL rather than an empty string.
44
+ function nonBlank(v: unknown): string | undefined {
45
+ return typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
46
+ }
47
+
48
+ const handler: AppJobHandler<In> = async (job, app) => {
49
+ const { prKey } = job.variables;
50
+ const answer = nonBlank(job.variables.answer);
51
+ const escs = app.data.table<Escalation>("escalations", "id");
52
+ // Retire EVERY still-open escalation for this PR. `pr.persist-escalation` always INSERTs a new
53
+ // open row, so a retry/duplicate activation can leave more than one open — answering only the
54
+ // newest would leave an older duplicate `open`, a phantom `activePrs` keeps deriving while the PR
55
+ // is still `escalated`. Answer the newest (it carries the operator's reply) and mark any remaining
56
+ // open rows `stale`, mirroring `answerEscalation` and `submitPr`'s resubmit cleanup.
57
+ const open = (await escs.find({ pr_key: prKey, status: "open" })).sort((a, b) => b.id - a.id);
58
+ if (open.length > 0) {
59
+ const ts = new Date().toISOString();
60
+ await escs.update(open[0].id, {
61
+ answer: answer ?? null,
62
+ status: "answered",
63
+ answered_at: ts,
64
+ });
65
+ for (const dup of open.slice(1)) {
66
+ await escs.update(dup.id, { status: "stale" });
67
+ }
68
+ // Mirror the merge loop's `answerEscalation`: move the PR off `status="escalated"` back to
69
+ // `"converging"` now that the question is answered. Without this the row stays `escalated` (with
70
+ // a now-null derived `openEscalation`) until the re-entered round's `persist-round` runs — a
71
+ // `/status` inconsistency and a divergence from the merge path both loops are meant to share.
72
+ const prs = app.data.table<PullRequest>("pull_requests", "pr_key");
73
+ await prs.update(prKey, { status: "converging", updated_at: ts });
74
+ }
75
+ return {};
76
+ };
77
+
78
+ export default handler;
@@ -0,0 +1,51 @@
1
+ // pr.converge-feature — hand a single-issue run's opened PR to the convergence loop (issue #172).
2
+ //
3
+ // Reached only via the `converge?` gateway's true branch (converge requested AND the agent opened a
4
+ // keyed PR). Reuses `submitPr` — the SAME enrollment the epic waves and the standalone
5
+ // `startConvergenceLoop` action use — so a feature run's PR gets identical review-round + merge
6
+ // behaviour with no duplicated machinery. `autoMerge` maps to `submitPr`'s `convergeOnly`
7
+ // (inverted): auto-merge → drive the merge-loop; otherwise stop at `converged`.
8
+ //
9
+ // The feature-run's OWN process ends here; the PR's live convergence/merge state lives on the
10
+ // `pull_requests` row keyed by `pr_key`, which the feature page links to. We therefore leave the
11
+ // `feature_runs` row in the terminal `converging` status (NOT an active status), so the
12
+ // instanceTracking reconciler does not mark it `abandoned` when feature.bpmn completes.
13
+ import type { AppJobHandler } from "@nanobpm/urban";
14
+ import { featureRuns } from "../../app/feature.ts";
15
+ import { MAX_ROUNDS, parsePr, submitPr } from "../../app/service.ts";
16
+
17
+ interface In extends Record<string, unknown> {
18
+ featureKey: string;
19
+ prKey?: unknown;
20
+ autoMerge?: unknown;
21
+ }
22
+
23
+ const handler: AppJobHandler<In, Record<string, never>> = async (job, app) => {
24
+ const featureKey = job.variables.featureKey;
25
+ const prKey = typeof job.variables.prKey === "string" ? job.variables.prKey.trim() : "";
26
+ const autoMerge = job.variables.autoMerge === true;
27
+ const parsed = prKey ? parsePr(prKey) : null;
28
+ if (!parsed) {
29
+ // Should not happen — the gateway only routes here when record-feature emitted a parseable
30
+ // prKey — but never enroll a phantom PR. Leave the row `opened` and end.
31
+ app.log.warn("converge-feature: no parseable PR key, skipping hand-off", { featureKey, prKey });
32
+ await featureRuns(app.data).update(featureKey, { status: "opened", updated_at: new Date().toISOString() });
33
+ return {};
34
+ }
35
+ // `convergeOnly` is the inverse of auto-merge: converge-only stops at `converged`; auto-merge lets
36
+ // the merge-loop drive the merge. `submitPr` is idempotent on the PR key.
37
+ await submitPr(app.data, app.engine, parsed, [], MAX_ROUNDS, !autoMerge);
38
+ await featureRuns(app.data).update(featureKey, {
39
+ status: "converging",
40
+ pr_key: parsed.prKey,
41
+ updated_at: new Date().toISOString(),
42
+ });
43
+ app.log.info("converge-feature: enrolled PR into convergence loop", {
44
+ featureKey,
45
+ prKey: parsed.prKey,
46
+ convergeOnly: !autoMerge,
47
+ });
48
+ return {};
49
+ };
50
+
51
+ export default handler;
@@ -0,0 +1,126 @@
1
+ // Integration coverage for the durable HEAD arm of ADR 0003 rule 2 — the `ensure-base-branch`
2
+ // service task (taskType `pr.ensure-base-branch`). The unit tests in workers/ensure-base-branch/
3
+ // worker.test.ts prove create/no-op in isolation; this file proves the END-TO-END belt-and-suspenders
4
+ // property across a RE-PLAN: the head task CREATES a missing epic/* base off default HEAD on the first
5
+ // pass, then NO-OPS on a second pass (idempotent — it neither errors nor resets the ref). Driven
6
+ // through the real worker handler against a faked github transport — no network, deterministic.
7
+ import { test } from "node:test";
8
+ import { assertEquals } from "#test-assert";
9
+ import { resetDefaultBranchCache } from "../../app/github.ts";
10
+ import handler from "./worker.ts";
11
+
12
+ interface GithubState {
13
+ repo: string;
14
+ defaultBranch: string;
15
+ branches: Map<string, string>; // branch → head sha
16
+ creates: { ref: string; sha: string }[];
17
+ resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
18
+ }
19
+
20
+ function githubFetch(state: GithubState) {
21
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
22
+ const u = new URL(String(url));
23
+ const method = (init?.method ?? "GET").toUpperCase();
24
+ const path = u.pathname;
25
+ const json = (obj: unknown, status = 200) =>
26
+ new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
27
+ if (method === "GET" && path === `/repos/${state.repo}`) {
28
+ return Promise.resolve(json({ default_branch: state.defaultBranch }));
29
+ }
30
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
31
+ if (method === "GET" && path.startsWith(refPrefix)) {
32
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
33
+ const sha = state.branches.get(branch);
34
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
35
+ return Promise.resolve(json({ ref: `refs/heads/${branch}`, object: { sha } }));
36
+ }
37
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
38
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
39
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
40
+ const ref = String(body.ref ?? "");
41
+ const sha = String(body.sha ?? "");
42
+ const branch = ref.replace(/^refs\/heads\//, "");
43
+ if (state.branches.has(branch)) return Promise.resolve(json({ message: "Reference already exists" }, 422));
44
+ state.creates.push({ ref, sha });
45
+ state.branches.set(branch, sha);
46
+ return Promise.resolve(json({ ref }, 201));
47
+ }
48
+ // A ref force-update (reset) would be a PATCH to .../git/refs/heads/<branch>. The idempotent head
49
+ // task must NEVER issue one; record it so the test can assert it stayed untouched.
50
+ if (method === "PATCH" && path.startsWith(`/repos/${state.repo}/git/refs/heads/`)) {
51
+ state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
52
+ return Promise.resolve(json({ ok: true }));
53
+ }
54
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
55
+ };
56
+ }
57
+
58
+ async function withGithub<T>(state: GithubState, fn: () => Promise<T>): Promise<T> {
59
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
60
+ const prevTok = process.env["GITHUB_TOKEN"];
61
+ const prevFetch = globalThis.fetch;
62
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
63
+ process.env["GITHUB_TOKEN"] = "tok";
64
+ resetDefaultBranchCache(); // isolate: don't inherit or leak another test's default-branch entry
65
+ globalThis.fetch = githubFetch(state) as typeof fetch;
66
+ try {
67
+ return await fn();
68
+ } finally {
69
+ resetDefaultBranchCache();
70
+ globalThis.fetch = prevFetch;
71
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
72
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
73
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
74
+ else process.env["GITHUB_TOKEN"] = prevTok;
75
+ }
76
+ }
77
+
78
+ const fakeApp = { log: { info() {}, warn() {}, error() {} } } as any;
79
+
80
+ function runHead(state: GithubState, repo: string, baseBranch: string) {
81
+ return withGithub(state, () => handler({ variables: { repo, baseBranch } } as any, fakeApp)) as Promise<{
82
+ baseBranchResult: string;
83
+ }>;
84
+ }
85
+
86
+ test("head task: creates a missing epic/* base on first pass, then no-ops on re-plan (idempotent)", async () => {
87
+ const state: GithubState = {
88
+ repo: "owner/epic-repo",
89
+ defaultBranch: "main",
90
+ branches: new Map([["main", "mainhead"]]),
91
+ creates: [],
92
+ resets: [],
93
+ };
94
+
95
+ // First pass (fresh plan): the epic/* base is missing → created off default HEAD.
96
+ const first = await runHead(state, state.repo, "epic/gate");
97
+ assertEquals(first.baseBranchResult, "created");
98
+ assertEquals(state.creates, [{ ref: "refs/heads/epic/gate", sha: "mainhead" }]);
99
+ assertEquals(state.branches.get("epic/gate"), "mainhead");
100
+
101
+ // Second pass (re-plan / crash-recovery): the branch now exists → clean no-op. No further create,
102
+ // and — critically — no reset of the existing ref (a re-plan must not clobber landed work).
103
+ const second = await runHead(state, state.repo, "epic/gate");
104
+ assertEquals(second.baseBranchResult, "exists");
105
+ assertEquals(state.creates.length, 1); // still just the first create
106
+ assertEquals(state.resets, []); // never reset the ref
107
+ assertEquals(state.branches.get("epic/gate"), "mainhead"); // ref untouched
108
+ });
109
+
110
+ test("head task: a pre-existing base is a pure no-op (no create, no reset)", async () => {
111
+ const state: GithubState = {
112
+ repo: "owner/epic-repo2",
113
+ defaultBranch: "main",
114
+ branches: new Map([
115
+ ["main", "mainhead"],
116
+ ["epic/landed", "landedsha"],
117
+ ]),
118
+ creates: [],
119
+ resets: [],
120
+ };
121
+ const out = await runHead(state, state.repo, "epic/landed");
122
+ assertEquals(out.baseBranchResult, "exists");
123
+ assertEquals(state.creates, []);
124
+ assertEquals(state.resets, []);
125
+ assertEquals(state.branches.get("epic/landed"), "landedsha"); // untouched
126
+ });
@@ -0,0 +1,104 @@
1
+ // pr.ensure-base-branch worker — the durable, retriable head arm of ADR 0003 rule 2.
2
+ //
3
+ // It re-runs the idempotent `ensureBaseBranch` primitive on the durable path, so it must CREATE a
4
+ // missing epic/* base off default HEAD and NO-OP when the branch already exists. Drive it through a
5
+ // faked github transport (token mode + stubbed `globalThis.fetch`) so no network is touched.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import handler from "./worker.ts";
9
+
10
+ interface FakeRepo {
11
+ repo: string;
12
+ defaultBranch: string;
13
+ branches: Map<string, string>; // branch name → head sha
14
+ creates: { ref: string; sha: string }[];
15
+ }
16
+
17
+ function jsonResponse(obj: unknown, status = 200): Response {
18
+ return new Response(JSON.stringify(obj), { status, headers: { "content-type": "application/json" } });
19
+ }
20
+
21
+ function githubFetch(state: FakeRepo) {
22
+ return (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
23
+ const u = new URL(String(url));
24
+ const method = (init?.method ?? "GET").toUpperCase();
25
+ const path = u.pathname;
26
+ if (method === "GET" && path === `/repos/${state.repo}`) {
27
+ return Promise.resolve(jsonResponse({ default_branch: state.defaultBranch }));
28
+ }
29
+ const refPrefix = `/repos/${state.repo}/git/ref/heads/`;
30
+ if (method === "GET" && path.startsWith(refPrefix)) {
31
+ const branch = decodeURIComponent(path.slice(refPrefix.length));
32
+ const sha = state.branches.get(branch);
33
+ if (sha === undefined) return Promise.resolve(new Response("Not Found", { status: 404 }));
34
+ return Promise.resolve(jsonResponse({ ref: `refs/heads/${branch}`, object: { sha } }));
35
+ }
36
+ if (method === "POST" && path === `/repos/${state.repo}/git/refs`) {
37
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
38
+ const body = JSON.parse(String(init?.body ?? "{}")) as { ref?: string; sha?: string };
39
+ const ref = String(body.ref ?? "");
40
+ const sha = String(body.sha ?? "");
41
+ const branch = ref.replace(/^refs\/heads\//, "");
42
+ if (state.branches.has(branch)) return Promise.resolve(jsonResponse({ message: "Reference already exists" }, 422));
43
+ state.creates.push({ ref, sha });
44
+ state.branches.set(branch, sha);
45
+ return Promise.resolve(jsonResponse({ ref }, 201));
46
+ }
47
+ return Promise.resolve(new Response(`unexpected ${method} ${path}`, { status: 500 }));
48
+ };
49
+ }
50
+
51
+ async function withGithub<T>(state: FakeRepo, fn: () => Promise<T>): Promise<T> {
52
+ const prevMode = process.env["NANO_PR_GITHUB_TRANSPORT"];
53
+ const prevTok = process.env["GITHUB_TOKEN"];
54
+ const prevFetch = globalThis.fetch;
55
+ process.env["NANO_PR_GITHUB_TRANSPORT"] = "token";
56
+ process.env["GITHUB_TOKEN"] = "tok";
57
+ globalThis.fetch = githubFetch(state) as typeof fetch;
58
+ try {
59
+ return await fn();
60
+ } finally {
61
+ globalThis.fetch = prevFetch;
62
+ if (prevMode === undefined) delete process.env["NANO_PR_GITHUB_TRANSPORT"];
63
+ else process.env["NANO_PR_GITHUB_TRANSPORT"] = prevMode;
64
+ if (prevTok === undefined) delete process.env["GITHUB_TOKEN"];
65
+ else process.env["GITHUB_TOKEN"] = prevTok;
66
+ }
67
+ }
68
+
69
+ const fakeApp = { log: { info() {}, warn() {}, error() {} } } as any;
70
+
71
+ async function run(state: FakeRepo, repo: string, baseBranch: string) {
72
+ return withGithub(state, () => handler({ variables: { repo, baseBranch } } as any, fakeApp)) as Promise<{
73
+ baseBranchResult: string;
74
+ }>;
75
+ }
76
+
77
+ test("ensure-base-branch worker: creates a missing epic/* base off default HEAD", async () => {
78
+ const state: FakeRepo = {
79
+ repo: "o/w-create",
80
+ defaultBranch: "main",
81
+ branches: new Map([["main", "defaulthead"]]),
82
+ creates: [],
83
+ };
84
+ const out = await run(state, state.repo, "epic/new");
85
+ assertEquals(out.baseBranchResult, "created");
86
+ assertEquals(state.creates, [{ ref: "refs/heads/epic/new", sha: "defaulthead" }]);
87
+ });
88
+
89
+ test("ensure-base-branch worker: no-ops when the branch already exists (idempotent re-plan)", async () => {
90
+ const state: FakeRepo = {
91
+ repo: "o/w-exists",
92
+ defaultBranch: "main",
93
+ branches: new Map([
94
+ ["main", "defaulthead"],
95
+ ["epic/already", "existingsha"],
96
+ ]),
97
+ creates: [],
98
+ };
99
+ const out = await run(state, state.repo, "epic/already");
100
+ assertEquals(out.baseBranchResult, "exists");
101
+ assertEquals(state.creates.length, 0);
102
+ // The existing ref must be left untouched.
103
+ assertEquals(state.branches.get("epic/already"), "existingsha");
104
+ });
@@ -0,0 +1,31 @@
1
+ // pr.ensure-base-branch — the durable, retriable head arm of ADR 0003 rule 2.
2
+ //
3
+ // `admitPlan` already ran `ensureBaseBranch` synchronously at admission (fail fast, so a missing
4
+ // non-`epic/*` base is a clean edge 400 and a missing `epic/*` base is created before fan-out).
5
+ // This head service task RE-RUNS the same idempotent primitive on the durable path — so a re-plan
6
+ // or a crash between admission and fan-out still guarantees the base exists. Because
7
+ // `ensureBaseBranch` never resets an existing ref, this is a clean no-op when the branch is already
8
+ // there; a missing `epic/*` base is created off default HEAD, and a missing non-`epic/*` base
9
+ // throws `BaseBranchMustExistError` (which fails the durable task rather than fanning out onto a
10
+ // wrong-rooted branch).
11
+ import type { AppJobHandler } from "@nanobpm/urban";
12
+ import { type EnsureBaseBranchResult, ensureBaseBranch } from "../../app/github.ts";
13
+
14
+ interface In extends Record<string, unknown> {
15
+ repo: string;
16
+ baseBranch: string;
17
+ }
18
+ interface Out extends Record<string, unknown> {
19
+ baseBranchResult: EnsureBaseBranchResult;
20
+ }
21
+
22
+ const handler: AppJobHandler<In, Out> = async (job, app) => {
23
+ const repo = job.variables.repo;
24
+ const branch = job.variables.baseBranch;
25
+ const token = process.env.GITHUB_TOKEN ?? "";
26
+ const result = await ensureBaseBranch(repo, branch, token);
27
+ app.log.info("ensure-base-branch", { repo, branch, result });
28
+ return { baseBranchResult: result };
29
+ };
30
+
31
+ export default handler;
@@ -101,8 +101,6 @@ const handler: AppJobHandler<In> = async (job, app) => {
101
101
  outcome: summary,
102
102
  converged_at: now,
103
103
  updated_at: now,
104
- open_escalation_id: null,
105
- open_escalation_question: null,
106
104
  });
107
105
 
108
106
  // Only the review-only terminal path ends the PR here as `converged` — in auto-merge mode the
@@ -13,8 +13,6 @@ const handler: AppJobHandler<In> = async (job, app) => {
13
13
  status: "merged",
14
14
  merged_at: now,
15
15
  updated_at: now,
16
- open_escalation_id: null,
17
- open_escalation_question: null,
18
16
  });
19
17
 
20
18
  // If this PR was the last of its epic to land, kick off the retrospective. Best-effort: a
@@ -11,9 +11,9 @@
11
11
  import type { AppJobHandler } from "@nanobpm/urban";
12
12
  import { matchTags, tag } from "@nanobpm/urban/effect";
13
13
  import { abandonTokenFromUrl } from "../../app/abandon.ts";
14
- import { checkBaseTarget } from "../../app/baseGuard.ts";
14
+ import { checkBaseTarget, classifyBaseGuard } from "../../app/baseGuard.ts";
15
15
  import { enqueueViaComment, fetchPrState, mergePr } from "../../app/github.ts";
16
- import { loadMergeProtocol } from "../../app/mergeProtocol.ts";
16
+ import { classifyMergeLanding, DEFAULT_MERGE_PROTOCOL, loadMergeProtocol } from "../../app/mergeProtocol.ts";
17
17
  import { ensurePr, MERGE_ADMIN, MERGE_METHOD } from "../../app/service.ts";
18
18
 
19
19
  interface In extends Record<string, unknown> {
@@ -77,7 +77,7 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
77
77
  // base (typical in a stacked epic) stays the target and reads CLEAN, so nothing else catches it.
78
78
  // Best-effort: a transport hiccup leaves `deadEnd:false`, so this never blocks a valid merge.
79
79
  const guard = await checkBaseTarget(repo, prNumber, token).catch(() => null);
80
- if (guard?.deadEnd) {
80
+ if (guard && classifyBaseGuard(guard) === "decision-required") {
81
81
  await app.data.table("merges", "id").insert({
82
82
  pr_key: prKey,
83
83
  outcome: "blocked",
@@ -112,8 +112,9 @@ const handler: AppJobHandler<In, Out> = async (job, app) => {
112
112
  outcome = ok ? "queued" : "blocked";
113
113
  detail = ok ? `enqueued via "${comment}"` : `failed to post enqueue comment "${comment}"`;
114
114
  auditMethod = "queue-comment";
115
- } else if (method === "ui") {
116
- // The repo requires a human to click Merge; Merlin can't. Escalate rather than pretend.
115
+ } else if (classifyMergeLanding(protocol ?? DEFAULT_MERGE_PROTOCOL) === "decision-required") {
116
+ // The repo requires a human to click Merge (`land.method=ui`); Merlin can't. This is the
117
+ // only decision-required land method — escalate rather than pretend.
117
118
  outcome = "blocked";
118
119
  detail = "repo merge protocol requires a manual UI merge (land.method=ui)";
119
120
  auditMethod = "ui";