@nanobpm/nano-workforce 0.181.0 → 0.182.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.182.1](https://github.com/nanobpm/nano-workforce/compare/v0.182.0...v0.182.1) (2026-09-04)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **deps:** update dependency @nanobpm/urban to ^0.92.0 ([#573](https://github.com/nanobpm/nano-workforce/issues/573)) ([a7acb21](https://github.com/nanobpm/nano-workforce/commit/a7acb214bc36752149ce8be51a907ff74acb073d))
6
+
7
+ ## [0.182.0](https://github.com/nanobpm/nano-workforce/compare/v0.181.0...v0.182.0) (2026-09-04)
8
+
9
+ ### Features
10
+
11
+ * stamp each agent node's repository for per-node delivery-graph isolation ([#742](https://github.com/nanobpm/nano-workforce/issues/742)) ([e8dcdfc](https://github.com/nanobpm/nano-workforce/commit/e8dcdfce2122bdb2a7e881a0900ca1f607d6d402)), closes [#739](https://github.com/nanobpm/nano-workforce/issues/739)
12
+
1
13
  ## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
2
14
 
3
15
  ### Features
@@ -19,8 +19,10 @@
19
19
  // Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
+ import { isPlausibleBranchName } from "./baseBranch.ts";
22
23
  import { isConvergeTarget } from "./convergeTargets.ts";
23
24
  import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
25
+ import { isResolvableRepo } from "./repoEnvelope.ts";
24
26
 
25
27
  /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
26
28
  * deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
@@ -94,6 +96,8 @@ export type DeliveryGraphErrorCode =
94
96
  | "raw-converge-node"
95
97
  | "merge-requires-converge"
96
98
  | "converge-merge-type"
99
+ | "invalid-node-repository"
100
+ | "invalid-node-base-branch"
97
101
  | "unbound-pr";
98
102
 
99
103
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
@@ -428,6 +432,31 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
428
432
  code: "merge-requires-converge",
429
433
  });
430
434
  }
435
+ // #739: an `agent` node may declare its OWN `repository` (`owner/repo`) + `baseBranch`, so a
436
+ // cross-repo graph provisions each cell's isolation envelope from that node's own repo (no
437
+ // uniform run-level repo, no `repoless`). Both are OPTIONAL (absent → the run-level fallback),
438
+ // but a PRESENT value must pass the SAME allowlists the dispatch door / `repoEnvelopeVars` apply
439
+ // — a plain `owner/repo` (no `.git`, no host/query chars) and a plausible git branch name — so a
440
+ // graph that bypassed OpenAPI shape validation cannot smuggle a malformed clone URL / ref past
441
+ // this gate into the per-node envelope. Rejected path-qualified rather than silently dropped.
442
+ if (kind === "agent" && config.repository !== undefined && !isResolvableRepo(config.repository)) {
443
+ errors.push({
444
+ path: `${path}.${configKey}.repository`,
445
+ message:
446
+ "`agent.repository`, when present, must be an `owner/repo` reference (no trailing `.git`, no " +
447
+ `host/query characters) — got ${JSON.stringify(config.repository)} (#739)`,
448
+ code: "invalid-node-repository",
449
+ });
450
+ }
451
+ if (kind === "agent" && config.baseBranch !== undefined && (typeof config.baseBranch !== "string" || !isPlausibleBranchName(config.baseBranch))) {
452
+ errors.push({
453
+ path: `${path}.${configKey}.baseBranch`,
454
+ message:
455
+ "`agent.baseBranch`, when present, must be a plausible git branch name (no whitespace, shell " +
456
+ `metacharacters, leading \`-\`, \`..\`/\`//\`, etc.) — got ${JSON.stringify(config.baseBranch)} (#739)`,
457
+ code: "invalid-node-base-branch",
458
+ });
459
+ }
431
460
  // #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
432
461
  // binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
433
462
  if (typeof id === "string" && id.length > 0) {
@@ -45,6 +45,18 @@ import {
45
45
  validateDeliveryGraph,
46
46
  } from "./deliveryGraph.ts";
47
47
  import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
48
+ import { AGENT_TASK_NS } from "./repoEnvelope.ts";
49
+
50
+ /** The task-header key that carries an `agent` node's DECLARED per-node repository spec (#739) into the
51
+ * compiled BPMN. It is a DIGEST-STABLE, env-free marker — pure graph content — so two graphs differing
52
+ * only in a node's declared `repository`/`baseBranch` content-address differently (they are different
53
+ * graphs), while the env-dependent parts of the real envelope (`cloneTimeoutMs`) and the run-level
54
+ * FALLBACK repo are NOT baked here (they are injected by the runner POST-digest, so the same graph in
55
+ * two environments / two runs still shares one id). The runner replaces this single marker header on
56
+ * every agent service task with the flattened EFFECTIVE `io.nanobpm.agentTask.*` envelope headers
57
+ * (declared ?? run-level), or strips it for an unresolved/`repoless` cell. The `__` prefix marks it as
58
+ * an internal marker the harness never reads. */
59
+ export const AGENT_REPO_SPEC_HEADER = `${AGENT_TASK_NS}.__repoSpec`;
48
60
 
49
61
  /** The engine-native BODY every node kind delegates to (Decision 2 — the graph SCHEDULES, it does not
50
62
  * re-implement execution). Each node compiles to an EMBEDDED `bpmn:subProcess` (call activities are a
@@ -1027,7 +1039,7 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
1027
1039
  // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1028
1040
  // emit) escalates AT this node instead of threading an incomplete result onward.
1029
1041
  const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1030
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate);
1042
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate, agentRepoSpecHeaderLines(node));
1031
1043
  }
1032
1044
  case "connector":
1033
1045
  return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
@@ -1040,11 +1052,25 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
1040
1052
  }
1041
1053
  }
1042
1054
 
1055
+ /** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
1056
+ * `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
1057
+ * DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY agent service
1058
+ * task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the runner has a
1059
+ * single, uniform anchor to replace with the effective envelope on every cell. Digest-stable and
1060
+ * env-free — only the declared values (pure graph content) appear here; the run-level fallback and the
1061
+ * env-dependent `cloneTimeoutMs` are injected by the runner POST-digest. Declared values pass the
1062
+ * `owner/repo` + branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile chars. */
1063
+ function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
1064
+ const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
1065
+ const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
1066
+ return [
1067
+ " <zeebe:taskHeaders>",
1068
+ ` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
1069
+ " </zeebe:taskHeaders>",
1070
+ ];
1071
+ }
1072
+
1043
1073
  /** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
1044
- * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
1045
- * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
1046
- * names the stalled work (job type / connector target) for the escalation task's context line (#499). */
1047
- /** The FEEL boolean an `agent` node's producer-contract gate (issue #731) evaluates on its `_gate`
1048
1074
  * exclusive split's SUCCESS flow: the completion proceeds onward only when the self-reported `status`
1049
1075
  * is a terminal success (or absent/null) AND every required-data-dependency emit is populated non-null.
1050
1076
  * Reads the job's returned variables from the subProcess scope (the emit source var for an agent fact
@@ -1095,23 +1121,16 @@ function serviceBodyLines(
1095
1121
  taskProps: readonly string[],
1096
1122
  descriptor: string,
1097
1123
  contractGate?: { requiredEmits: readonly DeliveryFact[] },
1124
+ taskHeaders: readonly string[] = [],
1098
1125
  ): string[] {
1099
1126
  const esc = escalationTaskElement(el);
1100
- const taskExt =
1101
- taskProps.length > 0
1102
- ? [
1103
- " <bpmn:extensionElements>",
1104
- ` <zeebe:taskDefinition ${taskDefAttr} />`,
1105
- " <zeebe:properties>",
1106
- ...taskProps,
1107
- " </zeebe:properties>",
1108
- " </bpmn:extensionElements>",
1109
- ]
1110
- : [
1111
- " <bpmn:extensionElements>",
1112
- ` <zeebe:taskDefinition ${taskDefAttr} />`,
1113
- " </bpmn:extensionElements>",
1114
- ];
1127
+ const taskExt = [
1128
+ " <bpmn:extensionElements>",
1129
+ ` <zeebe:taskDefinition ${taskDefAttr} />`,
1130
+ ...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
1131
+ ...taskHeaders,
1132
+ " </bpmn:extensionElements>",
1133
+ ];
1115
1134
  const timeoutEscalation = escalationTaskLines(
1116
1135
  esc,
1117
1136
  nodeId,
@@ -31,7 +31,11 @@ const GRAPH: DeliveryGraph = {
31
31
  };
32
32
 
33
33
  async function prepareOk(graph: DeliveryGraph, options = {}) {
34
- const r = await prepareDeliveryGraph(graph, options);
34
+ // Default to a resolvable run-level repository/baseBranch fallback (#739) so the many timeout/id/DI
35
+ // tests below — which don't care about repo provisioning — need not restate it; the per-node
36
+ // repository tests pass their own `options` (declared node repos, `repoless`, unresolved, …).
37
+ const opts = "repoless" in options || "repository" in options ? options : { repository: "owner/repo", baseBranch: "main", ...options };
38
+ const r = await prepareDeliveryGraph(graph, opts);
35
39
  assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
36
40
  return r.prepared;
37
41
  }
@@ -364,90 +368,148 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
364
368
  assertEquals(typeof r.handle.processInstanceKey, "string");
365
369
  });
366
370
 
367
- // Host-git provisioning (issue #684/#686): the delivery-graph runner must seed the canonical
368
- // `io.nanobpm.agentTask.repository` isolation envelope (`repoEnvelopeVars`) as a run-root process
369
- // variable so every agent cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
370
- // instead of mutating the worker's launch dir the delivery-graph analog of the plan.ts epic seed.
371
- // These pin the createInstance variables the harness (headers variables) reads.
372
- function captureCreateInstanceVars(): { engine: Parameters<typeof runDeliveryGraph>[0]; seen: () => Record<string, unknown> } {
373
- let captured: Record<string, unknown> = {};
374
- const engine = {
375
- deployResources: async () => [],
376
- createInstance: async (req: { variables?: Record<string, unknown> }) => {
377
- captured = req.variables ?? {};
378
- return { processInstanceKey: "1" };
379
- },
380
- };
381
- return { engine, seen: () => captured };
371
+ // Per-node repository isolation (issue #739): the delivery-graph runner seeds the canonical
372
+ // `io.nanobpm.agentTask.repository` isolation envelope PER agent cell as flattened `<zeebe:taskHeaders>`
373
+ // (`io.nanobpm.agentTask.repository.url`, …) injected into the deployable BPMN NOT as a single
374
+ // run-root process variable. Each agent node's effective repository is its own declared `agent.repository`
375
+ // (`agent.baseBranch`), else the run-level `repository`/`baseBranch` fallback. This lets one graph fan out
376
+ // across DIFFERENT repos (each cell its own isolated clone) without forcing `repoless`. These pin the
377
+ // per-node headers the harness reads and the loud-failure invariants (#729 preserved/strengthened).
378
+ function agentHeaders(bpmn: string): string[] {
379
+ return (bpmn.match(/io\.nanobpm\.agentTask\.[^\n]*/g) ?? []).map((s) => s.trim());
382
380
  }
383
381
 
384
- test("runDeliveryGraph seeds the repository isolation envelope when repository + baseBranch are supplied (#684/#686)", async () => {
385
- const { engine, seen } = captureCreateInstanceVars();
386
- const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo", baseBranch: "main" });
382
+ test("prepareDeliveryGraph injects the repository envelope PER agent cell from the run-level fallback (#684/#686/#739)", async () => {
383
+ const p = await prepareOk(GRAPH, { repository: "owner/repo", baseBranch: "main" });
384
+ const headers = agentHeaders(p.bpmn);
385
+ // The run's single agent cell (`open-b`) carries the flattened envelope headers.
386
+ assert(headers.some((h) => h.includes('repository.url" value="https://github.com/owner/repo.git"')), `expected a repository.url header, got ${JSON.stringify(headers)}`);
387
+ assert(headers.some((h) => h.includes('repository.ref" value="main"')), "ref = base branch");
388
+ assert(headers.some((h) => h.includes('repository.baseRef" value="main"')), "baseRef = base branch");
389
+ assert(headers.some((h) => h.includes('repository.provider" value="github"')), "provider header");
390
+ assert(headers.some((h) => h.includes('repository.singleBranch" value="true"')), "branch-scoped blobless clone (#287)");
391
+ assert(headers.some((h) => h.includes('repository.filter" value="blob:none"')), "blobless filter");
392
+ // No `__repoSpec` marker survives injection — it is the compiler's digest-stable anchor only.
393
+ assert(!p.bpmn.includes("__repoSpec"), "the __repoSpec marker is fully replaced");
394
+ // No run-root `io.nanobpm.agentTask` variable — the envelope rides headers now, not a run variable.
395
+ });
396
+
397
+ test("a node's DECLARED repository/baseBranch WINS over the run-level fallback; a bare node falls back (#739)", async () => {
398
+ const graph: DeliveryGraph = {
399
+ name: "cross-repo fan-out",
400
+ nodes: [
401
+ { id: "own", kind: "agent", agent: { jobType: "senior:feature", prompt: "own repo", repository: "acme/widget", baseBranch: "develop" } },
402
+ { id: "fallback", kind: "agent", agent: { jobType: "senior:feature", prompt: "run repo" } },
403
+ ],
404
+ edges: [{ from: "own", to: "fallback" }],
405
+ };
406
+ const p = await prepareOk(graph, { repository: "owner/repo", baseBranch: "main" });
407
+ const headers = agentHeaders(p.bpmn);
408
+ // The declared node points at its OWN repo + base…
409
+ assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/widget.git"')), "declared repo wins");
410
+ assert(headers.some((h) => h.includes('repository.ref" value="develop"')), "declared base wins");
411
+ // …while the bare node inherits the run-level fallback.
412
+ assert(headers.some((h) => h.includes('repository.url" value="https://github.com/owner/repo.git"')), "bare node falls back to run repo");
413
+ assert(headers.some((h) => h.includes('repository.ref" value="main"')), "bare node falls back to run base");
414
+ });
415
+
416
+ test("a fully NODE-PROVISIONED cross-repo graph dispatches with NO run-level repository and NO repoless (#739)", async () => {
417
+ const graph: DeliveryGraph = {
418
+ name: "self-provisioned",
419
+ nodes: [
420
+ { id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } },
421
+ { id: "b", kind: "agent", agent: { jobType: "senior:feature", prompt: "b", repository: "acme/two" } },
422
+ ],
423
+ edges: [{ from: "a", to: "b" }],
424
+ };
425
+ // Neither a run-level repository/baseBranch NOR repoless — every node self-provisions.
426
+ const p = await prepareOk(graph, {});
427
+ const headers = agentHeaders(p.bpmn);
428
+ assert(headers.some((h) => h.includes("acme/one.git")), "node a → acme/one");
429
+ assert(headers.some((h) => h.includes("acme/two.git")), "node b → acme/two");
430
+ });
431
+
432
+ test("a declared repository WITHOUT a base branch omits ref/baseRef — the harness clones the default branch (#739)", async () => {
433
+ const graph: DeliveryGraph = {
434
+ name: "no base",
435
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one" } }],
436
+ edges: [],
437
+ };
438
+ // Call the runner DIRECTLY with no run-level fallback (the shared `prepareOk` helper would inject one),
439
+ // so the node's declared repo is the sole source and its base is genuinely unknown.
440
+ const r = await prepareDeliveryGraph(graph, {});
387
441
  assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
388
- const env = (seen() as Record<string, { repository?: Record<string, unknown> }>)["io.nanobpm.agentTask"];
389
- assert(env?.repository, `expected the run-root vars to carry io.nanobpm.agentTask.repository, got ${JSON.stringify(seen())}`);
390
- const repo = env.repository as Record<string, unknown>;
391
- // PRE-PR shape: `ref = base` (the harness checks out the base; each agent cuts its own feat/<node.id>).
392
- assertEquals(repo.ref, "main");
393
- assertEquals(repo.url, "https://github.com/owner/repo.git");
394
- assertEquals(repo.provider, "github");
395
- // Branch-scoped blobless clone (#287) so large monorepos provision within the clone timeout.
396
- assertEquals(repo.singleBranch, true);
397
- assertEquals(repo.filter, "blob:none");
398
- // baseRef = base too, so `origin/<base>` stays reachable for the review 3-dot diff.
399
- assertEquals(repo.baseRef, "main");
400
- // NO branch.create at the run root — a run fans out to many agent nodes, each needing its own
401
- // feat/<node.id>, so a single run-level envelope names none (mirrors the plan.ts epic seed).
402
- assertEquals("branch" in repo, false);
442
+ const headers = agentHeaders(r.prepared.bpmn);
443
+ assert(headers.some((h) => h.includes('repository.url" value="https://github.com/acme/one.git"')), "url present");
444
+ assert(!headers.some((h) => h.includes("repository.ref")), "no ref → clone the repo default branch");
445
+ assert(!headers.some((h) => h.includes("repository.baseRef")), "no baseRef either");
403
446
  });
404
447
 
405
- test("runDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
406
- const { engine, seen } = captureCreateInstanceVars();
407
- const r = await runDeliveryGraph(engine, GRAPH, { repoless: true });
408
- assert(r.ok, `expected ok:true for an explicit repoless run, got ${JSON.stringify(r)}`);
409
- assertEquals("io.nanobpm.agentTask" in seen(), false, "an explicit repoless run must emit no envelope");
448
+ test("prepareDeliveryGraph seeds NO envelope ONLY on an EXPLICIT repoless run — the conscious opt-out (#729)", async () => {
449
+ const p = await prepareOk(GRAPH, { repoless: true });
450
+ assert(!p.bpmn.includes("io.nanobpm.agentTask.repository"), "an explicit repoless run must emit no repository envelope");
451
+ assert(!p.bpmn.includes("__repoSpec"), "the marker is stripped on a repoless run too");
410
452
  });
411
453
 
412
- test("runDeliveryGraph THROWS on an unresolved repo/base when NOT repoless — never a silent shared launch dir (#729)", async () => {
413
- // Issue #729: the fan-out seed is REQUIRED. Dispatching without a resolvable repository + base branch
414
- // (and without the explicit `repoless` opt-out) must fail LOUDLY at seed time rather than silently
415
- // emit `{}` and degrade every agent job to the worker's shared launch dir (issue #684's field failure
416
- // re-opened as a silent fallback). Missing both, or only one of the pair, is unresolved.
417
- for (const options of [{}, { repository: "owner/repo" }, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
418
- const { engine } = captureCreateInstanceVars();
454
+ test("prepareDeliveryGraph THROWS on an unresolved repo/base when NOT repoless — never a silent shared launch dir (#729/#739)", async () => {
455
+ // Issue #729: the fan-out seed is REQUIRED. An agent cell that declares no repository AND has no
456
+ // run-level repository fallback (and no explicit `repoless` opt-out) must fail LOUDLY at prepare time
457
+ // rather than silently degrade to the worker's shared launch dir (issue #684's field failure). GRAPH's
458
+ // `open-b` node declares no repository. A run-level repository WITHOUT a base is NOT unresolved (#739:
459
+ // the cell clones the repo's default branch) only a missing/blank repository is unresolved.
460
+ for (const options of [{}, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
419
461
  await assertRejects(
420
- () => runDeliveryGraph(engine, GRAPH, options),
462
+ () => prepareDeliveryGraph(GRAPH, options),
421
463
  RepoEnvelopeUnresolvedError,
422
464
  );
423
465
  }
424
466
  });
425
467
 
426
- test("runDeliveryGraph THROWS on a malformed repository rather than emitting a bogus clone URL (#729)", async () => {
427
- const { engine } = captureCreateInstanceVars();
428
- // A value that is not exactly `owner/repo` (a trailing `.git`) is an UNRESOLVED input on the required
429
- // path it must throw, never degrade to a double-suffixed `…/owner/repo.git.git` clone URL nor to a
430
- // silent no-envelope launch-dir fallback.
468
+ test("a run-level repository WITHOUT a base branch RESOLVES every bare cell the default-branch clone (#739)", async () => {
469
+ // The #739 relaxation: an issue ref carries only `owner/repo`, no branch. A run-level repository with
470
+ // no base is a legitimate fallback each bare cell clones that repo's DEFAULT branch, no ref header.
471
+ const r = await prepareDeliveryGraph(GRAPH, { repository: "owner/repo" });
472
+ assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
473
+ const headers = agentHeaders(r.prepared.bpmn);
474
+ assert(headers.some((h) => h.includes('repository.url" value="https://github.com/owner/repo.git"')), "bare cell inherits run repo");
475
+ assert(!headers.some((h) => h.includes("repository.ref")), "no run base → clone the default branch");
476
+ });
477
+
478
+ test("prepareDeliveryGraph THROWS on a malformed run-level repository rather than emitting a bogus clone URL (#729)", async () => {
479
+ // A value that is not exactly `owner/repo` (a trailing `.git`) is an UNRESOLVED fallback — it must
480
+ // throw, never degrade to a double-suffixed `…/owner/repo.git.git` clone URL.
431
481
  await assertRejects(
432
- () => runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" }),
482
+ () => prepareDeliveryGraph(GRAPH, { repository: "owner/repo.git", baseBranch: "main" }),
433
483
  RepoEnvelopeUnresolvedError,
434
484
  );
435
485
  });
436
486
 
437
- test("runDeliveryGraph THROWS when repoless is combined with repository/baseBranch — never silently disables isolation (#729)", async () => {
487
+ test("a malformed NODE-declared repository is REJECTED at validation (#739)", async () => {
488
+ const graph: DeliveryGraph = {
489
+ name: "bad node repo",
490
+ nodes: [{ id: "a", kind: "agent", agent: { jobType: "senior:feature", prompt: "a", repository: "acme/one.git" } }],
491
+ edges: [],
492
+ };
493
+ // A node whose declared repository is malformed (a trailing `.git`) is caught by the semantic validator
494
+ // as `invalid-node-repository` — the graph never compiles, so it can never inject a bogus clone URL
495
+ // nor silently fall back to the run level (which would mask the operator's typo).
496
+ const r = await prepareDeliveryGraph(graph, { repository: "owner/repo", baseBranch: "main" });
497
+ assert(!r.ok, "a malformed node repository fails to prepare");
498
+ assert(r.errors.some((e) => e.path === "nodes[0].agent.repository"), `expected an invalid-node-repository error, got ${JSON.stringify(r.errors)}`);
499
+ });
500
+
501
+ test("prepareDeliveryGraph THROWS when repoless is combined with repository/baseBranch — never silently disables isolation (#729)", async () => {
438
502
  // `repoless: true` is mutually exclusive with `repository`/`baseBranch`. The dispatch door rejects the
439
503
  // conflicting shape with a 400, but a PROGRAMMATIC caller that bypasses the door could pass both — and
440
504
  // the runner would silently drop the repo/base and emit no envelope, re-disabling the exact isolation
441
- // the repo/base named. The runner must fail LOUDLY too (defense-in-depth), so isolation can never be
442
- // silently disabled by a conflicting call. Either half of the pair alongside `repoless` is a conflict.
505
+ // the repo/base named. The runner must fail LOUDLY too (defense-in-depth).
443
506
  for (const options of [
444
507
  { repoless: true, repository: "owner/repo", baseBranch: "main" },
445
508
  { repoless: true, repository: "owner/repo" },
446
509
  { repoless: true, baseBranch: "main" },
447
510
  ]) {
448
- const { engine } = captureCreateInstanceVars();
449
511
  await assertRejects(
450
- () => runDeliveryGraph(engine, GRAPH, options),
512
+ () => prepareDeliveryGraph(GRAPH, options),
451
513
  RepoEnvelopeConflictError,
452
514
  );
453
515
  }
@@ -18,9 +18,9 @@ import { createHash, randomUUID } from "node:crypto";
18
18
  import type { EngineClient } from "@nanobpm/urban";
19
19
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
- import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
21
+ import { AGENT_REPO_SPEC_HEADER, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
22
22
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
23
- import { RepoEnvelopeConflictError, requireRepoEnvelopeVars } from "./repoEnvelope.ts";
23
+ import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
24
24
  import { isoDuration } from "./reviewWait.ts";
25
25
 
26
26
  /** The content digest of a compiled graph — `sha256(semanticBpmn)[:12]` — the single source of truth
@@ -62,26 +62,34 @@ export interface DeliveryRunOptions extends DeliveryRunTimeouts {
62
62
  * cross-correlate. Pass an explicit `runKey` only when you need a reproducible/externally-owned gate
63
63
  * scope. */
64
64
  runKey?: string;
65
- /** OPTIONAL `owner/repo` the run's `agent` nodes implement against. Together with `baseBranch` the
66
- * runner seeds the canonical repository-provisioning envelope (`io.nanobpm.agentTask.repository`, via
67
- * `requireRepoEnvelopeVars`) as a run-root `createInstance` process variable so each `agent` cell's
68
- * servicing `senior:*` job provisions an ISOLATED throwaway clone instead of inheriting the worker's
69
- * launch dir (issue #684/#686 the same isolation the legacy feature/plan paths got in #685).
65
+ /** OPTIONAL run-level `owner/repo` DEFAULT for `agent` nodes that do not declare their own
66
+ * `repository`. Per issue #739 the repository-provisioning envelope
67
+ * (`io.nanobpm.agentTask.repository`) is seeded PER agent cell from that node's own declared
68
+ * `repository`/`baseBranch` (`injectAgentRepoEnvelopes`, POST-digest, onto each service task's
69
+ * `<zeebe:taskHeaders>`), with this run-level value as the FALLBACK when a node omits it so a
70
+ * heterogeneous cross-repo graph (each node a different repo) needs no run-level repo at all. Each
71
+ * cell's servicing `senior:*` job provisions an ISOLATED throwaway clone instead of inheriting the
72
+ * worker's launch dir (issue #684/#686 — the same isolation the legacy feature/plan paths got in
73
+ * #685).
70
74
  *
71
- * Issue #729: the envelope is now REQUIRED unless the run is EXPLICITLY `repoless`. A run that is not
72
- * `repoless` but supplies an unresolved/missing `repository`/`baseBranch` throws
73
- * `RepoEnvelopeUnresolvedError` at seed time (a loud launch failure) rather than silently degrading to
74
- * the shared launch-dir behaviour that let concurrent fan-out workers clobber one checkout. */
75
+ * Issue #729/#739: the envelope is REQUIRED (per node) unless the run is EXPLICITLY `repoless`. A run
76
+ * that is not `repoless` but has an `agent` node whose repository resolves to neither a declared value
77
+ * nor this run-level fallback throws `RepoEnvelopeUnresolvedError` at seed time (a loud launch failure)
78
+ * rather than silently degrading to the shared launch-dir behaviour that let concurrent fan-out
79
+ * workers clobber one checkout. */
75
80
  repository?: string | null;
76
- /** OPTIONAL base branch the run's `agent` nodes branch off the `ref` the harness checks out in the
77
- * isolated clone (the PRE-PR shape: no PR head exists yet, so the agent cuts its own `feat/<node.id>`
78
- * branch off this base inside the clone). Required (with `repository`) unless the run is `repoless`. */
81
+ /** OPTIONAL run-level base-branch DEFAULT for `agent` nodes that declare a `repository` but no
82
+ * `baseBranch` — the `ref` the harness checks out in the isolated clone (the PRE-PR shape: no PR head
83
+ * exists yet, so the agent cuts its own `feat/<node.id>` branch off this base inside the clone). A
84
+ * node with a resolvable repository but no base clones the repo's default branch, so this is a
85
+ * convenience default, not a hard requirement. */
79
86
  baseBranch?: string | null;
80
87
  /** EXPLICIT opt-out of repository provisioning (issue #729). `true` → the run is dispatched with NO
81
- * isolation envelope (the legacy launch-dir behaviour), for a genuinely repo-less graph (e.g. one with
82
- * no `agent` nodes that touch a checkout). This must be a CONSCIOUS choice at the dispatch door so the
83
- * default can never silently share a checkout: when it is not set, `repository` + `baseBranch` are
84
- * mandatory and an unresolved pair is a hard launch failure, not a silent no-envelope fallback. */
88
+ * isolation envelope on ANY node (the per-node headers are stripped, the legacy launch-dir behaviour),
89
+ * for a genuinely repo-less graph (e.g. one with no `agent` nodes that touch a checkout). This must be
90
+ * a CONSCIOUS choice at the dispatch door so the default can never silently share a checkout: when it
91
+ * is not set, every `agent` node must resolve a repository (its own or the run-level fallback) and an
92
+ * unresolved node is a hard launch failure, not a silent no-envelope fallback. */
85
93
  repoless?: boolean;
86
94
  }
87
95
 
@@ -140,7 +148,15 @@ export async function prepareDeliveryGraph(
140
148
 
141
149
  const digest = deliveryGraphDigest(compiled.semanticBpmn);
142
150
  const processDefinitionId = `${DELIVERY_GRAPH_PROCESS_ID}-${digest}`;
143
- const bpmn = rewriteProcessId(compiled.bpmn, processDefinitionId);
151
+ // Per-node repository isolation (#739): resolve each agent cell's EFFECTIVE repository/base (its own
152
+ // declared `repository`/`baseBranch`, else the run-level fallback) and INJECT the flattened
153
+ // `io.nanobpm.agentTask.*` envelope task headers onto each agent service task POST-digest, replacing
154
+ // the compiler's digest-stable `__repoSpec` marker. This runs after `rewriteProcessId` and is a pure
155
+ // string transform of the laid-out BPMN (the digest is taken over the pre-injection semantic model,
156
+ // so the env-dependent `cloneTimeoutMs` and the run-level fallback never enter the content address).
157
+ // Throws `RepoEnvelopeUnresolvedError` when some agent cell resolves to NO repository and the run is
158
+ // not `repoless` — a loud launch failure, never a silent launch-dir share (#684/#729).
159
+ const bpmn = injectAgentRepoEnvelopes(rewriteProcessId(compiled.bpmn, processDefinitionId), graph, options);
144
160
 
145
161
  const runKey = options.runKey?.trim() || randomUUID();
146
162
  // Normalize the run-level timeouts through isoDuration so a programmatic caller that bypasses the
@@ -177,39 +193,14 @@ export async function runDeliveryGraph(
177
193
  const { processDefinitionId, bpmn, nodeInputs } = prep.prepared;
178
194
 
179
195
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
180
- const base = typeof options.baseBranch === "string" && options.baseBranch.trim() !== "" ? options.baseBranch.trim() : null;
181
- const repo = typeof options.repository === "string" && options.repository.trim() !== "" ? options.repository.trim() : null;
182
- // Host-git provisioning (c8ctl, issue #684/#686/#729): resolve the ONE canonical repository envelope
183
- // (`app/repoEnvelope.ts`) BEFORE seeding so every `agent` node's servicing `senior:*` job gets an
184
- // ISOLATED throwaway clone instead of inheriting the worker's launch dir otherwise several copilot
185
- // workers on one host share (and clobber) a single checkout, the exact field failure #684 described.
186
- // This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single run-root
187
- // `createInstance` process variable that propagates through each agent cell's subProcess into its job.
188
- // Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run fans out to MANY
189
- // agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a single run-level
190
- // envelope can't name one; each agent cuts its own branch off `base` inside the isolated clone (the
191
- // agent-guide's `feat/*` convention, kept idempotent by the #551 preflight). `baseRef = base` too, so
192
- // the harness keeps `origin/<base>` reachable for the review 3-dot diff.
193
- //
194
- // Issue #729: the envelope is REQUIRED here unless the run is EXPLICITLY `repoless`. A run that is not
195
- // `repoless` but whose `repository`/`baseBranch` are unresolved throws `RepoEnvelopeUnresolvedError`
196
- // (a loud launch failure the dispatch door surfaces as a 400 and `dispatchDeliveryGraphRun` marks the
197
- // run `failed`) rather than silently emitting `{}` and degrading every agent to the shared launch dir.
198
- // Only an explicit `repoless: true` (a conscious operator opt-in for a genuinely repo-less graph)
199
- // dispatches with no envelope.
200
- //
201
- // `repoless: true` is MUTUALLY EXCLUSIVE with `repository`/`baseBranch`: the dispatch door already
202
- // rejects the conflicting shape with a 400, but a PROGRAMMATIC caller (test/internal) that bypasses
203
- // the door could pass both — and silently disable isolation (the `repoless` arm just drops the
204
- // repo/base and emits `{}`). Re-enforce the exclusivity HERE too (defense-in-depth, mirroring the
205
- // door) so a conflicting-but-well-meant call fails LOUDLY at seed time rather than quietly degrading
206
- // to the shared launch dir it named a repo to avoid.
207
- if (options.repoless === true && (repo !== null || base !== null)) {
208
- throw new RepoEnvelopeConflictError(
209
- `repoless run also named repository=${JSON.stringify(repo)} baseBranch=${JSON.stringify(base)}`,
210
- );
211
- }
212
- const repoVars = options.repoless === true ? {} : requireRepoEnvelopeVars(repo ?? "", base, base);
196
+ // Per-node repository isolation (#739): the `io.nanobpm.agentTask.repository` envelope is now seeded
197
+ // PER agent cell as a task header (injected into `bpmn` by `prepareDeliveryGraph`
198
+ // `injectAgentRepoEnvelopes`), NOT as a single run-root `createInstance` variable. A uniform run-root
199
+ // variable would resolve to ONE repository for every cell (wrong for a cross-repo graph) AND — because
200
+ // the harness lets a variable WIN over a header — would clobber each cell's per-node header. So NO
201
+ // repository variable is seeded here; the resolution + loud-failure invariant (unresolved agent cell
202
+ // on a non-`repoless` run) and the `repoless`/repo-base conflict guard all live in
203
+ // `injectAgentRepoEnvelopes`, which the prepare step above already ran (throwing before deploy).
213
204
  const { processInstanceKey } = await engine.createInstance({
214
205
  processDefinitionId,
215
206
  variables: {
@@ -219,9 +210,6 @@ export async function runDeliveryGraph(
219
210
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
220
211
  // every node — and read down into each agent job via `=transcriptUrlBase`.
221
212
  [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
222
- // Spread the resolved repository-isolation envelope (empty `{}` only on an explicit `repoless`
223
- // run — see above) LAST so it never clobbers the other run-root vars.
224
- ...repoVars,
225
213
  },
226
214
  });
227
215
  // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
@@ -245,6 +233,117 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
245
233
  .replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
246
234
  }
247
235
 
236
+ /** The EFFECTIVE repository/base resolved for one delivery-graph `agent` cell (#739): its OWN declared
237
+ * `repository`/`baseBranch`, falling back to the run-level dispatch value for either that it omits. */
238
+ export interface ResolvedAgentRepo {
239
+ /** The agent node's id (for diagnostics / the unresolved-node error). */
240
+ nodeId: string;
241
+ /** The effective `owner/repo` — the node's declared `repository`, else the run-level fallback, else null. */
242
+ repository: string | null;
243
+ /** The effective base branch — the node's declared `baseBranch`, else the run-level fallback, else null. */
244
+ baseBranch: string | null;
245
+ }
246
+
247
+ /** Trim a caller/authored string to a non-empty value or null (a blank/whitespace/absent field is "not
248
+ * declared", so it falls back to the run level). */
249
+ function trimOrNull(value: unknown): string | null {
250
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
251
+ }
252
+
253
+ /** Resolve every `agent` node's EFFECTIVE repository + base (#739): the node's own declared
254
+ * `repository`/`baseBranch` wins, else the run-level dispatch `repository`/`baseBranch` is the fallback
255
+ * default. Non-agent nodes carry no repository, so they are excluded. This is the ONE resolution rule
256
+ * the injection AND the unresolved-node invariant both derive from, so the two can never disagree on
257
+ * what a cell resolves to. */
258
+ export function resolveAgentNodeRepos(graph: DeliveryGraph, options: Pick<DeliveryRunOptions, "repository" | "baseBranch">): ResolvedAgentRepo[] {
259
+ const runRepo = trimOrNull(options.repository);
260
+ const runBase = trimOrNull(options.baseBranch);
261
+ const out: ResolvedAgentRepo[] = [];
262
+ for (const node of graph.nodes) {
263
+ if (node.kind !== "agent") continue;
264
+ out.push({
265
+ nodeId: node.id,
266
+ repository: trimOrNull(node.agent.repository) ?? runRepo,
267
+ baseBranch: trimOrNull(node.agent.baseBranch) ?? runBase,
268
+ });
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /** The ids of every `agent` node that resolves to NO usable repository (#739) — it declared none AND no
274
+ * run-level fallback applies (or the resolved value is not a plain `owner/repo`). On a non-`repoless`
275
+ * run these are the cells that would silently share the worker's launch dir, so the runner throws when
276
+ * this is non-empty (issue #684/#729). A graph in which EVERY agent node resolves a repository (declared
277
+ * or defaulted) is fully node-provisioned and needs neither a run-level repository nor `repoless`. */
278
+ export function unresolvedAgentRepoNodes(graph: DeliveryGraph, options: Pick<DeliveryRunOptions, "repository" | "baseBranch">): string[] {
279
+ return resolveAgentNodeRepos(graph, options)
280
+ .filter((r) => !isResolvableRepo(r.repository))
281
+ .map((r) => r.nodeId);
282
+ }
283
+
284
+ /** Escape a scalar value for an XML double-quote attribute (the injected `<zeebe:header>` value). The
285
+ * envelope values are URLs / `owner/repo` / `blob:none` / stringified booleans+numbers — none carry
286
+ * XML-hostile characters — but escape defensively so the injected BPMN is always well-formed. */
287
+ function xmlAttr(value: string): string {
288
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
289
+ }
290
+
291
+ /** Replace each `agent` service task's digest-stable `__repoSpec` marker task header (emitted by the
292
+ * compiler, carrying the node's DECLARED `{ repository, baseBranch }`) with the flattened, EFFECTIVE
293
+ * `io.nanobpm.agentTask.*` repository-isolation headers (#739) — the per-CELL channel the c8ctl harness
294
+ * reads (headers ∪ variables). The effective repo/base is the marker's declared value, else the
295
+ * run-level fallback in `options`. This is a PURE post-digest string transform of the laid-out BPMN
296
+ * (the marker is env-free graph content in the digest; the injected `cloneTimeoutMs` + run-level
297
+ * fallback are NOT), so the content address is unaffected.
298
+ *
299
+ * Invariants (issue #684/#729), enforced here so a run that cannot isolate fails LOUDLY before deploy:
300
+ * • `repoless: true` is mutually exclusive with a run-level `repository`/`baseBranch` — a caller that
301
+ * bypasses the dispatch door and passes both throws `RepoEnvelopeConflictError` (never silently
302
+ * disables isolation).
303
+ * • On a non-`repoless` run, ANY agent cell that resolves to no repository throws
304
+ * `RepoEnvelopeUnresolvedError` (never a silent launch-dir share).
305
+ * On a `repoless` run every marker is stripped (no envelope — the conscious checkout-less opt-out). */
306
+ function injectAgentRepoEnvelopes(bpmn: string, graph: DeliveryGraph, options: DeliveryRunOptions): string {
307
+ const repoless = options.repoless === true;
308
+ const runRepo = trimOrNull(options.repository);
309
+ const runBase = trimOrNull(options.baseBranch);
310
+ if (repoless && (runRepo !== null || runBase !== null)) {
311
+ throw new RepoEnvelopeConflictError(
312
+ `repoless run also named repository=${JSON.stringify(runRepo)} baseBranch=${JSON.stringify(runBase)}`,
313
+ );
314
+ }
315
+ if (!repoless) {
316
+ const unresolved = unresolvedAgentRepoNodes(graph, options);
317
+ if (unresolved.length > 0) {
318
+ throw new RepoEnvelopeUnresolvedError(
319
+ `agent node(s) resolve to no repository and the run is not repoless: ${unresolved.join(", ")} — ` +
320
+ "declare each node's `repository`, supply a run-level `repository`/`baseBranch` fallback, or dispatch `repoless: true`",
321
+ );
322
+ }
323
+ }
324
+ const markerKey = AGENT_REPO_SPEC_HEADER.replace(/[.]/g, "\\.");
325
+ // The compiler emits the marker as a single `<zeebe:taskHeaders>` block per agent task; match the whole
326
+ // block (with its indentation) so a stripped cell leaves no empty `<zeebe:taskHeaders/>` behind.
327
+ const blockRe = new RegExp(
328
+ `([ \\t]*)<zeebe:taskHeaders>\\r?\\n[ \\t]*<zeebe:header key="${markerKey}" value=(?:'([^']*)'|"([^"]*)")\\s*/>\\r?\\n[ \\t]*</zeebe:taskHeaders>(\\r?\\n)`,
329
+ "g",
330
+ );
331
+ return bpmn.replace(blockRe, (_full, indent: string, sq: string | undefined, dq: string | undefined, tail: string) => {
332
+ const raw = sq ?? (dq ?? "").replace(/&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
333
+ const declared: { repository: string | null; baseBranch: string | null } = JSON.parse(raw);
334
+ // `repoless` → strip the block entirely (no isolation envelope, the launch-dir fallback).
335
+ if (repoless) return "";
336
+ const effRepo = trimOrNull(declared.repository) ?? runRepo;
337
+ const effBase = trimOrNull(declared.baseBranch) ?? runBase;
338
+ // The unresolved invariant above guarantees a resolvable repo here on a non-repoless run.
339
+ const envelope = agentNodeRepoEnvelope(effRepo ?? "", effBase);
340
+ const flat = flattenAgentTaskEnvelope(envelope);
341
+ const headerLines = Object.entries(flat).map(([k, v]) => `${indent} <zeebe:header key="${xmlAttr(k)}" value="${xmlAttr(v)}" />`);
342
+ if (headerLines.length === 0) return "";
343
+ return `${indent}<zeebe:taskHeaders>\n${headerLines.join("\n")}\n${indent}</zeebe:taskHeaders>${tail}`;
344
+ });
345
+ }
346
+
248
347
  /** The idempotency preflight prepended to EVERY `agent` node's `appendPrompt` (issue #551). A delivery
249
348
  * agent node dispatches a raw `senior:feature` job with no `feature_runs` idempotency row and no
250
349
  * PR-existence guard, and the job carries retries — so an idle/timeout re-dispatch hands the SAME
@@ -13,7 +13,7 @@ import { isCommitSha } from "./world/index.ts";
13
13
 
14
14
  /** The reserved namespace key the c8ctl nano worker harness reads the agent-task envelope from
15
15
  * (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
16
- const AGENT_TASK_NS = "io.nanobpm.agentTask";
16
+ export const AGENT_TASK_NS = "io.nanobpm.agentTask";
17
17
 
18
18
  /** The ONE canonical `owner/repo` allowlist, shared by `repoEnvelopeVars` (which degrades to `{}` on a
19
19
  * miss) and `requireRepoEnvelopeVars` (which throws on a miss) so the two can never drift apart. The
@@ -25,6 +25,47 @@ function isPlainOwnerRepo(repo: string): boolean {
25
25
  return OWNER_REPO_RE.test(repo) && !/\.git$/i.test(repo);
26
26
  }
27
27
 
28
+ /** Exported predicate mirroring the internal `owner/repo` allowlist (the ONE shared by
29
+ * `repoEnvelopeVars`/`requireRepoEnvelopeVars`), so a caller that must resolve a per-node effective
30
+ * repository BEFORE seeding an envelope (delivery-graph per-node isolation, #739) uses exactly the same
31
+ * gate — a node whose declared/defaulted repository is not a plain `owner/repo` is "unresolved" by the
32
+ * identical rule the seed helper throws on, never a second divergent check. */
33
+ export function isResolvableRepo(repo: unknown): repo is string {
34
+ return typeof repo === "string" && isPlainOwnerRepo(repo.trim());
35
+ }
36
+
37
+ /** Flatten a repository-isolation envelope object (as built by {@link repoEnvelopeVars} —
38
+ * `{ "io.nanobpm.agentTask": { repository: { … } } }`) into the FLAT, dotted `io.nanobpm.agentTask.*`
39
+ * key → string-value pairs the c8ctl worker harness reads from a service task's `<zeebe:taskHeaders>`
40
+ * (the same channel the retired `io.nanobpm.agentTask.task.prompt` header used; the harness deep-merges
41
+ * headers ∪ variables and reconstructs the nested envelope from dotted keys — see `normalizeTaskEnvelope`).
42
+ *
43
+ * Header VALUES are strings (a Camunda job header is `string → string`), so scalar leaves are stringified
44
+ * (`true`/`600000`/`https://…`); the harness coerces them back at the point of use. This is the ONLY
45
+ * per-CELL delivery channel for the envelope: a `createInstance` variable can only seed the reserved
46
+ * `io.nanobpm.agentTask` key ONCE at the run root (uniform for every cell), and a `<zeebe:ioMapping>`
47
+ * target with dots creates a NESTED `{io:{nanobpm:{agentTask:…}}}` object the flat-key harness never
48
+ * reads — so per-node isolation (#739) rides task headers, injected by the runner post-digest. Returns an
49
+ * empty map for an empty/`{}` envelope (an unresolved/`repoless` cell → no headers, the launch-dir
50
+ * fallback). */
51
+ export function flattenAgentTaskEnvelope(envelope: Record<string, unknown>): Record<string, string> {
52
+ const out: Record<string, string> = {};
53
+ const walk = (prefix: string, value: unknown): void => {
54
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
55
+ for (const [k, v] of Object.entries(value)) {
56
+ walk(prefix === "" ? k : `${prefix}.${k}`, v);
57
+ }
58
+ return;
59
+ }
60
+ // Scalar leaf — a taskHeader value is a string, so stringify booleans/numbers verbatim; the harness
61
+ // coerces (`singleBranch` truthy, `cloneTimeoutMs` numeric) at the point of use.
62
+ if (typeof value === "string") out[prefix] = value;
63
+ else if (typeof value === "number" || typeof value === "boolean") out[prefix] = String(value);
64
+ };
65
+ walk("", envelope);
66
+ return out;
67
+ }
68
+
28
69
  /** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
29
70
  * path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
30
71
  * `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
@@ -154,6 +195,37 @@ export function repoEnvelopeVars(
154
195
  };
155
196
  }
156
197
 
198
+ /** Build the repository-isolation envelope for ONE delivery-graph `agent` cell (#739), keyed under the
199
+ * reserved {@link AGENT_TASK_NS} namespace exactly like {@link repoEnvelopeVars}, but with the delivery
200
+ * per-node semantics: the `repository.url` is emitted whenever `repo` is a plain `owner/repo` (so the
201
+ * harness clones it) EVEN WHEN no base branch is known, and `ref`/`baseRef` are emitted only when `base`
202
+ * is present. This is the key difference from {@link repoEnvelopeVars} (which emits NOTHING without a
203
+ * `ref`): a cross-repo graph node carries only its `owner/repo` (an issue ref names no branch), so when
204
+ * neither the node nor the run declares a base the cell must still provision an isolated clone — of the
205
+ * repository's DEFAULT branch. The harness `provisionRepo` omits `--branch` when `ref` is blank, so a
206
+ * `ref`-less envelope clones the default branch (each agent then cuts its own `feat/<node.id>` branch
207
+ * inside the isolated clone, unchanged). Returns `{}` for a `repo` that is not a plain `owner/repo` (an
208
+ * unresolved cell — the runner's invariant rejects the run before this degrades to a launch-dir share).
209
+ * Blobless single-branch shaping + `cloneTimeoutMs` (issues #287/#694) are emitted on every cell. */
210
+ export function agentNodeRepoEnvelope(repo: string, base: string | null): Record<string, unknown> {
211
+ if (!isPlainOwnerRepo(repo)) return {};
212
+ const ref = typeof base === "string" && base.trim() !== "" ? base.trim() : null;
213
+ return {
214
+ [AGENT_TASK_NS]: {
215
+ repository: {
216
+ provider: "github",
217
+ url: `https://github.com/${repo}.git`,
218
+ singleBranch: true,
219
+ filter: "blob:none",
220
+ cloneTimeoutMs: cloneTimeoutMs(),
221
+ // A per-node base (declared, or defaulted from the run level) — the branch the agent cuts its
222
+ // `feat/<node.id>` off. Omitted when unknown so the harness clones the repo's default branch.
223
+ ...(ref ? { ref, baseRef: ref } : {}),
224
+ },
225
+ },
226
+ };
227
+ }
228
+
157
229
  /** The REQUIRED-envelope guard for the fan-out dispatch paths (issue #729). Same signature and output
158
230
  * as `repoEnvelopeVars`, but THROWS `RepoEnvelopeUnresolvedError` on an unresolved input (a blank
159
231
  * base/head `ref`, or a `repo` that is not a plain `owner/repo`) instead of degrading to the silent
@@ -35,7 +35,9 @@ function handAuthored(behind: string | null, issues: string[]): AnyGraph {
35
35
  nodes.push({
36
36
  id: `open-${n}`,
37
37
  kind: "agent",
38
- agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.` },
38
+ // #739: the generator stamps each agent cell with the issue's OWN repository, so a multi-repo
39
+ // sequence provisions each cell's isolation envelope from that issue's repo.
40
+ agent: { jobType: "senior:feature", prompt: `Implement ${issue} and open a PR.`, repository: issue.split("#")[0] },
39
41
  emits: [{ name: "pr", type: "pr" }],
40
42
  });
41
43
  nodes.push({ id: `land-${n}`, kind: "connector", connector: { target: "converge-merge", payload: { pr: `open-${n}.pr` } } });
@@ -106,6 +108,15 @@ test("sequenceIssues: each issue emits agent(senior:feature,emits pr) → connec
106
108
  assert(graph.edges.some((e) => e.from === "open-1.pr" && e.to === "merged-1"));
107
109
  });
108
110
 
111
+ test("sequenceIssues: each agent cell is stamped with its OWN issue's repository — a cross-repo sequence (#739)", () => {
112
+ // Two issues in DIFFERENT repos → each `open-*` agent node declares that issue's repository, so the
113
+ // delivery runner provisions each cell's isolation envelope from its own repo (no uniform run-level
114
+ // repository, no `repoless`). This is the generator half of #739.
115
+ const graph = ok({ issues: ["acme/one#1", "beta/two#2"] });
116
+ assertEquals(graph.nodes.find((n) => n.id === "open-1").agent.repository, "acme/one");
117
+ assertEquals(graph.nodes.find((n) => n.id === "open-2").agent.repository, "beta/two");
118
+ });
119
+
109
120
  test("sequenceIssues: merge/epic gates carry a realistic poll budget (not the 30-min default trap)", () => {
110
121
  const graph = ok({ behind: "acme/repo#9", issues: ["acme/repo#1"] });
111
122
  const gate = graph.nodes.find((n) => n.id === "gate-epic");
@@ -107,9 +107,10 @@ interface BuiltGate {
107
107
  credentialEnv?: string;
108
108
  }
109
109
 
110
- /** A parsed `issues[]` entry: the normalised issue plan-key plus its optional interleaved gate. */
110
+ /** A parsed `issues[]` entry: the parsed issue (carrying its `owner/repo#N` target, used to stamp the
111
+ * generated agent node's own `repository` — issue #739) plus its optional interleaved gate (#740). */
111
112
  interface ParsedEntry {
112
- issueKey: string;
113
+ issue: ParsedIssue;
113
114
  gate: BuiltGate | null;
114
115
  }
115
116
 
@@ -279,7 +280,7 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
279
280
  issues.push(...parsedGate.errors);
280
281
  gate = parsedGate.gate ?? null;
281
282
  }
282
- parsedEntries.push({ issueKey: parsed.planKey, gate });
283
+ parsedEntries.push({ issue: parsed, gate });
283
284
  });
284
285
  }
285
286
 
@@ -338,7 +339,8 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
338
339
  /** Assemble the canonical node/edge chain for the (already-validated) parsed entries + optional gate.
339
340
  * Each entry emits `agent → connector[converge-merge] → wait[pr, merged]`; an entry with an
340
341
  * interleaved `gate` gets a leading `wait[<gate.kind>]` node spliced between the prior sequence step
341
- * and its agent, so the agent starts only once the prior issue merged AND the gate went green. */
342
+ * and its agent, so the agent starts only once the prior issue merged AND the gate went green. Each
343
+ * agent node is stamped with its own `repository` (issue #739) from the issue it implements. */
342
344
  function assembleGraph(entries: ParsedEntry[], behindKey: string | null): DeliveryGraph {
343
345
  const nodes: DeliveryNode[] = [];
344
346
  const edges: DeliveryEdge[] = [];
@@ -361,12 +363,13 @@ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): Delive
361
363
  }
362
364
 
363
365
  entries.forEach((entry, i) => {
366
+ const issue = entry.issue;
364
367
  const n = i + 1;
365
368
  const openId = `open-${n}`;
366
369
  const landId = `land-${n}`;
367
370
  const mergedId = `merged-${n}`;
368
371
  const prRef = `${openId}.pr`;
369
- const issueKey = entry.issueKey;
372
+ const issueKey = issue.planKey;
370
373
 
371
374
  // The sequence predecessor whose completion releases THIS issue: the prior issue's merge, else the
372
375
  // leading epic gate for the first issue (null when the first issue is ungated by `behind`).
@@ -395,11 +398,14 @@ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): Delive
395
398
  agentPredecessor = gateId;
396
399
  }
397
400
 
398
- // agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
401
+ // agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind. Stamp the
402
+ // node's OWN `repository` (#739) from the `owner/repo#N` it implements, so a cross-repo sequence
403
+ // provisions each cell's isolation envelope node-locally — no uniform run-level repo, no `repoless`.
404
+ // The base branch is left to the run-level fallback (an issue ref names no branch — issue #739 Notes).
399
405
  nodes.push({
400
406
  id: openId,
401
407
  kind: "agent",
402
- agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.` },
408
+ agent: { jobType: AGENT_JOB_TYPE, prompt: `Implement ${issueKey} and open a PR.`, repository: issue.repo },
403
409
  emits: [{ ...PR_EMIT }],
404
410
  });
405
411
  // connector[converge-merge] → drive the opened PR through review convergence + the merge loop.
@@ -433,7 +439,7 @@ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): Delive
433
439
  const gateCount = entries.filter((e) => e.gate).length;
434
440
  const name =
435
441
  entries.length === 1
436
- ? `sequence ${entries[0].issueKey}`
442
+ ? `sequence ${entries[0].issue.planKey}`
437
443
  : `sequence ${entries.length} issues${gateCount > 0 ? ` with ${gateCount} gate${gateCount === 1 ? "" : "s"}` : ""}${behindKey ? ` behind ${behindKey}` : ""}`;
438
444
 
439
445
  return { name, nodes, edges };
package/openapi.yaml CHANGED
@@ -1849,6 +1849,30 @@ components:
1849
1849
  type: string
1850
1850
  maxLength: 20000
1851
1851
  description: OPTIONAL steering prompt appended to the node's job brief.
1852
+ repository:
1853
+ type: string
1854
+ maxLength: 255
1855
+ pattern: '^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$'
1856
+ description: >-
1857
+ OPTIONAL per-node `owner/repo` this agent node implements against (#739). A delivery
1858
+ graph provisions the `io.nanobpm.agentTask.repository` isolation envelope PER agent
1859
+ cell from THIS field, so a genuinely cross-repo graph (each node a different repo)
1860
+ isolates correctly without the operator ticking `repoless`. Absent → the node falls
1861
+ back to the run-level dispatch `repository`. When set it must be exactly `owner/repo`
1862
+ (a trailing `.git` and any non-`owner/repo` shape are rejected at submit), the same
1863
+ allowlist `repoEnvelopeVars`/the dispatch door apply. The `sequenceIssues` generator
1864
+ populates this automatically from the `owner/repo#N` each node implements.
1865
+ baseBranch:
1866
+ type: string
1867
+ maxLength: 255
1868
+ pattern: '^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$'
1869
+ description: >-
1870
+ OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the
1871
+ harness checks out in the isolated clone (the pre-PR shape: the agent cuts its own
1872
+ `feat/<node.id>` branch off this base). Absent → the run-level dispatch `baseBranch`
1873
+ (else the node's repository default branch). A value that is not a plausible git
1874
+ branch name is rejected at submit; the pattern mirrors the authoritative server-side
1875
+ gate (`isPlausibleBranchName`, app/baseBranch.ts).
1852
1876
  converge:
1853
1877
  type: boolean
1854
1878
  description: >-
@@ -2098,17 +2122,20 @@ components:
2098
2122
  action — this request carries NO graph and NO token (the graph is already staged; the operator's
2099
2123
  click is the approval).
2100
2124
 
2101
- Repository provisioning is REQUIRED by default (#729): the run either names EXACTLY the
2102
- `repository` + `baseBranch` its `agent` nodes implement against, OR opts out with `repoless: true`
2103
- for a genuinely checkout-less graph — never both, never neither. Modeled as `oneOf` named variants
2104
- (Camunda REST v2 pattern, as the convergence/plan/feature start bodies are) so the runtime rejects
2105
- the "neither/nor" and "both" shapes AT THE EDGE with a 400 that names the allowed shapes, rather
2106
- than the delegate being the only thing that enforces the mutual exclusivity. Dispatching without
2107
- either would silently share the worker's launch dir across concurrent fan-out agents (the issue
2108
- #684 field failure).
2125
+ Repository provisioning is REQUIRED by default (#729) unless the graph is FULLY node-provisioned
2126
+ (#739). The run EITHER names the run-level `repository` + `baseBranch` its `agent` nodes fall back
2127
+ to, OR opts out with `repoless: true` for a genuinely checkout-less graph, OR when EVERY `agent`
2128
+ node declares its OWN `repository` (a cross-repo graph, #739) supplies NEITHER (the node-
2129
+ provisioned shape). Modeled as `oneOf` named variants (Camunda REST v2 pattern, as the
2130
+ convergence/plan/feature start bodies are) so the runtime rejects the "both" shape AT THE EDGE
2131
+ with a 400 that names the allowed shapes. The remaining invariant that no `agent` node resolves
2132
+ to NO repository on a non-`repoless` run — is enforced after the staged graph is loaded (the door
2133
+ has the graph; the request alone does not), so a graph with an unprovisioned node still fails
2134
+ loudly rather than silently sharing the worker's launch dir (the issue #684 field failure).
2109
2135
  oneOf:
2110
2136
  - $ref: "#/components/schemas/DeliveryGraphDispatchWithRepository"
2111
2137
  - $ref: "#/components/schemas/DeliveryGraphDispatchRepoless"
2138
+ - $ref: "#/components/schemas/DeliveryGraphDispatchNodeProvisioned"
2112
2139
  DeliveryGraphDispatchWithRepository:
2113
2140
  description: >-
2114
2141
  The repository-provisioned dispatch shape (#729): names the `repository` + `baseBranch` the run's
@@ -2228,6 +2255,52 @@ components:
2228
2255
  fields are not members of this variant, so supplying either alongside `repoless: true` fails
2229
2256
  `oneOf` matching and is a 400. To provision a repository, use the repository variant (supply
2230
2257
  `repository` + `baseBranch`) and omit `repoless` entirely.
2258
+ DeliveryGraphDispatchNodeProvisioned:
2259
+ description: >-
2260
+ The FULLY node-provisioned dispatch shape (#739): supplies NEITHER a run-level `repository`/
2261
+ `baseBranch` NOR `repoless`, for a cross-repo graph in which EVERY `agent` node declares its own
2262
+ `repository` (so the run needs no uniform fallback and no checkout-less opt-out). The runner
2263
+ seeds each agent cell's `io.nanobpm.agentTask.repository` isolation envelope from that node's own
2264
+ declared repository. The post-load invariant still holds: if ANY `agent` node resolves to no
2265
+ repository under this shape (it declared none and there is no run-level fallback), the dispatch
2266
+ fails loudly with a 400 rather than silently sharing the launch dir — so this shape is only valid
2267
+ for a graph whose every agent node is self-provisioned. `repository`/`baseBranch`/`repoless` are
2268
+ not members of this variant (`additionalProperties: false`), so supplying any of them selects a
2269
+ different variant instead.
2270
+ type: object
2271
+ additionalProperties: false
2272
+ required:
2273
+ - digest
2274
+ properties:
2275
+ digest:
2276
+ type: string
2277
+ description: The staged proposal's content digest (its primary key) — the proposal to dispatch.
2278
+ idempotencyKey:
2279
+ type: string
2280
+ maxLength: 255
2281
+ description: OPTIONAL idempotency key. A re-dispatch with the same key (or, when omitted, the same digest) does not double-launch. Blank/whitespace is treated as absent.
2282
+ nodeTimeout:
2283
+ type: string
2284
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
2285
+ maxLength: 64
2286
+ description: >-
2287
+ OPTIONAL run-level ISO-8601 SLA timeout for `agent`/`connector` nodes (#505) — the
2288
+ bounded-timeout → escalate boundary bound every such node inherits unless it declares its own
2289
+ per-node `timeout`. Absent → the `PT1H` default. An invalid duration is rejected at submit.
2290
+ probeTimeout:
2291
+ type: string
2292
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
2293
+ maxLength: 64
2294
+ description: >-
2295
+ OPTIONAL run-level ISO-8601 poll budget for `wait` gates (#505) before they escalate. Absent →
2296
+ the `PT30M` default. An invalid duration is rejected at submit.
2297
+ escalationSlaTimeout:
2298
+ type: string
2299
+ pattern: '^[Pp](?!$)(\d+[Yy])?(\d+[Mm])?(\d+[Ww])?(\d+[Dd])?([Tt](?=\d)(\d+[Hh])?(\d+[Mm])?(\d+[Ss])?)?$'
2300
+ maxLength: 64
2301
+ description: >-
2302
+ OPTIONAL run-level ISO-8601 SLA for `human` nodes (#505) before they record an `escalated`
2303
+ outcome. Absent → the `P1D` default. An invalid duration is rejected at submit.
2231
2304
  DeliveryGraphDismissRequest:
2232
2305
  description: >-
2233
2306
  The OPERATOR dismiss request (#520). The cockpit's staged-proposals grid posts the content
@@ -4528,6 +4601,16 @@ paths:
4528
4601
  type: string
4529
4602
  maxLength: 20000
4530
4603
  description: OPTIONAL steering prompt appended to the node's job brief.
4604
+ repository:
4605
+ type: string
4606
+ maxLength: 255
4607
+ pattern: ^[A-Za-z0-9-]+/(?!.*\.[Gg][Ii][Tt]$)[A-Za-z0-9._-]+$
4608
+ description: OPTIONAL per-node `owner/repo` this agent node implements against (#739). A delivery graph provisions the `io.nanobpm.agentTask.repository` isolation envelope PER agent cell from THIS field, so a genuinely cross-repo graph (each node a different repo) isolates correctly without the operator ticking `repoless`. Absent → the node falls back to the run-level dispatch `repository`. When set it must be exactly `owner/repo` (a trailing `.git` and any non-`owner/repo` shape are rejected at submit), the same allowlist `repoEnvelopeVars`/the dispatch door apply. The `sequenceIssues` generator populates this automatically from the `owner/repo#N` each node implements.
4609
+ baseBranch:
4610
+ type: string
4611
+ maxLength: 255
4612
+ pattern: ^(?![/.-])(?!.*[/.]$)(?!.*\.\.)(?!.*//)(?!.*/\.)(?!.*\.lock(?:/|$))[A-Za-z0-9._/-]+$
4613
+ description: "OPTIONAL per-node base branch this agent node branches off (#739) — the `ref` the harness checks out in the isolated clone (the pre-PR shape: the agent cuts its own `feat/<node.id>` branch off this base). Absent → the run-level dispatch `baseBranch` (else the node's repository default branch). A value that is not a plausible git branch name is rejected at submit; the pattern mirrors the authoritative server-side gate (`isPlausibleBranchName`, app/baseBranch.ts)."
4531
4614
  converge:
4532
4615
  type: boolean
4533
4616
  description: 'OPTIONAL first-class CONVERGE policy (ADR 0006 §3 / S5) — a DECLARED, compiler- validated completion-policy flag on this cell node. It declares that the node''s opened PR is to be driven through the review-convergence loop to green as an edge-gated completion policy; this slice adds and validates the flag, with the delivery-graph execution wiring that consumes it landing in a follow-up slice. It supersedes (in intent) the emergent `feature.bpmn` `gw-converge` gateway and the "un-draft + merge #B" prompt prose a delivery-graph `agent` node used to smuggle. Converge and merge are SEPARABLE phases; a node may converge without merging (stop at green and gate the landing behind a downstream node).'
@@ -318,23 +318,69 @@ describe("dispatchDeliveryGraph — operator dispatch by staged-proposal digest"
318
318
  // dispatch envelope-less and let concurrent fan-out agents share (and clobber) the worker's launch
319
319
  // dir (issue #684's field failure re-opened as a silent fallback). This "neither/nor" shape is now
320
320
  // rejected at the EDGE by the request `oneOf` (the two allowed variants), not only inside the door.
321
- test("a dispatch with NEITHER repository/baseBranch NOR repoless is rejected at submit → 400, nothing launched (#729)", async () => {
321
+ test("a bare dispatch of a graph with an UNPROVISIONED agent node is rejected → 400, nothing launched (#729/#739)", async () => {
322
322
  const app = await boot();
323
323
  assert.ok(app.api);
324
324
  const api = app.api;
325
- const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
325
+ // SIDE_EFFECTING's `open-b` agent node declares NO repository, and this bare dispatch supplies no
326
+ // run-level fallback and no `repoless` — so the cell would silently share the worker's launch dir.
327
+ // The door rejects it LOUDLY (#729) rather than launching or degrading (#684).
328
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: SIDE_EFFECTING });
326
329
  const res = await api.call<{ ok?: boolean; error?: string }>("dispatchDeliveryGraph", {
327
330
  body: { digest: staged.body.digest },
328
331
  });
329
332
  assert.equal(res.status, 400);
330
- // The `oneOf` edge validator names the two allowed shapes — the neither/nor body matches neither.
331
- assert.ok(typeof res.body.error === "string" && res.body.error.length > 0);
333
+ assert.ok(typeof res.body.error === "string" && res.body.error.includes("open-b"), `error should name the unprovisioned node, got ${res.body.error}`);
332
334
  // Loud, not silent: nothing launched and the proposal stays staged (re-dispatchable once a repo /
333
335
  // repoless choice is supplied).
334
336
  assert.equal((await deliveryGraphRuns(app.db).all()).length, 0);
335
337
  assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "staged");
336
338
  });
337
339
 
340
+ test("a bare dispatch of a HUMAN-ONLY graph (nothing to provision) SUCCEEDS → 202 running (#739)", async () => {
341
+ const app = await boot();
342
+ assert.ok(app.api);
343
+ const api = app.api;
344
+ // A graph with no `agent` nodes needs neither a run-level repository nor `repoless` — there is nothing
345
+ // to isolate. Under #739 a bare `{ digest }` dispatch (the node-provisioned variant) launches cleanly.
346
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: HUMAN_ONLY });
347
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
348
+ body: { digest: staged.body.digest },
349
+ });
350
+ assert.equal(res.status, 202);
351
+ assert.equal(res.body.ok, true);
352
+ assert.equal(res.body.status, "running");
353
+ await app.settle();
354
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
355
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
356
+ });
357
+
358
+ test("a bare dispatch of a fully NODE-PROVISIONED agent graph SUCCEEDS → 202 running (#739)", async () => {
359
+ const app = await boot();
360
+ assert.ok(app.api);
361
+ const api = app.api;
362
+ // Every agent node declares its OWN repository, so the graph self-provisions each cell's isolation
363
+ // envelope — no run-level repository, no `repoless`. This is the cross-repo delivery graph #739 enables.
364
+ const NODE_PROVISIONED = {
365
+ name: "cross-repo",
366
+ nodes: [
367
+ { id: "open-b", kind: "agent", agent: { jobType: "senior:demo", prompt: "implement", repository: "acme/one" } },
368
+ { id: "publish", kind: "human", human: { prompt: "publish" } },
369
+ ],
370
+ edges: [{ from: "open-b", to: "publish" }],
371
+ };
372
+ const staged = await api.call<{ digest: string }>("compileDeliveryGraph", { body: NODE_PROVISIONED });
373
+ const res = await api.call<{ ok: boolean; status: string }>("dispatchDeliveryGraph", {
374
+ body: { digest: staged.body.digest },
375
+ });
376
+ assert.equal(res.status, 202);
377
+ assert.equal(res.body.ok, true);
378
+ assert.equal(res.body.status, "running");
379
+ await app.settle();
380
+ assert.equal((await deliveryGraphRuns(app.db).all()).length, 1);
381
+ assert.equal((await deliveryGraphProposals(app.db).get(staged.body.digest))?.status, "dispatched");
382
+ });
383
+
338
384
  test("a dispatch with ONLY repository (no baseBranch) is rejected → 400, nothing launched (#729)", async () => {
339
385
  const app = await boot();
340
386
  assert.ok(app.api);
@@ -11,10 +11,12 @@
11
11
  // superseded / already-dispatched digest is a clean 400.
12
12
 
13
13
  import { isPlausibleBranchName } from "../app/baseBranch.ts";
14
+ import { validateDeliveryGraph } from "../app/deliveryGraph.ts";
14
15
  import { dispatchDeliveryGraphRun } from "../app/deliveryGraphDispatch.ts";
15
16
  import { getStagedProposal, markProposalDispatched, markProposalExpired } from "../app/deliveryGraphProposals.ts";
17
+ import { unresolvedAgentRepoNodes } from "../app/deliveryRunner.ts";
16
18
  import { isValidIsoDuration } from "../app/reviewWait.ts";
17
- import type { DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
19
+ import type { DeliveryGraph, DeliveryGraphTextResult } from "../nano-generated/api-io.d.ts";
18
20
  import { defineOperation } from "../nano-generated/operations.ts";
19
21
 
20
22
  /** Cap an untrusted, rejected duration string before it is echoed into logs/response bodies. `openapi.yaml`
@@ -108,28 +110,27 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
108
110
  baseBranch = baseRaw;
109
111
  }
110
112
 
111
- // Repository provisioning is REQUIRED on this fan-out door (issue #729): a delivery-graph run whose
112
- // `agent` nodes implement against a repo MUST be dispatched with a resolvable `repository` + base
113
- // branch so every `senior:*` job provisions an ISOLATED clone. Silently dispatching envelope-less
114
- // (issue #684's field failure, re-opened as a silent fallback) let concurrent fan-out workers on one
115
- // host share and clobber a single launch-dir checkout. So the operator must EITHER supply BOTH
116
- // `repository` and `baseBranch`, OR explicitly opt out with `repoless: true` for a genuinely
117
- // checkout-less graph the default can never silently share a checkout.
113
+ // Repository provisioning (issue #729 / #739): a delivery-graph run whose `agent` nodes implement
114
+ // against a repo MUST provision an ISOLATED clone per cell silently dispatching envelope-less (issue
115
+ // #684's field failure) let concurrent fan-out workers on one host share and clobber a single launch
116
+ // dir. The operator supplies EITHER a run-level `repository` + `baseBranch` (the fallback default for
117
+ // nodes that don't declare their own), OR `repoless: true` for a genuinely checkout-less graph, OR
118
+ // when every agent node declares its OWN `repository` (#739) NEITHER. The remaining invariant (no
119
+ // agent node resolves to NO repository on a non-`repoless` run) is checked AFTER the staged graph is
120
+ // loaded below, since the request alone cannot see whether every node is self-provisioned.
118
121
  const hasRepoless = body !== null && typeof body === "object" && "repoless" in body;
119
122
  const repolessRaw = hasRepoless ? body.repoless : undefined;
120
123
  // `repoless` is a `true`-ONLY opt-out, exactly as the OpenAPI `oneOf` models it: it is `enum: [true]`
121
- // on the repoless variant and NOT a member of the repository variant (`additionalProperties: false`).
124
+ // on the repoless variant and NOT a member of the other variants (`additionalProperties: false`).
122
125
  // So reject any present-but-not-`true` value — `false`, `"yes"`, `0`, … — rather than silently folding
123
- // `repoless: false` into the "not repoless" path. Otherwise a caller that bypasses schema validation
124
- // could send `{ digest, repository, baseBranch, repoless: false }` (or `{ digest, repoless: false }`)
125
- // and slip through with a confusing tri-state the `oneOf` contract never permits.
126
+ // `repoless: false` into the "not repoless" path.
126
127
  if (hasRepoless && repolessRaw !== true) {
127
128
  app.log.warn("dispatch-delivery-graph rejected: repoless is a true-only opt-out", { type: typeof repolessRaw });
128
129
  return {
129
130
  status: 400,
130
131
  body: {
131
132
  ok: false,
132
- error: "`repoless` is a `true`-only opt-out — omit it to provision a repository (with `repository` + `baseBranch`), or set `repoless: true` to dispatch a checkout-less graph",
133
+ error: "`repoless` is a `true`-only opt-out — omit it to provision a repository (with `repository` + `baseBranch`, or per-node declared repositories), or set `repoless: true` to dispatch a checkout-less graph",
133
134
  },
134
135
  };
135
136
  }
@@ -141,20 +142,6 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
141
142
  body: { ok: false, error: "`repoless: true` is mutually exclusive with `repository`/`baseBranch` — pass one or the other, not both" },
142
143
  };
143
144
  }
144
- if (!repoless && (repository === undefined || baseBranch === undefined)) {
145
- app.log.warn("dispatch-delivery-graph rejected: missing repository/baseBranch (no repoless opt-in)", {
146
- hasRepository: repository !== undefined,
147
- hasBaseBranch: baseBranch !== undefined,
148
- });
149
- return {
150
- status: 400,
151
- body: {
152
- ok: false,
153
- error:
154
- "a delivery-graph dispatch must provision an isolated checkout: supply BOTH `repository` (`owner/repo`) and `baseBranch`, or set `repoless: true` to dispatch a checkout-less graph — dispatching without either would silently share the worker's launch dir across agents (issue #729)",
155
- },
156
- };
157
- }
158
145
 
159
146
  // Load the live staged proposal for this digest — refuses an unknown/expired/superseded/already-
160
147
  // dispatched digest cleanly (no run is launched).
@@ -179,6 +166,36 @@ export default defineOperation("dispatchDeliveryGraph", async ({ body }, app) =>
179
166
  return { status: 400, body: { ok: false, error: `staged proposal ${digest} is corrupt: ${err instanceof Error ? err.message : String(err)}` } };
180
167
  }
181
168
 
169
+ // Per-node provisioning invariant (#739): now that the graph is loaded, reject a non-`repoless` run
170
+ // in which some `agent` node resolves to NO repository — it declared none AND no run-level fallback
171
+ // was supplied. Such a cell would silently share the worker's launch dir (issue #684), so fail loudly
172
+ // with a clean 400 here rather than surfacing later as a launch-time throw (a 500). A graph in which
173
+ // EVERY agent node resolves a repository (declared, or defaulted from the run level) passes — a fully
174
+ // node-provisioned cross-repo graph needs neither a run-level repository nor `repoless`. The runner
175
+ // re-enforces this at seed time (defense in depth). Guarded on a well-formed graph; a malformed one
176
+ // falls through to `dispatchDeliveryGraphRun`'s own validation below.
177
+ if (!repoless) {
178
+ const graphErrors = validateDeliveryGraph(graph);
179
+ if (graphErrors.length === 0) {
180
+ // biome-ignore lint/plugin: validated staged graph narrowed to its contract after validateDeliveryGraph
181
+ const typedGraph = graph as DeliveryGraph;
182
+ const unresolved = unresolvedAgentRepoNodes(typedGraph, { repository, baseBranch });
183
+ if (unresolved.length > 0) {
184
+ app.log.warn("dispatch-delivery-graph rejected: unprovisioned agent node(s)", { digest, unresolved });
185
+ return {
186
+ status: 400,
187
+ body: {
188
+ ok: false,
189
+ error:
190
+ `${unresolved.length} agent node(s) resolve to no repository (${unresolved.join(", ")}): each must declare its own ` +
191
+ "`repository`, or the dispatch must supply a run-level `repository` + `baseBranch` fallback, or set `repoless: true` " +
192
+ "for a genuinely checkout-less graph — dispatching an unprovisioned node would silently share the worker's launch dir (issue #684/#739)",
193
+ },
194
+ };
195
+ }
196
+ }
197
+ }
198
+
182
199
  const dispatched = await dispatchDeliveryGraphRun(app, graph, { runKey: idempotencyKey, title: proposal.title, repository, baseBranch, repoless, ...timeouts });
183
200
  if (!dispatched.ok) {
184
201
  app.log.warn("dispatch-delivery-graph refused: compile", { digest, errors: dispatched.errors.length });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.181.0",
3
+ "version": "0.182.1",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@nanobpm/agentic": "^0.12.0",
68
- "@nanobpm/urban": "^0.91.0",
68
+ "@nanobpm/urban": "^0.92.0",
69
69
  "bpmn-auto-layout": "^2.0.0-alpha.2"
70
70
  },
71
71
  "devDependencies": {