@nanobpm/nano-workforce 0.201.2 → 0.201.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/app/contracts.ts +2 -2
- package/app/deliveryRunner.test.ts +14 -11
- package/app/feature.test.ts +5 -3
- package/app/plan.test.ts +5 -2
- package/app/repoEnvelope.test.ts +139 -0
- package/app/repoEnvelope.ts +24 -15
- package/app/service.test.ts +21 -16
- package/e2e/feature-run.e2e.ts +6 -4
- package/e2e/plan-fanout.e2e.ts +6 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.201.3](https://github.com/nanobpm/nano-workforce/compare/v0.201.2...v0.201.3) (2026-10-10)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **repo-envelope:** emit branch.create as a top-level sibling of repository ([#885](https://github.com/nanobpm/nano-workforce/issues/885)) ([abb7b1c](https://github.com/nanobpm/nano-workforce/commit/abb7b1cc8439229724668fa85ffac22afd95c470)), closes [nanobpm/nano-bpm#1344](https://github.com/nanobpm/nano-bpm/issues/1344) [#227](https://github.com/nanobpm/nano-workforce/issues/227) [#882](https://github.com/nanobpm/nano-workforce/issues/882)
|
|
6
|
+
|
|
1
7
|
## [0.201.2](https://github.com/nanobpm/nano-workforce/compare/v0.201.1...v0.201.2) (2026-10-08)
|
|
2
8
|
|
|
3
9
|
### Bug Fixes
|
package/app/contracts.ts
CHANGED
|
@@ -448,9 +448,9 @@ export const WIRE_CONTRACTS = {
|
|
|
448
448
|
name: "io.nanobpm.agentTask.repository",
|
|
449
449
|
owner: "app/repoEnvelope.ts",
|
|
450
450
|
semantics:
|
|
451
|
-
"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).",
|
|
451
|
+
"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.",
|
|
452
452
|
shape:
|
|
453
|
-
'io.nanobpm.agentTask: { repository: { provider: "github", url: string, ref?: string, singleBranch: true, filter: "blob:none", cloneTimeoutMs: number, baseRef?: string, sha?: string, branch?: { create: string }
|
|
453
|
+
'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`).',
|
|
454
454
|
},
|
|
455
455
|
"epicSet.submit": {
|
|
456
456
|
category: "wire",
|
|
@@ -533,7 +533,7 @@ test("prepareDeliveryGraph injects the repository envelope PER agent cell from t
|
|
|
533
533
|
assert(headers.some((h) => h.includes('task.allowPr" value="true"')), `expected a task.allowPr header, got ${JSON.stringify(headers)}`);
|
|
534
534
|
// The deterministic per-node isolation branch (issue #776): the harness cuts `feat/<node.id>` itself,
|
|
535
535
|
// so the agent can never be left committing on the base branch (a non-ff push that strands the run).
|
|
536
|
-
assert(headers.some((h) => h.includes('
|
|
536
|
+
assert(headers.some((h) => h.includes('io.nanobpm.agentTask.branch.create" value="feat/open-b"')), `expected a per-node branch.create header, got ${JSON.stringify(headers)}`);
|
|
537
537
|
// No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
|
|
538
538
|
assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
|
|
539
539
|
// No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
|
|
@@ -590,7 +590,7 @@ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harn
|
|
|
590
590
|
assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
|
|
591
591
|
// Even without a known base, the deterministic isolation branch is still emitted (issue #776): the
|
|
592
592
|
// harness cuts `feat/<node.id>` off the cloned default tip, so the agent never commits on the default.
|
|
593
|
-
assert(headers.some((h) => h.includes('
|
|
593
|
+
assert(headers.some((h) => h.includes('io.nanobpm.agentTask.branch.create" value="feat/a"')), `expected branch.create even without a base, got ${JSON.stringify(headers)}`);
|
|
594
594
|
});
|
|
595
595
|
|
|
596
596
|
test("each agent cell gets its OWN deterministic feat/<node.id> branch.create — never a shared branch (#776)", async () => {
|
|
@@ -603,7 +603,7 @@ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create
|
|
|
603
603
|
edges: [{ from: "impl-a", to: "impl-b" }],
|
|
604
604
|
};
|
|
605
605
|
const p = await prepareOk(graph, {});
|
|
606
|
-
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("
|
|
606
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("io.nanobpm.agentTask.branch.create"));
|
|
607
607
|
// Two distinct cells → two distinct per-node branches, so no two agents can collide on one branch.
|
|
608
608
|
assert(branches.some((h) => h.includes('value="feat/impl-a"')), `expected feat/impl-a, got ${JSON.stringify(branches)}`);
|
|
609
609
|
assert(branches.some((h) => h.includes('value="feat/impl-b"')), `expected feat/impl-b, got ${JSON.stringify(branches)}`);
|
|
@@ -612,7 +612,7 @@ test("each agent cell gets its OWN deterministic feat/<node.id> branch.create
|
|
|
612
612
|
|
|
613
613
|
test("an explicit repoless run emits NO branch.create — no envelope at all (#776/#729)", async () => {
|
|
614
614
|
const p = await prepareOk(GRAPH, { repoless: true });
|
|
615
|
-
assert(!p.bpmn.includes("
|
|
615
|
+
assert(!p.bpmn.includes("io.nanobpm.agentTask.branch.create"), "a repoless run carries no isolation branch either");
|
|
616
616
|
});
|
|
617
617
|
|
|
618
618
|
test("a node id that derives an invalid git ref degrades to NO branch.create — never emits an unusable feat/... (#776)", async () => {
|
|
@@ -630,7 +630,7 @@ test("a node id that derives an invalid git ref degrades to NO branch.create —
|
|
|
630
630
|
edges: [{ from: "a..b", to: "a.lock" }, { from: "a.lock", to: "ok" }],
|
|
631
631
|
};
|
|
632
632
|
const p = await prepareOk(graph, {});
|
|
633
|
-
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("
|
|
633
|
+
const branches = agentHeaders(p.bpmn).filter((h) => h.includes("io.nanobpm.agentTask.branch.create"));
|
|
634
634
|
// Only the valid-id cell keeps a branch.create; the two invalid-ref ids emit none (their agents still
|
|
635
635
|
// provision an isolated clone, they just cut their own branch inside it — the pre-#776 fallback).
|
|
636
636
|
assertEquals(branches.length, 1, `only the valid id keeps a branch.create, got ${JSON.stringify(branches)}`);
|
|
@@ -638,16 +638,19 @@ test("a node id that derives an invalid git ref degrades to NO branch.create —
|
|
|
638
638
|
});
|
|
639
639
|
|
|
640
640
|
test("agentNodeRepoEnvelope emits branch.create off the base when known, off the default when not, and omits it when blank (#776)", () => {
|
|
641
|
-
|
|
642
|
-
|
|
641
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the
|
|
642
|
+
// envelope root, NOT from `repository.branch`.
|
|
643
|
+
const withBase = (agentNodeRepoEnvelope("acme/one", "main", "feat/n1") as any)["io.nanobpm.agentTask"];
|
|
644
|
+
assertEquals(withBase.repository.ref, "main", "known base → checked-out ref");
|
|
643
645
|
assertEquals(withBase.branch.create, "feat/n1", "the harness cuts feat/<node.id> off the base");
|
|
644
|
-
|
|
645
|
-
|
|
646
|
+
assertEquals("branch" in withBase.repository, false, "branch must NOT be nested under repository");
|
|
647
|
+
const noBase = (agentNodeRepoEnvelope("acme/one", null, "feat/n2") as any)["io.nanobpm.agentTask"];
|
|
648
|
+
assertEquals("ref" in noBase.repository, false, "no base → clone the default branch");
|
|
646
649
|
assertEquals(noBase.branch.create, "feat/n2", "branch.create is still emitted off the default tip");
|
|
647
650
|
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key (pre-#776 behaviour).
|
|
648
|
-
assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].
|
|
651
|
+
assertEquals((agentNodeRepoEnvelope("acme/one", "main", " feat/n3 ") as any)["io.nanobpm.agentTask"].branch.create, "feat/n3");
|
|
649
652
|
for (const blank of [null, undefined, "", " "]) {
|
|
650
|
-
const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"]
|
|
653
|
+
const r = (agentNodeRepoEnvelope("acme/one", "main", blank as any) as any)["io.nanobpm.agentTask"];
|
|
651
654
|
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
652
655
|
}
|
|
653
656
|
});
|
package/app/feature.test.ts
CHANGED
|
@@ -196,11 +196,13 @@ test("startFeature: seeds the pre-PR repository envelope so the harness provisio
|
|
|
196
196
|
// shared launch dir; with it, the harness clones a throwaway workspace. The implementation path is
|
|
197
197
|
// PRE-PR, so it checks out the BASE branch (`ref`) and the harness cuts the deterministic
|
|
198
198
|
// `feat/<task.id>` feature branch off it (`branch.create`).
|
|
199
|
-
const
|
|
199
|
+
const ns = (captured.variables as Record<string, any>)["io.nanobpm.agentTask"];
|
|
200
|
+
const repo = ns.repository;
|
|
200
201
|
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
201
202
|
assertEquals(repo.ref, "epic/x");
|
|
202
|
-
|
|
203
|
-
assertEquals(
|
|
203
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the root.
|
|
204
|
+
assertEquals(ns.branch.create, "feat/issue-42");
|
|
205
|
+
assertEquals(ns.branch.create, `feat/${featureTaskId(PARSED.number)}`);
|
|
204
206
|
// The blobless/single-branch monorepo shaping rides along, exactly like the PR-based envelope.
|
|
205
207
|
assertEquals(repo.singleBranch, true);
|
|
206
208
|
assertEquals(repo.filter, "blob:none");
|
package/app/plan.test.ts
CHANGED
|
@@ -244,10 +244,13 @@ test("startPlan pins the base branch: persisted on the row + seeded as baseBranc
|
|
|
244
244
|
// Host-git provisioning (#684): the whole-epic repository envelope so each slice's implementation
|
|
245
245
|
// agent gets an isolated clone. The epic seed checks out the BASE branch (`ref`) — each slice's
|
|
246
246
|
// `feat/<task.id>` branch differs per MI child, so NO `branch.create` here (the agent branches).
|
|
247
|
-
const
|
|
247
|
+
const ns = seen["io.nanobpm.agentTask"];
|
|
248
|
+
const repo = ns.repository;
|
|
248
249
|
assertEquals(repo.url, "https://github.com/owner/repo.git");
|
|
249
250
|
assertEquals(repo.ref, "epic/agent-protocol");
|
|
250
|
-
|
|
251
|
+
// No isolation branch on the epic seed — `branch` is a top-level sibling of `repository` (#882),
|
|
252
|
+
// and it is omitted entirely here.
|
|
253
|
+
assertEquals("branch" in ns, false);
|
|
251
254
|
assertEquals(repo.singleBranch, true);
|
|
252
255
|
assertEquals(repo.filter, "blob:none");
|
|
253
256
|
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Contract test for the agent-task repository envelope (issue #882): the producer
|
|
2
|
+
// (`app/repoEnvelope.ts`) and the consumer (the c8ctl nano worker harness's
|
|
3
|
+
// `normalizeTaskEnvelope`, c8ctl-plugin.js) must agree on WHERE the deterministic
|
|
4
|
+
// `branch.create` lives. The harness reads it from the TOP LEVEL of the envelope
|
|
5
|
+
// (`raw.branch`), NOT nested under `repository`. The producer once nested it under
|
|
6
|
+
// `repository.branch` — a shape the consumer never reads — so `branch.create`
|
|
7
|
+
// silently never took effect (retries couldn't resume their WIP checkpoint, pushes
|
|
8
|
+
// were refused off the work branch; nanobpm/nano-bpm#1344). This is the
|
|
9
|
+
// two-sides-of-one-contract drift #227 guards against, so this test round-trips the
|
|
10
|
+
// emitted envelope through a faithful mirror of the harness's normalizer shape and
|
|
11
|
+
// asserts the consumer actually recovers `branch.create` — pinning producer and
|
|
12
|
+
// consumer together so they cannot drift again.
|
|
13
|
+
import { test } from "node:test";
|
|
14
|
+
import { assert, assertEquals } from "#test-assert";
|
|
15
|
+
import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, repoEnvelopeVars } from "./repoEnvelope.ts";
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── The round-trip contract ──────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
test("contract: repoEnvelopeVars emits branch.create where the harness reads it (envelope TOP LEVEL, not repository.branch) (#882)", () => {
|
|
95
|
+
// The pre-PR implementation path: a deterministic feat/<task.id> branch the harness must cut.
|
|
96
|
+
const vars = repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7");
|
|
97
|
+
// Round-trip the emitted PROCESS-VARIABLE envelope through the harness normalizer shape.
|
|
98
|
+
const recovered = harnessReadsBranchCreate({}, vars);
|
|
99
|
+
assertEquals(
|
|
100
|
+
recovered,
|
|
101
|
+
"feat/issue-7",
|
|
102
|
+
"the harness reads branch.create from the envelope top level — nesting it under repository.branch drops it",
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("contract: repoEnvelopeVars PR path (ref == create == head) round-trips branch.create to the harness (#882/#231)", () => {
|
|
107
|
+
const vars = repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12");
|
|
108
|
+
assertEquals(harnessReadsBranchCreate({}, vars), "feat/issue-12");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("contract: agentNodeRepoEnvelope round-trips the deterministic per-node branch through the harness (#882/#776)", () => {
|
|
112
|
+
const vars = agentNodeRepoEnvelope("acme/one", "main", "feat/n1");
|
|
113
|
+
assertEquals(harnessReadsBranchCreate({}, vars), "feat/n1");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("contract: the FLATTENED per-cell task headers round-trip branch.create to the harness (#882/#739)", () => {
|
|
117
|
+
// The delivery-graph path delivers the envelope as FLAT dotted task headers, which the harness
|
|
118
|
+
// reassembles via collectEnvelopeFrom's dotpath branch. Round-trip the flattened headers through
|
|
119
|
+
// the same normalizer shape so the header channel can't drift from the consumer either.
|
|
120
|
+
const envelope = agentNodeRepoEnvelope("acme/one", "main", "feat/open-b");
|
|
121
|
+
const flat = flattenAgentTaskEnvelope(envelope);
|
|
122
|
+
// The flattened key must be the TOP-LEVEL `branch.create`, NOT `repository.branch.create`.
|
|
123
|
+
assert(
|
|
124
|
+
"io.nanobpm.agentTask.branch.create" in flat,
|
|
125
|
+
`expected a top-level io.nanobpm.agentTask.branch.create header, got ${JSON.stringify(Object.keys(flat))}`,
|
|
126
|
+
);
|
|
127
|
+
assert(
|
|
128
|
+
!("io.nanobpm.agentTask.repository.branch.create" in flat),
|
|
129
|
+
"branch.create must NOT be nested under repository — the harness never reads it there",
|
|
130
|
+
);
|
|
131
|
+
const recovered = harnessReadsBranchCreate(flat, {});
|
|
132
|
+
assertEquals(recovered, "feat/open-b", "the harness reassembles the flattened header into branch.create");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("contract: an envelope with NO branch round-trips to undefined (the consumer's default), never a bogus value (#882)", () => {
|
|
136
|
+
// A blank branchCreate omits the key entirely; the harness must then see no branch.create.
|
|
137
|
+
const vars = repoEnvelopeVars("owner/repo", "main", null, null, null);
|
|
138
|
+
assertEquals(harnessReadsBranchCreate({}, vars), undefined);
|
|
139
|
+
});
|
package/app/repoEnvelope.ts
CHANGED
|
@@ -187,19 +187,25 @@ export function repoEnvelopeVars(
|
|
|
187
187
|
// omission — the harness then clones the head branch tip, the pre-#324 behaviour. Omitted too
|
|
188
188
|
// when the PR has no durable push-checkpoint yet.
|
|
189
189
|
...(isCommitSha(commitSha) ? { sha: commitSha } : {}),
|
|
190
|
-
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
191
|
-
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
192
|
-
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
|
193
|
-
// off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
|
|
194
|
-
// hand — making the isolated clone land on the right branch deterministically across a resume.
|
|
195
|
-
// The PR-based paths (review/fix-ci/rebase) ALSO pass `branchCreate = ref` (the PR head): without
|
|
196
|
-
// it the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231) cuts a throwaway
|
|
197
|
-
// `nano/agent-work/*` fallback OFF the PR; with `ref == create == head` it commits/pushes to the
|
|
198
|
-
// head branch. Only a blank/omitted value drops the key.
|
|
199
|
-
...(typeof branchCreate === "string" && branchCreate.trim() !== ""
|
|
200
|
-
? { branch: { create: branchCreate.trim() } }
|
|
201
|
-
: {}),
|
|
202
190
|
},
|
|
191
|
+
// Pre-PR provisioning (issue #684): the implementation path (feature.bpmn / plan-fanout's
|
|
192
|
+
// implement-cell) dispatches its agent BEFORE any PR exists, so `ref` is the BASE branch, not a
|
|
193
|
+
// head. `branchCreate` asks the harness to cut the deterministic `feat/<task.id>` feature branch
|
|
194
|
+
// off that base itself (the agent-guide's `feat/*` convention) instead of the agent branching by
|
|
195
|
+
// hand — making the isolated clone land on the right branch deterministically across a resume.
|
|
196
|
+
// The PR-based paths (review/fix-ci/rebase) ALSO pass `branchCreate = ref` (the PR head): without
|
|
197
|
+
// it the harness's base-branch guard (jwulf/c8ctl-plugin-nano#231) cuts a throwaway
|
|
198
|
+
// `nano/agent-work/*` fallback OFF the PR; with `ref == create == head` it commits/pushes to the
|
|
199
|
+
// head branch. Only a blank/omitted value drops the key.
|
|
200
|
+
//
|
|
201
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness's
|
|
202
|
+
// `normalizeTaskEnvelope` reads it from the envelope root (`isPlainObject(raw.branch)`), NOT from
|
|
203
|
+
// `repository.branch`. Nesting it under `repository` was the two-sides-of-one-contract drift
|
|
204
|
+
// (#227) that left `branch.create` silently unread (retries couldn't resume, pushes were
|
|
205
|
+
// refused — nanobpm/nano-bpm#1344).
|
|
206
|
+
...(typeof branchCreate === "string" && branchCreate.trim() !== ""
|
|
207
|
+
? { branch: { create: branchCreate.trim() } }
|
|
208
|
+
: {}),
|
|
203
209
|
// Repo-provisioning auth gate (issue #770): c8ctl-plugin-nano (≥1.60.2) only resolves the
|
|
204
210
|
// git credential (GITHUB_TOKEN, or the `gh` default when it's absent) for repository
|
|
205
211
|
// provisioning when `task.allowPr` is truthy — `resolveJobSecrets` pulls the token behind that
|
|
@@ -251,10 +257,13 @@ export function agentNodeRepoEnvelope(repo: string, base: string | null, branchC
|
|
|
251
257
|
// A per-node base (declared, or defaulted from the run level) — the branch the harness cuts its
|
|
252
258
|
// `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
|
|
253
259
|
...(ref ? { ref, baseRef: ref } : {}),
|
|
254
|
-
// The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
|
|
255
|
-
// left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
|
|
256
|
-
...(create ? { branch: { create } } : {}),
|
|
257
260
|
},
|
|
261
|
+
// The deterministic isolation branch the harness cuts (issue #776), so the agent can never be
|
|
262
|
+
// left committing on the checked-out base branch. Off `ref` when known, else off the default tip.
|
|
263
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness's
|
|
264
|
+
// `normalizeTaskEnvelope` reads it from the envelope root, NOT from `repository.branch`, so the
|
|
265
|
+
// flattened per-cell header is `io.nanobpm.agentTask.branch.create` (not `…repository.branch.create`).
|
|
266
|
+
...(create ? { branch: { create } } : {}),
|
|
258
267
|
// Repo-provisioning auth gate (issue #770): as in `repoEnvelopeVars`, a repo-backed cell must
|
|
259
268
|
// opt the harness into git-credential resolution (`task.allowPr`) or the clone fails with
|
|
260
269
|
// `unable to get password from user`. Emitted on every resolved cell (past the `owner/repo`
|
package/app/service.test.ts
CHANGED
|
@@ -1415,27 +1415,30 @@ test("repoEnvelopeVars emits the push-checkpoint under the harness-read `sha` ke
|
|
|
1415
1415
|
// to `ref`) so the harness commits/pushes to the PR head branch instead of cutting a throwaway
|
|
1416
1416
|
// `nano/agent-work/*` fallback (jwulf/c8ctl-plugin-nano#231); see the PR-path test below.
|
|
1417
1417
|
test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#684)", () => {
|
|
1418
|
-
const
|
|
1419
|
-
|
|
1418
|
+
const envelope = repoEnvelopeVars("owner/repo", "main", null, null, "feat/issue-7") as any;
|
|
1419
|
+
const ns = envelope["io.nanobpm.agentTask"];
|
|
1420
|
+
const repo = ns.repository;
|
|
1420
1421
|
assertEquals(repo.ref, "main", "the pre-PR envelope checks out the BASE branch as its ref");
|
|
1421
|
-
|
|
1422
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the
|
|
1423
|
+
// envelope root, NOT from `repository.branch`.
|
|
1424
|
+
assertEquals(ns.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch off the base");
|
|
1425
|
+
assertEquals("branch" in repo, false, "branch must NOT be nested under repository — the harness never reads it there");
|
|
1422
1426
|
// Still branch-scoped and blobless like the PR-based envelope.
|
|
1423
1427
|
assertEquals(repo.singleBranch, true);
|
|
1424
1428
|
assertEquals(repo.filter, "blob:none");
|
|
1425
1429
|
// A whitespace-tainted branch is trimmed; a blank/absent one omits the `branch` key entirely so the
|
|
1426
1430
|
// PR-based paths (and any caller that doesn't pre-create a branch) are unaffected.
|
|
1427
1431
|
assertEquals(
|
|
1428
|
-
(repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"]
|
|
1432
|
+
(repoEnvelopeVars("owner/repo", "main", null, null, " feat/issue-9 ") as any)["io.nanobpm.agentTask"]
|
|
1429
1433
|
.branch.create,
|
|
1430
1434
|
"feat/issue-9",
|
|
1431
1435
|
);
|
|
1432
1436
|
for (const blank of [null, undefined, "", " "]) {
|
|
1433
|
-
const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"]
|
|
1434
|
-
.repository;
|
|
1437
|
+
const r = (repoEnvelopeVars("owner/repo", "main", null, null, blank as any) as any)["io.nanobpm.agentTask"];
|
|
1435
1438
|
assertEquals("branch" in r, false, `expected no branch key for ${JSON.stringify(blank)}`);
|
|
1436
1439
|
}
|
|
1437
1440
|
// The default (4-arg) PR-based call never emits a branch.create.
|
|
1438
|
-
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"]
|
|
1441
|
+
assertEquals("branch" in (repoEnvelopeVars("owner/repo", "feat/x", "main") as any)["io.nanobpm.agentTask"], false);
|
|
1439
1442
|
});
|
|
1440
1443
|
|
|
1441
1444
|
// PR-path provisioning (jwulf/c8ctl-plugin-nano#231): the review-round / fix-ci / rebase call sites
|
|
@@ -1445,11 +1448,13 @@ test("repoEnvelopeVars emits branch.create only for a non-blank pre-PR branch (#
|
|
|
1445
1448
|
// `ref == create == head` the harness does a no-op `checkout -B <head>` and commits/pushes to the PR
|
|
1446
1449
|
// head branch itself.
|
|
1447
1450
|
test("repoEnvelopeVars emits branch.create = head on the PR path (ref == create == head) (#231)", () => {
|
|
1448
|
-
const
|
|
1451
|
+
const ns = (repoEnvelopeVars("owner/repo", "feat/issue-12", "main", null, "feat/issue-12") as any)[
|
|
1449
1452
|
"io.nanobpm.agentTask"
|
|
1450
|
-
]
|
|
1453
|
+
];
|
|
1454
|
+
const repo = ns.repository;
|
|
1451
1455
|
assertEquals(repo.ref, "feat/issue-12", "the PR-path envelope checks out the head branch");
|
|
1452
|
-
|
|
1456
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882), read by the harness from the root.
|
|
1457
|
+
assertEquals(ns.branch.create, "feat/issue-12", "and asks the harness to work on that same head branch, not a fallback");
|
|
1453
1458
|
// Still branch-scoped and blobless, and the base tip stays reachable for the 3-dot diff.
|
|
1454
1459
|
assertEquals(repo.singleBranch, true);
|
|
1455
1460
|
assertEquals(repo.filter, "blob:none");
|
|
@@ -1496,9 +1501,9 @@ test("submitPr threads the resolved PR head into the envelope branch.create (#23
|
|
|
1496
1501
|
url: "https://github.com/owner/repo/pull/12",
|
|
1497
1502
|
prKey: "owner/repo#12",
|
|
1498
1503
|
});
|
|
1499
|
-
const
|
|
1500
|
-
assertEquals(
|
|
1501
|
-
assertEquals(
|
|
1504
|
+
const ns = get()?.["io.nanobpm.agentTask"] as any;
|
|
1505
|
+
assertEquals(ns?.repository?.ref, "feat/issue-12", "submitPr checks out the PR head branch");
|
|
1506
|
+
assertEquals(ns?.branch?.create, "feat/issue-12", "submitPr passes headRef as branch.create, not a fallback");
|
|
1502
1507
|
});
|
|
1503
1508
|
});
|
|
1504
1509
|
|
|
@@ -1521,9 +1526,9 @@ test("startMerge threads the resolved PR head into the envelope branch.create (#
|
|
|
1521
1526
|
prKey: "owner/repo#13",
|
|
1522
1527
|
round: 2,
|
|
1523
1528
|
});
|
|
1524
|
-
const
|
|
1525
|
-
assertEquals(
|
|
1526
|
-
assertEquals(
|
|
1529
|
+
const ns = get()?.["io.nanobpm.agentTask"] as any;
|
|
1530
|
+
assertEquals(ns?.repository?.ref, "feat/issue-13", "startMerge checks out the PR head branch");
|
|
1531
|
+
assertEquals(ns?.branch?.create, "feat/issue-13", "startMerge passes headRef as branch.create, not a fallback");
|
|
1527
1532
|
});
|
|
1528
1533
|
});
|
|
1529
1534
|
|
package/e2e/feature-run.e2e.ts
CHANGED
|
@@ -133,11 +133,11 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
133
133
|
// carries — proof the implement-cell agent gets an ISOLATED clone (base branch + a harness-cut
|
|
134
134
|
// `feat/<task.id>`) instead of inheriting the worker's launch dir. It rides the process variable
|
|
135
135
|
// all the way down through feature.bpmn's `implement` callActivity into the cell's agent job.
|
|
136
|
-
let
|
|
136
|
+
let agentTask: any = null;
|
|
137
137
|
await withApp(
|
|
138
138
|
{
|
|
139
139
|
"senior:feature": (job) => {
|
|
140
|
-
|
|
140
|
+
agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
|
|
141
141
|
return { status: "opened", pr: "owner/repo#101", summary: "built it" };
|
|
142
142
|
},
|
|
143
143
|
},
|
|
@@ -150,10 +150,12 @@ describe("single-issue feature run (#172 — feature.bpmn)", () => {
|
|
|
150
150
|
);
|
|
151
151
|
assert.ok(!flows.includes("gw-converge->converge"), "the converge hand-off branch was NOT taken");
|
|
152
152
|
|
|
153
|
-
assert.ok(
|
|
153
|
+
assert.ok(agentTask, "the senior:feature job carried the io.nanobpm.agentTask repository envelope");
|
|
154
|
+
const agentRepo = agentTask.repository;
|
|
154
155
|
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
155
156
|
assert.equal(agentRepo.ref, "epic/e2e", "a PRE-PR job checks out the BASE branch, not a head");
|
|
156
|
-
|
|
157
|
+
// `branch` is a TOP-LEVEL sibling of `repository` (issue #882) — the harness reads it from the root.
|
|
158
|
+
assert.equal(agentTask.branch.create, "feat/issue-7", "the harness cuts the deterministic feature branch");
|
|
157
159
|
|
|
158
160
|
const run = await featureRow(app, featureKey);
|
|
159
161
|
assert.equal(run.status, "opened", "the run settled at opened");
|
package/e2e/plan-fanout.e2e.ts
CHANGED
|
@@ -131,22 +131,24 @@ describe("plan-fanout escalations (U2 — task + plan-review + trial-merge → u
|
|
|
131
131
|
// rides down through the wave subprocess + `implement-cell-call` callActivity into each agent job.
|
|
132
132
|
// The epic seed carries the BASE branch as `ref` but NO `branch.create` — each slice's
|
|
133
133
|
// `feat/<task.id>` differs per MI child, so the agent cuts its own branch inside the clone.
|
|
134
|
-
let
|
|
134
|
+
let agentTask: any = null;
|
|
135
135
|
await withApp(
|
|
136
136
|
{
|
|
137
137
|
"senior:plan": singleTaskPlan,
|
|
138
138
|
"senior:plan-review": approveReview,
|
|
139
139
|
"senior:feature": (job) => {
|
|
140
|
-
|
|
140
|
+
agentTask = (job.variables as Record<string, any>)["io.nanobpm.agentTask"] ?? null;
|
|
141
141
|
return { status: "blocked", summary: "n/a" };
|
|
142
142
|
},
|
|
143
143
|
},
|
|
144
144
|
async ({ app }) => {
|
|
145
145
|
await app.settle();
|
|
146
|
-
assert.ok(
|
|
146
|
+
assert.ok(agentTask, "the epic slice's senior:feature job carried the io.nanobpm.agentTask envelope");
|
|
147
|
+
const agentRepo = agentTask.repository;
|
|
147
148
|
assert.equal(agentRepo.url, "https://github.com/owner/repo.git", "the harness clones the target repo");
|
|
148
149
|
assert.equal(agentRepo.ref, "epic/e2e", "the epic seed checks out the BASE (integration) branch");
|
|
149
|
-
|
|
150
|
+
// `branch` is a top-level sibling of `repository` (#882); the epic seed omits it entirely.
|
|
151
|
+
assert.equal("branch" in agentTask, false, "the epic seed omits branch.create — the agent branches per slice");
|
|
150
152
|
assert.equal(agentRepo.singleBranch, true, "the blobless/single-branch monorepo shaping rides along");
|
|
151
153
|
assert.equal(agentRepo.filter, "blob:none");
|
|
152
154
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.201.
|
|
3
|
+
"version": "0.201.3",
|
|
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",
|