@nanobpm/nano-workforce 0.184.1 → 0.185.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/app/deliveryGraphCompiler.ts +5 -2
- package/app/deliveryRunner.test.ts +102 -6
- package/app/deliveryRunner.ts +100 -4
- package/app/readiness.test.ts +51 -0
- package/app/readiness.ts +55 -12
- package/docs/agent-guide.md +16 -0
- package/e2e/delivery-graph.e2e.ts +63 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.185.1](https://github.com/nanobpm/nano-workforce/compare/v0.185.0...v0.185.1) (2026-09-08)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **agentic:** capability probe recognises single-package v-prefixed release tags ([#765](https://github.com/nanobpm/nano-workforce/issues/765)) ([79faa67](https://github.com/nanobpm/nano-workforce/commit/79faa676f46882d4e10f3ce0bb70080f40e3e757)), closes [#764](https://github.com/nanobpm/nano-workforce/issues/764)
|
|
6
|
+
|
|
7
|
+
## [0.185.0](https://github.com/nanobpm/nano-workforce/compare/v0.184.1...v0.185.0) (2026-09-08)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **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)
|
|
12
|
+
|
|
1
13
|
## [0.184.1](https://github.com/nanobpm/nano-workforce/compare/v0.184.0...v0.184.1) (2026-09-08)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
|
@@ -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
|
-
|
|
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 {
|
|
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 (
|
|
80
|
-
|
|
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
|
|
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
|
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -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
|
-
|
|
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 });
|
package/app/readiness.test.ts
CHANGED
|
@@ -220,6 +220,57 @@ test("matchCapability: the bare '#274' ref form resolves identically to 'nano-id
|
|
|
220
220
|
assertEquals(res.bind?.resolvedArtifact, "@nanobpm/urban@1.2.3");
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
// ── #764: single-package repos tag releases `v<version>` / bare `<version>` (semantic-release default) ──
|
|
224
|
+
|
|
225
|
+
const c8Match = { capabilityRef: "c8ctl-plugin-nano#206", package: "c8ctl-plugin-nano" };
|
|
226
|
+
|
|
227
|
+
test("#764 matchCapability: a `v<version>` release (body refs the cap) resolves in a single-package repo", () => {
|
|
228
|
+
// Red before the fix: `v1.58.0` never matched versionForPackage(tag, 'c8ctl-plugin-nano'),
|
|
229
|
+
// so the gate reported 'not published yet' though the npm package WAS published.
|
|
230
|
+
const res = matchCapability(c8Match, [rel("v1.58.0", [206])]);
|
|
231
|
+
assert(res.ready);
|
|
232
|
+
assertEquals(res.bind?.resolvedArtifact, "c8ctl-plugin-nano@1.58.0");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("#764 matchCapability: a bare `<version>` release resolves the same way in a single-package repo", () => {
|
|
236
|
+
const res = matchCapability(c8Match, [rel("1.58.0", [206])]);
|
|
237
|
+
assert(res.ready);
|
|
238
|
+
assertEquals(res.bind?.resolvedArtifact, "c8ctl-plugin-nano@1.58.0");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("#764 monorepo guard: a stray `v2.0.0` alongside package-scoped tags is NOT attributed to another package", () => {
|
|
242
|
+
// The repo emits package-scoped tags → it is a monorepo → bare `v` tags are ignored, so the stray
|
|
243
|
+
// v2.0.0 can never leak into @nanobpm/other's resolution.
|
|
244
|
+
const releases = [rel("@nanobpm/other@1.0.0", [200]), rel("v2.0.0", [274])];
|
|
245
|
+
const res = matchCapability({ capabilityRef: "nano-ide#274", package: "@nanobpm/other" }, releases);
|
|
246
|
+
assert(!res.ready, "the stray v2.0.0 must not resolve #274 for @nanobpm/other");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("#764 summariseCapabilityCandidates: shows the extended `v`-tag candidate in a single-package repo", () => {
|
|
250
|
+
const summary = summariseCapabilityCandidates(c8Match, [rel("v1.58.0", [206]), rel("v1.57.0", [200])]);
|
|
251
|
+
assertStringIncludes(summary, "2 c8ctl-plugin-nano release(s) observed");
|
|
252
|
+
assertStringIncludes(summary, "c8ctl-plugin-nano@1.58.0 refs #206");
|
|
253
|
+
assertStringIncludes(summary, "c8ctl-plugin-nano@1.57.0 no #206");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("#764 summariseCapabilityCandidates: in a monorepo a stray `v` tag is not summarised as another package's candidate", () => {
|
|
257
|
+
const summary = summariseCapabilityCandidates({ capabilityRef: "nano-ide#274", package: "@nanobpm/other" }, [
|
|
258
|
+
rel("@nanobpm/other@1.0.0", [200]),
|
|
259
|
+
rel("v2.0.0", [274]),
|
|
260
|
+
]);
|
|
261
|
+
assertStringIncludes(summary, "1 @nanobpm/other release(s) observed");
|
|
262
|
+
assert(!summary.includes("2.0.0"), "the stray monorepo v2.0.0 is not a candidate for @nanobpm/other");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("#764 newestPublishedVersion: resolves from `v`-tags under the single-package condition", () => {
|
|
266
|
+
assertEquals(newestPublishedVersion("c8ctl-plugin-nano", [rel("v1.57.0", []), rel("v1.58.0", [])]), "1.58.0");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("#764 newestPublishedVersion: a stray `v` tag in a monorepo is ignored for a scoped package", () => {
|
|
270
|
+
const releases = [rel("@nanobpm/urban@0.9.0", []), rel("v9.9.9", [])];
|
|
271
|
+
assertEquals(newestPublishedVersion("@nanobpm/urban", releases), "0.9.0");
|
|
272
|
+
});
|
|
273
|
+
|
|
223
274
|
// ── #514 Defect A: the capability gate is self-diagnosing on escalation ──────────────────────────
|
|
224
275
|
|
|
225
276
|
test("#514 Defect A summariseCapabilityCandidates: lists candidate releases (newest first) and whether each references the ref", () => {
|
package/app/readiness.ts
CHANGED
|
@@ -527,18 +527,58 @@ function bodyReferences(body: string, num: string): boolean {
|
|
|
527
527
|
return new RegExp(`#${num}(?!\\d)`).test(body);
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
-
/**
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
530
|
+
/** A numeric-dotted SemVer-ish core, e.g. `1.58.0`. */
|
|
531
|
+
const VERSION_CORE = /^\d+(\.\d+)*$/;
|
|
532
|
+
|
|
533
|
+
/** Is `tag` a **package-scoped** version tag — `<anything>@<version>` (numeric-dotted), the monorepo
|
|
534
|
+
* convention (e.g. `@nanobpm/urban@0.54.0`, `c8ctl-plugin-nano@1.58.0`)? Used to decide whether a
|
|
535
|
+
* repo follows the single-package (`v1.58.0` / bare) convention: a repo is "single-package" only when
|
|
536
|
+
* it emits NO package-scoped tags at all. Anchored on the LAST `@` so scoped npm names (`@org/name@1.0.0`)
|
|
537
|
+
* are recognised. */
|
|
538
|
+
function isPackageScopedVersionTag(tag: string): boolean {
|
|
539
|
+
const at = tag.lastIndexOf("@");
|
|
540
|
+
if (at <= 0) return false; // no `@`, or a leading `@` (scoped-name start) with nothing before it
|
|
541
|
+
return VERSION_CORE.test(tag.slice(at + 1).trim());
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** True when the repo's observed release set contains NO `<anything>@<version>`-prefixed package-scoped
|
|
545
|
+
* tags at all — i.e. it follows the single-package `v<version>` / bare `<version>` convention
|
|
546
|
+
* (semantic-release default), so a bare `v1.58.0` tag may be attributed to the sole package (#764).
|
|
547
|
+
* A repo using package-scoped tags is a monorepo: bare `v` tags are ignored there, keeping
|
|
548
|
+
* per-package provenance scoping airtight (a sibling package's provenance can never leak). */
|
|
549
|
+
function repoIsSinglePackage(releases: readonly GithubRelease[]): boolean {
|
|
550
|
+
for (const rel of releases) {
|
|
551
|
+
if (rel && typeof rel.tag === "string" && isPackageScopedVersionTag(rel.tag)) return false;
|
|
552
|
+
}
|
|
553
|
+
return true;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The `<version>` a release tag carries for `pkg`, or undefined when it belongs to another package /
|
|
557
|
+
* is not a version tag. A tag is a candidate for `pkg` when **either**:
|
|
558
|
+
*
|
|
559
|
+
* 1. it is `<pkg>@<version>` (numeric-dotted) — the monorepo convention, per-package scoped so a
|
|
560
|
+
* sibling package's provenance can never leak; **or**
|
|
561
|
+
* 2. `singlePackage` is true (the repo emits no package-scoped tags — see {@link repoIsSinglePackage})
|
|
562
|
+
* AND the tag is `v<version>` or a bare `<version>` — the single-package / semantic-release-default
|
|
563
|
+
* convention (#764). In a monorepo (`singlePackage` false) bare `v` tags are ignored, so scoping
|
|
564
|
+
* stays airtight. */
|
|
565
|
+
function versionForPackage(tag: string, pkg: string, singlePackage: boolean): string | undefined {
|
|
534
566
|
const prefix = `${pkg}@`;
|
|
535
|
-
if (
|
|
536
|
-
|
|
537
|
-
|
|
567
|
+
if (tag.startsWith(prefix)) {
|
|
568
|
+
const v = tag.slice(prefix.length).trim();
|
|
569
|
+
return VERSION_CORE.test(v) ? v : undefined;
|
|
570
|
+
}
|
|
571
|
+
if (singlePackage) {
|
|
572
|
+
const v = (tag.startsWith("v") ? tag.slice(1) : tag).trim();
|
|
573
|
+
if (VERSION_CORE.test(v)) return v;
|
|
574
|
+
}
|
|
575
|
+
return undefined;
|
|
538
576
|
}
|
|
539
577
|
|
|
540
578
|
/** A compact, deterministic, BOUNDED summary of the capability candidate releases a probe observed
|
|
541
|
-
* (issue #514 Defect A). Lists the releases
|
|
579
|
+
* (issue #514 Defect A). Lists the releases carrying a resolvable `<match.package>` version — tagged
|
|
580
|
+
* `<match.package>@<version>`, or (for a single-package repo) a `v<version>`/bare `<version>` tag —
|
|
581
|
+
* newest first, and,
|
|
542
582
|
* for each, whether its body referenced `match.capabilityRef` — so an escalated capability gate can
|
|
543
583
|
* show a human/agent EXACTLY what the probe saw (a genuine "not published yet" vs. a transient
|
|
544
584
|
* false-negative where a matching release was live but its provenance body was momentarily empty).
|
|
@@ -548,6 +588,7 @@ function versionForPackage(tag: string, pkg: string): string | undefined {
|
|
|
548
588
|
export function summariseCapabilityCandidates(
|
|
549
589
|
match: ProbeMatch | undefined,
|
|
550
590
|
releases: readonly GithubRelease[],
|
|
591
|
+
singlePackage: boolean = repoIsSinglePackage(releases),
|
|
551
592
|
): string {
|
|
552
593
|
const pkg = match?.package;
|
|
553
594
|
const ref = match?.capabilityRef;
|
|
@@ -556,7 +597,7 @@ export function summariseCapabilityCandidates(
|
|
|
556
597
|
const candidates: { version: string; refs: boolean }[] = [];
|
|
557
598
|
for (const rel of releases) {
|
|
558
599
|
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
559
|
-
const version = versionForPackage(rel.tag, pkg);
|
|
600
|
+
const version = versionForPackage(rel.tag, pkg, singlePackage);
|
|
560
601
|
if (!version) continue;
|
|
561
602
|
candidates.push({ version, refs: num !== undefined && bodyReferences(rel.body, num) });
|
|
562
603
|
}
|
|
@@ -591,7 +632,8 @@ const CAPABILITY_SUMMARY_LIMIT = 8;
|
|
|
591
632
|
export function matchCapability(match: ProbeMatch | undefined, releases: readonly GithubRelease[]): ProbeResult {
|
|
592
633
|
const pkg = match?.package;
|
|
593
634
|
const ref = match?.capabilityRef;
|
|
594
|
-
const
|
|
635
|
+
const singlePackage = repoIsSinglePackage(releases);
|
|
636
|
+
const observed = summariseCapabilityCandidates(match, releases, singlePackage);
|
|
595
637
|
if (!pkg || !ref) return { ready: false, detail: "capability: missing package/capabilityRef", observed };
|
|
596
638
|
const num = capabilityNumber(ref);
|
|
597
639
|
if (!num) return { ready: false, detail: "capability: unparseable capabilityRef (no #NNN)", observed };
|
|
@@ -599,7 +641,7 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
|
|
|
599
641
|
let firstVersion: string | undefined;
|
|
600
642
|
for (const rel of releases) {
|
|
601
643
|
if (!rel || typeof rel.tag !== "string" || typeof rel.body !== "string") continue;
|
|
602
|
-
const version = versionForPackage(rel.tag, pkg);
|
|
644
|
+
const version = versionForPackage(rel.tag, pkg, singlePackage);
|
|
603
645
|
if (!version) continue;
|
|
604
646
|
if (!bodyReferences(rel.body, num)) continue;
|
|
605
647
|
if (firstVersion === undefined || cmpVersion(version, firstVersion) < 0) firstVersion = version;
|
|
@@ -615,9 +657,10 @@ export function matchCapability(match: ProbeMatch | undefined, releases: readonl
|
|
|
615
657
|
export function newestPublishedVersion(pkg: string | undefined, releases: readonly GithubRelease[]): string | undefined {
|
|
616
658
|
if (!pkg) return undefined;
|
|
617
659
|
let newest: string | undefined;
|
|
660
|
+
const singlePackage = repoIsSinglePackage(releases);
|
|
618
661
|
for (const rel of releases) {
|
|
619
662
|
if (!rel || typeof rel.tag !== "string") continue;
|
|
620
|
-
const version = versionForPackage(rel.tag, pkg);
|
|
663
|
+
const version = versionForPackage(rel.tag, pkg, singlePackage);
|
|
621
664
|
if (!version) continue;
|
|
622
665
|
if (newest === undefined || cmpVersion(version, newest) > 0) newest = version;
|
|
623
666
|
}
|
package/docs/agent-guide.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "0.185.1",
|
|
4
4
|
"description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "main.ts",
|