@nanobpm/nano-workforce 0.180.0 → 0.182.0

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.
@@ -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