@nanobpm/nano-workforce 0.184.0 → 0.185.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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.185.0](https://github.com/nanobpm/nano-workforce/compare/v0.184.1...v0.185.0) (2026-09-08)
2
+
3
+ ### Features
4
+
5
+ * **delivery:** auto-inject the producer-completion contract into every agent node's prompt ([#761](https://github.com/nanobpm/nano-workforce/issues/761)) ([3eb90a3](https://github.com/nanobpm/nano-workforce/commit/3eb90a3ed3bca8a5fc8cb542716f9d19fa82a541)), closes [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#551](https://github.com/nanobpm/nano-workforce/issues/551) [#506](https://github.com/nanobpm/nano-workforce/issues/506) [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#760](https://github.com/nanobpm/nano-workforce/issues/760) [#731](https://github.com/nanobpm/nano-workforce/issues/731) [#731](https://github.com/nanobpm/nano-workforce/issues/731)
6
+
7
+ ## [0.184.1](https://github.com/nanobpm/nano-workforce/compare/v0.184.0...v0.184.1) (2026-09-08)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery:** allow node repositories in staged dispatch form ([#759](https://github.com/nanobpm/nano-workforce/issues/759)) ([a9fb9f6](https://github.com/nanobpm/nano-workforce/commit/a9fb9f672a2b3cd365c191755847a303b906a343)), closes [#758](https://github.com/nanobpm/nano-workforce/issues/758)
12
+
1
13
  ## [0.184.0](https://github.com/nanobpm/nano-workforce/compare/v0.183.2...v0.184.0) (2026-09-07)
2
14
 
3
15
  ### Features
@@ -119,8 +119,11 @@ function contractEscalationTaskElement(element: string): string {
119
119
  * and escalates AT the node instead of threading an incomplete result into a downstream consumer. An
120
120
  * ABSENT/null status passes the gate (a status-less completion — an older fleet worker or a bare test
121
121
  * stub — is not itself the failure mode; the required-emit gate still catches a missing data fact).
122
- * Sorted for the compiler's byte-identical-output determinism. */
123
- const AGENT_TERMINAL_SUCCESS_STATUSES: readonly string[] = ["done", "opened", "skipped"];
122
+ * Sorted for the compiler's byte-identical-output determinism. Exported as the SINGLE SOURCE OF TRUTH:
123
+ * the compiler's contract gate reads it here, and the runner's `renderProducerContract` (#760) derives
124
+ * the agent-facing status vocabulary from the SAME list — changing it changes both the gate and the
125
+ * prompt at once, so the two representations of the producer contract can never drift. */
126
+ export const AGENT_TERMINAL_SUCCESS_STATUSES: readonly string[] = ["done", "opened", "skipped"];
124
127
 
125
128
  /** A never-reached exhaustiveness guard: `compileNode`'s `switch` covers every allowlisted kind, so
126
129
  * the closed union narrows to `never` here. If a future kind is added to the vocabulary without a
@@ -11,7 +11,8 @@
11
11
  // proven end-to-end in `e2e/delivery-graph.e2e.ts`.
12
12
  import { test } from "node:test";
13
13
  import { assert, assertEquals, assertRejects } from "#test-assert";
14
- import { prepareDeliveryGraph, renderIdempotencyPreamble, runDeliveryGraph } from "./deliveryRunner.ts";
14
+ import { AGENT_TERMINAL_SUCCESS_STATUSES } from "./deliveryGraphCompiler.ts";
15
+ import { prepareDeliveryGraph, renderEmitContract, renderIdempotencyPreamble, renderProducerContract, runDeliveryGraph } from "./deliveryRunner.ts";
15
16
  import { RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
16
17
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
17
18
 
@@ -76,8 +77,9 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
76
77
 
77
78
  const agent = byField((v) => v.jobType === "senior:feature");
78
79
  // Every agent node's appendPrompt is prefixed with the idempotency preflight (#551), then the
79
- // authored prompt (this node declares no emits, so the emit contract adds nothing).
80
- assertEquals(agent, { jobType: "senior:feature", appendPrompt: renderIdempotencyPreamble() + "un-draft + merge #B", timeout: "PT10M" });
80
+ // authored prompt, then the (emit-less) producer completion contract (#760) this node declares no
81
+ // emits, so the emit contract adds nothing but the status block still applies.
82
+ assertEquals(agent, { jobType: "senior:feature", appendPrompt: renderIdempotencyPreamble() + "un-draft + merge #B" + renderEmitContract([]) + renderProducerContract([]), timeout: "PT10M" });
81
83
 
82
84
  const wait = byField((v) => "gateKey" in v);
83
85
  assertEquals(wait?.gateKey, "run-7:n3");
@@ -156,9 +158,103 @@ test("agent node classifier-emit contract (#506): a declared `emits` threads the
156
158
  assert(adoptPrompt.includes("breaking | compatible"), "the fact's optional description rides the contract");
157
159
  assert(adoptPrompt.includes("AGENT_RESULT_FILE"), "the contract names the completion channel the fact rides");
158
160
 
159
- // A node that declares NO facts still carries the preflight, then exactly the authored prompt — the
160
- // emit contract contributes nothing.
161
- assertEquals(plain?.appendPrompt, renderIdempotencyPreamble() + "just implement it");
161
+ // A node that declares NO facts still carries the preflight and the (emit-less) producer contract,
162
+ // then exactly the authored prompt — the emit contract contributes nothing.
163
+ assertEquals(plain?.appendPrompt, renderIdempotencyPreamble() + "just implement it" + renderEmitContract([]) + renderProducerContract([]));
164
+ });
165
+
166
+ test("agent node producer completion contract (#760): every agent prompt carries the terminal-status vocabulary derived from AGENT_TERMINAL_SUCCESS_STATUSES + required-emit names; a non-agent node never does", async () => {
167
+ // #760: the #731 producer gate's terminal-status vocabulary (AGENT_TERMINAL_SUCCESS_STATUSES) lived
168
+ // ONLY in the compiler's gate — nothing told the agent which statuses count as success, so a
169
+ // correctly-done agent that self-reported `status: "success"` was parked on a __contract escalation
170
+ // (instance 15697). Auto-inject the vocabulary into every agent node's appendPrompt, derived from the
171
+ // single source of truth so the list and the prompt can never drift.
172
+ const graph: DeliveryGraph = {
173
+ name: "producer contract",
174
+ nodes: [
175
+ { id: "emit", kind: "agent", agent: { jobType: "senior:feature", prompt: "do the thing" }, emits: [{ name: "pr", type: "pr" }] },
176
+ { id: "plain", kind: "agent", agent: { jobType: "senior:feature", prompt: "no emits here" } },
177
+ { id: "gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" } } },
178
+ ],
179
+ edges: [{ from: "emit.pr", to: "plain" }, { from: "plain", to: "gate" }],
180
+ };
181
+ const p = await prepareOk(graph);
182
+ const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
183
+ assertEquals(agents.length, 2, "both agent nodes are seeded");
184
+
185
+ // Every agent node — emit-declaring OR not — carries the producer contract heading and the EXACT
186
+ // allowlist strings, derived from the single source of truth.
187
+ for (const a of agents) {
188
+ const prompt = String(a.appendPrompt);
189
+ assert(prompt.includes("Producer completion contract"), `every agent prompt carries the producer contract, got: ${prompt}`);
190
+ for (const status of AGENT_TERMINAL_SUCCESS_STATUSES) {
191
+ assert(prompt.includes(`\`${status}\``), `the allowlist status ${status} is surfaced verbatim, got: ${prompt}`);
192
+ }
193
+ assert(prompt.includes("escalation"), "it warns an out-of-vocabulary status parks a human escalation");
194
+ }
195
+
196
+ // The emit-declaring node names its required emit in the producer contract; the no-emit node carries
197
+ // the status block unchanged in every other respect (per #760 acceptance) but names no emit.
198
+ const emit = agents.find((v) => String(v.appendPrompt).includes("do the thing"));
199
+ const plain = agents.find((v) => String(v.appendPrompt).includes("no emits here"));
200
+ assert(String(emit?.appendPrompt).includes(renderProducerContract([{ name: "pr", type: "pr" }])), "the emit-declaring node's producer contract names its required emit");
201
+ assert(String(plain?.appendPrompt).includes(renderProducerContract([])), "the no-emit node still carries the status block");
202
+
203
+ // The vocabulary is AGENT-ONLY — a wait node's seed carries no prompt at all.
204
+ const wait = Object.values(p.nodeInputs).find((v) => "gateKey" in v) as Record<string, unknown> | undefined;
205
+ assert(wait, "the wait node is seeded");
206
+ assert(!("appendPrompt" in wait!), "a non-agent node never carries the producer completion contract");
207
+ });
208
+
209
+
210
+ test("producer contract required-emit subset (#761): a routing-only emit is NOT listed as a required field, but the classifier-emit contract still names it", async () => {
211
+ // #761: `renderProducerContract` was passed a node's FULL declared `emits`, so it told the agent to
212
+ // populate EVERY declared emit non-null — even a routing-only fact (named only in an edge `when`
213
+ // guard) that the #731 gate deliberately leaves optional. That contradicted the classifier-emit
214
+ // contract's "OMIT an undecidable routing fact (default branch)" guidance and pushed agents to guess.
215
+ // The producer contract must list ONLY the required-data-dependency subset (`from: "<node>.<fact>"`),
216
+ // while the emit contract still lists every declared fact.
217
+ const graph: DeliveryGraph = {
218
+ name: "routing-only producer",
219
+ nodes: [
220
+ { id: "classify", kind: "agent", agent: { jobType: "senior:feature", prompt: "classify it" }, emits: [{ name: "decision", type: "string" }] },
221
+ { id: "migrate", kind: "connector", connector: { target: "npm:install", dedupeKey: "m-1" } },
222
+ { id: "release", kind: "connector", connector: { target: "npm:publish", dedupeKey: "r-1" } },
223
+ ],
224
+ edges: [
225
+ { from: "classify", to: "migrate", when: "classify.decision", equals: "breaking" },
226
+ { from: "classify", to: "release", default: true },
227
+ ],
228
+ };
229
+ const p = await prepareOk(graph);
230
+ const classify = Object.values(p.nodeInputs).find((v) => "jobType" in v && String((v as Record<string, unknown>).appendPrompt).includes("classify it")) as Record<string, unknown> | undefined;
231
+ assert(classify, "the classify agent node is seeded");
232
+ const prompt = String(classify!.appendPrompt);
233
+ // The producer contract still gates on status but names NO required emit (routing-only ⇒ optional).
234
+ assert(prompt.includes("Producer completion contract"), "the producer contract is present");
235
+ assert(prompt.includes(renderProducerContract([])), "the producer contract lists no required emit for a purely routing-only producer");
236
+ assert(!prompt.includes("populate each of these top-level fields"), "no required-emit sentence is rendered when every emit is routing-only");
237
+ // The classifier-emit contract STILL tells the agent to return the routing fact (and omit if undecidable).
238
+ assert(prompt.includes("Classifier emit contract"), "the classifier-emit contract is present");
239
+ assert(prompt.includes("`decision`"), "the routing fact is still named by the classifier-emit contract");
240
+ });
241
+
242
+
243
+ test("producer contract semantics coverage (#761 follow-up): renderProducerContract emits a documented bullet for EVERY allowlisted status and fails fast on an undocumented one", () => {
244
+ // Copilot review follow-up: `renderProducerContract` previously FILTERED the allowlist against
245
+ // PRODUCER_STATUS_SEMANTICS, so adding a status to AGENT_TERMINAL_SUCCESS_STATUSES without documenting
246
+ // its semantics would silently render a prompt that LISTS the status in the vocabulary line yet gives
247
+ // no explanatory bullet — a quiet drift between the allowlist and the surfaced contract. It now emits
248
+ // a bullet for every allowlisted status and throws if any lacks semantics. Pin: every currently
249
+ // allowlisted status carries a documented `- \`<status>\` — …` bullet (so the throw path is
250
+ // unreachable for the shipped allowlist, and any future undocumented addition breaks the build).
251
+ const rendered = renderProducerContract([]);
252
+ for (const status of AGENT_TERMINAL_SUCCESS_STATUSES) {
253
+ assert(
254
+ rendered.includes(`- \`${status}\` — `),
255
+ `every allowlisted status carries a documented semantics bullet, missing: ${status}, got: ${rendered}`,
256
+ );
257
+ }
162
258
  });
163
259
 
164
260
 
@@ -18,7 +18,7 @@ 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 { AGENT_REPO_SPEC_HEADER, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
21
+ import { AGENT_REPO_SPEC_HEADER, AGENT_TERMINAL_SUCCESS_STATUSES, assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
22
22
  import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
23
23
  import { agentNodeRepoEnvelope, flattenAgentTaskEnvelope, isResolvableRepo, RepoEnvelopeConflictError, RepoEnvelopeUnresolvedError } from "./repoEnvelope.ts";
24
24
  import { isoDuration } from "./reviewWait.ts";
@@ -100,6 +100,10 @@ const DEFAULTS: Required<Omit<DeliveryRunTimeouts, "escalationAssignee">> = {
100
100
  escalationSlaTimeout: "P1D",
101
101
  };
102
102
 
103
+ /** Shared empty required-emit set for a node whose declared emits are all routing-only (or which
104
+ * declares none) — avoids allocating a throwaway `Set` per such node while seeding. */
105
+ const EMPTY_REQUIRED_EMITS: ReadonlySet<string> = new Set<string>();
106
+
103
107
  /** The per-node config the compiled subProcess ioMappings read from `nodeInputs.<element>`. A closed
104
108
  * union mirrored by the compiler's `ioMappingLines` — the two must agree on field names (a drift here
105
109
  * silently seeds `null` into a node body), so both derive from the same node kinds. */
@@ -170,11 +174,22 @@ export async function prepareDeliveryGraph(
170
174
  escalationAssignee: options.escalationAssignee ?? null,
171
175
  };
172
176
  const elementByNodeId = new Map(compiled.resolved.nodes.map((n) => [n.id, n.element]));
177
+ // Required-emit subset per node (#761), derived from the SAME canonical `resolved.edges` the compiler's
178
+ // `requiredEmitsByElement` gate uses: a fact is a required data dependency exactly when some edge
179
+ // threads it as a fact-qualified `from: "<node>.<fact>"` (`fromFact` set). A routing-only fact (named
180
+ // only in a `when` guard) is deliberately absent, so the producer contract leaves it optional.
181
+ const requiredEmitsByNodeId = new Map<string, Set<string>>();
182
+ for (const edge of compiled.resolved.edges) {
183
+ if (edge.fromFact === undefined) continue;
184
+ const set = requiredEmitsByNodeId.get(edge.fromNode) ?? new Set<string>();
185
+ set.add(edge.fromFact);
186
+ requiredEmitsByNodeId.set(edge.fromNode, set);
187
+ }
173
188
  const nodeInputs: Record<string, NodeInput> = {};
174
189
  for (const node of graph.nodes) {
175
190
  const element = elementByNodeId.get(node.id);
176
191
  if (element === undefined) continue; // unreachable — resolved covers every node — but keep total.
177
- nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts });
192
+ nodeInputs[element] = buildNodeInput(node, { runKey, element, ...timeouts, requiredEmits: requiredEmitsByNodeId.get(node.id) ?? EMPTY_REQUIRED_EMITS });
178
193
  }
179
194
  return { ok: true, prepared: { processDefinitionId, bpmn, nodeInputs } };
180
195
  }
@@ -419,11 +434,86 @@ export function renderEmitContract(emits: readonly DeliveryFact[]): string {
419
434
  }
420
435
 
421
436
 
437
+ /** Per-status semantics for the producer-completion contract (#760). Keyed by the SAME status strings
438
+ * as {@link AGENT_TERMINAL_SUCCESS_STATUSES} so the rendered bullets are DERIVED from the single source
439
+ * of truth: {@link renderProducerContract} iterates the allowlist and emits a bullet for EVERY status,
440
+ * failing fast if any allowlisted status has no entry here. Removing a status from the allowlist drops
441
+ * its bullet; adding one WITHOUT documenting its semantics here is a build/boot-time error (not a
442
+ * silently under-explained prompt) — so the surfaced list and the allowlist can never drift. */
443
+ const PRODUCER_STATUS_SEMANTICS: Readonly<Record<string, string>> = {
444
+ opened: "you opened OR adopted a PR (return it in your `pr` emit if this node declares one)",
445
+ done: "the work completed with no PR to open",
446
+ skipped: "there was genuinely nothing to do",
447
+ };
448
+
449
+ /** Render the producer-completion contract auto-injected into EVERY `agent` node's `appendPrompt`
450
+ * (issue #760) — the missing THIRD contract block alongside {@link renderIdempotencyPreamble} (#551)
451
+ * and {@link renderEmitContract} (#506). The #731 producer gate (`app/deliveryGraphCompiler.ts`) only
452
+ * routes a completion onward when its self-reported `status` is one of `AGENT_TERMINAL_SUCCESS_STATUSES`
453
+ * AND every required emit is non-null; before this block that vocabulary lived ONLY in the gate, so a
454
+ * correctly-finished agent that self-reported an out-of-vocabulary `status` (e.g. `"success"`) was
455
+ * parked on a `__contract` escalation despite good work (instance 15697). This block hands the agent the
456
+ * same vocabulary through its sole steering channel, DERIVED from `AGENT_TERMINAL_SUCCESS_STATUSES` (and
457
+ * the node's REQUIRED emits) so the gate and the prompt cannot drift — changing the allowlist changes
458
+ * this block. Deterministic: fixed wording, statuses + emit names in declared order, so identical graphs
459
+ * still compile+seed byte-identically. Unconditional — a no-emit node still gets the status block (the
460
+ * gate applies to it too); only the required-emit sentence is elided when there are none.
461
+ *
462
+ * `requiredEmits` is the subset of the node's declared `emits` the #731 gate actually gates on — those
463
+ * consumed downstream as a REQUIRED DATA DEPENDENCY (threaded on a fact-qualified `from: "<node>.<fact>"`
464
+ * edge), derived from the SAME `requiredEmitsByElement` source the compiler's proceed-condition uses
465
+ * (see `prepareDeliveryGraph`). It deliberately EXCLUDES a routing-only fact (named only in an edge
466
+ * `when` guard) — the gate leaves those optional (omit ⇒ default branch), and the classifier-emit
467
+ * contract already tells the agent to omit an undecidable routing fact. Listing every DECLARED emit
468
+ * here instead would contradict that guidance and push agents to guess values that should stay
469
+ * optional (#761). */
470
+ export function renderProducerContract(requiredEmits: readonly DeliveryFact[]): string {
471
+ const list = AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => `\`${s}\``).join(", ");
472
+ const semantics = AGENT_TERMINAL_SUCCESS_STATUSES.map((s) => {
473
+ const doc = PRODUCER_STATUS_SEMANTICS[s];
474
+ if (doc === undefined) {
475
+ throw new Error(
476
+ `renderProducerContract: allowlisted status "${s}" has no PRODUCER_STATUS_SEMANTICS entry — ` +
477
+ "document its semantics so the producer-contract prompt and AGENT_TERMINAL_SUCCESS_STATUSES cannot drift.",
478
+ );
479
+ }
480
+ return `- \`${s}\` — ${doc}.`;
481
+ });
482
+ const lines = [
483
+ "",
484
+ "",
485
+ "---",
486
+ "",
487
+ "## Producer completion contract (delivery graph)",
488
+ "",
489
+ "This node is a PRODUCER in a delivery graph: a completion barrier gates your result before it can",
490
+ "route to a downstream consumer. The structured result you write to `AGENT_RESULT_FILE` MUST end",
491
+ `with a \`status\` field that is one of the terminal-success values ${list}:`,
492
+ "",
493
+ ...semantics,
494
+ "",
495
+ `Any \`status\` OUTSIDE ${list} — including a free-form \`success\`/\`in_progress\`/\`failed\` — parks the`,
496
+ "run on a human escalation (the gate is fail-closed), EVEN when your underlying work was correct. So",
497
+ "do not invent a status: report exactly one of the allowlisted values above.",
498
+ ];
499
+ if (requiredEmits.length > 0) {
500
+ lines.push(
501
+ "",
502
+ "AND every emit a downstream node requires must be populated non-null before your result routes",
503
+ "onward — populate each of these top-level fields:",
504
+ "",
505
+ ...requiredEmits.map((f) => `- \`${f.name}\``),
506
+ );
507
+ }
508
+ return lines.join("\n");
509
+ }
510
+
511
+
422
512
  /** Build the `nodeInputs.<element>` seed for one node, per its kind — the exact fields the compiled
423
513
  * subProcess ioMapping pulls. Total over the closed kind set. */
424
514
  function buildNodeInput(
425
515
  node: DeliveryNode,
426
- ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null },
516
+ ctx: { runKey: string; element: string; nodeTimeout: string; probeTimeout: string; probePollEvery: string; escalationSlaTimeout: string; escalationAssignee: string | null; requiredEmits: ReadonlySet<string> },
427
517
  ): NodeInput {
428
518
  switch (node.kind) {
429
519
  case "agent": {
@@ -439,7 +529,13 @@ function buildNodeInput(
439
529
  // truth. A no-emit node appends nothing, so a plain implementation node is unchanged.
440
530
  const basePrompt = node.agent.prompt ?? "";
441
531
  const emits = Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
442
- return { jobType: node.agent.jobType, appendPrompt: renderIdempotencyPreamble() + basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
532
+ // The classifier-emit contract lists ALL declared emits (the agent returns each fact it can, and
533
+ // OMITS an undecidable routing fact). The producer contract's required-emit sentence instead lists
534
+ // only the subset the #731 gate fails closed on — the facts consumed downstream as a required data
535
+ // dependency (`ctx.requiredEmits`) — so it never contradicts the emit contract by demanding a
536
+ // routing-only fact be non-null (#761).
537
+ const requiredEmits = emits.filter((f) => ctx.requiredEmits.has(f.name));
538
+ return { jobType: node.agent.jobType, appendPrompt: renderIdempotencyPreamble() + basePrompt + renderEmitContract(emits) + renderProducerContract(requiredEmits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
443
539
  }
444
540
  case "wait": {
445
541
  const probe = parseProbe(node.wait, { allowLateBoundTarget: true });
@@ -621,6 +621,22 @@ agent node and guard the downstream edge on `<node>.<name>`; a contract-followin
621
621
  **omits** it, and the split takes its `default` (else) branch — the deadlock-safe fallback. A
622
622
  node that declares no `emits` gets no contract text and behaves exactly as before.
623
623
 
624
+ **Producer completion contract (auto-injected — do NOT hand-encode it).** Every `agent` node
625
+ *also* has a **producer completion contract** appended to its prompt at dispatch, alongside the
626
+ idempotency preflight and — only for a node that declares `emits` — the classifier emit contract.
627
+ It hands the agent the terminal-status vocabulary
628
+ the `#731` producer gate enforces: the injected text tells the agent it MUST end its result with
629
+ one of `AGENT_TERMINAL_SUCCESS_STATUSES` (`done` / `opened` / `skipped`) — never omit `status` or
630
+ invent one. The gate routes a completion onward only when its self-reported `status` is one of that
631
+ allowlist **and** every required emit is non-null; any *explicitly non-terminal* status parks the
632
+ run on a human `__contract` escalation (fail-closed). (As a backward-compat concession the gate
633
+ *also* routes onward an **absent/null** status — for legacy workers / stubs that report none — but
634
+ the injected contract never invites a real agent to lean on that: always return an allowlisted
635
+ status.) The wording is **derived from that single allowlist** (changing the
636
+ list changes the prompt — no second copy), so **authors must not hand-encode status vocabulary
637
+ in a node's prompt.** Unlike the emit contract, a no-emit node still receives the status block
638
+ (the gate applies to it too).
639
+
624
640
  ### 9.2 The agent loop: draft → compile → stage → ask an operator to dispatch
625
641
 
626
642
  ```
@@ -209,7 +209,11 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
209
209
  processInstanceKey: job.processInstanceKey ?? null,
210
210
  elementId: job.elementId ?? null,
211
211
  });
212
- return await dispatchConnector(app.db, { dedupeKey: dedupeKey ?? "x", target, payload, boundFacts }, new Date().toISOString());
212
+ // Mirror the real worker's fail-closed contract: an un-dedupable dispatch (no author key AND
213
+ // no engine identity) throws rather than papering over it with a hardcoded fallback that would
214
+ // mask a regression where the connector node stops seeding `dedupeKey`.
215
+ if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
216
+ return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
213
217
  },
214
218
  { fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
215
219
  );
@@ -246,6 +250,64 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
246
250
  assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the resumed producer's result reaches End");
247
251
  });
248
252
 
253
+ test("#760 producer contract satisfied: an agent completing with an allowlisted status + its required emit passes the gate with NO __contract escalation and threads onward", async () => {
254
+ const app = track(await boot(freshDir()));
255
+
256
+ // The instance-15697 failure mode: the agent DID the work correctly and returned its required emit,
257
+ // but self-reported an out-of-vocabulary `status` (e.g. "success") and so was wrongly parked on a
258
+ // __contract escalation — because the terminal-status vocabulary lived ONLY in the gate. With #760
259
+ // the vocabulary is auto-injected into the agent's appendPrompt (proven in the runner unit tests);
260
+ // here we prove the gate's happy path: a status FROM `AGENT_TERMINAL_SUCCESS_STATUSES` ("opened")
261
+ // WITH the required emit sails through — no escalation, the downstream connector fires.
262
+ let agentFired = 0;
263
+ await app.engine.registerWorker("senior:demo", async () => {
264
+ agentFired++;
265
+ return { status: "opened", pr: "owner/repo#99", summary: "PR opened and green." };
266
+ });
267
+ let connectorFired = 0;
268
+ await app.engine.registerWorker(
269
+ "pr.delivery-connector",
270
+ async (job) => {
271
+ connectorFired++;
272
+ const vars = job.variables as Record<string, unknown>;
273
+ const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
274
+ const dedupeKey = connectorDedupeKey({
275
+ dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
276
+ processInstanceKey: job.processInstanceKey ?? null,
277
+ elementId: job.elementId ?? null,
278
+ });
279
+ // Mirror the real worker's fail-closed contract: an un-dedupable dispatch (no author key AND
280
+ // no engine identity) throws rather than papering over it with a hardcoded fallback that would
281
+ // mask a regression where the connector node stops seeding `dedupeKey`.
282
+ if (!dedupeKey) throw new Error("connector stub: no dedupe key (author-supplied or graph-derived) available");
283
+ return await dispatchConnector(app.db, { dedupeKey, target, payload, boundFacts }, new Date().toISOString());
284
+ },
285
+ { fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
286
+ );
287
+
288
+ const graph: DeliveryGraph = {
289
+ name: "e2e producer gate satisfied",
290
+ nodes: [
291
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
292
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-760" } },
293
+ ],
294
+ edges: [{ from: "open.pr", to: "land" }],
295
+ };
296
+
297
+ const run = await runDeliveryGraph(app.engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
298
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
299
+ await app.settle();
300
+
301
+ // The producer satisfied its contract (allowlisted status + non-null required emit): NO __contract
302
+ // escalation was raised, the downstream connector fired once, and the graph reached End.
303
+ assert.equal(agentFired, 1, "the agent node's job fired and completed");
304
+ const createdTasks = await app.engine.searchUserTasks({ state: "CREATED" });
305
+ const contract = createdTasks.find((t) => t.elementId?.startsWith("delivery-human-task__") && t.elementId?.endsWith("__contract"));
306
+ assert.ok(!contract, `an allowlisted status + required emit must NOT escalate, got ${JSON.stringify(createdTasks.map((t) => t.elementId))}`);
307
+ assert.equal(connectorFired, 1, "the satisfied producer threads its result to the downstream connector");
308
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the satisfied producer's result reaches End");
309
+ });
310
+
249
311
  test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
250
312
  const app = track(await boot(freshDir()));
251
313
  // The connector fired once above's-style; here prove the idempotency directly against the ledger a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.184.0",
3
+ "version": "0.185.0",
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",
@@ -4,8 +4,8 @@
4
4
  // • Preview DI — recompile the proposal's BPMN (with diagram interchange) and hand it UP to the host
5
5
  // console's process explorer over the `nano-navigate` bridge, rendered read-only BEFORE dispatch;
6
6
  // • Dispatch — the operator's launch action (#460): POST the proposal's `digest` — plus the
7
- // repository-isolation envelope (`repository` + `baseBranch`, or an explicit `repoless` opt-out,
8
- // #729) the operator supplies inline to the dispatch door. Clicking Dispatch IS the approval,
7
+ // optional run-level repository fallback or explicit `repoless` opt-out — to the dispatch door.
8
+ // By default the graph's nodes supply their own repositories (#739/#758). Clicking Dispatch IS the approval,
9
9
  // content-addressed to exactly the graph previewed.
10
10
  //
11
11
  // It REPLACES the old declarative `dataGrid` (a grid row-action can POST but cannot take the recompiled
@@ -100,22 +100,23 @@ function isPending(pending, kind, digest) {
100
100
  // an in-DOM "Confirm dispatch / Cancel" pair (NOT a native window.confirm, suppressed in the sandboxed
101
101
  // App-View iframe — #569). Clicking "Confirm dispatch" IS the operator approval (#460).
102
102
  //
103
- // The dispatch door (issue #729) now REQUIRES a repository-isolation envelope: the operator must supply
104
- // BOTH `repository` (`owner/repo`) and `baseBranch`, OR explicitly opt out with `repoless` for a
105
- // checkout-less graph dispatching with neither would silently share the worker's launch dir across
106
- // agents. A staged proposal carries no repository metadata, so the operator provides it HERE at
107
- // dispatch time via the inline fields below. Ticking "checkout-less" disables the repo/base inputs.
103
+ // The list omits the graph's repository metadata. Default to node repositories and let the dispatch
104
+ // door validate provisioning (#739/#758); don't duplicate its graph-resolution algorithm here.
105
+ // Run-level fields are an explicit fallback, not an override of nodes' own declarations.
108
106
  function dispatchControl(p, pending) {
109
107
  if (isPending(pending, "dispatch", p.digest)) {
110
108
  const repository = typeof pending.repository === "string" ? pending.repository : "";
111
109
  const baseBranch = typeof pending.baseBranch === "string" ? pending.baseBranch : "";
112
- const repoless = pending.repoless === true;
113
- const provDisabled = repoless ? " disabled" : "";
110
+ const mode = pending.mode;
111
+ const provDisabled = mode === "fallback" ? "" : " disabled";
114
112
  return `<span class="confirm" data-confirm="dispatch">
115
113
  <span class="confirm-msg">${esc(DISPATCH_CONFIRM)}</span>
114
+ <label class="confirm-check"><input type="radio" name="dispatch-mode-${esc(p.digest)}" data-dispatch-mode="${esc(p.digest)}" value="nodes"${mode === "nodes" ? " checked" : ""} /> Use node repositories</label>
115
+ <label class="confirm-check"><input type="radio" name="dispatch-mode-${esc(p.digest)}" data-dispatch-mode="${esc(p.digest)}" value="fallback"${mode === "fallback" ? " checked" : ""} /> Supply run-level fallback</label>
116
+ <label class="confirm-check"><input type="radio" name="dispatch-mode-${esc(p.digest)}" data-dispatch-mode="${esc(p.digest)}" data-dispatch-repoless="${esc(p.digest)}" value="repoless"${mode === "repoless" ? " checked" : ""} /> Dispatch checkout-less (no repository)</label>
117
+ <span class="confirm-msg">Use node repositories sends no run-level repository settings; the server validates that every agent node is provisioned. A run-level repository is a fallback for nodes without their own repository, not an override of node declarations. Checkout-less explicitly opts out of repository provisioning.</span>
116
118
  <label class="confirm-field">Repository <input class="confirm-input" type="text" data-dispatch-repository="${esc(p.digest)}" value="${esc(repository)}" placeholder="owner/repo" aria-label="Repository (owner/repo)"${provDisabled} /></label>
117
119
  <label class="confirm-field">Base branch <input class="confirm-input" type="text" data-dispatch-base="${esc(p.digest)}" value="${esc(baseBranch)}" placeholder="main" aria-label="Base branch"${provDisabled} /></label>
118
- <label class="confirm-check"><input type="checkbox" data-dispatch-repoless="${esc(p.digest)}"${repoless ? " checked" : ""} /> Dispatch checkout-less (no repository)</label>
119
120
  <button class="btn btn-primary" type="button" data-dispatch-confirm="${esc(p.digest)}">Confirm dispatch</button>
120
121
  <button class="btn btn-ghost" type="button" data-dispatch-cancel="${esc(p.digest)}">Cancel</button>
121
122
  </span>`;
@@ -245,10 +246,10 @@ export function mountStagedProposals(host, config = {}) {
245
246
  // click can re-render the SAME list synchronously (without waiting for the next poll).
246
247
  let currentProposals = [];
247
248
  // The single open in-DOM confirmation, or null. Shape: { kind: "dispatch"|"dismiss"|"save",
248
- // digest: string, name?: string, repository?: string, baseBranch?: string, repoless?: boolean }. This
249
+ // digest: string, name?: string, repository?: string, baseBranch?: string, mode: "nodes"|"fallback"|"repoless" }. This
249
250
  // REPLACES the native window.confirm/window.prompt the console's sandboxed App-View iframe suppresses
250
251
  // (#569): the operator approval is an inline two-step control (Confirm/Cancel), an inline name input
251
- // (Save), or — for Dispatch — the repository/baseBranch/repoless envelope fields (#729), all rendered
252
+ // (Save), or — for Dispatch — the provisioning mode and optional fallback fields, all rendered
252
253
  // by renderProposal from this state so a background poll re-render preserves what the operator typed.
253
254
  let pending = null;
254
255
  // A re-render (renderList → new buttons) resets every button to enabled, so the disabled state is
@@ -369,22 +370,7 @@ export function mountStagedProposals(host, config = {}) {
369
370
  // the click handler, #569) — by the time we're here the operator has clicked "Confirm dispatch", so we
370
371
  // POST the digest to the dispatch door; on success the proposal flips to `dispatched` and drops off
371
372
  // the list on the next poll — refresh immediately so the operator sees it leave.
372
- async function doDispatch(digest, opts) {
373
- const staged = typeof digest === "string" ? digest.trim() : "";
374
- if (staged === "") return;
375
- const options = opts && typeof opts === "object" ? opts : {};
376
- const repoless = options.repoless === true;
377
- const repository = typeof options.repository === "string" ? options.repository.trim() : "";
378
- const baseBranch = typeof options.baseBranch === "string" ? options.baseBranch.trim() : "";
379
- // The dispatch door (issue #729) requires an isolation envelope: BOTH `repository` + `baseBranch`,
380
- // or an explicit `repoless: true` opt-out. Guard here so the operator gets an inline hint instead of
381
- // a bare 400 from the door (the confirm has already been closed by the click handler on a valid one).
382
- if (!repoless && (repository === "" || baseBranch === "")) {
383
- setStatus("Provide both a repository (owner/repo) and a base branch, or tick “Dispatch checkout-less”.", "err");
384
- return;
385
- }
386
- // Mirror the door's mutual-exclusivity contract: pass EITHER the repo envelope OR `repoless`, never both.
387
- const payload = repoless ? { digest: staged, repoless: true } : { digest: staged, repository, baseBranch };
373
+ async function doDispatch(payload) {
388
374
  busy(true);
389
375
  setStatus("Dispatching…");
390
376
  try {
@@ -461,7 +447,7 @@ export function mountStagedProposals(host, config = {}) {
461
447
  function openConfirm(kind, digest, name) {
462
448
  const staged = typeof digest === "string" ? digest.trim() : "";
463
449
  if (staged === "") return;
464
- pending = { kind, digest: staged, name: typeof name === "string" ? name : "", repository: "", baseBranch: "", repoless: false };
450
+ pending = { kind, digest: staged, name: typeof name === "string" ? name : "", repository: "", baseBranch: "", mode: "nodes" };
465
451
  rerender();
466
452
  // Move focus into the name input so the operator can type immediately (best-effort; not all hosts
467
453
  // implement focus()).
@@ -496,12 +482,6 @@ export function mountStagedProposals(host, config = {}) {
496
482
  return input && typeof input.value === "string" ? input.value : "";
497
483
  }
498
484
 
499
- // Read the current state of the inline "Dispatch checkout-less" checkbox for `digest`.
500
- function readRepoless(digest) {
501
- const box = listEl.querySelector(`[data-dispatch-repoless="${cssAttr(digest)}"]`);
502
- return !!(box && box.checked);
503
- }
504
-
505
485
  // Keep pending in sync as the operator types, so a background poll re-render (or a later confirm)
506
486
  // preserves what they've entered — the library name (Save) and the repo/base envelope fields (Dispatch).
507
487
  listEl.addEventListener("input", (ev) => {
@@ -523,14 +503,15 @@ export function mountStagedProposals(host, config = {}) {
523
503
  }
524
504
  });
525
505
 
526
- // The "Dispatch checkout-less" checkbox toggles the repo/base inputs (disabled when checkout-less), so
527
- // re-render on change — first hoisting the live text values into pending so the toggle doesn't drop them.
506
+ // Preserve fallback text across mode changes, but only enable and submit it in fallback mode.
528
507
  listEl.addEventListener("change", (ev) => {
529
- const box = ev.target && ev.target.closest ? ev.target.closest("[data-dispatch-repoless]") : null;
530
- if (box && pending != null && pending.kind === "dispatch") {
508
+ const choice = ev.target && ev.target.closest ? ev.target.closest("[data-dispatch-mode]") : null;
509
+ if (choice && choice.checked && pending != null && pending.kind === "dispatch" &&
510
+ choice.getAttribute("data-dispatch-mode") === pending.digest &&
511
+ ["nodes", "fallback", "repoless"].includes(choice.value)) {
531
512
  pending.repository = readDispatchField(pending.digest, "repository");
532
513
  pending.baseBranch = readDispatchField(pending.digest, "base");
533
- pending.repoless = !!box.checked;
514
+ pending.mode = choice.value;
534
515
  rerender();
535
516
  }
536
517
  });
@@ -556,21 +537,26 @@ export function mountStagedProposals(host, config = {}) {
556
537
  const dispatchConfirmBtn = closest("[data-dispatch-confirm]");
557
538
  if (dispatchConfirmBtn) {
558
539
  ev.preventDefault();
559
- const digest = dispatchConfirmBtn.getAttribute("data-dispatch-confirm");
540
+ const rawDigest = dispatchConfirmBtn.getAttribute("data-dispatch-confirm");
541
+ const digest = typeof rawDigest === "string" ? rawDigest.trim() : "";
560
542
  const isThisPending = pending != null && pending.kind === "dispatch" && pending.digest === digest;
561
- // `pending.repoless` is the durable source of truth (synced on the checkbox `change`); fall back to
562
- // the live DOM checkbox so a confirm without a prior toggle still reads correctly.
563
- const repoless = readRepoless(digest) || (isThisPending && pending.repoless === true);
543
+ if (!isThisPending) return;
544
+ const mode = pending.mode;
564
545
  const repository = readDispatchField(digest, "repository").trim();
565
546
  const baseBranch = readDispatchField(digest, "base").trim();
566
547
  // Keep the inline confirmation OPEN on an incomplete envelope so the operator can fix it in place
567
548
  // (issue #729) — closing it would drop the fields they'd started filling.
568
- if (!repoless && (repository === "" || baseBranch === "")) {
569
- setStatus("Provide both a repository (owner/repo) and a base branch, or tick “Dispatch checkout-less”.", "err");
549
+ if (mode === "fallback" && (repository === "" || baseBranch === "")) {
550
+ setStatus("Run-level fallback requires both a repository (owner/repo) and a base branch.", "err");
570
551
  return;
571
552
  }
553
+ // Construct only the selected mode's fields: disabled fallback text must never leak into node
554
+ // or checkout-less dispatches, and repoless must never be inferred from blank fields.
555
+ const payload = mode === "fallback"
556
+ ? { digest, repository, baseBranch }
557
+ : mode === "repoless" ? { digest, repoless: true } : { digest };
572
558
  closeConfirm();
573
- doDispatch(digest, { repository, baseBranch, repoless });
559
+ doDispatch(payload);
574
560
  return;
575
561
  }
576
562
  const dispatchCancelBtn = closest("[data-dispatch-cancel]");
@@ -37,7 +37,7 @@ interface FetchCall {
37
37
 
38
38
  /** Boot the real mount over a linkedom DOM whose `window` mimics a sandboxed App-View iframe (native
39
39
  * confirm/prompt SUPPRESSED → false/null), with a recording fetch double. */
40
- function harness() {
40
+ function harness(dispatchError?: string) {
41
41
  const { window: domWindow, document } = parseHTML(
42
42
  "<!doctype html><html><body><div id='host'></div></body></html>",
43
43
  );
@@ -54,7 +54,11 @@ function harness() {
54
54
  if (method === "GET" && url === STAGED_URL) {
55
55
  return new Response(JSON.stringify({ proposals: [PROPOSAL] }), { status: 200 });
56
56
  }
57
- if (url === DISPATCH_URL) return new Response(JSON.stringify({ ok: true }), { status: 202 });
57
+ if (url === DISPATCH_URL) {
58
+ return dispatchError
59
+ ? new Response(JSON.stringify({ ok: false, error: dispatchError }), { status: 400 })
60
+ : new Response(JSON.stringify({ ok: true }), { status: 202 });
61
+ }
58
62
  if (url === DISMISS_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
59
63
  if (url === SAVE_URL) return new Response(JSON.stringify({ ok: true }), { status: 200 });
60
64
  return new Response(JSON.stringify({}), { status: 404 });
@@ -93,8 +97,14 @@ function harness() {
93
97
  el.dispatchEvent(new domWindow.Event("click", { bubbles: true, cancelable: true }));
94
98
  const fire = (el: { dispatchEvent: (ev: unknown) => boolean }, type: string) =>
95
99
  el.dispatchEvent(new domWindow.Event(type, { bubbles: true, cancelable: true }));
100
+ const mode = (value: string) => {
101
+ const input = host.querySelector(`[data-dispatch-mode][value="${value}"]`);
102
+ assert(input, `the mounted confirmation must offer ${value} mode`);
103
+ input.checked = true;
104
+ fire(input, "change");
105
+ };
96
106
 
97
- return { host, calls, teardown, flush, click, fire };
107
+ return { host, calls, teardown, flush, click, fire, mode };
98
108
  }
99
109
 
100
110
  const posts = (calls: FetchCall[], url: string) => calls.filter((c) => c.method === "POST" && c.url === url);
@@ -112,12 +122,12 @@ test("#569/#729: Dispatch dispatches via the in-DOM confirmation with the repo e
112
122
  h.click(dispatchBtn);
113
123
  await h.flush();
114
124
  assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "Dispatch must not POST before the operator confirms in-DOM");
125
+ h.mode("fallback");
115
126
  const confirmBtn = h.host.querySelector("[data-dispatch-confirm]");
116
127
  assert(confirmBtn, "clicking Dispatch must reveal an in-DOM Confirm-dispatch control (#569), not call window.confirm");
117
128
  assert(!h.host.querySelector("[data-dispatch]"), "the plain Dispatch button is replaced by the inline confirmation while it is open");
118
129
 
119
- // The operator supplies the repository-isolation envelope the door now requires (#729): a staged
120
- // proposal carries no repo metadata, so the cockpit collects it here.
130
+ // In fallback mode the operator explicitly supplies the run-level repository-isolation envelope.
121
131
  const repoInput = h.host.querySelector("[data-dispatch-repository]");
122
132
  const baseInput = h.host.querySelector("[data-dispatch-base]");
123
133
  assert(repoInput && baseInput, "the Dispatch confirmation must expose repository + base-branch fields (#729)");
@@ -165,23 +175,134 @@ test("#729: Dispatch checkout-less posts `repoless: true` (no repo envelope) ins
165
175
  }
166
176
  });
167
177
 
168
- test("#729: confirming a dispatch with neither the repo envelope nor checkout-less does NOT POST (keeps the confirmation open)", async () => {
178
+ test("#729/#758: fallback mode requires both repository and base branch and keeps incomplete confirmation open", async () => {
169
179
  const h = harness();
170
180
  try {
171
181
  await h.flush();
172
182
  h.click(h.host.querySelector("[data-dispatch]"));
173
183
  await h.flush();
174
- // Confirm with blank repository/baseBranch and checkout-less unticked — the door would 400, so the
175
- // mount guards client-side: no POST, and the confirmation stays open so the operator can fix it.
184
+ h.mode("fallback");
185
+ // An explicitly selected fallback needs both fields; keep it editable rather than silently
186
+ // changing to node repositories or checkout-less when the fields are blank.
176
187
  h.click(h.host.querySelector("[data-dispatch-confirm]"));
177
188
  await h.flush();
178
189
  assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "an incomplete envelope must not POST to the dispatch door (#729)");
179
190
  assert(h.host.querySelector("[data-dispatch-confirm]"), "the confirmation stays open so the operator can supply the envelope");
191
+ const repoInput = h.host.querySelector("[data-dispatch-repository]");
192
+ const baseInput = h.host.querySelector("[data-dispatch-base]");
193
+ for (const [repository, baseBranch] of [["acme/widgets", " "], [" ", "main"]]) {
194
+ repoInput.value = repository;
195
+ baseInput.value = baseBranch;
196
+ h.click(h.host.querySelector("[data-dispatch-confirm]"));
197
+ await h.flush();
198
+ assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "each fallback field is required");
199
+ assert(h.host.querySelector("[data-dispatch-confirm]"), "invalid fallback remains editable");
200
+ }
180
201
  } finally {
181
202
  h.teardown();
182
203
  }
183
204
  });
184
205
 
206
+ test("#758: node-provisioned graph confirms through the real mount with digest only", async () => {
207
+ const h = harness();
208
+ try {
209
+ await h.flush();
210
+ h.click(h.host.querySelector("[data-dispatch]"));
211
+ assertEquals(posts(h.calls, DISPATCH_URL).length, 0, "opening confirmation must not dispatch");
212
+ h.click(h.host.querySelector("[data-dispatch-confirm]"));
213
+ await h.flush();
214
+ const dispatched = posts(h.calls, DISPATCH_URL);
215
+ assertEquals(dispatched.length, 1, "node repositories must reach the authoritative dispatch door");
216
+ assertEquals(JSON.parse(dispatched[0].body), { digest: PROPOSAL.digest });
217
+ assert(h.host.querySelector("#dg-staged-status").textContent.includes("Dispatched"));
218
+ } finally {
219
+ h.teardown();
220
+ }
221
+ });
222
+
223
+ test("dispatch normalizes the attribute-sourced digest before POSTing (untrusted DOM value)", async () => {
224
+ // The confirm digest is read back from a DOM attribute (untrusted) at confirm time; like doDismiss/
225
+ // doSaveToLibrary/doPreviewDi, dispatch must trim it so accidental whitespace never reaches the door.
226
+ const h = harness();
227
+ try {
228
+ await h.flush();
229
+ h.click(h.host.querySelector("[data-dispatch]"));
230
+ const confirmBtn = h.host.querySelector("[data-dispatch-confirm]");
231
+ assert(confirmBtn, "the in-DOM Confirm dispatch affordance must appear");
232
+ // Simulate an untrusted DOM value: pad the attribute the confirm handler reads the digest from.
233
+ confirmBtn.setAttribute("data-dispatch-confirm", ` ${PROPOSAL.digest} `);
234
+ h.click(confirmBtn);
235
+ await h.flush();
236
+ const dispatched = posts(h.calls, DISPATCH_URL);
237
+ assertEquals(dispatched.length, 1, "the confirmed dispatch must POST despite the padded attribute");
238
+ assertEquals(JSON.parse(dispatched[0].body), { digest: PROPOSAL.digest }, "the digest is trimmed before dispatch");
239
+ } finally {
240
+ h.teardown();
241
+ }
242
+ });
243
+
244
+ test("#758: node mode displays the server's missing-node provisioning error without inferring checkout-less", async () => {
245
+ const error = "1 agent node(s) resolve to no repository (implement-api): each must declare its own `repository`, or the dispatch must supply a run-level `repository` + `baseBranch` fallback";
246
+ const h = harness(error);
247
+ try {
248
+ await h.flush();
249
+ h.click(h.host.querySelector("[data-dispatch]"));
250
+ h.click(h.host.querySelector("[data-dispatch-confirm]"));
251
+ await h.flush();
252
+ assertEquals(h.host.querySelector("#dg-staged-status").textContent, error);
253
+ assertEquals(posts(h.calls, DISPATCH_URL).map((call) => JSON.parse(call.body)), [{ digest: PROPOSAL.digest }]);
254
+ assert(h.host.querySelector("[data-dispatch]"), "the rejected proposal remains available");
255
+ } finally {
256
+ h.teardown();
257
+ }
258
+ });
259
+
260
+ test("#758: repository choices explain server validation and run-level fallback semantics", async () => {
261
+ const h = harness();
262
+ try {
263
+ await h.flush();
264
+ h.click(h.host.querySelector("[data-dispatch]"));
265
+ const nodes = h.host.querySelector('[data-dispatch-mode][value="nodes"]');
266
+ assert(nodes?.hasAttribute("checked"), "node repositories is the explicit default, not checkout-less");
267
+ assert(h.host.querySelector("[data-dispatch-repository]").disabled);
268
+ assert(h.host.querySelector("[data-dispatch-base]").disabled);
269
+ const text = h.host.textContent;
270
+ assert(text.includes("Use node repositories"));
271
+ assert(text.includes("fallback for nodes without their own repository"));
272
+ assert(text.includes("server validates"));
273
+ } finally {
274
+ h.teardown();
275
+ }
276
+ });
277
+
278
+ for (const target of ["nodes", "repoless", "fallback"]) {
279
+ test(`#758: switching modes to ${target} submits only the selected provisioning choice`, async () => {
280
+ const h = harness();
281
+ try {
282
+ await h.flush();
283
+ h.click(h.host.querySelector("[data-dispatch]"));
284
+ h.mode("fallback");
285
+ h.host.querySelector("[data-dispatch-repository]").value = " acme/widgets ";
286
+ h.host.querySelector("[data-dispatch-base]").value = " main ";
287
+ h.mode("repoless");
288
+ assert(h.host.querySelector("[data-dispatch-repository]").disabled);
289
+ assert(h.host.querySelector("[data-dispatch-base]").disabled);
290
+ h.mode("nodes");
291
+ h.mode(target);
292
+ h.click(h.host.querySelector("[data-dispatch-confirm]"));
293
+ await h.flush();
294
+ const expected = target === "fallback"
295
+ ? { digest: PROPOSAL.digest, repository: "acme/widgets", baseBranch: "main" }
296
+ : target === "repoless"
297
+ ? { digest: PROPOSAL.digest, repoless: true }
298
+ : { digest: PROPOSAL.digest };
299
+ assertEquals(posts(h.calls, DISPATCH_URL).map((call) => JSON.parse(call.body)), [expected]);
300
+ } finally {
301
+ h.teardown();
302
+ }
303
+ });
304
+ }
305
+
185
306
  test("#569: Cancel on the in-DOM Dispatch confirmation aborts without POSTing", async () => {
186
307
  const h = harness();
187
308
  try {