@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
package/app/github.ts CHANGED
@@ -454,6 +454,12 @@ export async function fetchDefaultBranch(repo: string, token: string): Promise<s
454
454
  return name;
455
455
  }
456
456
 
457
+ /** Test-only: drop the memoized default-branch entries so a suite can't leak a warmed cache
458
+ * (which ignores transport/token state on a hit) into a test that expects a cold lookup. */
459
+ export function resetDefaultBranchCache(): void {
460
+ defaultBranchCache.clear();
461
+ }
462
+
457
463
  /** Whether a branch has already *landed* — i.e. it is the head of a `MERGED` PR. Returns:
458
464
  * • `landed` — a merged PR exists from this branch → the branch is a dead-end target
459
465
  * • `open` — an open PR exists from it (still alive)
@@ -672,3 +678,129 @@ export async function enqueueViaComment(
672
678
  });
673
679
  return r.ok;
674
680
  }
681
+
682
+ // ── Epic base-branch admission (ADR 0003, rule 2) ───────────────────────────
683
+ // `ensureBaseBranch` is the create-if-missing primitive that guarantees an epic's integration
684
+ // branch exists BEFORE any task fans out, with an `epic/*` guard so a typo can't silently spawn a
685
+ // wrong-rooted branch. It is idempotent — an existing branch is a NO-OP (the ref is never reset,
686
+ // which would nuke in-flight task PRs stacked on it) — so it is safe to call repeatedly: at
687
+ // admission (fail fast), from the durable `ensure-base-branch` head task, and again on a re-plan.
688
+
689
+ /** Thrown when a base branch that does NOT match the `epic/*` convention is missing. A
690
+ * non-`epic/*` base must already exist — a mistyped name is an operator error, not something to
691
+ * auto-create off the default branch (that would silently produce a wrong-rooted branch). */
692
+ export class BaseBranchMustExistError extends Error {
693
+ readonly branch: string;
694
+ constructor(branch: string) {
695
+ super(
696
+ `base branch "${branch}" does not exist and is not an epic/* branch, so it will not be ` +
697
+ `auto-created — create it first, or use the epic/* convention for an auto-created ` +
698
+ `integration branch`,
699
+ );
700
+ this.name = "BaseBranchMustExistError";
701
+ this.branch = branch;
702
+ }
703
+ }
704
+
705
+ /** Whether `branch` matches the auto-creatable `epic/*` convention (migration 019). */
706
+ function isEpicBranch(branch: string): boolean {
707
+ return branch.startsWith("epic/");
708
+ }
709
+
710
+ /** Resolve the head commit SHA of `branch` on `repo`, or `null` when the branch does not exist
711
+ * (a 404 from the git-ref endpoint). Throws only on a genuine transport failure. */
712
+ async function branchHeadSha(repo: string, branch: string, token: string): Promise<string | null> {
713
+ const apiPath = `repos/${repo}/git/ref/heads/${branch}`;
714
+ if (await useGh()) {
715
+ try {
716
+ const out = await runGh(["api", apiPath]);
717
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
718
+ const j = JSON.parse(out) as { object?: { sha?: string } };
719
+ return j.object?.sha ?? null;
720
+ } catch (err) {
721
+ const msg = err instanceof Error ? err.message : String(err);
722
+ if (/\b404\b|not found|no such/i.test(msg)) return null;
723
+ throw err;
724
+ }
725
+ }
726
+ if (!token) throw new Error(`no GitHub transport available to read ${apiPath}`);
727
+ const r = await fetch(`https://api.github.com/${apiPath}`, {
728
+ headers: { authorization: `Bearer ${token}`, accept: "application/vnd.github+json" },
729
+ });
730
+ if (r.status === 404) return null;
731
+ if (!r.ok) throw new Error(`github ${r.status} ${r.statusText}`.trim());
732
+ // biome-ignore lint/plugin: runtime/framework contract boundary for external data shape
733
+ const j = (await r.json()) as { object?: { sha?: string } };
734
+ return j.object?.sha ?? null;
735
+ }
736
+
737
+ /** Create `refs/heads/<branch>` pointing at `sha`. Idempotent: a concurrent create / re-plan
738
+ * that already made the ref (GitHub `422 Reference already exists`) is treated as a no-op.
739
+ * Returns `true` when this call actually created the ref, `false` when it lost the race and the
740
+ * ref already existed (the 422 case) — so the caller can report an honest exists/created outcome. */
741
+ async function createBranchRef(
742
+ repo: string,
743
+ branch: string,
744
+ sha: string,
745
+ token: string,
746
+ ): Promise<boolean> {
747
+ const ref = `refs/heads/${branch}`;
748
+ if (await useGh()) {
749
+ try {
750
+ await runGh(["api", `repos/${repo}/git/refs`, "-X", "POST", "-f", `ref=${ref}`, "-f", `sha=${sha}`]);
751
+ } catch (err) {
752
+ const msg = err instanceof Error ? err.message : String(err);
753
+ if (/\b422\b|already exists/i.test(msg)) return false; // idempotent — someone else created it
754
+ throw err;
755
+ }
756
+ return true;
757
+ }
758
+ if (!token) throw new Error(`no GitHub transport available to create ${ref}`);
759
+ const r = await fetch(`https://api.github.com/repos/${repo}/git/refs`, {
760
+ method: "POST",
761
+ headers: {
762
+ authorization: `Bearer ${token}`,
763
+ accept: "application/vnd.github+json",
764
+ "content-type": "application/json",
765
+ },
766
+ body: JSON.stringify({ ref, sha }),
767
+ });
768
+ if (r.ok) return true;
769
+ if (r.status === 422) return false; // reference already exists — idempotent
770
+ throw new Error(`github ${r.status} ${r.statusText}: ${(await r.text()).slice(0, 300)}`.trim());
771
+ }
772
+
773
+ /** The outcome of `ensureBaseBranch`: the branch was already present (`exists`, a no-op) or was
774
+ * just created off the default branch HEAD (`created`). */
775
+ export type EnsureBaseBranchResult = "exists" | "created";
776
+
777
+ /** Guarantee the epic base `branch` exists on `repo` (ADR 0003 rule 2), idempotently:
778
+ * • already exists → `"exists"` — NO-OP; the ref is never moved/reset.
779
+ * • missing and matches `epic/*` → create `refs/heads/<branch>` off the default branch HEAD,
780
+ * return `"created"`.
781
+ * • missing and not `epic/*` → throw `BaseBranchMustExistError` (a non-`epic/*` base must
782
+ * pre-exist; a typo must fail fast, not silently spawn a wrong-rooted branch).
783
+ * Safe to call repeatedly (at admission AND as the durable head task, and on a re-plan). */
784
+ export async function ensureBaseBranch(
785
+ repo: string,
786
+ branch: string,
787
+ token: string,
788
+ ): Promise<EnsureBaseBranchResult> {
789
+ const existing = await branchHeadSha(repo, branch, token);
790
+ if (existing !== null) return "exists"; // never reset an existing ref
791
+
792
+ if (!isEpicBranch(branch)) throw new BaseBranchMustExistError(branch);
793
+
794
+ const defaultBranch = await fetchDefaultBranch(repo, token);
795
+ if (!defaultBranch) {
796
+ throw new Error(`cannot resolve the default branch of ${repo} to create ${branch}`);
797
+ }
798
+ const defaultSha = await branchHeadSha(repo, defaultBranch, token);
799
+ if (!defaultSha) {
800
+ throw new Error(`cannot resolve HEAD of default branch ${defaultBranch} on ${repo} to create ${branch}`);
801
+ }
802
+ // A concurrent create / re-plan may have raced us to the ref (GitHub 422); in that case it
803
+ // already exists and we did not create it, so report "exists" rather than misleading "created".
804
+ const created = await createBranchRef(repo, branch, defaultSha, token);
805
+ return created ? "created" : "exists";
806
+ }
@@ -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