@nanobpm/nano-workforce 0.188.0 → 0.188.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.188.1](https://github.com/nanobpm/nano-workforce/compare/v0.188.0...v0.188.1) (2026-09-17)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **feature:** reconcile the implement-step result from GitHub before escalating ([#805](https://github.com/nanobpm/nano-workforce/issues/805)) ([7099c31](https://github.com/nanobpm/nano-workforce/commit/7099c31793a6dd4543fddd2494615b166241d302)), closes [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#796](https://github.com/nanobpm/nano-workforce/issues/796) [#801](https://github.com/nanobpm/nano-workforce/issues/801)
6
+
1
7
  ## [0.188.0](https://github.com/nanobpm/nano-workforce/compare/v0.187.6...v0.188.0) (2026-09-17)
2
8
 
3
9
  ### Features
@@ -52,6 +52,29 @@ test("implement-cell runs the senior:feature loop and delegates escalation to th
52
52
  assert(/targetRef="implement-task"/.test(xml), "implement-cell loops the answer back into implement-task");
53
53
  });
54
54
 
55
+ test("implement-cell reconciles from GitHub before escalating (issue #801)", () => {
56
+ const xml = flat("implement-cell");
57
+ // The escalate arm passes through the reconcile step BEFORE the human escalation recorder, so a
58
+ // machine-recoverable result (an open PR on the cell's branch) adopts-and-converges instead of
59
+ // dead-ending at a person.
60
+ assert(
61
+ /<zeebe:taskDefinition\b[^>]*\btype="pr.reconcile-implement"/.test(xml),
62
+ "implement-cell must reconcile via pr.reconcile-implement on the escalate arm",
63
+ );
64
+ assert(
65
+ /<bpmn:exclusiveGateway\b[^>]*\bid="ic_reconcile_gw"/.test(xml),
66
+ "implement-cell must gate the reconcile outcome (adopt vs escalate) on ic_reconcile_gw",
67
+ );
68
+ assert(
69
+ /<bpmn:sequenceFlow\b[^>]*\bid="ic_reconciled"[^>]*\btargetRef="ic_end"/.test(xml),
70
+ "a reconciled (adopted) PR routes straight to the cell's done end — no human escalation",
71
+ );
72
+ assert(
73
+ /<bpmn:sequenceFlow\b[^>]*\bid="ic_toRecordEscalation"[^>]*\btargetRef="record-escalation"/.test(xml),
74
+ "an unreconciled result still escalates through record-escalation",
75
+ );
76
+ });
77
+
55
78
  test("converge-cell and merge-cell keep their engine-native handoff task types", () => {
56
79
  assert(/<zeebe:taskDefinition\b[^>]*\btype="pr.converge-feature"/.test(flat("converge-cell")), "converge-cell hands off via pr.converge-feature");
57
80
  assert(/<zeebe:taskDefinition\b[^>]*\btype="senior:trial-merge"/.test(flat("merge-cell")), "merge-cell runs the senior:trial-merge agent");
@@ -0,0 +1,166 @@
1
+ // Red-first coverage for the implement-cell reconcile decision (issue #801) — the implement-stage twin
2
+ // of #796. The defect: an implement-step that returns NO machine-readable `status` but has an OPEN PR
3
+ // on its `feat/<task.id>` branch was dead-ended at a human escalation instead of adopting that PR and
4
+ // converging. These tests pin the canonical `ic_reconcile_gw` decision that reconciles from GitHub
5
+ // before escalating.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import type { HeadPr } from "./github.ts";
9
+ import {
10
+ implementCellBranch,
11
+ pickAdoptablePr,
12
+ reconcileImplement,
13
+ shouldReconcileImplement,
14
+ } from "./implementReconcile.ts";
15
+
16
+ const openPr = (number: number, base = "main"): HeadPr => ({
17
+ number,
18
+ url: `https://github.com/owner/repo/pull/${number}`,
19
+ state: "open",
20
+ baseRef: base,
21
+ });
22
+
23
+ test("implementCellBranch: the deterministic feat/<task.id> branch", () => {
24
+ assertEquals(implementCellBranch("issue-796"), "feat/issue-796");
25
+ });
26
+
27
+ test("shouldReconcileImplement: only a blank/absent status reconciles", () => {
28
+ assertEquals(shouldReconcileImplement(null), true);
29
+ assertEquals(shouldReconcileImplement(undefined), true);
30
+ assertEquals(shouldReconcileImplement(" "), true);
31
+ assertEquals(shouldReconcileImplement("escalated"), false);
32
+ assertEquals(shouldReconcileImplement("opened"), false);
33
+ });
34
+
35
+ test("pickAdoptablePr: the first OPEN PR wins; merged/closed are not adoptable", () => {
36
+ assertEquals(pickAdoptablePr(null), null);
37
+ assertEquals(pickAdoptablePr([]), null);
38
+ assertEquals(pickAdoptablePr([{ ...openPr(1), state: "merged" }]), null);
39
+ assertEquals(pickAdoptablePr([{ ...openPr(2), state: "closed" }, openPr(3)])?.number, 3);
40
+ });
41
+
42
+ test("pickAdoptablePr: a known baseBranch adopts only an open PR that targets it", () => {
43
+ // Multiple open PRs from the same head branch to different bases — only the one matching the
44
+ // run's pinned base is adoptable; a stale/wrong-base PR (even if first) is never adopted.
45
+ const prs = [openPr(10, "old-epic-base"), openPr(11, "epic/feat-x")];
46
+ assertEquals(pickAdoptablePr(prs, "epic/feat-x")?.number, 11);
47
+ // No open PR targets the pinned base → nothing adoptable (escalate rather than converge the wrong PR).
48
+ assertEquals(pickAdoptablePr([openPr(12, "some-other-base")], "epic/feat-x"), null);
49
+ // Whitespace-only base is treated as "unknown" → first-open fallback.
50
+ assertEquals(pickAdoptablePr([openPr(13, "main")], " ")?.number, 13);
51
+ });
52
+
53
+ // The core defect reproduction: blank status + an open PR on the branch → adopt & converge, no escalation.
54
+ test("reconcileImplement: blank status + open PR on feat/<task.id> → adopt (status=opened, pr set)", async () => {
55
+ const calls: Array<{ repo: string; branch: string }> = [];
56
+ const lookup = async (repo: string, branch: string): Promise<HeadPr[]> => {
57
+ calls.push({ repo, branch });
58
+ return [openPr(800)];
59
+ };
60
+ const res = await reconcileImplement(
61
+ { status: null, subjectKey: "nanobpm/nano-workforce#796", taskId: "issue-796" },
62
+ lookup,
63
+ "token",
64
+ );
65
+ assertEquals(res, { reconciled: true, status: "opened", pr: "nanobpm/nano-workforce#800" });
66
+ assertEquals(calls, [{ repo: "nanobpm/nano-workforce", branch: "feat/issue-796" }]);
67
+ });
68
+
69
+ test("reconcileImplement: blank status but NO branch/PR → escalate (unchanged behaviour)", async () => {
70
+ const res = await reconcileImplement(
71
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
72
+ async () => [],
73
+ "token",
74
+ );
75
+ assertEquals(res, { reconciled: false, status: null, pr: null });
76
+ });
77
+
78
+ test("reconcileImplement: a genuine escalation status is honoured, GitHub never consulted", async () => {
79
+ let consulted = false;
80
+ const res = await reconcileImplement(
81
+ { status: "escalated", subjectKey: "owner/repo#7", taskId: "issue-7" },
82
+ async () => {
83
+ consulted = true;
84
+ return [openPr(9)];
85
+ },
86
+ "token",
87
+ );
88
+ assertEquals(res, { reconciled: false, status: "escalated", pr: null });
89
+ assertEquals(consulted, false);
90
+ });
91
+
92
+ test("reconcileImplement: only merged/closed PRs on the branch → escalate (nothing in-flight to adopt)", async () => {
93
+ const res = await reconcileImplement(
94
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
95
+ async () => [{ ...openPr(5), state: "merged" }],
96
+ "token",
97
+ );
98
+ assertEquals(res.reconciled, false);
99
+ });
100
+
101
+ test("reconcileImplement: a lookup transport failure falls through to escalate (best-effort)", async () => {
102
+ const res = await reconcileImplement(
103
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7" },
104
+ async () => {
105
+ throw new Error("github 502");
106
+ },
107
+ "token",
108
+ );
109
+ assertEquals(res, { reconciled: false, status: null, pr: null });
110
+ });
111
+
112
+ test("reconcileImplement: an existing pr is carried through unchanged on fall-through (never wiped)", async () => {
113
+ // A genuine escalation status → escalate; any pr already in scope must survive the re-emit.
114
+ const escalated = await reconcileImplement(
115
+ { status: "escalated", subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
116
+ async () => [],
117
+ "token",
118
+ );
119
+ assertEquals(escalated, { reconciled: false, status: "escalated", pr: "owner/repo#42" });
120
+
121
+ // Blank status but no adoptable PR → escalate; an existing pr still survives.
122
+ const noAdopt = await reconcileImplement(
123
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
124
+ async () => [],
125
+ "token",
126
+ );
127
+ assertEquals(noAdopt, { reconciled: false, status: null, pr: "owner/repo#42" });
128
+ });
129
+
130
+ test("reconcileImplement: a successful adoption overwrites any existing pr with the adopted key", async () => {
131
+ const res = await reconcileImplement(
132
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", pr: "owner/repo#42" },
133
+ async () => [openPr(99)],
134
+ "token",
135
+ );
136
+ assertEquals(res, { reconciled: true, status: "opened", pr: "owner/repo#99" });
137
+ });
138
+
139
+ test("reconcileImplement: with a pinned baseBranch, only a PR targeting it is adopted", async () => {
140
+ // The head branch carries two open PRs to different bases — adopt the one matching the run's base.
141
+ const adopt = await reconcileImplement(
142
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x" },
143
+ async () => [openPr(50, "stale-base"), openPr(51, "epic/feat-x")],
144
+ "token",
145
+ );
146
+ assertEquals(adopt, { reconciled: true, status: "opened", pr: "owner/repo#51" });
147
+
148
+ // Only a wrong-base PR exists → escalate rather than converge the wrong branch.
149
+ const escalate = await reconcileImplement(
150
+ { status: null, subjectKey: "owner/repo#7", taskId: "issue-7", baseBranch: "epic/feat-x", pr: "owner/repo#42" },
151
+ async () => [openPr(52, "stale-base")],
152
+ "token",
153
+ );
154
+ assertEquals(escalate, { reconciled: false, status: null, pr: "owner/repo#42" });
155
+ });
156
+
157
+ test("reconcileImplement: a missing taskId or unparseable subjectKey → escalate, no lookup", async () => {
158
+ let consulted = false;
159
+ const lookup = async (): Promise<HeadPr[]> => {
160
+ consulted = true;
161
+ return [openPr(1)];
162
+ };
163
+ assertEquals((await reconcileImplement({ status: null, subjectKey: "owner/repo#7", taskId: null }, lookup, "t")).reconciled, false);
164
+ assertEquals((await reconcileImplement({ status: null, subjectKey: "not-a-key", taskId: "issue-7" }, lookup, "t")).reconciled, false);
165
+ assertEquals(consulted, false);
166
+ });
@@ -0,0 +1,112 @@
1
+ // Implement-step reconcile — reconcile the implement-cell result from GitHub BEFORE escalating to a
2
+ // human (issue #801).
3
+ //
4
+ // The shared `implement-cell` (resources/processes/implement-cell.bpmn) routes ANY implement-step
5
+ // outcome that is not a clean terminal (`opened`/`blocked`/`skipped`) to a human escalation. But a
6
+ // harness that returns NO machine-readable result envelope (a blank/absent `status`) can still have
7
+ // pushed the slice's branch and opened a green PR — a machine-observable, recoverable outcome the
8
+ // escalation question literally asked a human to go and check (#796's implement-stage twin). Dead-
9
+ // ending that at a person is the defect.
10
+ //
11
+ // This is the CANONICAL, pure decision for the cell's reconcile step: on a blank/absent `status`,
12
+ // look for an OPEN PR opened from the cell's deterministic branch (`feat/<task.id>`, the agent-guide
13
+ // convention every implement-cell caller shares — see resources/prompts/feature.md) and, when one
14
+ // exists, ADOPT it (derive `status = "opened"` + a `pr` key) so the run converges exactly as if the
15
+ // agent had reported it — no human escalation. Only when nothing is observable does the run escalate
16
+ // as today. The GitHub read is injected (the canonical `listPrsForHead`) so this stays a pure,
17
+ // exhaustively testable mirror of the `ic_reconcile_gw` gateway — no second GitHub reconciler.
18
+ import type { HeadPr } from "./github.ts";
19
+ import { parsePr } from "./prParse.ts";
20
+
21
+ /** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
22
+ * cell's `owner/repo#N` subject (a feature run's `feature_key`, or a wave slice's epic `plan_key`) —
23
+ * its `owner/repo` half is the repository to look in. `taskId` is `task.id`, which fixes the
24
+ * deterministic implement branch `feat/<task.id>`. `status` is the (blank, on this arm) implement-step
25
+ * status. `pr` is any PR key already in scope (the implement harness may have set it) — carried through
26
+ * unchanged on the non-adopt fall-through so re-emitting the output never wipes it. `baseBranch` is the
27
+ * run's pinned base branch (the epic/graph integration branch every implement-cell caller maps into the
28
+ * cell scope): when known, only a PR whose base matches it is adoptable, so a stale/unrelated PR sharing
29
+ * the deterministic head branch but targeting a different base is never adopted. */
30
+ export interface ReconcileImplementInput {
31
+ status: unknown;
32
+ subjectKey: unknown;
33
+ taskId: unknown;
34
+ pr?: unknown;
35
+ baseBranch?: unknown;
36
+ }
37
+
38
+ /** The reconcile decision. `reconciled` is the `ic_reconcile_gw` gate: true → adopt-and-converge (with
39
+ * `status = "opened"` + `pr` set); false → escalate as today. `status`/`pr` are re-emitted so the
40
+ * cell (and its caller) route on the adopted values. */
41
+ export interface ReconcileImplementResult {
42
+ reconciled: boolean;
43
+ status: string | null;
44
+ pr: string | null;
45
+ }
46
+
47
+ /** The injected GitHub read — the canonical `listPrsForHead(repo, headBranch, token)` (so this module
48
+ * never grows a second PR-lookup transport). */
49
+ export type OpenPrLookup = (repo: string, branch: string, token: string) => Promise<HeadPr[] | null>;
50
+
51
+ const str = (v: unknown): string | undefined =>
52
+ typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
53
+
54
+ /** Reconcile ONLY when the agent left no machine-readable status (blank/absent) — the #796/#801
55
+ * no-result condition. A genuine escalation that carries its own status string is honoured (escalate),
56
+ * never silently overridden by a branch PR that may be unrelated to the agent's question. */
57
+ export function shouldReconcileImplement(status: unknown): boolean {
58
+ return str(status) === undefined;
59
+ }
60
+
61
+ /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
62
+ export function implementCellBranch(taskId: string): string {
63
+ return `feat/${taskId}`;
64
+ }
65
+
66
+ /** The adoptable PR from a head-branch listing: the first OPEN one (a merged/closed PR on the branch is
67
+ * not an in-flight result to converge). When `baseBranch` is given, only an open PR whose `baseRef`
68
+ * matches it is adoptable — GitHub can carry multiple open PRs from one head branch to different bases,
69
+ * so adopting blind to the base could converge a stale/unrelated PR; with no base known, fall back to
70
+ * the first open PR (unchanged best-effort behaviour). `null` when the listing is absent (no transport)
71
+ * or has no adoptable PR. */
72
+ export function pickAdoptablePr(prs: HeadPr[] | null, baseBranch?: string): HeadPr | null {
73
+ if (!prs) return null;
74
+ const open = prs.filter((p) => p.state === "open");
75
+ const base = typeof baseBranch === "string" ? baseBranch.trim() : "";
76
+ if (base) return open.find((p) => p.baseRef === base) ?? null;
77
+ return open[0] ?? null;
78
+ }
79
+
80
+ /** The canonical implement-cell reconcile decision (mirror of `ic_reconcile_gw`). Best-effort: any
81
+ * missing input, unusable transport, or lookup failure falls through to `escalate` — never worse than
82
+ * today's behaviour, and idempotent (a pure GitHub read that adopts the SAME open PR on a re-run, so
83
+ * a re-dispatch never opens or double-adopts a second PR). */
84
+ export async function reconcileImplement(
85
+ input: ReconcileImplementInput,
86
+ lookup: OpenPrLookup,
87
+ token: string,
88
+ ): Promise<ReconcileImplementResult> {
89
+ const escalate: ReconcileImplementResult = {
90
+ reconciled: false,
91
+ status: str(input.status) ?? null,
92
+ // Carry any existing PR key through unchanged — the reconcile step's `pr` output is mapped back
93
+ // into the process variable, so returning a bare `null` here would wipe a `pr` the implement
94
+ // harness already set. Only a successful adoption below overwrites it.
95
+ pr: str(input.pr) ?? null,
96
+ };
97
+ if (!shouldReconcileImplement(input.status)) return escalate;
98
+ const taskId = str(input.taskId);
99
+ // `subjectKey` shares the `owner/repo#N` shape parsePr validates; we use only its `repo` half.
100
+ const parsed = parsePr(input.subjectKey);
101
+ if (!taskId || !parsed) return escalate;
102
+ const branch = implementCellBranch(taskId);
103
+ let prs: HeadPr[] | null;
104
+ try {
105
+ prs = await lookup(parsed.repo, branch, token);
106
+ } catch {
107
+ return escalate; // transport hiccup → escalate as today
108
+ }
109
+ const adopt = pickAdoptablePr(prs, str(input.baseBranch));
110
+ if (!adopt) return escalate;
111
+ return { reconciled: true, status: "opened", pr: `${parsed.repo}#${adopt.number}` };
112
+ }
@@ -67,6 +67,7 @@ interface PrRow {
67
67
  describe("single-issue feature run (#172 — feature.bpmn)", () => {
68
68
  const savedEnv = new Map<string, string | undefined>();
69
69
  let restoreGithub: (() => void) | undefined;
70
+ const githubState = admitGithubState("owner/repo", "main");
70
71
 
71
72
  before(() => {
72
73
  for (const [k, v] of Object.entries(GITHUB_ENV_OVERRIDES)) {
@@ -75,7 +76,7 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
75
76
  }
76
77
  // ADR 0003: `startFeature` + the `pr.ensure-base-branch` head task pass through base admission,
77
78
  // which reads/creates the base ref. Pin the hermetic `token` transport + fetch stub.
78
- restoreGithub = installAdmitGithub(admitGithubState("owner/repo", "main"));
79
+ restoreGithub = installAdmitGithub(githubState);
79
80
  });
80
81
 
81
82
  after(() => {
@@ -331,6 +332,49 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
331
332
  }
332
333
  });
333
334
 
335
+ test("reconcile before escalate: a no-status result with an open PR on the branch adopts & converges (issue #801)", async () => {
336
+ // The #796/#801 defect: a harness returns NO machine-readable status but has pushed the branch and
337
+ // opened a green PR. Instead of dead-ending at a human, the cell's reconcile step observes the open
338
+ // PR on `feat/<task.id>` (= `feat/issue-7`), adopts it (status=opened, prKey), and converges.
339
+ githubState.openPrs.set("feat/issue-7", { number: 801, base: "epic/e2e" });
340
+ try {
341
+ await withApp(
342
+ { "senior:feature": () => ({ summary: "opened a PR but reported no status" }) },
343
+ { baseBranch: "epic/e2e", converge: true },
344
+ async ({ app, featureKey }) => {
345
+ const flows = takenFlows(app);
346
+ assert.ok(
347
+ flows.includes("ic_reconcile_gw->ic_end"),
348
+ `the adopted PR routed straight to the cell's done end (flows: ${flows.join(", ")})`,
349
+ );
350
+ assert.ok(
351
+ !flows.includes("ic_reconcile_gw->record-escalation"),
352
+ "the run did NOT escalate to a human",
353
+ );
354
+ assert.ok(
355
+ flows.includes("gw-converge->converge"),
356
+ `the adopted PR was handed to the convergence loop (flows: ${flows.join(", ")})`,
357
+ );
358
+ const run = await featureRow(app, featureKey);
359
+ assert.equal(run.status, "converging", "the reconciled run settled at converging");
360
+ assert.equal(run.pr_key, "owner/repo#801", "the adopted PR key is recorded on the run");
361
+ const prs = await app.db.table<PrRow>("pull_requests", "pr_key").find({ pr_key: "owner/repo#801" });
362
+ assert.equal(prs.length, 1, "the adopted PR was enrolled into the convergence loop (submitPr)");
363
+
364
+ // A native user-task escalation was never parked — the machine-recoverable outcome was
365
+ // reconciled without pulling in a person.
366
+ const tasks = await app.engine.searchUserTasks({ rootProcessInstanceKey: run.process_key! });
367
+ assert.ok(
368
+ !tasks.some((t) => t.elementId === "escalation"),
369
+ "no human-escalation task was created for the adopted run",
370
+ );
371
+ },
372
+ );
373
+ } finally {
374
+ githubState.openPrs.delete("feat/issue-7");
375
+ }
376
+ });
377
+
334
378
  test("escalate + abandon: abandoning routes to record-feature (default flow)", async () => {
335
379
  await withApp(
336
380
  {
@@ -16,6 +16,10 @@ export interface AdmitGithubState {
16
16
  branches: Map<string, string>; // branch → head sha
17
17
  creates: { ref: string; sha: string }[];
18
18
  resets: string[]; // any PATCH/force-update on an existing ref (must stay empty)
19
+ /** Open PRs keyed by head branch (issue #801): the implement-cell reconcile step lists PRs for a
20
+ * head via `listPrsForHead`. Empty by default → the pulls listing returns `[]` (no adoptable PR),
21
+ * so suites that don't opt in keep exactly today's escalate behaviour. */
22
+ openPrs: Map<string, { number: number; base?: string }>;
19
23
  }
20
24
 
21
25
  /** Build a fresh admit-github state with the default branch pre-seeded with a HEAD sha so an
@@ -30,6 +34,7 @@ export function admitGithubState(
30
34
  branches: new Map([[defaultBranch, "0".repeat(40)]]),
31
35
  creates: [],
32
36
  resets: [],
37
+ openPrs: new Map(),
33
38
  };
34
39
  }
35
40
 
@@ -72,6 +77,24 @@ function admitFetch(state: AdmitGithubState) {
72
77
  state.resets.push(decodeURIComponent(path.split("/git/refs/heads/")[1] ?? ""));
73
78
  return Promise.resolve(json({ ok: true }));
74
79
  }
80
+ // GET /repos/{repo}/pulls?state=…&head=owner:branch → the open PRs for a head branch, as read by
81
+ // `listPrsForHead` (the implement-cell reconcile step, issue #801). Default empty state → `[]`.
82
+ if (method === "GET" && path === `/repos/${state.repo}/pulls`) {
83
+ const head = u.searchParams.get("head") ?? "";
84
+ const branch = head.includes(":") ? head.slice(head.indexOf(":") + 1) : head;
85
+ const hit = state.openPrs.get(branch);
86
+ if (!hit) return Promise.resolve(json([]));
87
+ return Promise.resolve(
88
+ json([
89
+ {
90
+ number: hit.number,
91
+ html_url: `https://github.com/${state.repo}/pull/${hit.number}`,
92
+ state: "open",
93
+ base: { ref: hit.base ?? state.defaultBranch },
94
+ },
95
+ ]),
96
+ );
97
+ }
75
98
  // Any other endpoint is a best-effort read the sealed transport used to skip → 404 (null).
76
99
  return Promise.resolve(new Response("Not Found", { status: 404 }));
77
100
  };
package/nano.app.json CHANGED
@@ -239,6 +239,10 @@
239
239
  {
240
240
  "taskType": "pr.record-feature-implementing",
241
241
  "handler": "workers/record-feature-implementing/worker.ts"
242
+ },
243
+ {
244
+ "taskType": "pr.reconcile-implement",
245
+ "handler": "workers/reconcile-implement/worker.ts"
242
246
  }
243
247
  ],
244
248
  "externalTaskTypes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.188.0",
3
+ "version": "0.188.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -174,6 +174,7 @@
174
174
  <zeebe:calledElement processId="implement-cell" />
175
175
  <zeebe:ioMapping>
176
176
  <zeebe:input source="=task" target="task" />
177
+ <zeebe:input source="=baseBranch" target="baseBranch" />
177
178
  <zeebe:input source="=if (is defined(baseBranchBrief)) then baseBranchBrief else null" target="baseBranchBrief" />
178
179
  <zeebe:input source="=if (is defined(resolvedArtifacts)) then resolvedArtifacts else null" target="resolvedArtifacts" />
179
180
  <zeebe:input source="=if (is defined(customInstructions)) then customInstructions else null" target="customInstructions" />
@@ -37,6 +37,23 @@
37
37
  <bpmn:outgoing>ic_escalate</bpmn:outgoing>
38
38
  <bpmn:outgoing>ic_done</bpmn:outgoing>
39
39
  </bpmn:exclusiveGateway>
40
+ <bpmn:serviceTask id="reconcile-implement" name="Reconcile from GitHub">
41
+ <bpmn:extensionElements>
42
+ <zeebe:taskDefinition type="pr.reconcile-implement" />
43
+ <zeebe:ioMapping>
44
+ <zeebe:output source="=reconciled" target="reconciled" />
45
+ <zeebe:output source="=status" target="status" />
46
+ <zeebe:output source="=pr" target="pr" />
47
+ </zeebe:ioMapping>
48
+ </bpmn:extensionElements>
49
+ <bpmn:incoming>ic_escalate</bpmn:incoming>
50
+ <bpmn:outgoing>ic_toReconcileGw</bpmn:outgoing>
51
+ </bpmn:serviceTask>
52
+ <bpmn:exclusiveGateway id="ic_reconcile_gw" name="reconciled?" default="ic_toRecordEscalation">
53
+ <bpmn:incoming>ic_toReconcileGw</bpmn:incoming>
54
+ <bpmn:outgoing>ic_reconciled</bpmn:outgoing>
55
+ <bpmn:outgoing>ic_toRecordEscalation</bpmn:outgoing>
56
+ </bpmn:exclusiveGateway>
40
57
  <bpmn:callActivity id="escalate" name="Human escalation">
41
58
  <bpmn:extensionElements>
42
59
  <zeebe:calledElement processId="human-escalation" />
@@ -61,7 +78,7 @@
61
78
  <zeebe:output source="=question" target="question" />
62
79
  </zeebe:ioMapping>
63
80
  </bpmn:extensionElements>
64
- <bpmn:incoming>ic_escalate</bpmn:incoming>
81
+ <bpmn:incoming>ic_toRecordEscalation</bpmn:incoming>
65
82
  <bpmn:outgoing>ic_toEscalate</bpmn:outgoing>
66
83
  </bpmn:serviceTask>
67
84
  <bpmn:exclusiveGateway id="ic_gw_answer" name="answer?" default="ic_abandon">
@@ -81,6 +98,7 @@
81
98
  </bpmn:serviceTask>
82
99
  <bpmn:endEvent id="ic_end" name="Task done">
83
100
  <bpmn:incoming>ic_done</bpmn:incoming>
101
+ <bpmn:incoming>ic_reconciled</bpmn:incoming>
84
102
  <bpmn:incoming>ic_abandon</bpmn:incoming>
85
103
  </bpmn:endEvent>
86
104
  <bpmn:sequenceFlow id="ic_toImplement" sourceRef="Start" targetRef="implement-task" />
@@ -88,7 +106,12 @@
88
106
  <bpmn:sequenceFlow id="ic_done" name="done" sourceRef="ic_gw" targetRef="ic_end">
89
107
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "opened" or status = "blocked" or status = "skipped"</bpmn:conditionExpression>
90
108
  </bpmn:sequenceFlow>
91
- <bpmn:sequenceFlow id="ic_escalate" name="escalated" sourceRef="ic_gw" targetRef="record-escalation" />
109
+ <bpmn:sequenceFlow id="ic_escalate" name="reconcile" sourceRef="ic_gw" targetRef="reconcile-implement" />
110
+ <bpmn:sequenceFlow id="ic_toReconcileGw" sourceRef="reconcile-implement" targetRef="ic_reconcile_gw" />
111
+ <bpmn:sequenceFlow id="ic_reconciled" name="adopted" sourceRef="ic_reconcile_gw" targetRef="ic_end">
112
+ <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=reconciled = true</bpmn:conditionExpression>
113
+ </bpmn:sequenceFlow>
114
+ <bpmn:sequenceFlow id="ic_toRecordEscalation" name="escalated" sourceRef="ic_reconcile_gw" targetRef="record-escalation" />
92
115
  <bpmn:sequenceFlow id="ic_toEscalate" sourceRef="record-escalation" targetRef="escalate" />
93
116
  <bpmn:sequenceFlow id="ic_toAnswerGw" sourceRef="escalate" targetRef="ic_gw_answer" />
94
117
  <bpmn:sequenceFlow id="ic_answerLoop" name="answer" sourceRef="ic_gw_answer" targetRef="record-implementing">
@@ -114,25 +137,34 @@
114
137
  <dc:Bounds x="406" y="62" width="70" height="28" />
115
138
  </bpmndi:BPMNLabel>
116
139
  </bpmndi:BPMNShape>
140
+ <bpmndi:BPMNShape id="BPMNShape_reconcile-implement" bpmnElement="reconcile-implement">
141
+ <dc:Bounds x="566" y="80" width="100" height="80" />
142
+ </bpmndi:BPMNShape>
143
+ <bpmndi:BPMNShape id="BPMNShape_ic_reconcile_gw" bpmnElement="ic_reconcile_gw" isMarkerVisible="true">
144
+ <dc:Bounds x="766" y="95" width="50" height="50" />
145
+ <bpmndi:BPMNLabel>
146
+ <dc:Bounds x="751" y="76" width="81" height="14" />
147
+ </bpmndi:BPMNLabel>
148
+ </bpmndi:BPMNShape>
117
149
  <bpmndi:BPMNShape id="BPMNShape_escalate" bpmnElement="escalate">
118
- <dc:Bounds x="766" y="80" width="100" height="80" />
150
+ <dc:Bounds x="1116" y="80" width="100" height="80" />
119
151
  </bpmndi:BPMNShape>
120
152
  <bpmndi:BPMNShape id="BPMNShape_record-escalation" bpmnElement="record-escalation">
121
- <dc:Bounds x="566" y="80" width="100" height="80" />
153
+ <dc:Bounds x="916" y="80" width="100" height="80" />
122
154
  </bpmndi:BPMNShape>
123
155
  <bpmndi:BPMNShape id="BPMNShape_ic_gw_answer" bpmnElement="ic_gw_answer" isMarkerVisible="true">
124
- <dc:Bounds x="966" y="95" width="50" height="50" />
156
+ <dc:Bounds x="1316" y="95" width="50" height="50" />
125
157
  <bpmndi:BPMNLabel>
126
- <dc:Bounds x="963" y="76" width="56" height="14" />
158
+ <dc:Bounds x="1313" y="76" width="56" height="14" />
127
159
  </bpmndi:BPMNLabel>
128
160
  </bpmndi:BPMNShape>
129
161
  <bpmndi:BPMNShape id="BPMNShape_record-implementing" bpmnElement="record-implementing">
130
- <dc:Bounds x="1116" y="240" width="100" height="80" />
162
+ <dc:Bounds x="1466" y="240" width="100" height="80" />
131
163
  </bpmndi:BPMNShape>
132
164
  <bpmndi:BPMNShape id="BPMNShape_ic_end" bpmnElement="ic_end">
133
- <dc:Bounds x="1148" y="102" width="36" height="36" />
165
+ <dc:Bounds x="1498" y="102" width="36" height="36" />
134
166
  <bpmndi:BPMNLabel>
135
- <dc:Bounds x="1133" y="83" width="66" height="14" />
167
+ <dc:Bounds x="1483" y="83" width="66" height="14" />
136
168
  </bpmndi:BPMNLabel>
137
169
  </bpmndi:BPMNShape>
138
170
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
@@ -150,41 +182,61 @@
150
182
  <dc:Bounds x="483" y="128" width="67" height="14" />
151
183
  </bpmndi:BPMNLabel>
152
184
  </bpmndi:BPMNEdge>
153
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toEscalate" bpmnElement="ic_toEscalate">
185
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toReconcileGw" bpmnElement="ic_toReconcileGw">
154
186
  <di:waypoint x="666" y="120" />
155
187
  <di:waypoint x="766" y="120" />
156
188
  </bpmndi:BPMNEdge>
189
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toRecordEscalation" bpmnElement="ic_toRecordEscalation">
190
+ <di:waypoint x="816" y="120" />
191
+ <di:waypoint x="916" y="120" />
192
+ <bpmndi:BPMNLabel>
193
+ <dc:Bounds x="833" y="128" width="67" height="14" />
194
+ </bpmndi:BPMNLabel>
195
+ </bpmndi:BPMNEdge>
196
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toEscalate" bpmnElement="ic_toEscalate">
197
+ <di:waypoint x="1016" y="120" />
198
+ <di:waypoint x="1116" y="120" />
199
+ </bpmndi:BPMNEdge>
157
200
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toAnswerGw" bpmnElement="ic_toAnswerGw">
158
- <di:waypoint x="866" y="120" />
159
- <di:waypoint x="966" y="120" />
201
+ <di:waypoint x="1216" y="120" />
202
+ <di:waypoint x="1316" y="120" />
160
203
  </bpmndi:BPMNEdge>
161
204
  <bpmndi:BPMNEdge id="BPMNEdge_ic_abandon" bpmnElement="ic_abandon">
162
- <di:waypoint x="1016" y="120" />
163
- <di:waypoint x="1148" y="120" />
205
+ <di:waypoint x="1366" y="120" />
206
+ <di:waypoint x="1498" y="120" />
164
207
  <bpmndi:BPMNLabel>
165
- <dc:Bounds x="1056" y="98" width="53" height="14" />
208
+ <dc:Bounds x="1406" y="98" width="53" height="14" />
166
209
  </bpmndi:BPMNLabel>
167
210
  </bpmndi:BPMNEdge>
168
211
  <bpmndi:BPMNEdge id="BPMNEdge_ic_answerLoop" bpmnElement="ic_answerLoop">
169
- <di:waypoint x="991" y="145" />
170
- <di:waypoint x="991" y="280" />
171
- <di:waypoint x="1116" y="280" />
212
+ <di:waypoint x="1341" y="145" />
213
+ <di:waypoint x="1341" y="280" />
214
+ <di:waypoint x="1466" y="280" />
172
215
  <bpmndi:BPMNLabel>
173
- <dc:Bounds x="996" y="206" width="49" height="14" />
216
+ <dc:Bounds x="1346" y="206" width="49" height="14" />
174
217
  </bpmndi:BPMNLabel>
175
218
  </bpmndi:BPMNEdge>
176
219
  <bpmndi:BPMNEdge id="BPMNEdge_ic_done" bpmnElement="ic_done">
177
220
  <di:waypoint x="441" y="145" />
178
- <di:waypoint x="441" y="180" />
179
- <di:waypoint x="1166" y="180" />
180
- <di:waypoint x="1166" y="138" />
221
+ <di:waypoint x="441" y="200" />
222
+ <di:waypoint x="1516" y="200" />
223
+ <di:waypoint x="1516" y="138" />
224
+ <bpmndi:BPMNLabel>
225
+ <dc:Bounds x="963" y="208" width="32" height="14" />
226
+ </bpmndi:BPMNLabel>
227
+ </bpmndi:BPMNEdge>
228
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_reconciled" bpmnElement="ic_reconciled">
229
+ <di:waypoint x="791" y="145" />
230
+ <di:waypoint x="791" y="180" />
231
+ <di:waypoint x="1516" y="180" />
232
+ <di:waypoint x="1516" y="138" />
181
233
  <bpmndi:BPMNLabel>
182
- <dc:Bounds x="788" y="188" width="32" height="14" />
234
+ <dc:Bounds x="1047" y="158" width="53" height="14" />
183
235
  </bpmndi:BPMNLabel>
184
236
  </bpmndi:BPMNEdge>
185
237
  <bpmndi:BPMNEdge id="BPMNEdge_ic_reImplement" bpmnElement="ic_reImplement">
186
- <di:waypoint x="1166" y="320" />
187
- <di:waypoint x="1166" y="340" />
238
+ <di:waypoint x="1516" y="320" />
239
+ <di:waypoint x="1516" y="340" />
188
240
  <di:waypoint x="266" y="340" />
189
241
  <di:waypoint x="266" y="160" />
190
242
  </bpmndi:BPMNEdge>
@@ -443,6 +443,7 @@
443
443
  <zeebe:ioMapping>
444
444
  <zeebe:input source="=task" target="task" />
445
445
  <zeebe:input source="=planKey" target="subjectKey" />
446
+ <zeebe:input source="=baseBranch" target="baseBranch" />
446
447
  <zeebe:input source="=escalationSlaTimeout" target="escalationSlaTimeout" />
447
448
  <zeebe:input source="=if (is defined(escalationAssignee)) then escalationAssignee else null" target="escalationAssignee" />
448
449
  <zeebe:input source="=if (is defined(baseBranchBrief)) then baseBranchBrief else null" target="baseBranchBrief" />
@@ -0,0 +1,87 @@
1
+ // Unit coverage for pr.reconcile-implement — the implement-cell's reconcile-before-escalate step
2
+ // (issue #801). It wraps the canonical `reconcileImplement` decision with the injected GitHub read;
3
+ // here we assert the handler wires job variables through and re-emits the decision (the gate variable
4
+ // `reconciled` plus the adopted `status`/`pr`). The decision logic itself is exhaustively covered in
5
+ // app/implementReconcile.test.ts.
6
+ import { test } from "node:test";
7
+ import { assertEquals } from "#test-assert";
8
+ import { noopLog } from "../../test/log.ts";
9
+ import handler from "./worker.ts";
10
+
11
+ // biome-ignore lint/suspicious/noExplicitAny: tiny app double — the handler only touches app.log.
12
+ const fakeApp: any = { log: noopLog() };
13
+
14
+ test("adopts an open PR on the branch: emits reconciled + status=opened + pr", async () => {
15
+ const prevToken = process.env.GITHUB_TOKEN;
16
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
17
+ const prevFetch = globalThis.fetch;
18
+ process.env.GITHUB_TOKEN = "t";
19
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
20
+ // Token transport (never shells out to `gh`): stub the pulls listing so `listPrsForHead` returns one
21
+ // open PR opened from `feat/issue-801`.
22
+ globalThis.fetch = (async () =>
23
+ new Response(JSON.stringify([{ number: 801, html_url: "https://github.com/owner/repo/pull/801", state: "open", base: { ref: "main" } }]), {
24
+ status: 200,
25
+ })) as typeof fetch;
26
+ try {
27
+ const out = await handler(
28
+ { jobKey: "j1", variables: { subjectKey: "owner/repo#801", task: { id: "issue-801" }, status: null } } as never,
29
+ fakeApp,
30
+ );
31
+ assertEquals(out, { reconciled: true, status: "opened", pr: "owner/repo#801" });
32
+ } finally {
33
+ globalThis.fetch = prevFetch;
34
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
35
+ else process.env.GITHUB_TOKEN = prevToken;
36
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
37
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
38
+ }
39
+ });
40
+
41
+ test("wires baseBranch through: a wrong-base open PR is NOT adopted → escalate", async () => {
42
+ const prevToken = process.env.GITHUB_TOKEN;
43
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
44
+ const prevFetch = globalThis.fetch;
45
+ process.env.GITHUB_TOKEN = "t";
46
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
47
+ // The only open PR on the head branch targets a different base than the run's pinned `baseBranch`.
48
+ globalThis.fetch = (async () =>
49
+ new Response(JSON.stringify([{ number: 55, html_url: "https://github.com/owner/repo/pull/55", state: "open", base: { ref: "stale-base" } }]), {
50
+ status: 200,
51
+ })) as typeof fetch;
52
+ try {
53
+ const out = await handler(
54
+ { jobKey: "j3", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null, baseBranch: "epic/feat-x" } } as never,
55
+ fakeApp,
56
+ );
57
+ assertEquals(out, { reconciled: false, status: null, pr: null });
58
+ } finally {
59
+ globalThis.fetch = prevFetch;
60
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
61
+ else process.env.GITHUB_TOKEN = prevToken;
62
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
63
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
64
+ }
65
+ });
66
+
67
+ test("no open PR on the branch: falls through to escalate (reconciled=false)", async () => {
68
+ const prevToken = process.env.GITHUB_TOKEN;
69
+ const prevTransport = process.env.NANO_PR_GITHUB_TRANSPORT;
70
+ const prevFetch = globalThis.fetch;
71
+ process.env.GITHUB_TOKEN = "t";
72
+ process.env.NANO_PR_GITHUB_TRANSPORT = "token";
73
+ globalThis.fetch = (async () => new Response(JSON.stringify([]), { status: 200 })) as typeof fetch;
74
+ try {
75
+ const out = await handler(
76
+ { jobKey: "j2", variables: { subjectKey: "owner/repo#7", task: { id: "issue-7" }, status: null } } as never,
77
+ fakeApp,
78
+ );
79
+ assertEquals(out, { reconciled: false, status: null, pr: null });
80
+ } finally {
81
+ globalThis.fetch = prevFetch;
82
+ if (prevToken === undefined) delete process.env.GITHUB_TOKEN;
83
+ else process.env.GITHUB_TOKEN = prevToken;
84
+ if (prevTransport === undefined) delete process.env.NANO_PR_GITHUB_TRANSPORT;
85
+ else process.env.NANO_PR_GITHUB_TRANSPORT = prevTransport;
86
+ }
87
+ });
@@ -0,0 +1,60 @@
1
+ // pr.reconcile-implement — reconcile the implement-cell result from GitHub before escalating (issue #801).
2
+ //
3
+ // Runs on the shared `implement-cell`'s escalate arm (`ic_gw` "clean terminal?" → here), IMMEDIATELY
4
+ // before the human escalation, for EVERY caller that composes the cell: a standalone `feature` run and
5
+ // a plan-fanout wave slice. When the implement step returned no machine-readable `status` (a harness
6
+ // that opened a green PR but reported nothing — #796's implement-stage twin), it looks for an OPEN PR
7
+ // on the cell's deterministic `feat/<task.id>` branch and, when one exists, ADOPTS it — emitting
8
+ // `reconciled = true` (the `ic_reconcile_gw` gate routes straight to the cell's done end) plus
9
+ // `status = "opened"` and a `pr` key so the caller converges the adopted PR exactly as if the agent
10
+ // had reported it. Only when nothing is observable does it fall through (`reconciled = false`) to the
11
+ // human escalation as today.
12
+ //
13
+ // The decision + the injected GitHub read (`listPrsForHead`) live in the canonical, exhaustively
14
+ // tested `app/implementReconcile.ts` mirror — this handler is the thin engine seam. `taskId` is
15
+ // derived from the in-scope `task` process variable (`task.id`) — `implement-cell.bpmn` defines no
16
+ // `<zeebe:ioMapping>` input for this step; the engine populates `task` (and `subjectKey`, `status`,
17
+ // `pr`, `baseBranch`) from process scope. `pr` is passed through so the reconcile step's `pr` output
18
+ // never wipes a PR key the implement harness already set. `baseBranch` (the run's pinned base) is
19
+ // passed through so an open PR on the deterministic head branch is only adopted when it targets that
20
+ // base — never a stale/unrelated PR sharing the head branch but aimed at a different base.
21
+ import type { AppJobHandler } from "@nanobpm/urban";
22
+ import { listPrsForHead } from "../../app/github.ts";
23
+ import { type ReconcileImplementResult, reconcileImplement } from "../../app/implementReconcile.ts";
24
+
25
+ interface In extends Record<string, unknown> {
26
+ subjectKey?: unknown;
27
+ task?: unknown;
28
+ status?: unknown;
29
+ pr?: unknown;
30
+ baseBranch?: unknown;
31
+ }
32
+
33
+ /** The cell's deterministic branch is `feat/<task.id>`; `task` is the implement-cell's slice object.
34
+ * Read it defensively (the process variable is untyped at the engine seam). */
35
+ function taskId(task: unknown): unknown {
36
+ if (task !== null && typeof task === "object" && "id" in task) return task.id;
37
+ return undefined;
38
+ }
39
+
40
+ const handler: AppJobHandler<In, ReconcileImplementResult> = async (job, app) => {
41
+ const res = await reconcileImplement(
42
+ {
43
+ status: job.variables.status,
44
+ subjectKey: job.variables.subjectKey,
45
+ taskId: taskId(job.variables.task),
46
+ pr: job.variables.pr,
47
+ baseBranch: job.variables.baseBranch,
48
+ },
49
+ listPrsForHead,
50
+ process.env.GITHUB_TOKEN ?? "",
51
+ );
52
+ app.log.info("reconcile-implement", {
53
+ subjectKey: job.variables.subjectKey ?? null,
54
+ reconciled: res.reconciled,
55
+ pr: res.pr,
56
+ });
57
+ return res;
58
+ };
59
+
60
+ export default handler;