@nanobpm/nano-workforce 0.178.4 → 0.179.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.
@@ -17,6 +17,18 @@ on:
17
17
  # `main`'s HEAD (the merge_group checks are attached to that commit), so a
18
18
  # manual recovery still never ships from a red `main`.
19
19
  workflow_dispatch:
20
+ # Self-heal a dropped `push:main` event (#725). The `push`→`workflow_run` chain
21
+ # is the ONLY automatic release path, so a single dropped event (a transient
22
+ # Actions delivery hiccup, or the release-App bypass push racing the queue —
23
+ # both hit #722/#723) wedges the release until a human dispatches. This periodic
24
+ # tick re-runs the SAME green-gate against `main` HEAD (`github.sha`) and lets
25
+ # `semantic-release` — which is idempotent (no-ops when nothing is releasable,
26
+ # never republishes an existing version) — backfill the missed run automatically.
27
+ # It never ships from a red `main` (same required-check gate) and the gate's
28
+ # schedule-scoped dedup below runs `semantic-release` at most once per HEAD SHA,
29
+ # so a healthy `main` costs only the tiny gate job per tick.
30
+ schedule:
31
+ - cron: "*/15 * * * *"
20
32
 
21
33
  # Prevent overlapping releases from racing on the same branch.
22
34
  concurrency:
@@ -37,10 +49,12 @@ jobs:
37
49
  runs-on: ubuntu-latest
38
50
  if: >-
39
51
  github.event_name == 'workflow_dispatch' ||
52
+ github.event_name == 'schedule' ||
40
53
  (github.event.workflow_run.event == 'push' &&
41
54
  github.event.workflow_run.head_branch == 'main' &&
42
55
  github.event.workflow_run.conclusion == 'success')
43
56
  permissions:
57
+ actions: read # schedule dedup: list this workflow's prior runs for the HEAD SHA
44
58
  checks: read
45
59
  contents: read
46
60
  outputs:
@@ -52,6 +66,7 @@ jobs:
52
66
  GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
53
67
  SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
54
68
  REPO: ${{ github.repository }}
69
+ EVENT: ${{ github.event_name }}
55
70
  run: |
56
71
  set -euo pipefail
57
72
  # These must ALL be `success` on the release commit before we ship.
@@ -77,6 +92,25 @@ jobs:
77
92
  echo "gate: '$name' = ${concl:-missing}"
78
93
  [ "${concl:-missing}" = "success" ] || should=false
79
94
  done
95
+ # Schedule-scoped dedup (#725). The scheduled tick exists ONLY to backfill a
96
+ # release whose `push:main` trigger was dropped. If any prior Release run has
97
+ # already reached a terminal `success` for this exact HEAD SHA, the analyzer
98
+ # already ran (via `push`/`workflow_dispatch`, or an earlier tick) — the tree
99
+ # is unchanged, so re-running would just spin an idempotent no-op every tick
100
+ # (e.g. when HEAD is a green no-release `chore:`/`ci:` commit). This dedup is
101
+ # gated to `schedule` so it never perturbs the push path's two-trigger race,
102
+ # where the neutral early trigger also concludes `success` and the LATE
103
+ # trigger must still be allowed to release. The in-progress current run is not
104
+ # yet `success`, so it never counts itself out.
105
+ if [ "$EVENT" = "schedule" ] && [ "$should" = "true" ]; then
106
+ handled="$(gh api \
107
+ "repos/$REPO/actions/workflows/release.yml/runs?head_sha=$SHA&status=completed&per_page=100" \
108
+ --jq '[.workflow_runs[] | select(.conclusion=="success")] | length')"
109
+ if [ "${handled:-0}" -gt 0 ]; then
110
+ echo "gate: HEAD $SHA already analysed by a prior successful Release run — nothing to backfill."
111
+ should=false
112
+ fi
113
+ fi
80
114
  echo "should_release=$should" >> "$GITHUB_OUTPUT"
81
115
 
82
116
  release:
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.179.0](https://github.com/nanobpm/nano-workforce/compare/v0.178.5...v0.179.0) (2026-09-04)
2
+
3
+ ### Features
4
+
5
+ * **fan-out:** require the repository-isolation envelope on fan-out dispatch ([#730](https://github.com/nanobpm/nano-workforce/issues/730)) ([f94425d](https://github.com/nanobpm/nano-workforce/commit/f94425db5d447260a0e4c9f31b120695f8d18a7c)), closes [#729](https://github.com/nanobpm/nano-workforce/issues/729)
6
+
7
+ ## [0.178.5](https://github.com/nanobpm/nano-workforce/compare/v0.178.4...v0.178.5) (2026-09-03)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **ci:** self-heal a dropped push:main event via a scheduled release gate ([#726](https://github.com/nanobpm/nano-workforce/issues/726)) ([3e09ec5](https://github.com/nanobpm/nano-workforce/commit/3e09ec50b1ebd71c19f5bde3daf3771a660ba5c2)), closes [722/#723](https://github.com/722/nano-workforce/issues/723) [#725](https://github.com/nanobpm/nano-workforce/issues/725)
12
+
1
13
  ## [0.178.4](https://github.com/nanobpm/nano-workforce/compare/v0.178.3...v0.178.4) (2026-09-03)
2
14
 
3
15
  ### Bug Fixes
@@ -68,7 +68,7 @@ test("deploy+advance: a well-formed graph deploys through the real engine and ev
68
68
  await engine.registerWorker("pr.readiness-probe", async () => ({ ready: true, mergedSha: "deadbeefcafe" }));
69
69
  await engine.registerWorker(DELIVERY_CONNECTOR_TASK_TYPE, async () => ({}));
70
70
 
71
- const run = await runDeliveryGraph(engine, MATRIX_GRAPH);
71
+ const run = await runDeliveryGraph(engine, MATRIX_GRAPH, { repoless: true });
72
72
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
73
73
  const key = run.handle.processInstanceKey;
74
74
 
@@ -120,7 +120,7 @@ test("deploy+advance: a stalled service node escalates on its node-timeout bound
120
120
  };
121
121
  // Short node timeout so the boundary fires within one virtual-clock advance; a long SLA so the
122
122
  // human node's own escalation boundary never fires during the drive.
123
- const run = await runDeliveryGraph(engine, graph, { nodeTimeout: "PT1M", escalationSlaTimeout: "PT1H" });
123
+ const run = await runDeliveryGraph(engine, graph, { nodeTimeout: "PT1M", escalationSlaTimeout: "PT1H", repoless: true });
124
124
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
125
125
  const key = run.handle.processInstanceKey;
126
126
 
@@ -256,7 +256,7 @@ test("#543 transcript correlation: a completed agent job exposes a resolvable in
256
256
  ],
257
257
  edges: [{ from: "impl", to: "review" }],
258
258
  };
259
- const run = await runDeliveryGraph(engine, graph);
259
+ const run = await runDeliveryGraph(engine, graph, { repoless: true });
260
260
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
261
261
  const key = run.handle.processInstanceKey;
262
262
 
@@ -324,7 +324,7 @@ async function driveGuarded(outcome: "breaking" | "green"): Promise<{ state: str
324
324
  return {};
325
325
  });
326
326
 
327
- const run = await runDeliveryGraph(engine, GUARDED_ADOPT);
327
+ const run = await runDeliveryGraph(engine, GUARDED_ADOPT, { repoless: true });
328
328
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
329
329
  const key = run.handle.processInstanceKey;
330
330
 
@@ -425,7 +425,7 @@ async function driveGuardedRealAgent(outcome: "breaking" | "compatible"): Promis
425
425
  return {};
426
426
  });
427
427
 
428
- const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL);
428
+ const run = await runDeliveryGraph(engine, GUARDED_ADOPT_REAL, { repoless: true });
429
429
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
430
430
  const key = run.handle.processInstanceKey;
431
431
 
@@ -487,7 +487,7 @@ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge
487
487
  doneRan = true;
488
488
  return {};
489
489
  });
490
- const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
490
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
491
491
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
492
492
  const key = run.handle.processInstanceKey;
493
493
  let state = "?";
@@ -516,7 +516,7 @@ test("S7 deploy+route: mutually-exclusive leaves join End on an exclusive merge
516
516
  doneRan = true;
517
517
  return {};
518
518
  });
519
- const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H" });
519
+ const run = await runDeliveryGraph(engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
520
520
  assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
521
521
  const key = run.handle.processInstanceKey;
522
522
  let parked = "";
@@ -91,7 +91,7 @@ const SIDE_EFFECTING = {
91
91
 
92
92
  test("dispatchDeliveryGraphRun: a human-only graph launches straight away (running), one engine instance", async () => {
93
93
  const { app, started, runs } = makeApp();
94
- const res = await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
94
+ const res = await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { repoless: true });
95
95
  assertEquals(res.ok, true);
96
96
  if (!res.ok) return;
97
97
  assertEquals(res.status, "running");
@@ -103,7 +103,7 @@ test("dispatchDeliveryGraphRun: a human-only graph launches straight away (runni
103
103
 
104
104
  test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approval token — the operator seam IS the approval", async () => {
105
105
  const { app, started, runs } = makeApp();
106
- const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
106
+ const res = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
107
107
  assertEquals(res.ok, true);
108
108
  if (!res.ok) return;
109
109
  assertEquals(res.status, "running");
@@ -114,9 +114,9 @@ test("dispatchDeliveryGraphRun: a side-effecting graph dispatches with NO approv
114
114
 
115
115
  test("dispatchDeliveryGraphRun: a re-dispatch of a still-running run short-circuits (alreadyRunning) — the side effect launches at most once", async () => {
116
116
  const { app, started } = makeApp();
117
- const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
117
+ const first = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
118
118
  assert(first.ok);
119
- const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING);
119
+ const second = await dispatchDeliveryGraphRun(app, SIDE_EFFECTING, { repoless: true });
120
120
  assertEquals(second.ok, true);
121
121
  if (!second.ok) return;
122
122
  assertEquals(second.alreadyRunning, true);
@@ -125,8 +125,8 @@ test("dispatchDeliveryGraphRun: a re-dispatch of a still-running run short-circu
125
125
 
126
126
  test("dispatchDeliveryGraphRun: an explicit idempotency key forces a distinct run row", async () => {
127
127
  const { app, started } = makeApp();
128
- await dispatchDeliveryGraphRun(app, HUMAN_ONLY);
129
- await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { runKey: "second-run" });
128
+ await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { repoless: true });
129
+ await dispatchDeliveryGraphRun(app, HUMAN_ONLY, { runKey: "second-run", repoless: true });
130
130
  assertEquals(started.length, 2);
131
131
  });
132
132
 
@@ -49,7 +49,7 @@ export type DispatchDeliveryGraphResult =
49
49
  export async function dispatchDeliveryGraphRun(
50
50
  app: Pick<AppApi, "data" | "engine" | "log">,
51
51
  graph: unknown,
52
- options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null } & DeliveryRunTimeouts = {},
52
+ options: { runKey?: string | null; title?: string | null; repository?: string | null; baseBranch?: string | null; repoless?: boolean } & DeliveryRunTimeouts = {},
53
53
  ): Promise<DispatchDeliveryGraphResult> {
54
54
  const validationErrors = validateDeliveryGraph(graph);
55
55
  if (validationErrors.length > 0) {
@@ -140,11 +140,14 @@ export async function dispatchDeliveryGraphRun(
140
140
  escalationSlaTimeout: options.escalationSlaTimeout,
141
141
  probePollEvery: options.probePollEvery,
142
142
  escalationAssignee: options.escalationAssignee,
143
- // Host-git provisioning (#684/#686): forward the run-level repo/base so the runner seeds the
144
- // `io.nanobpm.agentTask.repository` isolation envelope onto every agent cell's job (absent the
145
- // runner emits no envelope and the harness keeps its legacy launch-dir behaviour).
143
+ // Host-git provisioning (#684/#686/#729): forward the run-level repo/base so the runner seeds the
144
+ // `io.nanobpm.agentTask.repository` isolation envelope onto every agent cell's job. The envelope is
145
+ // REQUIRED unless the run is EXPLICITLY `repoless` an unresolved repo/base on a non-`repoless`
146
+ // run is a hard launch failure (marked `failed` below via the catch), never a silent no-envelope
147
+ // fallback to the shared launch dir. The dispatch door enforces the same contract at submit (400).
146
148
  repository: options.repository,
147
149
  baseBranch: options.baseBranch,
150
+ repoless: options.repoless,
148
151
  });
149
152
  } catch (err) {
150
153
  await markClaimFailed();
@@ -10,8 +10,9 @@
10
10
  // The engine-native EXECUTION of a prepared graph (deploy + run + gate + fan-in + late-bind + dedupe) is
11
11
  // proven end-to-end in `e2e/delivery-graph.e2e.ts`.
12
12
  import { test } from "node:test";
13
- import { assert, assertEquals } from "#test-assert";
13
+ import { assert, assertEquals, assertRejects } from "#test-assert";
14
14
  import { prepareDeliveryGraph, renderIdempotencyPreamble, runDeliveryGraph } from "./deliveryRunner.ts";
15
+ import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
15
16
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
16
17
 
17
18
  const GRAPH: DeliveryGraph = {
@@ -357,7 +358,7 @@ test("runDeliveryGraph coerces a numeric engine processInstanceKey to a string h
357
358
  deployResources: async () => [],
358
359
  createInstance: async () => ({ processInstanceKey: 987654321 as unknown as string }),
359
360
  };
360
- const r = await runDeliveryGraph(engine, GRAPH);
361
+ const r = await runDeliveryGraph(engine, GRAPH, { repoless: true });
361
362
  assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
362
363
  assertEquals(r.handle.processInstanceKey, "987654321");
363
364
  assertEquals(typeof r.handle.processInstanceKey, "string");
@@ -401,22 +402,55 @@ test("runDeliveryGraph seeds the repository isolation envelope when repository +
401
402
  assertEquals("branch" in repo, false);
402
403
  });
403
404
 
404
- test("runDeliveryGraph emits NO envelope when repository/baseBranch are absentrepo-less graphs unchanged (#686)", async () => {
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");
410
+ });
411
+
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.
405
417
  for (const options of [{}, { repository: "owner/repo" }, { baseBranch: "main" }, { repository: " ", baseBranch: "main" }]) {
406
- const { engine, seen } = captureCreateInstanceVars();
407
- const r = await runDeliveryGraph(engine, GRAPH, options);
408
- assert(r.ok, `expected ok:true for ${JSON.stringify(options)}, got ${JSON.stringify(r)}`);
409
- assertEquals("io.nanobpm.agentTask" in seen(), false, `no envelope expected for ${JSON.stringify(options)}`);
418
+ const { engine } = captureCreateInstanceVars();
419
+ await assertRejects(
420
+ () => runDeliveryGraph(engine, GRAPH, options),
421
+ RepoEnvelopeUnresolvedError,
422
+ );
410
423
  }
411
424
  });
412
425
 
413
- test("runDeliveryGraph drops a malformed repository rather than emitting a bogus clone URL (#686)", async () => {
414
- const { engine, seen } = captureCreateInstanceVars();
415
- // A value that is not exactly `owner/repo` (a trailing `.git`) must degrade to NO envelope the
416
- // helper's defence-in-depth guard never a double-suffixed `…/owner/repo.git.git` clone URL.
417
- const r = await runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" });
418
- assert(r.ok, `expected ok:true, got ${JSON.stringify(r)}`);
419
- assertEquals("io.nanobpm.agentTask" in seen(), false);
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.
431
+ await assertRejects(
432
+ () => runDeliveryGraph(engine, GRAPH, { repository: "owner/repo.git", baseBranch: "main" }),
433
+ RepoEnvelopeUnresolvedError,
434
+ );
435
+ });
436
+
437
+ test("runDeliveryGraph THROWS when repoless is combined with repository/baseBranch — never silently disables isolation (#729)", async () => {
438
+ // `repoless: true` is mutually exclusive with `repository`/`baseBranch`. The dispatch door rejects the
439
+ // conflicting shape with a 400, but a PROGRAMMATIC caller that bypasses the door could pass both — and
440
+ // 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.
443
+ for (const options of [
444
+ { repoless: true, repository: "owner/repo", baseBranch: "main" },
445
+ { repoless: true, repository: "owner/repo" },
446
+ { repoless: true, baseBranch: "main" },
447
+ ]) {
448
+ const { engine } = captureCreateInstanceVars();
449
+ await assertRejects(
450
+ () => runDeliveryGraph(engine, GRAPH, options),
451
+ RepoEnvelopeConflictError,
452
+ );
453
+ }
420
454
  });
421
455
 
422
456
  test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATCHES with a fact-bound wait target (#570)", async () => {
@@ -444,7 +478,7 @@ test("the canonical `agent → converge-merge → wait[pr merged]` graph DISPATC
444
478
  deployResources: async () => [],
445
479
  createInstance: async () => ({ processInstanceKey: "555" }),
446
480
  };
447
- const r = await runDeliveryGraph(engine, graph);
481
+ const r = await runDeliveryGraph(engine, graph, { repoless: true });
448
482
  assert(r.ok, `expected the canonical fact-bound wait[pr] graph to launch, got ${JSON.stringify(r)}`);
449
483
  assertEquals(r.handle.processInstanceKey, "555");
450
484
  });
@@ -20,7 +20,7 @@ import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generate
20
20
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
21
21
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
22
22
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
23
- import { repoEnvelopeVars } from "./repoEnvelope.ts";
23
+ import { RepoEnvelopeConflictError, requireRepoEnvelopeVars } 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,18 +62,27 @@ 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. When supplied together with
66
- * `baseBranch`, the runner seeds the canonical repository-provisioning envelope
67
- * (`io.nanobpm.agentTask.repository`, via `repoEnvelopeVars`) as a run-root `createInstance` process
68
- * variable so each `agent` cell's servicing `senior:*` job provisions an ISOLATED throwaway clone
69
- * instead of inheriting the worker's launch dir (issue #684/#686 — the same isolation the legacy
70
- * feature/plan paths got in #685). Absent/unresolved → NO envelope is emitted and the harness falls
71
- * back to the legacy launch-dir behaviour, so today's repo-less graphs are unchanged. */
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).
70
+ *
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. */
72
75
  repository?: string | null;
73
76
  /** OPTIONAL base branch the run's `agent` nodes branch off — the `ref` the harness checks out in the
74
77
  * isolated clone (the PRE-PR shape: no PR head exists yet, so the agent cuts its own `feat/<node.id>`
75
- * branch off this base inside the clone). Only consulted when `repository` is also set. */
78
+ * branch off this base inside the clone). Required (with `repository`) unless the run is `repoless`. */
76
79
  baseBranch?: string | null;
80
+ /** 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. */
85
+ repoless?: boolean;
77
86
  }
78
87
 
79
88
  const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
@@ -170,6 +179,37 @@ export async function runDeliveryGraph(
170
179
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
171
180
  const base = typeof options.baseBranch === "string" && options.baseBranch.trim() !== "" ? options.baseBranch.trim() : null;
172
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);
173
213
  const { processInstanceKey } = await engine.createInstance({
174
214
  processDefinitionId,
175
215
  variables: {
@@ -179,20 +219,9 @@ export async function runDeliveryGraph(
179
219
  // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
180
220
  // every node — and read down into each agent job via `=transcriptUrlBase`.
181
221
  [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
182
- // Host-git provisioning (c8ctl, issue #684/#686): deliver the ONE canonical repository envelope
183
- // (`repoEnvelopeVars`, app/repoEnvelope.ts) so every `agent` node's servicing `senior:*` job gets
184
- // an ISOLATED throwaway clone instead of inheriting the worker's launch dir — otherwise several
185
- // copilot workers on one host share (and clobber) a single checkout, the exact field failure #684
186
- // described. This is the delivery-graph analog of the whole-epic seed in `app/plan.ts`: a single
187
- // run-root `createInstance` process variable that propagates through each agent cell's subProcess
188
- // into its job. Like plan.ts's fan-out seed it carries `ref = base` but NO `branchCreate` — a run
189
- // fans out to MANY agent nodes, each needing its own deterministic `feat/<node.id>` branch, so a
190
- // single run-level envelope can't name one; each agent cuts its own branch off `base` inside the
191
- // isolated clone (the agent-guide's `feat/*` convention, kept idempotent by the #551 preflight).
192
- // `baseRef = base` too, so the harness keeps `origin/<base>` reachable for the review 3-dot diff.
193
- // Spread last so an unresolved repo/base (`{}`) leaves the other run-root vars untouched — a
194
- // repo-less graph is then dispatched exactly as before (legacy launch-dir behaviour).
195
- ...repoEnvelopeVars(repo ?? "", base, base),
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,
196
225
  },
197
226
  });
198
227
  // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
package/app/feature.ts CHANGED
@@ -20,7 +20,7 @@ import { coalesceTitle, fetchIssueTitle } from "./github.ts";
20
20
  import { derivedTrackingTable } from "./instanceTracking.ts";
21
21
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
22
22
  import type { ReadinessProbe } from "./readiness.ts";
23
- import { repoEnvelopeVars } from "./repoEnvelope.ts";
23
+ import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
24
24
 
25
25
  /** Optional intake-time readiness gate for a feature run (issue #295): the `capability`/`command`/…
26
26
  * probes the run must ALL satisfy before its implementation agent is dispatched (parked, durably, at
@@ -491,9 +491,12 @@ export async function startFeature(
491
491
  // implementation jobs share — and clobber — one checkout, violating the durable-resume design).
492
492
  // A feature run is PRE-PR: there is no head branch yet, so the harness checks out the BASE
493
493
  // branch (`ref = base`) and creates the deterministic `feat/<task.id>` feature branch itself
494
- // (`branchCreate`), matching the agent-guide's `feat/*` convention. Spread last so an unresolved
495
- // repo (`{}`) leaves the other vars untouched.
496
- ...repoEnvelopeVars(parsed.repo, base, null, null, prePrBranch),
494
+ // (`branchCreate`), matching the agent-guide's `feat/*` convention. This is a fan-out seed, so it
495
+ // is REQUIRED (issue #729): `parsed.repo` is regex-bound (`parseIssue`) and `base` is
496
+ // `normalizeBaseBranch`-validated non-blank, so the `requireRepoEnvelopeVars` guard never trips
497
+ // here — but it turns an unresolved repo/base into a HARD launch failure rather than a silent `{}`
498
+ // that would degrade the agent to the shared launch dir.
499
+ ...requireRepoEnvelopeVars(parsed.repo, base, null, null, prePrBranch),
497
500
  },
498
501
  });
499
502
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
package/app/plan.ts CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  import { derivedTrackingTable } from "./instanceTracking.ts";
31
31
  import { clearExclusions } from "./mergeExclusion.ts";
32
32
  import type { ReadinessProbe } from "./readiness.ts";
33
- import { repoEnvelopeVars } from "./repoEnvelope.ts";
33
+ import { requireRepoEnvelopeVars } from "./repoEnvelope.ts";
34
34
  import { clearTaskDeltas } from "./taskDelta.ts";
35
35
 
36
36
  /** The BPMN process this module drives (resources/processes/plan-fanout.bpmn). */
@@ -1048,8 +1048,11 @@ export async function startPlan(
1048
1048
  // but NO `branchCreate`: each slice's deterministic `feat/<task.id>` branch differs per MI child,
1049
1049
  // so the agent cuts its own branch inside the isolated clone (per resources/prompts/feature.md).
1050
1050
  // The process-level variable propagates through the wave subprocess + `implement-cell` callActivity
1051
- // into each agent job. Spread last so an unresolved repo (`{}`) leaves the other vars untouched.
1052
- ...repoEnvelopeVars(parsed.repo, base),
1051
+ // into each agent job. This is a fan-out seed, so it is REQUIRED (issue #729): `parsed.repo` is
1052
+ // regex-bound (`parseIssue`) and `base` is `normalizeBaseBranch`-validated non-blank, so the
1053
+ // `requireRepoEnvelopeVars` guard never trips here — but it makes an unresolved repo/base a HARD
1054
+ // launch failure rather than a silent `{}` that would degrade every slice to the shared launch dir.
1055
+ ...requireRepoEnvelopeVars(parsed.repo, base),
1053
1056
  },
1054
1057
  });
1055
1058
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
@@ -15,6 +15,49 @@ import { isCommitSha } from "./world/index.ts";
15
15
  * (headers ∪ variables, deep-merged). See c8ctl `normalizeTaskEnvelope`. */
16
16
  const AGENT_TASK_NS = "io.nanobpm.agentTask";
17
17
 
18
+ /** The ONE canonical `owner/repo` allowlist, shared by `repoEnvelopeVars` (which degrades to `{}` on a
19
+ * miss) and `requireRepoEnvelopeVars` (which throws on a miss) so the two can never drift apart. The
20
+ * owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows `.` and
21
+ * `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed `…/owner/repo.git.git`,
22
+ * and the anchored allowlist bars query/fragment/host-injection chars. */
23
+ const OWNER_REPO_RE = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
24
+ function isPlainOwnerRepo(repo: string): boolean {
25
+ return OWNER_REPO_RE.test(repo) && !/\.git$/i.test(repo);
26
+ }
27
+
28
+ /** Raised by `requireRepoEnvelopeVars` when the repository-isolation envelope is REQUIRED on a fan-out
29
+ * path but its inputs are unresolved (a blank base/head `ref`, or a `repo` that is not a plain
30
+ * `owner/repo`). Issue #729: the fan-out dispatch paths must fail loudly here rather than let
31
+ * `repoEnvelopeVars` silently emit `{}` and degrade to the shared launch-dir behaviour (issue #684's
32
+ * field failure re-opened as a silent fallback). The API edge maps this to a clean 400 / launch error;
33
+ * a caller that legitimately wants no clone must opt in EXPLICITLY (never reach this helper) so the
34
+ * default can never silently share a checkout. */
35
+ export class RepoEnvelopeUnresolvedError extends Error {
36
+ readonly reason: string;
37
+ constructor(reason: string) {
38
+ super(`repository-isolation envelope is required but its input is unresolved: ${reason}`);
39
+ this.name = "RepoEnvelopeUnresolvedError";
40
+ this.reason = reason;
41
+ }
42
+ }
43
+
44
+ /** Raised when a fan-out dispatch supplies a repository-isolation envelope (`repository`/`baseBranch`)
45
+ * AND an explicit `repoless: true` opt-out at the same time (issue #729). The two are mutually
46
+ * exclusive: `repoless: true` means "dispatch a genuinely checkout-less graph with NO envelope", so a
47
+ * caller that also names a repo/base is contradictory. The dispatch door already rejects this shape
48
+ * with a 400, but a PROGRAMMATIC caller that bypasses the door could still pass both — and silently
49
+ * disable the very isolation the envelope guards (the runner would drop the repo/base and emit `{}`).
50
+ * Failing loudly here too keeps the mutual exclusivity enforced at the seed point, not only at the HTTP
51
+ * edge, so isolation can never be silently disabled by a conflicting-but-well-meant call. */
52
+ export class RepoEnvelopeConflictError extends Error {
53
+ readonly reason: string;
54
+ constructor(reason: string) {
55
+ super(`repository-isolation envelope conflicts with an explicit repoless opt-out: ${reason}`);
56
+ this.name = "RepoEnvelopeConflictError";
57
+ this.reason = reason;
58
+ }
59
+ }
60
+
18
61
  /** Build the repository slice of the agent-task envelope for an agent job. Delivered as a *process
19
62
  * variable* under the reserved `io.nanobpm.agentTask` key so the harness provisions an isolated
20
63
  * clone — instead of the agent inheriting whatever directory the worker was launched from (which
@@ -63,10 +106,7 @@ export function repoEnvelopeVars(
63
106
  // `owner/repo`), but this is an exported helper the fan-out epic gives many new callers. A repo
64
107
  // that is not exactly `owner/repo` would build a bogus clone URL, so emit nothing (the harness
65
108
  // then falls back to the launch-dir behaviour) rather than handing the harness a malformed URL.
66
- // The owner is a GitHub login (alphanumeric + hyphen); the repo-name segment additionally allows
67
- // `.` and `_`. A trailing `.git` is rejected outright so we never emit a double-suffixed
68
- // `…/owner/repo.git.git`, and the anchored allowlist bars query/fragment/host-injection chars.
69
- if (!/^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/.test(repo) || /\.git$/i.test(repo)) return {};
109
+ if (!isPlainOwnerRepo(repo)) return {};
70
110
  return {
71
111
  [AGENT_TASK_NS]: {
72
112
  repository: {
@@ -114,6 +154,37 @@ export function repoEnvelopeVars(
114
154
  };
115
155
  }
116
156
 
157
+ /** The REQUIRED-envelope guard for the fan-out dispatch paths (issue #729). Same signature and output
158
+ * as `repoEnvelopeVars`, but THROWS `RepoEnvelopeUnresolvedError` on an unresolved input (a blank
159
+ * base/head `ref`, or a `repo` that is not a plain `owner/repo`) instead of degrading to the silent
160
+ * `{}` that leaves the agent job inheriting the worker's launch dir. Every fan-out seed
161
+ * (`app/deliveryRunner.ts` `runDeliveryGraph`, `app/plan.ts` `startPlan`, `app/feature.ts`
162
+ * `startFeature`) MUST route through this helper so a run that can't be isolated fails LOUDLY at seed
163
+ * time — never silently shares (and clobbers) one checkout across concurrent workers on a host (issue
164
+ * #684, re-opened as a silent fallback). A dispatch that legitimately wants no clone must opt in
165
+ * EXPLICITLY at its door (e.g. delivery-graph `repoless: true`) and simply never call this. */
166
+ export function requireRepoEnvelopeVars(
167
+ repo: string,
168
+ ref: string | null,
169
+ baseRef: string | null = null,
170
+ commitSha: string | null = null,
171
+ branchCreate: string | null = null,
172
+ ): Record<string, unknown> {
173
+ if (typeof ref !== "string" || ref.trim() === "") {
174
+ throw new RepoEnvelopeUnresolvedError("base/head ref is blank — the harness has no branch to check out");
175
+ }
176
+ if (!isPlainOwnerRepo(repo)) {
177
+ throw new RepoEnvelopeUnresolvedError(`repo is not an \`owner/repo\` reference: ${JSON.stringify(repo)}`);
178
+ }
179
+ const vars = repoEnvelopeVars(repo, ref, baseRef, commitSha, branchCreate);
180
+ // repoEnvelopeVars degrades to `{}` on exactly the two conditions rejected above, so a non-empty
181
+ // envelope is guaranteed here. Assert it so the degrade-vs-throw pair can never silently drift.
182
+ if (Object.keys(vars).length === 0) {
183
+ throw new RepoEnvelopeUnresolvedError(`envelope resolved empty for repo=${JSON.stringify(repo)} ref=${JSON.stringify(ref)}`);
184
+ }
185
+ return vars;
186
+ }
187
+
117
188
  /** Resolve the clone timeout (ms) the harness applies to provisioning, from the one typed knob
118
189
  * `NANO_PR_CLONE_TIMEOUT_MS` (default 600000 = 10 min; issue #694). A branch-scoped blobless clone
119
190
  * of a large monorepo still approaches/exceeds the harness's 120s default, so we raise it here. A
@@ -100,7 +100,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
100
100
  assert.equal(proposal?.status, "staged");
101
101
 
102
102
  // ── Operator dispatch: dispatch the digest → deploys + runs engine-natively ───────────────────
103
- const dispatched = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest } });
103
+ const dispatched = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest, repoless: true } });
104
104
  assert.equal(dispatched.status, 202, "dispatching a staged digest launches the run");
105
105
  assert.equal(dispatched.body.status, "running");
106
106
  assert.equal(dispatched.body.alreadyRunning, false);
@@ -122,7 +122,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
122
122
  assert.match(String(phased?.phase), /^Parked on human node:/, `phase shows the parked human node, got ${phased?.phase}`);
123
123
 
124
124
  // ── No replay: the consumed proposal cannot re-launch ─────────────────────────────────────────
125
- const replay = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest } });
125
+ const replay = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest, repoless: true } });
126
126
  assert.equal(replay.status, 400, "an already-dispatched digest cannot be re-dispatched");
127
127
  await app.settle();
128
128
  assert.equal(agentFired, 1, "the agent side effect STILL fired only once (no double-launch)");
@@ -146,7 +146,7 @@ describe("delivery-graph dispatch — agent compiles→stages, operator dispatch
146
146
  const graph: DeliveryGraph = { name: "manual gate", nodes: [{ id: "ack", kind: "human", human: { prompt: "click done" } }] };
147
147
  const staged = await api.call<StagedResult>("compileDeliveryGraph", { body: graph });
148
148
  assert.equal(staged.status, 200);
149
- const res = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest: staged.body.digest } });
149
+ const res = await api.call<DispatchResult>("dispatchDeliveryGraph", { body: { digest: staged.body.digest, repoless: true } });
150
150
  assert.equal(res.status, 202, "dispatching a human-only graph runs it");
151
151
  assert.equal(res.body.status, "running");
152
152
  assert.equal(res.body.sideEffecting, false);
@@ -125,7 +125,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
125
125
  ],
126
126
  };
127
127
 
128
- const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
128
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", repoless: true });
129
129
  assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
130
130
  await app.settle();
131
131
 
@@ -174,7 +174,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
174
174
  edges: [{ from: "a.cmd", to: "w" }],
175
175
  };
176
176
 
177
- const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S" });
177
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", repoless: true });
178
178
  assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
179
179
  await app.settle();
180
180
 
@@ -209,7 +209,7 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
209
209
  ],
210
210
  edges: [],
211
211
  };
212
- const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H" });
212
+ const run = await runDeliveryGraph(app.engine, graph, { probeTimeout: "PT2S", probePollEvery: "PT1S", escalationSlaTimeout: "PT1H", repoless: true });
213
213
  assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
214
214
  await app.settle();
215
215