@nanobpm/nano-workforce 0.208.3 → 0.208.4

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/AGENTS.md CHANGED
@@ -253,6 +253,23 @@ Two different `nano:dataEnvelope` uses have different rules — don't conflate t
253
253
  it as a process variable: besides being unnecessary, seeding a var an
254
254
  in-subprocess gateway reads re-introduces the multi-instance `=null`-shadow
255
255
  gotcha above.
256
+ - **A per-MI-child value reaches an agent job's reserved `io.nanobpm.agentTask`
257
+ envelope via a worker result key literally NAMED with the dotted path — never a
258
+ dotted `ioMapping` target.** The whole-run seed can only set the
259
+ `io.nanobpm.agentTask` object ONCE at the root (uniform for every cell), so a
260
+ per-slice field (e.g. the implement-cell's deterministic `branch.create =
261
+ feat/<task.id>`, issue #945) can't ride it. A `<zeebe:output
262
+ target="io.nanobpm.agentTask.branch.create">` does NOT work — a dotted ioMapping
263
+ target builds a NESTED `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness
264
+ never reads. The working channel: a worker running in a per-slice process instance (e.g.
265
+ `pr.slice-branch`, the first step of each implement-cell instance — NOT a task in the shared MI body, whose undeclared result keys land on the shared root where a sibling overwrites them) returns a result key literally named
266
+ `"io.nanobpm.agentTask.branch.create"`; the engine propagates worker result
267
+ variables verbatim (mapped or not), materialising a FLAT dotted process variable
268
+ that the harness's `collectEnvelopeFrom` deep-merges with the whole-object seed.
269
+ Derive the value from the ONE canonical `sliceBranchCreate` (app/repoEnvelope.ts)
270
+ so the envelope, the prompt's branch instruction, and the reconcile lookup share a
271
+ single source. `recoverBranchCreate(headers, variables)` (same module) is the ONE
272
+ consumer-side mirror tests use to assert "the harness sees this branch".
256
273
 
257
274
  ## Urban page runtime: rendering primitives are not JS-truthy
258
275
 
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [0.208.4](https://github.com/nanobpm/nano-workforce/compare/v0.208.3...v0.208.4) (2026-11-11)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **plan-fanout:** emit deterministic branch.create on the implement-cell envelope ([#945](https://github.com/nanobpm/nano-workforce/issues/945)) ([#946](https://github.com/nanobpm/nano-workforce/issues/946)) ([0977243](https://github.com/nanobpm/nano-workforce/commit/097724344a2f3942af5b1796e2e0a7e8363b44f9))
6
+
1
7
  ## [0.208.3](https://github.com/nanobpm/nano-workforce/compare/v0.208.2...v0.208.3) (2026-11-11)
2
8
 
3
9
  ### Bug Fixes
package/app/contracts.ts CHANGED
@@ -479,7 +479,7 @@ export const WIRE_CONTRACTS = {
479
479
  name: "io.nanobpm.agentTask.repository",
480
480
  owner: "app/repoEnvelope.ts",
481
481
  semantics:
482
- "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout seed still omits it because its MI children each cut a per-child `feat/<task.id>` the app can't name at compile time, so those agents branch themselves). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231). PLACEMENT (issue #882): `branch` is emitted as a TOP-LEVEL sibling of `repository` (and of `task`) under the namespace — the harness `normalizeTaskEnvelope` reads `branch` from the envelope ROOT (`isPlainObject(raw.branch)`), never from `repository.branch`, so the producer must not nest it there (nesting was the #227 two-sides-of-one-contract drift that left `branch.create` silently unread — nanobpm/nano-bpm#1344). The round-trip contract test `app/repoEnvelope.test.ts` pins producer and consumer together.",
482
+ "Repo-provisioning envelope the app emits as a `createInstance` process variable (`repoEnvelopeVars`, app/repoEnvelope.ts) and the c8ctl worker harness consumes to provision an isolated clone — instead of the agent inheriting the worker's launch dir (issue #684). `ref` is the branch checked out: the PR HEAD branch on the PR-based paths (review-round / fix-ci / rebase), or — on the PRE-PR implementation path (feature.bpmn / plan-fanout's `implement-cell`, issue #684; the delivery-graph runner's agent cells, issue #686) — the BASE branch, off which the harness cuts a new feature branch named by the optional `branch.create`. On the PRE-PR path `branch.create` is the deterministic `feat/<task.id>` (emitted for a single-task feature run AND — per issue #776 — the deterministic `feat/<node.id>` for each single-instance delivery-graph agent cell, injected per-cell by `agentNodeRepoEnvelope`/`app/deliveryRunner.ts` so a forgetful agent can never be left committing on the base branch and stranding its run on a non-ff push; the epic plan-fanout's per-slice `feat/<task.id>` differs per MI child and so can't ride the whole-epic seed — instead the `pr.slice-branch` worker — the first step of EACH implement-cell process instance, so the value is instance-local and sibling slices cannot overwrite it — stamps it as a FLAT dotted `io.nanobpm.agentTask.branch.create` process variable (issue #945, derived via the ONE canonical `sliceBranchCreate`), which the harness's `collectEnvelopeFrom` deep-merges with the whole-object seed so a retry re-derives the SAME branch and the durable WIP checkpoint restore matches). On the PR-based paths `branch.create` is set to the PR HEAD branch itself (equal to `ref`, jwulf/c8ctl-plugin-nano#231): without it the harness's base-branch guard treats the checkout as base-like and cuts a throwaway `nano/agent-work/<base>-<run>` fallback, stranding the round's commits OFF the PR; with `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR head branch. So `branch.create` is present on both the pre-PR and PR-based paths (only a blank/omitted value drops the key). Beyond `{provider,url,ref}`, it carries clone-shaping fields for large monorepos (issue #287): `singleBranch:true` + `filter:\"blob:none\"` (a branch-scoped, blobless partial clone — trees fetched up-front, blobs lazily, no `--depth 1` so the merge-base/3-dot diff stays valid) and an optional `baseRef` (the PR base branch, emitted only when resolvable, so the harness fetches its tip and keeps `origin/<base>` reachable) and a `cloneTimeoutMs` (from `NANO_PR_CLONE_TIMEOUT_MS`, default 600000 = 10 min) that raises the harness's 120s default so a large monorepo's blobless single-branch clone provisions instead of dying at 120s (issue #694). World-restore (issue #324, ADR 0062 Slice 4/5): an optional `sha` — the last durable push-checkpoint — is emitted so a REPLACEMENT activation on a fresh worktree reconstructs the tree to the EXACT pushed SHA (inverting the round's `git push` into `git fetch && git checkout <sha>`), omitted when the PR has no checkpoint yet. The field is named `sha` because that is the field the c8ctl harness's `provisionRepo` reads to drive the checkout — an earlier `commitSha` key was a silent no-op (issue #695). Alongside the `repository` slice the envelope carries a sibling `task.allowPr: true` (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the git credential (GITHUB_TOKEN, or the `gh` default) for repo provisioning behind that flag, so every repo-backed envelope sets it or the clone dies with `unable to get password from user`; the repoless path emits no envelope and so no `task`. Gated on c8ctl provisioner support (jwulf/c8ctl-plugin-nano#91, branch-cut guard jwulf/c8ctl-plugin-nano#231). PLACEMENT (issue #882): `branch` is emitted as a TOP-LEVEL sibling of `repository` (and of `task`) under the namespace — the harness `normalizeTaskEnvelope` reads `branch` from the envelope ROOT (`isPlainObject(raw.branch)`), never from `repository.branch`, so the producer must not nest it there (nesting was the #227 two-sides-of-one-contract drift that left `branch.create` silently unread — nanobpm/nano-bpm#1344). The round-trip contract test `app/repoEnvelope.test.ts` pins producer and consumer together.",
483
483
  shape:
484
484
  'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string }, branch?: { create: string }, task: { allowPr: true } } — `branch` is a TOP-LEVEL sibling of `repository` (issue #882): the harness `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from `repository.branch`, so the flattened header is `io.nanobpm.agentTask.branch.create` (never `…repository.branch.create`).',
485
485
  },
package/app/feature.ts CHANGED
@@ -21,7 +21,7 @@ import { coalesceTitle, fetchIssueTitle } from "./github.ts";
21
21
  import { derivedTrackingTable } from "./instanceTracking.ts";
22
22
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
23
23
  import type { ReadinessProbe } from "./readiness.ts";
24
- import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
24
+ import { requireRepoEnvelopeVars, sliceBranchCreate } from "./repoEnvelope.ts";
25
25
 
26
26
  /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
27
27
  * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
@@ -503,7 +503,10 @@ export async function startFeature(
503
503
  const taskId = featureTaskId(parsed.number);
504
504
  // Pre-PR feature branch (issue #684): the deterministic `feat/<task.id>` the harness creates off the
505
505
  // base for the implementation agent (the agent-guide's `feat/*` convention), matching `task.id`.
506
- const prePrBranch = `feat/${taskId}`;
506
+ // Derived via the ONE canonical `sliceBranchCreate` (issue #945) so the whole-run feature seed and the
507
+ // epic plan-fanout's per-slice emission can never drift into two branch-naming sources. A single-issue
508
+ // task id is always `issue-<number>`, so the derivation is never null here.
509
+ const prePrBranch = sliceBranchCreate(taskId) ?? `feat/${taskId}`;
507
510
  const { processInstanceKey } = await engine.createInstance({
508
511
  processDefinitionId: FEATURE_PROCESS_ID,
509
512
  variables: {
@@ -0,0 +1,168 @@
1
+ // DEFECT-CLASS GUARD (issue #945): every process path that activates an agent job backed by a
2
+ // repository envelope MUST name a deterministic `branch.create`, and that branch MUST be the one the
3
+ // agent's prompt tells it to use — `feat/<task.id>` (resources/prompts/feature.md). Before #945 the
4
+ // plan-fanout implement-cell carried NO `branch.create`: the deterministic branch existed only as
5
+ // prose in the prompt, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch and the
6
+ // durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded the
7
+ // prior session's work. The root cause was a DRIFT SURFACE: two sources of truth (the prompt text and
8
+ // the envelope) for one fact.
9
+ //
10
+ // This guard pins the single source of truth (`sliceBranchCreate`, app/repoEnvelope.ts) and asserts
11
+ // every consumer derives the SAME branch from it, so a new agent-job-with-repo path — or an edit that
12
+ // re-introduces a hand-written branch — fails CI:
13
+ //
14
+ // 1. The ONE derivation: `sliceBranchCreate(taskId)` is `feat/<taskId>`, validated as a plausible git
15
+ // ref (an ill-formed id degrades to omission, never an unusable branch.create).
16
+ // 2. Every consumer delegates to it: the whole-run feature seed (`feature.ts`), the implement-cell
17
+ // reconcile lookup (`implementReconcile.ts` `implementCellBranch`), and the per-slice envelope
18
+ // emission (`workers/caps-prepare`).
19
+ // 3. The prompt's branch instruction is the SAME `feat/<task.id>` the envelope cuts — the two read
20
+ // off one value, not two.
21
+ // 4. Every execution path into the `implement-task` agent job passes through the `pr.slice-branch`
22
+ // emitter, which runs INSIDE each implement-cell process instance (so siblings can't clobber it) —
23
+ // checked on the sourceRef/targetRef graph, not on XML declaration order.
24
+ import { readFileSync } from "node:fs";
25
+ import { test } from "node:test";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+ import { assert, assertEquals } from "#test-assert";
29
+ import { implementCellBranch } from "./implementReconcile.ts";
30
+ import { AGENT_TASK_BRANCH_CREATE_VAR, sliceBranchCreate } from "./repoEnvelope.ts";
31
+
32
+ const PROCESSES_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/processes");
33
+ const PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "../resources/prompts");
34
+
35
+ function flat(rel: string): string {
36
+ return readFileSync(join(PROCESSES_DIR, rel), "utf8").replace(/\s+/g, " ");
37
+ }
38
+
39
+ test("contract: sliceBranchCreate is the ONE canonical feat/<task.id> derivation (#945)", () => {
40
+ // The deterministic branch for a real task id.
41
+ assertEquals(sliceBranchCreate("issue-7"), "feat/issue-7");
42
+ assertEquals(sliceBranchCreate("tool-metadata-engine"), "feat/tool-metadata-engine");
43
+ assertEquals(sliceBranchCreate("t1"), "feat/t1");
44
+ // A blank / missing id yields NO branch (the caller degrades to omission, never a bare `feat/`).
45
+ assertEquals(sliceBranchCreate(""), null);
46
+ assertEquals(sliceBranchCreate(" "), null);
47
+ assertEquals(sliceBranchCreate(null), null);
48
+ assertEquals(sliceBranchCreate(undefined), null);
49
+ // An id whose derived branch is not a plausible git ref degrades to null (no unusable branch.create).
50
+ assertEquals(sliceBranchCreate("bad..id"), null);
51
+ assertEquals(sliceBranchCreate("has space"), null);
52
+ });
53
+
54
+ test("contract: every implement-cell branch consumer derives from the ONE source (#945)", () => {
55
+ // The reconcile lookup (`implementCellBranch`) — which finds the adoptable PR on the slice's branch —
56
+ // MUST name the same branch the harness cuts (the envelope) and the prompt instructs. It delegates to
57
+ // `sliceBranchCreate`, so the two can never drift.
58
+ for (const taskId of ["issue-7", "tool-metadata-engine", "t1"]) {
59
+ assertEquals(
60
+ implementCellBranch(taskId),
61
+ sliceBranchCreate(taskId),
62
+ `implementCellBranch(${taskId}) must equal the canonical sliceBranchCreate (one source)`,
63
+ );
64
+ }
65
+ });
66
+
67
+ test("contract: the agent prompt's branch instruction is the SAME feat/<task.id> the envelope cuts (#945)", () => {
68
+ // The implement-cell's base prompt (feature.md) tells the agent to branch `feat/<task.id>`. That
69
+ // prose is the SECOND half of the contract — it must name the very branch `sliceBranchCreate`
70
+ // derives for the envelope, so the agent's self-cut branch (pre-#945 fallback) and the harness-cut
71
+ // branch (post-#945) are identical. Pin the literal placeholder so a prompt edit that renames the
72
+ // branch convention fails here.
73
+ const prompt = readFileSync(join(PROMPTS_DIR, "feature.md"), "utf8");
74
+ assert(
75
+ prompt.includes("feat/<task.id>"),
76
+ "feature.md must instruct the agent to branch `feat/<task.id>` — the same branch sliceBranchCreate derives",
77
+ );
78
+ // And the derivation of a concrete id must slot into that placeholder exactly.
79
+ assertEquals(`feat/<task.id>`.replace("<task.id>", "t1"), sliceBranchCreate("t1"));
80
+ });
81
+
82
+ /** Parse the `<bpmn:sequenceFlow>` graph (sourceRef → targetRef) and the boundary-event attachments
83
+ * (host → boundary, an implicit edge) of one process file. */
84
+ function flowGraph(xml: string): Map<string, string[]> {
85
+ const g = new Map<string, string[]>();
86
+ const add = (from: string, to: string): void => {
87
+ g.set(from, [...(g.get(from) ?? []), to]);
88
+ };
89
+ for (const m of xml.matchAll(/<bpmn:sequenceFlow\b[^>]*>/g)) {
90
+ const src = /sourceRef="([^"]+)"/.exec(m[0])?.[1];
91
+ const tgt = /targetRef="([^"]+)"/.exec(m[0])?.[1];
92
+ if (src && tgt) add(src, tgt);
93
+ }
94
+ for (const m of xml.matchAll(/<bpmn:boundaryEvent\b[^>]*>/g)) {
95
+ const id = /\bid="([^"]+)"/.exec(m[0])?.[1];
96
+ const host = /attachedToRef="([^"]+)"/.exec(m[0])?.[1];
97
+ if (id && host) add(host, id);
98
+ }
99
+ return g;
100
+ }
101
+
102
+ /** Can `to` be reached from any of `from` without visiting `avoid`? */
103
+ function reachableAvoiding(g: Map<string, string[]>, from: string[], to: string, avoid: string): boolean {
104
+ const seen = new Set<string>();
105
+ const stack = from.filter((f) => f !== avoid);
106
+ while (stack.length > 0) {
107
+ const n = stack.pop();
108
+ if (n === undefined || seen.has(n)) continue;
109
+ if (n === to) return true;
110
+ seen.add(n);
111
+ for (const next of g.get(n) ?? []) if (next !== avoid) stack.push(next);
112
+ }
113
+ return false;
114
+ }
115
+
116
+ function startEvents(xml: string): string[] {
117
+ return [...xml.matchAll(/<bpmn:startEvent\b[^>]*\bid="([^"]+)"/g)].map((m) => m[1] ?? "");
118
+ }
119
+
120
+ test("contract: every path into the implement-task agent job passes through the slice-branch emitter (#945)", () => {
121
+ const xml = flat("implement-cell.bpmn");
122
+ assert(
123
+ /<bpmn:serviceTask id="slice-branch"[^>]*> <bpmn:extensionElements> <zeebe:taskDefinition type="pr\.slice-branch"/.test(xml),
124
+ "implement-cell must declare the slice-branch task backed by the pr.slice-branch worker",
125
+ );
126
+ const g = flowGraph(xml);
127
+ const starts = startEvents(xml);
128
+ assert(starts.length > 0, "implement-cell must have a start event");
129
+ assert(reachableAvoiding(g, starts, "implement-task", "__none__"), "implement-task must be reachable at all");
130
+ assert(
131
+ !reachableAvoiding(g, starts, "implement-task", "slice-branch"),
132
+ "no execution path may reach implement-task without first passing the slice-branch emitter",
133
+ );
134
+ });
135
+
136
+ test("contract: the path checker detects a bypass of the emitter (guards the guard) (#945)", () => {
137
+ const bypass = flat("implement-cell.bpmn").replace(
138
+ /<bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="slice-branch" \/>/,
139
+ '<bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="implement-task" />',
140
+ );
141
+ assert(bypass.includes('sourceRef="Start" targetRef="implement-task"'), "fixture rewrite must apply");
142
+ assert(reachableAvoiding(flowGraph(bypass), startEvents(bypass), "implement-task", "slice-branch"));
143
+ });
144
+
145
+ test("contract: plan-fanout composes the implement step as a callActivity to implement-cell (#945)", () => {
146
+ // The emitter must run in a SEPARATE process instance per slice (its result keys land on that
147
+ // instance's root, never the shared epic root), so the MI body must call implement-cell rather than
148
+ // inline the agent task.
149
+ const xml = flat("plan-fanout.bpmn");
150
+ assert(
151
+ /<bpmn:callActivity[^>]*id="implement-cell-call"/.test(xml) &&
152
+ /<zeebe:calledElement processId="implement-cell"/.test(xml),
153
+ "plan-fanout must compose the per-wave implement step as a callActivity to implement-cell",
154
+ );
155
+ assert(!xml.includes('type="senior:feature"'), "plan-fanout must not inline the implement agent task");
156
+ });
157
+
158
+ test("contract: the per-slice branch.create rides the FLAT dotted variable the harness reads (#945)", () => {
159
+ // The emission channel is a process variable literally NAMED `io.nanobpm.agentTask.branch.create` —
160
+ // the dotted key the harness's `collectEnvelopeFrom` reassembles and deep-merges with the whole-object
161
+ // seed envelope. Pin the constant to that exact dotted name so a refactor can't silently move it to a
162
+ // nested object (a `<zeebe:ioMapping>` dotted target) the harness never reads.
163
+ assertEquals(AGENT_TASK_BRANCH_CREATE_VAR, "io.nanobpm.agentTask.branch.create");
164
+ assert(
165
+ AGENT_TASK_BRANCH_CREATE_VAR.startsWith("io.nanobpm.agentTask."),
166
+ "the per-slice branch.create variable must live under the reserved io.nanobpm.agentTask namespace",
167
+ );
168
+ });
@@ -22,6 +22,7 @@ import { hasAnswerableQuestion } from "./escalationTaxonomy.ts";
22
22
  import type { HeadPr } from "./github.ts";
23
23
  import { isClaimedCompletion } from "./implementEscalationReason.ts";
24
24
  import { parsePr } from "./prParse.ts";
25
+ import { sliceBranchCreate } from "./repoEnvelope.ts";
25
26
 
26
27
  /** The escalate-arm inputs the reconcile step reads from the implement-cell scope. `subjectKey` is the
27
28
  * cell's `owner/repo#N` subject (a feature run's `feature_key`, or a wave slice's epic `plan_key`) —
@@ -99,9 +100,14 @@ export function retryNudgeFor(status: string): string {
99
100
  return `\n\n---\n\n**Automatic retry — your previous attempt claimed completion but no adoptable pull request was found.** You reported status "${status}", yet a GitHub lookup confirmed there is no adoptable pull request for this slice on its base branch. Your work may NOT be lost: if you already pushed the \`feat/<task.id>\` branch, recover it — check the remote with \`git ls-remote --heads origin feat/<task.id>\`, and if a pull request exists but targets the WRONG base, retarget it onto this run's base branch with \`gh pr edit --base <base>\`. Otherwise you likely worked OUTSIDE your run workspace (e.g. \`cd /tmp\`), and those edits were discarded on teardown. This is your FINAL automatic retry; the next failure escalates to a human. Work ONLY inside your run workspace, then \`git commit -s\`, push \`feat/<task.id>\`, and open a PR on the correct base with \`gh pr create\`. Report \`status: "opened"\` with the \`pr\` key — not "${status}".`;
100
101
  }
101
102
 
102
- /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md). */
103
+ /** The cell's deterministic implement branch — `feat/<task.id>` (resources/prompts/feature.md).
104
+ * Delegates to the ONE canonical `sliceBranchCreate` (issue #945) so the reconcile lookup, the
105
+ * repository-envelope `branch.create` the harness cuts, and the prompt's branch instruction all share
106
+ * a single derivation — they can never drift into two branch-naming sources. A task id reaching the
107
+ * reconcile step is always a real id, so the derivation is never null here; the `?? `` fallback is
108
+ * defensive only (an implausible id would simply find no adoptable PR, the pre-existing behaviour). */
103
109
  export function implementCellBranch(taskId: string): string {
104
- return `feat/${taskId}`;
110
+ return sliceBranchCreate(taskId) ?? `feat/${taskId}`;
105
111
  }
106
112
 
107
113
  /** The adoptable PR from a head-branch listing: the first OPEN one (a merged/closed PR on the branch is
@@ -12,82 +12,18 @@
12
12
  // consumer together so they cannot drift again.
13
13
  import { test } from "node:test";
14
14
  import { assert, assertEquals } from "#test-assert";
15
- import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
15
+ import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, recoverBranchCreate, repoEnvelopeVars } from "./repoEnvelope.ts";
16
16
 
17
- // ─── A faithful mirror of the harness's consumer shape ───────────────────────────────
18
- // These mirror the EXACT semantics of c8ctl-plugin-nano `normalizeTaskEnvelope`
19
- // (c8ctl-plugin.js): `collectEnvelopeFrom` reassembles the reserved
20
- // `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
21
- // dotpath keys, `deepMerge`s the two sources, and `normalizeTaskEnvelope` then reads
22
- // `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`),
23
- // NOT from `raw.repository.branch`. Keeping this mirror in lock-step with the harness
24
- // is the point of the test: if the producer emits a shape the consumer doesn't read,
25
- // the round-trip below drops `branch.create` and fails.
26
- const AGENT_TASK_NS = "io.nanobpm.agentTask";
27
-
28
- function isPlainObject(v: unknown): v is Record<string, unknown> {
29
- return v != null && typeof v === "object" && !Array.isArray(v);
30
- }
31
-
32
- function deepMerge(base: unknown, over: unknown): unknown {
33
- if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
34
- const out: Record<string, unknown> = { ...base };
35
- if (!isPlainObject(over)) return out;
36
- for (const [k, v] of Object.entries(over)) {
37
- if (v === undefined) continue;
38
- out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
39
- }
40
- return out;
41
- }
42
-
43
- function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
44
- let cur = obj;
45
- for (let i = 0; i < path.length - 1; i++) {
46
- const k = path[i];
47
- if (!isPlainObject(cur[k])) cur[k] = {};
48
- cur = cur[k] as Record<string, unknown>;
49
- }
50
- cur[path[path.length - 1]] = value;
51
- }
52
-
53
- /** Mirror of the harness's `collectEnvelopeFrom`: reassemble the reserved namespace from a
54
- * whole-object/JSON value AND flattened dotpath keys (element templates / task headers emit the
55
- * latter). */
56
- function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
57
- const out: Record<string, unknown> = {};
58
- if (!isPlainObject(source)) return out;
59
- const whole = source[AGENT_TASK_NS];
60
- if (whole != null) {
61
- let val: unknown = whole;
62
- if (typeof whole === "string") {
63
- try {
64
- val = JSON.parse(whole);
65
- } catch {
66
- val = undefined;
67
- }
68
- }
69
- if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
70
- }
71
- const prefix = `${AGENT_TASK_NS}.`;
72
- for (const [key, value] of Object.entries(source)) {
73
- if (!key.startsWith(prefix)) continue;
74
- const rest = key.slice(prefix.length);
75
- if (!rest) continue;
76
- setPath(out, rest.split("."), value);
77
- }
78
- return out;
79
- }
80
-
81
- /** Mirror of the harness's `normalizeTaskEnvelope` branch slice: it reads `branch` from the TOP
82
- * LEVEL of the merged envelope — `isPlainObject(raw.branch) ? raw.branch : {}` — never from
83
- * `raw.repository.branch`. Returns the consumer's recovered `branch.create` (undefined when the
84
- * producer put it somewhere the consumer doesn't look). */
85
- function harnessReadsBranchCreate(customHeaders: Record<string, unknown>, variables: Record<string, unknown>): string | undefined {
86
- const raw = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables)) as Record<string, unknown>;
87
- const branch = isPlainObject(raw.branch) ? raw.branch : {};
88
- const create = branch.create;
89
- return create == null ? undefined : String(create);
90
- }
17
+ // ─── The consumer mirror ─────────────────────────────────────────────────────────────
18
+ // `recoverBranchCreate` (app/repoEnvelope.ts) is the ONE canonical mirror of the c8ctl harness's
19
+ // consumer shape (`collectEnvelopeFrom` + `normalizeTaskEnvelope`): it reassembles the reserved
20
+ // `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened dotpath keys,
21
+ // `deepMerge`s the two sources (variables over headers), and reads `branch` from the TOP LEVEL of the
22
+ // merged envelope (`isPlainObject(raw.branch)`), NOT from `raw.repository.branch`. Keeping that mirror
23
+ // in lock-step with the harness is the point of this test: if the producer emits a shape the consumer
24
+ // doesn't read, the round-trip below drops `branch.create` and fails. Aliased here under the
25
+ // harness-centric name the assertions read naturally with.
26
+ const harnessReadsBranchCreate = recoverBranchCreate;
91
27
 
92
28
  // ─── The round-trip contract ──────────────────────────────────────────────────────────
93
29
 
@@ -8,6 +8,7 @@
8
8
  // rebase) and the PRE-PR implementation dispatch (`feature.ts`: `startFeature`; `plan.ts`:
9
9
  // `startPlan` → the epic's `implement-cell`) derive from this single implementation without a
10
10
  // `plan.ts ↔ service.ts` import cycle (AGENTS.md "derivation over duplication — no drift surfaces").
11
+ import { isPlausibleBranchName } from "./baseBranch.ts";
11
12
  import { readEnvOr } from "./contracts.ts";
12
13
  import { isCommitSha } from "./world/index.ts";
13
14
 
@@ -34,6 +35,43 @@ export function isResolvableRepo(repo: unknown): repo is string {
34
35
  return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
35
36
  }
36
37
 
38
+ /** The FLAT, dotted process-variable NAME a per-slice worker emits to attach the deterministic
39
+ * `branch.create` to the implement-cell's agent-job envelope (issue #945). The c8ctl harness's
40
+ * `collectEnvelopeFrom` reassembles the reserved namespace from BOTH the whole-object
41
+ * `io.nanobpm.agentTask` variable (the whole-epic seed, which carries `repository` + `task` but no
42
+ * per-slice `branch`) AND any dotted `io.nanobpm.agentTask.*` variables in scope, deep-merging them —
43
+ * so a dotted-name variable set in the MI-child scope lands on the SAME envelope the seed started,
44
+ * and `normalizeTaskEnvelope` then reads `branch.create` from the merged top level. A
45
+ * `<zeebe:ioMapping>` target with dots canNOT produce this: it builds a NESTED
46
+ * `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never reads (see {@link
47
+ * flattenAgentTaskEnvelope}). Only a worker result key literally NAMED with this dotted string (the
48
+ * engine propagates worker result variables verbatim, mapped or not) materialises the dotted
49
+ * variable. */
50
+ export const AGENT_TASK_BRANCH_CREATE_VAR = `${AGENT_TASK_NS}.branch.create`;
51
+
52
+ /** The ONE canonical derivation of an implementation slice's deterministic pre-PR branch —
53
+ * `feat/<taskId>` (the agent-guide's `feat/*` convention, resources/prompts/feature.md). This is the
54
+ * single source of truth for BOTH the whole-run feature seed (`feature.ts` `startFeature`, where the
55
+ * branch is known at seed time and rides the envelope's `branch.create` directly) AND the epic
56
+ * plan-fanout's per-slice emission (`workers/slice-branch`, run inside each implement-cell instance, which stamps it onto the implement-cell
57
+ * envelope via {@link AGENT_TASK_BRANCH_CREATE_VAR} because each slice's `task.id` differs per child
58
+ * and can't be named at the whole-epic seed). Issue #945: the plan-fanout implement-cell previously
59
+ * carried NO `branch.create`, so every c8ctl retry cut a random `nano/agent-work/*` fallback branch
60
+ * and the durable WIP checkpoint (keyed on `feat/<task.id>`) was never restored — each retry discarded
61
+ * the prior session's work. Deriving it here once keeps the envelope and the prompt's branch
62
+ * instruction (which tells the agent to use `feat/<task.id>`) from drifting into two sources.
63
+ *
64
+ * Returns `null` for a `taskId` whose derived branch is not a plausible git ref (mirrors the
65
+ * delivery-runner's `feat/<node.id>` guard, #776): an ill-formed `branch.create` would turn the
66
+ * isolation guarantee into a launch/checkout failure, so a blank/implausible id degrades to omission
67
+ * (the pre-#945 agent-cuts-its-own-branch behaviour) rather than emit an unusable value. */
68
+ export function sliceBranchCreate(taskId: string | null | undefined): string | null {
69
+ const id = typeof taskId === "string" ? taskId.trim() : "";
70
+ if (id === "") return null;
71
+ const branch = `feat/${id}`;
72
+ return isPlausibleBranchName(branch) ? branch : null;
73
+ }
74
+
37
75
  /** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
38
76
  * `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
39
77
  * key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
@@ -66,6 +104,90 @@ export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Rec
66
104
  return out;
67
105
  }
68
106
 
107
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
108
+ return v != null && typeof v === "object" && !Array.isArray(v);
109
+ }
110
+
111
+ function deepMerge(base: unknown, over: unknown): unknown {
112
+ if (!isPlainObject(base)) return isPlainObject(over) ? deepMerge({}, over) : over;
113
+ const out: Record<string, unknown> = { ...base };
114
+ if (!isPlainObject(over)) return out;
115
+ for (const [k, v] of Object.entries(over)) {
116
+ if (v === undefined) continue;
117
+ out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : v;
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function setPath(obj: Record<string, unknown>, path: string[], value: unknown): void {
123
+ let cur = obj;
124
+ for (let i = 0; i < path.length - 1; i++) {
125
+ const k = path[i];
126
+ const next = cur[k];
127
+ // After the ensure-object step the slot is always a plain object; narrow via the type guard
128
+ // (no `as` — the ban-`as` gate) so the descent stays type-safe.
129
+ if (!isPlainObject(next)) {
130
+ const fresh: Record<string, unknown> = {};
131
+ cur[k] = fresh;
132
+ cur = fresh;
133
+ } else {
134
+ cur = next;
135
+ }
136
+ }
137
+ cur[path[path.length - 1]] = value;
138
+ }
139
+
140
+ /** Mirror of the c8ctl harness's `collectEnvelopeFrom`: reassemble the reserved namespace out of BOTH
141
+ * a whole-object/JSON `io.nanobpm.agentTask` value AND flattened dotted `io.nanobpm.agentTask.*` keys
142
+ * (the channel a per-slice worker's dotted-name result variable uses, issue #945), deep-merging the
143
+ * two. This is the ONE consumer-side mirror shared by the round-trip contract tests
144
+ * (`app/repoEnvelope.test.ts`) and the e2e fan-out proof (`e2e/plan-fanout.e2e.ts`) so producer and
145
+ * consumer can never drift into two readings of where `branch.create` lives. */
146
+ function collectEnvelopeFrom(source: Record<string, unknown>): Record<string, unknown> {
147
+ const out: Record<string, unknown> = {};
148
+ if (!isPlainObject(source)) return out;
149
+ const whole = source[AGENT_TASK_NS];
150
+ if (whole != null) {
151
+ let val: unknown = whole;
152
+ if (typeof whole === "string") {
153
+ try {
154
+ val = JSON.parse(whole);
155
+ } catch {
156
+ val = undefined;
157
+ }
158
+ }
159
+ if (isPlainObject(val)) Object.assign(out, deepMerge(out, val));
160
+ }
161
+ const prefix = `${AGENT_TASK_NS}.`;
162
+ for (const [key, value] of Object.entries(source)) {
163
+ if (!key.startsWith(prefix)) continue;
164
+ const rest = key.slice(prefix.length);
165
+ if (!rest) continue;
166
+ setPath(out, rest.split("."), value);
167
+ }
168
+ return out;
169
+ }
170
+
171
+ /** Mirror of the c8ctl harness's `normalizeTaskEnvelope` branch slice: deep-merge the envelope
172
+ * recovered from `customHeaders` under that recovered from `variables` (variables win), then read
173
+ * `branch` from the TOP LEVEL of the merged envelope (`isPlainObject(raw.branch)`), NEVER from
174
+ * `raw.repository.branch`. Returns the consumer's recovered `branch.create` (`undefined` when the
175
+ * producer put it somewhere the consumer doesn't look, or omitted it). This is the ONE place tests
176
+ * assert "the harness sees this branch" — keeping producer (`repoEnvelopeVars` / the per-slice dotted
177
+ * variable) and consumer pinned together (issues #882/#945). */
178
+ export function recoverBranchCreate(
179
+ customHeaders: Record<string, unknown>,
180
+ variables: Record<string, unknown>,
181
+ ): string | undefined {
182
+ const merged = deepMerge(collectEnvelopeFrom(customHeaders), collectEnvelopeFrom(variables));
183
+ // deepMerge of two plain-object envelope slices is always a plain object; narrow via the type guard
184
+ // (no `as` — the ban-`as` gate). A non-object merge (impossible here) reads as "no branch".
185
+ const raw = isPlainObject(merged) ? merged : {};
186
+ const branch = isPlainObject(raw.branch) ? raw.branch : {};
187
+ const create = branch.create;
188
+ return create == null ? undefined : String(create);
189
+ }
190
+
69
191
  /** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
70
192
  * path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
71
193
  * `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
package/build-info.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.208.3",
3
- "gitSha": "ff42e2a6fa77fd780949822adf3d4f2b9caefc38",
4
- "gitTag": "v0.208.3",
5
- "builtAt": "2026-11-11T02:35:58.462Z"
2
+ "version": "0.208.4",
3
+ "gitSha": "097724344a2f3942af5b1796e2e0a7e8363b44f9",
4
+ "gitTag": "v0.208.4",
5
+ "builtAt": "2026-11-11T03:16:35.107Z"
6
6
  }
@@ -159,7 +159,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
159
159
  assert.ok(
160
160
  flows.includes("ensure-base-branch->record-feature-implementing") &&
161
161
  flows.includes("record-feature-implementing->implement") &&
162
- flows.includes("Start->implement-task"),
162
+ flows.includes("slice-branch->implement-task"),
163
163
  `the run reaches the implement agent (via the implement cell) only after the gate (flows: ${flows.join(", ")})`,
164
164
  );
165
165
  // A green probe never escalates.
@@ -192,7 +192,7 @@ describe("single-issue feature intake readiness gate (feature.bpmn, issue #295)"
192
192
  assert.ok(!flows.includes("gw-readiness->readiness-preflight"), "an ungated feature never enters the preflight");
193
193
  assert.ok(
194
194
  flows.includes("record-feature-implementing->implement") &&
195
- flows.includes("Start->implement-task"),
195
+ flows.includes("slice-branch->implement-task"),
196
196
  "an ungated feature reaches the implement agent (via the implement cell)",
197
197
  );
198
198
  } finally {
@@ -21,6 +21,7 @@ import { after, before, describe, test } from "node:test";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import type { EngineJob } from "@nanobpm/urban/runtime";
23
23
  import { bootTestApp, type TestApp } from "@nanobpm/urban-testkit";
24
+ import { recoverBranchCreate } from "../app/repoEnvelope.ts";
24
25
  import { admitGithubState, installAdmitGithub } from "./support/github-admit.ts";
25
26
  import { advancePastTimer, settleFully } from "./support/time.ts";
26
27
 
@@ -127,32 +128,127 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
127
128
  const singleTaskPlan: Stub = () => ({ tasks: [{ id: "t1", title: "T1", prompt: "do t1" }] });
128
129
  const approveReview: Stub = () => ({ approved: true, findings: "" });
129
130
 
130
- test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope (#684)", async () => {
131
+ test("host-git provisioning: each slice's implement-cell agent job carries the repository envelope AND the deterministic branch.create (#684/#945)", async () => {
131
132
  // The whole-epic seed emits `io.nanobpm.agentTask.repository` so every slice's `senior:feature`
132
133
  // agent gets an ISOLATED clone instead of clobbering the worker's launch dir. The process variable
133
134
  // rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
134
135
  // The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
135
- // `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
136
- let agentTask: any = null;
136
+ // `feat/<task.id>` differs per MI child, so it can't be named at seed time. Issue #945: the
137
+ // per-instance `slice-branch` worker (first step of each implement-cell) now stamps the deterministic `branch.create` (`feat/<task.id>`)
138
+ // onto the envelope as a FLAT dotted process variable (`io.nanobpm.agentTask.branch.create`), which
139
+ // the c8ctl harness deep-merges with the whole-object seed — so a retry re-derives the SAME branch
140
+ // and the durable WIP checkpoint restore matches (previously every retry cut a random
141
+ // `nano/agent-work/*` fallback and discarded the prior session's work).
142
+ let agentVars: Record<string, unknown> = {};
137
143
  await withApp(
138
144
  {
139
145
  "senior:plan": singleTaskPlan,
140
146
  "senior:plan-review": approveReview,
141
147
  "senior:feature": (job) => {
142
- agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
148
+ agentVars = job.variables as Record<string, unknown>;
143
149
  return { status: "blocked", summary: "n/a" };
144
150
  },
145
151
  },
146
152
  async ({ app }) => {
147
153
  await app.settle();
154
+ const agentTask = agentVars["io.nanobpm.agentTask"] as Record<string, any> | undefined;
148
155
  assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
149
156
  const agentRepo = agentTask.repository;
150
157
  assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
151
158
  assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
152
- // `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
153
- assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
154
159
  assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
155
160
  assert.equal(agentRepo.filter, "blob:none");
161
+ // The whole-object seed still omits `branch` (it can't name a per-child branch)…
162
+ assert.equal("branch" in agentTask, false, "the whole-object seed carries no per-slice branch");
163
+ // …but the per-instance slice-branch worker stamps it as a FLAT dotted variable, which the harness
164
+ // merges onto the SAME envelope. Assert the dotted variable is present and names the
165
+ // deterministic `feat/<task.id>` branch (the single task here is `t1`).
166
+ assert.equal(
167
+ agentVars["io.nanobpm.agentTask.branch.create"],
168
+ "feat/t1",
169
+ "slice-branch stamps the deterministic per-slice branch.create as a flat dotted variable (#945)",
170
+ );
171
+ // Round-trip through the harness normalizer shape: the merged envelope must recover
172
+ // `branch.create` from the top level (proving the dotted variable and the whole-object seed
173
+ // merge into one envelope the harness reads).
174
+ assert.equal(
175
+ recoverBranchCreate({}, agentVars),
176
+ "feat/t1",
177
+ "the harness merges the dotted branch.create with the whole-object seed envelope (#945)",
178
+ );
179
+ },
180
+ );
181
+ });
182
+
183
+ test("sibling isolation: two parallel slices each see their OWN branch.create, never a sibling's (#945)", async () => {
184
+ // A per-slice branch emitted into the SHARED plan-fanout MI body lands on the epic root, so a
185
+ // sibling's emission overwrites it and a slice can inherit `feat/<sibling>` — sending its work and
186
+ // checkpoint to the wrong branch. The emitter runs inside each implement-cell instance instead, so
187
+ // every activation's recovered branch must be `feat/<its own task.id>`.
188
+ const seen: Array<{ id: unknown; branch: string | undefined }> = [];
189
+ await withApp(
190
+ {
191
+ "senior:plan": () => ({
192
+ tasks: [
193
+ { id: "t1", title: "T1", prompt: "do t1" },
194
+ { id: "t2", title: "T2", prompt: "do t2" },
195
+ ],
196
+ }),
197
+ "senior:plan-review": approveReview,
198
+ "senior:feature": (job) => {
199
+ const vars = job.variables as Record<string, any>;
200
+ seen.push({ id: vars.task?.id, branch: recoverBranchCreate({}, vars) });
201
+ return { status: "blocked", summary: "n/a" };
202
+ },
203
+ },
204
+ async ({ app }) => {
205
+ await app.settle();
206
+ assert.deepEqual(
207
+ seen.map((s) => s.id).sort(),
208
+ ["t1", "t2"],
209
+ "both sibling slices activated their implement-task",
210
+ );
211
+ for (const s of seen) {
212
+ assert.equal(s.branch, `feat/${String(s.id)}`, `slice ${String(s.id)} must see its OWN branch.create`);
213
+ }
214
+ },
215
+ );
216
+ });
217
+
218
+ test("retry determinism: a re-dispatched implement-task names the SAME branch.create, so the WIP checkpoint restore matches (#945)", async () => {
219
+ // Issue #945's field failure: every c8ctl retry of an implement-cell slice cut a NEW random
220
+ // `nano/agent-work/*` fallback branch, so the durable WIP checkpoint (keyed on the deterministic
221
+ // `feat/<task.id>`) was NEVER restored — each retry started from the epic base and discarded the
222
+ // prior session's work. The fix makes `branch.create` a pure function of `task.id`, so a retry
223
+ // re-derives the SAME branch and the checkpoint restore matches. Drive the implement-task through
224
+ // TWO activations of the SAME slice (an escalation parks it; answering re-dispatches it — the same
225
+ // re-activation an idle-kill retry produces) and assert both carry the identical recovered
226
+ // `branch.create`.
227
+ const seenBranches: Array<string | undefined> = [];
228
+ await withApp(
229
+ {
230
+ "senior:plan": singleTaskPlan,
231
+ "senior:plan-review": approveReview,
232
+ "senior:feature": (job) => {
233
+ seenBranches.push(recoverBranchCreate({}, job.variables as Record<string, unknown>));
234
+ // First activation escalates (parks for a human); the resume (second activation) blocks.
235
+ return seenBranches.length === 1
236
+ ? { status: "escalated", question: "Which API should I use?", summary: "parked for a human" }
237
+ : { status: "blocked", summary: "resumed after answer" };
238
+ },
239
+ },
240
+ async ({ app, processKey }) => {
241
+ // First activation parked on the human-escalation task; answer it to re-dispatch the SAME slice.
242
+ const task = await openTask(app, processKey, "escalation");
243
+ await app.engine.completeUserTask(task.userTaskKey, { resolution: "answer", answer: "use v2" });
244
+ await app.settle();
245
+ assert.equal(seenBranches.length, 2, "the implement-task was activated twice (initial + retry)");
246
+ assert.equal(seenBranches[0], "feat/t1", "the FIRST activation carries the deterministic branch.create");
247
+ assert.equal(
248
+ seenBranches[1],
249
+ seenBranches[0],
250
+ "the RETRY activation re-derives the SAME branch.create — the WIP checkpoint restore matches (#945)",
251
+ );
156
252
  },
157
253
  );
158
254
  });
@@ -532,7 +628,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
532
628
 
533
629
  const flows = takenFlows(app);
534
630
  assert.ok(
535
- flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("Start->implement-task"),
631
+ flows.includes("wait-caps-resolved->implement-cell-call") && flows.includes("slice-branch->implement-task"),
536
632
  `caps-resolved released the barrier into the implement cell (flows: ${flows.join(", ")})`,
537
633
  );
538
634
  assert.equal(
@@ -614,7 +710,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
614
710
  assert.ok(
615
711
  answered.includes("w_gw_answer->record-task-implementing") &&
616
712
  answered.includes("record-task-implementing->implement-cell-call") &&
617
- answered.includes("Start->implement-task"),
713
+ answered.includes("slice-branch->implement-task"),
618
714
  `answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${answered.join(", ")})`,
619
715
  );
620
716
  assert.equal(featureRan, true, "the agent was dispatched once the operator answered the caps escalation");
@@ -668,7 +764,7 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
668
764
  assert.ok(
669
765
  entered.includes("w_gw_answer->record-task-implementing") &&
670
766
  entered.includes("record-task-implementing->implement-cell-call") &&
671
- entered.includes("Start->implement-task"),
767
+ entered.includes("slice-branch->implement-task"),
672
768
  `answering the caps escalation reset the stale question and released the barrier into the implement cell (flows: ${entered.join(", ")})`,
673
769
  );
674
770
 
package/nano.app.json CHANGED
@@ -180,6 +180,10 @@
180
180
  "taskType": "pr.select-wave",
181
181
  "handler": "workers/select-wave/worker.ts"
182
182
  },
183
+ {
184
+ "taskType": "pr.slice-branch",
185
+ "handler": "workers/slice-branch/worker.ts"
186
+ },
183
187
  {
184
188
  "taskType": "pr.caps-prepare",
185
189
  "handler": "workers/caps-prepare/worker.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.208.3",
3
+ "version": "0.208.4",
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",
@@ -17,8 +17,15 @@
17
17
  </nano:shapes>
18
18
  </bpmn:extensionElements>
19
19
  <bpmn:startEvent id="Start" name="Task">
20
- <bpmn:outgoing>ic_toImplement</bpmn:outgoing>
20
+ <bpmn:outgoing>ic_toSliceBranch</bpmn:outgoing>
21
21
  </bpmn:startEvent>
22
+ <bpmn:serviceTask id="slice-branch" name="Stamp slice branch">
23
+ <bpmn:extensionElements>
24
+ <zeebe:taskDefinition type="pr.slice-branch" />
25
+ </bpmn:extensionElements>
26
+ <bpmn:incoming>ic_toSliceBranch</bpmn:incoming>
27
+ <bpmn:outgoing>ic_toImplement</bpmn:outgoing>
28
+ </bpmn:serviceTask>
22
29
  <bpmn:serviceTask id="implement-task" name="Implement (agent)">
23
30
  <bpmn:extensionElements>
24
31
  <zeebe:taskDefinition type="senior:feature" />
@@ -143,7 +150,8 @@
143
150
  <bpmn:incoming>ic_reconciled</bpmn:incoming>
144
151
  <bpmn:incoming>ic_abandon</bpmn:incoming>
145
152
  </bpmn:endEvent>
146
- <bpmn:sequenceFlow id="ic_toImplement" sourceRef="Start" targetRef="implement-task" />
153
+ <bpmn:sequenceFlow id="ic_toSliceBranch" sourceRef="Start" targetRef="slice-branch" />
154
+ <bpmn:sequenceFlow id="ic_toImplement" sourceRef="slice-branch" targetRef="implement-task" />
147
155
  <bpmn:sequenceFlow id="ic_toGw" sourceRef="implement-task" targetRef="ic_gw" />
148
156
  <bpmn:sequenceFlow id="ic_done" name="done" sourceRef="ic_gw" targetRef="ic_end">
149
157
  <bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=status = "opened" or status = "blocked" or status = "skipped"</bpmn:conditionExpression>
@@ -175,149 +183,156 @@
175
183
  <dc:Bounds x="81" y="143" width="34" height="14" />
176
184
  </bpmndi:BPMNLabel>
177
185
  </bpmndi:BPMNShape>
178
- <bpmndi:BPMNShape id="BPMNShape_implement-task" bpmnElement="implement-task">
186
+ <bpmndi:BPMNShape id="BPMNShape_slice-branch" bpmnElement="slice-branch">
179
187
  <dc:Bounds x="216" y="80" width="100" height="80" />
180
188
  </bpmndi:BPMNShape>
189
+ <bpmndi:BPMNShape id="BPMNShape_implement-task" bpmnElement="implement-task">
190
+ <dc:Bounds x="416" y="80" width="100" height="80" />
191
+ </bpmndi:BPMNShape>
181
192
  <bpmndi:BPMNShape id="BPMNShape_ic_gw" bpmnElement="ic_gw" isMarkerVisible="true">
182
- <dc:Bounds x="441" y="95" width="50" height="50" />
193
+ <dc:Bounds x="641" y="95" width="50" height="50" />
183
194
  <bpmndi:BPMNLabel>
184
- <dc:Bounds x="431" y="62" width="70" height="28" />
195
+ <dc:Bounds x="631" y="62" width="70" height="28" />
185
196
  </bpmndi:BPMNLabel>
186
197
  </bpmndi:BPMNShape>
187
198
  <bpmndi:BPMNShape id="BPMNShape_reconcile-implement" bpmnElement="reconcile-implement">
188
- <dc:Bounds x="616" y="80" width="100" height="80" />
199
+ <dc:Bounds x="816" y="80" width="100" height="80" />
189
200
  </bpmndi:BPMNShape>
190
201
  <bpmndi:BPMNShape id="BPMNShape_ic_reconcile_gw" bpmnElement="ic_reconcile_gw" isMarkerVisible="true">
191
- <dc:Bounds x="816" y="95" width="50" height="50" />
202
+ <dc:Bounds x="1016" y="95" width="50" height="50" />
192
203
  <bpmndi:BPMNLabel>
193
- <dc:Bounds x="801" y="76" width="81" height="14" />
204
+ <dc:Bounds x="1001" y="76" width="81" height="14" />
194
205
  </bpmndi:BPMNLabel>
195
206
  </bpmndi:BPMNShape>
196
207
  <bpmndi:BPMNShape id="BPMNShape_escalate" bpmnElement="escalate">
197
- <dc:Bounds x="1166" y="80" width="100" height="80" />
208
+ <dc:Bounds x="1366" y="80" width="100" height="80" />
198
209
  </bpmndi:BPMNShape>
199
210
  <bpmndi:BPMNShape id="BPMNShape_record-escalation" bpmnElement="record-escalation">
200
- <dc:Bounds x="966" y="80" width="100" height="80" />
211
+ <dc:Bounds x="1166" y="80" width="100" height="80" />
201
212
  </bpmndi:BPMNShape>
202
213
  <bpmndi:BPMNShape id="BPMNShape_record-escalation-sla" bpmnElement="record-escalation-sla">
203
- <dc:Bounds x="416" y="400" width="100" height="80" />
214
+ <dc:Bounds x="616" y="400" width="100" height="80" />
204
215
  </bpmndi:BPMNShape>
205
216
  <bpmndi:BPMNShape id="BPMNShape_ic_gw_answer" bpmnElement="ic_gw_answer" isMarkerVisible="true">
206
- <dc:Bounds x="1366" y="95" width="50" height="50" />
217
+ <dc:Bounds x="1566" y="95" width="50" height="50" />
207
218
  <bpmndi:BPMNLabel>
208
- <dc:Bounds x="1323" y="150" width="56" height="14" />
219
+ <dc:Bounds x="1523" y="150" width="56" height="14" />
209
220
  </bpmndi:BPMNLabel>
210
221
  </bpmndi:BPMNShape>
211
222
  <bpmndi:BPMNShape id="BPMNShape_record-implementing" bpmnElement="record-implementing">
212
- <dc:Bounds x="1516" y="240" width="100" height="80" />
223
+ <dc:Bounds x="1716" y="240" width="100" height="80" />
213
224
  </bpmndi:BPMNShape>
214
225
  <bpmndi:BPMNShape id="BPMNShape_ic_end" bpmnElement="ic_end">
215
- <dc:Bounds x="1548" y="102" width="36" height="36" />
226
+ <dc:Bounds x="1748" y="102" width="36" height="36" />
216
227
  <bpmndi:BPMNLabel>
217
- <dc:Bounds x="1493" y="143" width="66" height="14" />
228
+ <dc:Bounds x="1693" y="143" width="66" height="14" />
218
229
  </bpmndi:BPMNLabel>
219
230
  </bpmndi:BPMNShape>
220
231
  <bpmndi:BPMNShape id="BPMNShape_be_implement_sla" bpmnElement="be_implement_sla">
221
- <dc:Bounds x="248" y="142" width="36" height="36" />
232
+ <dc:Bounds x="448" y="142" width="36" height="36" />
222
233
  <bpmndi:BPMNLabel>
223
- <dc:Bounds x="170" y="183" width="72" height="28" />
234
+ <dc:Bounds x="370" y="183" width="72" height="28" />
224
235
  </bpmndi:BPMNLabel>
225
236
  </bpmndi:BPMNShape>
226
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
237
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toSliceBranch" bpmnElement="ic_toSliceBranch">
227
238
  <di:waypoint x="116" y="120" />
228
239
  <di:waypoint x="216" y="120" />
229
240
  </bpmndi:BPMNEdge>
230
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toGw" bpmnElement="ic_toGw">
241
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
231
242
  <di:waypoint x="316" y="120" />
232
- <di:waypoint x="441" y="120" />
243
+ <di:waypoint x="416" y="120" />
244
+ </bpmndi:BPMNEdge>
245
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toGw" bpmnElement="ic_toGw">
246
+ <di:waypoint x="516" y="120" />
247
+ <di:waypoint x="641" y="120" />
233
248
  </bpmndi:BPMNEdge>
234
249
  <bpmndi:BPMNEdge id="BPMNEdge_ic_escalate" bpmnElement="ic_escalate">
235
- <di:waypoint x="491" y="120" />
236
- <di:waypoint x="616" y="120" />
250
+ <di:waypoint x="691" y="120" />
251
+ <di:waypoint x="816" y="120" />
237
252
  <bpmndi:BPMNLabel>
238
- <dc:Bounds x="520" y="98" width="67" height="14" />
253
+ <dc:Bounds x="720" y="98" width="67" height="14" />
239
254
  </bpmndi:BPMNLabel>
240
255
  </bpmndi:BPMNEdge>
241
256
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toReconcileGw" bpmnElement="ic_toReconcileGw">
242
- <di:waypoint x="716" y="120" />
243
- <di:waypoint x="816" y="120" />
257
+ <di:waypoint x="916" y="120" />
258
+ <di:waypoint x="1016" y="120" />
244
259
  </bpmndi:BPMNEdge>
245
260
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toRecordEscalation" bpmnElement="ic_toRecordEscalation">
246
- <di:waypoint x="866" y="120" />
247
- <di:waypoint x="966" y="120" />
261
+ <di:waypoint x="1066" y="120" />
262
+ <di:waypoint x="1166" y="120" />
248
263
  <bpmndi:BPMNLabel>
249
- <dc:Bounds x="883" y="128" width="67" height="14" />
264
+ <dc:Bounds x="1083" y="128" width="67" height="14" />
250
265
  </bpmndi:BPMNLabel>
251
266
  </bpmndi:BPMNEdge>
252
267
  <bpmndi:BPMNEdge id="BPMNEdge_ic_toEscalate" bpmnElement="ic_toEscalate">
253
- <di:waypoint x="1066" y="120" />
254
- <di:waypoint x="1166" y="120" />
255
- </bpmndi:BPMNEdge>
256
- <bpmndi:BPMNEdge id="BPMNEdge_ic_toAnswerGw" bpmnElement="ic_toAnswerGw">
257
268
  <di:waypoint x="1266" y="120" />
258
269
  <di:waypoint x="1366" y="120" />
259
270
  </bpmndi:BPMNEdge>
271
+ <bpmndi:BPMNEdge id="BPMNEdge_ic_toAnswerGw" bpmnElement="ic_toAnswerGw">
272
+ <di:waypoint x="1466" y="120" />
273
+ <di:waypoint x="1566" y="120" />
274
+ </bpmndi:BPMNEdge>
260
275
  <bpmndi:BPMNEdge id="BPMNEdge_ic_abandon" bpmnElement="ic_abandon">
261
- <di:waypoint x="1416" y="120" />
262
- <di:waypoint x="1548" y="120" />
276
+ <di:waypoint x="1616" y="120" />
277
+ <di:waypoint x="1748" y="120" />
263
278
  <bpmndi:BPMNLabel>
264
- <dc:Bounds x="1456" y="98" width="53" height="14" />
279
+ <dc:Bounds x="1656" y="98" width="53" height="14" />
265
280
  </bpmndi:BPMNLabel>
266
281
  </bpmndi:BPMNEdge>
267
282
  <bpmndi:BPMNEdge id="BPMNEdge_ic_retry" bpmnElement="ic_retry">
268
- <di:waypoint x="841" y="145" />
269
- <di:waypoint x="841" y="280" />
270
- <di:waypoint x="1516" y="280" />
283
+ <di:waypoint x="1041" y="145" />
284
+ <di:waypoint x="1041" y="280" />
285
+ <di:waypoint x="1716" y="280" />
271
286
  <bpmndi:BPMNLabel>
272
- <dc:Bounds x="846" y="223" width="39" height="14" />
287
+ <dc:Bounds x="1046" y="223" width="39" height="14" />
273
288
  </bpmndi:BPMNLabel>
274
289
  </bpmndi:BPMNEdge>
275
290
  <bpmndi:BPMNEdge id="BPMNEdge_ic_answerLoop" bpmnElement="ic_answerLoop">
276
- <di:waypoint x="1391" y="145" />
277
- <di:waypoint x="1391" y="280" />
278
- <di:waypoint x="1516" y="280" />
291
+ <di:waypoint x="1591" y="145" />
292
+ <di:waypoint x="1591" y="280" />
293
+ <di:waypoint x="1716" y="280" />
279
294
  <bpmndi:BPMNLabel>
280
- <dc:Bounds x="1396" y="206" width="49" height="14" />
295
+ <dc:Bounds x="1596" y="206" width="49" height="14" />
281
296
  </bpmndi:BPMNLabel>
282
297
  </bpmndi:BPMNEdge>
283
298
  <bpmndi:BPMNEdge id="BPMNEdge_ic_done" bpmnElement="ic_done">
284
- <di:waypoint x="466" y="145" />
285
- <di:waypoint x="466" y="200" />
286
- <di:waypoint x="1566" y="200" />
287
- <di:waypoint x="1566" y="138" />
299
+ <di:waypoint x="666" y="145" />
300
+ <di:waypoint x="666" y="200" />
301
+ <di:waypoint x="1766" y="200" />
302
+ <di:waypoint x="1766" y="138" />
288
303
  <bpmndi:BPMNLabel>
289
- <dc:Bounds x="1000" y="208" width="32" height="14" />
304
+ <dc:Bounds x="1200" y="208" width="32" height="14" />
290
305
  </bpmndi:BPMNLabel>
291
306
  </bpmndi:BPMNEdge>
292
307
  <bpmndi:BPMNEdge id="BPMNEdge_ic_reconciled" bpmnElement="ic_reconciled">
293
- <di:waypoint x="841" y="145" />
294
- <di:waypoint x="841" y="180" />
295
- <di:waypoint x="1566" y="180" />
296
- <di:waypoint x="1566" y="138" />
308
+ <di:waypoint x="1041" y="145" />
309
+ <di:waypoint x="1041" y="180" />
310
+ <di:waypoint x="1766" y="180" />
311
+ <di:waypoint x="1766" y="138" />
297
312
  <bpmndi:BPMNLabel>
298
- <dc:Bounds x="1097" y="158" width="53" height="14" />
313
+ <dc:Bounds x="1297" y="158" width="53" height="14" />
299
314
  </bpmndi:BPMNLabel>
300
315
  </bpmndi:BPMNEdge>
301
316
  <bpmndi:BPMNEdge id="BPMNEdge_ic_slaToEscalate" bpmnElement="ic_slaToEscalate">
302
- <di:waypoint x="516" y="440" />
303
- <di:waypoint x="1636" y="440" />
304
- <di:waypoint x="1636" y="82" />
305
- <di:waypoint x="1266" y="82" />
317
+ <di:waypoint x="716" y="440" />
318
+ <di:waypoint x="1836" y="440" />
319
+ <di:waypoint x="1836" y="82" />
320
+ <di:waypoint x="1466" y="82" />
306
321
  </bpmndi:BPMNEdge>
307
322
  <bpmndi:BPMNEdge id="BPMNEdge_ic_sla" bpmnElement="ic_sla">
308
- <di:waypoint x="266" y="178" />
309
- <di:waypoint x="266" y="440" />
310
- <di:waypoint x="416" y="440" />
323
+ <di:waypoint x="466" y="178" />
324
+ <di:waypoint x="466" y="440" />
325
+ <di:waypoint x="616" y="440" />
311
326
  <bpmndi:BPMNLabel>
312
- <dc:Bounds x="271" y="295" width="70" height="28" />
327
+ <dc:Bounds x="471" y="295" width="70" height="28" />
313
328
  </bpmndi:BPMNLabel>
314
329
  </bpmndi:BPMNEdge>
315
330
  <bpmndi:BPMNEdge id="BPMNEdge_ic_reImplement" bpmnElement="ic_reImplement">
316
- <di:waypoint x="1516" y="280" />
331
+ <di:waypoint x="1716" y="280" />
317
332
  <di:waypoint x="60" y="280" />
318
333
  <di:waypoint x="60" y="60" />
319
- <di:waypoint x="266" y="60" />
320
- <di:waypoint x="266" y="80" />
334
+ <di:waypoint x="466" y="60" />
335
+ <di:waypoint x="466" y="80" />
321
336
  </bpmndi:BPMNEdge>
322
337
  </bpmndi:BPMNPlane>
323
338
  </bpmndi:BPMNDiagram>
@@ -60,3 +60,14 @@ test("caps-prepare: a malformed need is dropped — only a valid remainder gates
60
60
  );
61
61
  assertEquals(mixed.hasNeeds, true, "a valid need still gates even alongside a malformed one");
62
62
  });
63
+
64
+ // Issue #945: caps-prepare runs in the plan-fanout MI body, whose un-declared result keys land on the
65
+ // SHARED epic root — so it must NOT stamp the per-slice branch.create (a sibling would overwrite it).
66
+ // The emitter lives in each implement-cell instance (`pr.slice-branch`) instead.
67
+ test("caps-prepare: does NOT emit the per-slice branch.create (it would leak across MI siblings) (#945)", async () => {
68
+ const out = await handler(
69
+ { variables: { planKey: "owner/repo#7", taskId: "t1", needs: [] } } as any,
70
+ {} as any,
71
+ );
72
+ assertEquals(Object.keys(out).filter((k) => k.startsWith("io.nanobpm.agentTask")), []);
73
+ });
@@ -0,0 +1,31 @@
1
+ // Unit coverage for `pr.slice-branch` (issue #945): the per-slice deterministic `branch.create`,
2
+ // emitted as the FLAT dotted variable the c8ctl harness reads, derived from the ONE canonical
3
+ // `sliceBranchCreate`.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { AGENT_TASK_BRANCH_CREATE_VAR, sliceBranchCreate } from "../../app/repoEnvelope.ts";
7
+ import handler from "./worker.ts";
8
+
9
+ async function run(task: unknown): Promise<Record<string, unknown>> {
10
+ return (await handler({ variables: { task } } as any, {} as any)) ?? {};
11
+ }
12
+
13
+ test("slice-branch: stamps feat/<task.id> as the flat dotted branch.create (#945)", async () => {
14
+ const out = await run({ id: "t1", prompt: "x" });
15
+ assertEquals(out[AGENT_TASK_BRANCH_CREATE_VAR], "feat/t1");
16
+ assertEquals(out[AGENT_TASK_BRANCH_CREATE_VAR], sliceBranchCreate("t1"));
17
+ });
18
+
19
+ test("slice-branch: distinct slices derive distinct branches (sibling isolation is per-instance) (#945)", async () => {
20
+ const a = await run({ id: "t1" });
21
+ const b = await run({ id: "t2" });
22
+ assertEquals(a[AGENT_TASK_BRANCH_CREATE_VAR], "feat/t1");
23
+ assertEquals(b[AGENT_TASK_BRANCH_CREATE_VAR], "feat/t2");
24
+ });
25
+
26
+ test("slice-branch: a missing/blank/implausible/non-string id emits nothing (#945)", async () => {
27
+ for (const task of [undefined, null, "t1", [], {}, { id: "" }, { id: " " }, { id: "bad..id" }, { id: 7 }]) {
28
+ const out = await run(task);
29
+ assertEquals(AGENT_TASK_BRANCH_CREATE_VAR in out, false, `no branch.create for ${JSON.stringify(task)}`);
30
+ }
31
+ });
@@ -0,0 +1,39 @@
1
+ // pr.slice-branch — stamp the implement-cell agent job's deterministic per-slice `branch.create`
2
+ // (issue #945).
3
+ //
4
+ // Runs as the FIRST step of the `implement-cell` process, i.e. inside each slice's OWN called process
5
+ // instance. That is what makes the value child-local: a worker result key is propagated to the root
6
+ // of the process instance the job belongs to, and every implement-cell call is a separate instance —
7
+ // so sibling slices of one plan-fanout wave can never overwrite one another's branch (a key emitted
8
+ // from the plan-fanout MI body would land on the shared epic root, where a sibling's prepare could
9
+ // clobber it while this slice waits at its capability barrier).
10
+ //
11
+ // The value rides a result key literally NAMED `io.nanobpm.agentTask.branch.create`, which the engine
12
+ // materialises as a FLAT dotted process variable the c8ctl harness's `collectEnvelopeFrom` deep-merges
13
+ // with the whole-object seed envelope. A `<zeebe:ioMapping>` dotted target canNOT do this (it builds a
14
+ // nested object the harness never reads). It is derived via the ONE canonical `sliceBranchCreate`, so
15
+ // the envelope, the prompt's `feat/<task.id>` instruction, and the reconcile lookup share one source;
16
+ // a retry (record-implementing → implement-task) stays in the same instance and re-reads the SAME
17
+ // value, so the durable WIP checkpoint restore matches.
18
+ //
19
+ // PURE: no I/O. An implausible/missing `task.id` emits nothing (degrades to the agent cutting its own
20
+ // branch) rather than an unusable branch.create.
21
+ import type { AppJobHandler } from "@nanobpm/urban";
22
+ import { AGENT_TASK_BRANCH_CREATE_VAR, sliceBranchCreate } from "../../app/repoEnvelope.ts";
23
+
24
+ interface In {
25
+ task?: unknown;
26
+ }
27
+
28
+ export function sliceTaskId(task: unknown): string | null {
29
+ if (task === null || typeof task !== "object" || Array.isArray(task)) return null;
30
+ const id: unknown = Reflect.get(task, "id");
31
+ return typeof id === "string" ? id : null;
32
+ }
33
+
34
+ const handler: AppJobHandler<In, Record<string, unknown>> = async (job) => {
35
+ const branchCreate = sliceBranchCreate(sliceTaskId(job.variables.task));
36
+ return branchCreate !== null ? { [AGENT_TASK_BRANCH_CREATE_VAR]: branchCreate } : {};
37
+ };
38
+
39
+ export default handler;