@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 +17 -0
- package/CHANGELOG.md +6 -0
- package/app/contracts.ts +1 -1
- package/app/feature.ts +5 -2
- package/app/implementBranchContract.test.ts +168 -0
- package/app/implementReconcile.ts +8 -2
- package/app/repoEnvelope.test.ts +11 -75
- package/app/repoEnvelope.ts +122 -0
- package/build-info.json +4 -4
- package/e2e/feature-preflight.e2e.ts +2 -2
- package/e2e/plan-fanout.e2e.ts +105 -9
- package/nano.app.json +4 -0
- package/package.json +1 -1
- package/resources/processes/implement-cell.bpmn +80 -65
- package/workers/caps-prepare/worker.test.ts +11 -0
- package/workers/slice-branch/worker.test.ts +31 -0
- package/workers/slice-branch/worker.ts +39 -0
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
|
|
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
|
-
|
|
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
|
package/app/repoEnvelope.test.ts
CHANGED
|
@@ -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
|
-
// ───
|
|
18
|
-
//
|
|
19
|
-
// (
|
|
20
|
-
// `io.nanobpm.agentTask` namespace out of BOTH a whole-object/JSON value AND flattened
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
const
|
|
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
|
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -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
|
-
"gitSha": "
|
|
4
|
-
"gitTag": "v0.208.
|
|
5
|
-
"builtAt": "2026-11-
|
|
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("
|
|
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("
|
|
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 {
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -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
|
|
136
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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
|
+
"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>
|
|
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="
|
|
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="
|
|
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="
|
|
193
|
+
<dc:Bounds x="641" y="95" width="50" height="50" />
|
|
183
194
|
<bpmndi:BPMNLabel>
|
|
184
|
-
<dc:Bounds x="
|
|
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="
|
|
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="
|
|
202
|
+
<dc:Bounds x="1016" y="95" width="50" height="50" />
|
|
192
203
|
<bpmndi:BPMNLabel>
|
|
193
|
-
<dc:Bounds x="
|
|
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="
|
|
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="
|
|
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="
|
|
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="
|
|
217
|
+
<dc:Bounds x="1566" y="95" width="50" height="50" />
|
|
207
218
|
<bpmndi:BPMNLabel>
|
|
208
|
-
<dc:Bounds x="
|
|
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="
|
|
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="
|
|
226
|
+
<dc:Bounds x="1748" y="102" width="36" height="36" />
|
|
216
227
|
<bpmndi:BPMNLabel>
|
|
217
|
-
<dc:Bounds x="
|
|
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="
|
|
232
|
+
<dc:Bounds x="448" y="142" width="36" height="36" />
|
|
222
233
|
<bpmndi:BPMNLabel>
|
|
223
|
-
<dc:Bounds x="
|
|
234
|
+
<dc:Bounds x="370" y="183" width="72" height="28" />
|
|
224
235
|
</bpmndi:BPMNLabel>
|
|
225
236
|
</bpmndi:BPMNShape>
|
|
226
|
-
<bpmndi:BPMNEdge id="
|
|
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="
|
|
241
|
+
<bpmndi:BPMNEdge id="BPMNEdge_ic_toImplement" bpmnElement="ic_toImplement">
|
|
231
242
|
<di:waypoint x="316" y="120" />
|
|
232
|
-
<di:waypoint x="
|
|
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="
|
|
236
|
-
<di:waypoint x="
|
|
250
|
+
<di:waypoint x="691" y="120" />
|
|
251
|
+
<di:waypoint x="816" y="120" />
|
|
237
252
|
<bpmndi:BPMNLabel>
|
|
238
|
-
<dc:Bounds x="
|
|
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="
|
|
243
|
-
<di:waypoint x="
|
|
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="
|
|
247
|
-
<di:waypoint x="
|
|
261
|
+
<di:waypoint x="1066" y="120" />
|
|
262
|
+
<di:waypoint x="1166" y="120" />
|
|
248
263
|
<bpmndi:BPMNLabel>
|
|
249
|
-
<dc:Bounds x="
|
|
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="
|
|
262
|
-
<di:waypoint x="
|
|
276
|
+
<di:waypoint x="1616" y="120" />
|
|
277
|
+
<di:waypoint x="1748" y="120" />
|
|
263
278
|
<bpmndi:BPMNLabel>
|
|
264
|
-
<dc:Bounds x="
|
|
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="
|
|
269
|
-
<di:waypoint x="
|
|
270
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
277
|
-
<di:waypoint x="
|
|
278
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
285
|
-
<di:waypoint x="
|
|
286
|
-
<di:waypoint x="
|
|
287
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
294
|
-
<di:waypoint x="
|
|
295
|
-
<di:waypoint x="
|
|
296
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
303
|
-
<di:waypoint x="
|
|
304
|
-
<di:waypoint x="
|
|
305
|
-
<di:waypoint x="
|
|
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="
|
|
309
|
-
<di:waypoint x="
|
|
310
|
-
<di:waypoint x="
|
|
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="
|
|
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="
|
|
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="
|
|
320
|
-
<di:waypoint x="
|
|
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;
|