@nanobpm/nano-workforce 0.142.0 → 0.143.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 +6 -0
- package/app/deliveryRunner.test.ts +50 -8
- package/app/deliveryRunner.ts +43 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## [0.143.0](https://github.com/nanobpm/nano-workforce/compare/v0.142.0...v0.143.0) (2026-08-25)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **delivery-graph:** idempotency preflight on agent nodes ([#551](https://github.com/nanobpm/nano-workforce/issues/551)) ([#552](https://github.com/nanobpm/nano-workforce/issues/552)) ([f16fa6d](https://github.com/nanobpm/nano-workforce/commit/f16fa6d32336fdd025b810ba763403b352986937)), closes [979/#980](https://github.com/979/nano-workforce/issues/980) [#506](https://github.com/nanobpm/nano-workforce/issues/506)
|
|
6
|
+
|
|
1
7
|
## [0.142.0](https://github.com/nanobpm/nano-workforce/compare/v0.141.0...v0.142.0) (2026-08-25)
|
|
2
8
|
|
|
3
9
|
### Features
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// proven end-to-end in `e2e/delivery-graph.e2e.ts`.
|
|
12
12
|
import { test } from "node:test";
|
|
13
13
|
import { assert, assertEquals } from "#test-assert";
|
|
14
|
-
import { prepareDeliveryGraph, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
14
|
+
import { prepareDeliveryGraph, renderIdempotencyPreamble, runDeliveryGraph } from "./deliveryRunner.ts";
|
|
15
15
|
import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
|
|
16
16
|
|
|
17
17
|
const GRAPH: DeliveryGraph = {
|
|
@@ -70,7 +70,9 @@ test("nodeInputs seeds the exact per-kind fields each node's subProcess ioMappin
|
|
|
70
70
|
const byField = (pred: (v: Record<string, unknown>) => boolean) => Object.values(inputs).find((v) => pred(v as Record<string, unknown>)) as Record<string, unknown> | undefined;
|
|
71
71
|
|
|
72
72
|
const agent = byField((v) => v.jobType === "senior:feature");
|
|
73
|
-
|
|
73
|
+
// Every agent node's appendPrompt is prefixed with the idempotency preflight (#551), then the
|
|
74
|
+
// authored prompt (this node declares no emits, so the emit contract adds nothing).
|
|
75
|
+
assertEquals(agent, { jobType: "senior:feature", appendPrompt: renderIdempotencyPreamble() + "un-draft + merge #B", timeout: "PT10M" });
|
|
74
76
|
|
|
75
77
|
const wait = byField((v) => "gateKey" in v);
|
|
76
78
|
assertEquals(wait?.gateKey, "run-7:n3");
|
|
@@ -135,20 +137,60 @@ test("agent node classifier-emit contract (#506): a declared `emits` threads the
|
|
|
135
137
|
};
|
|
136
138
|
const p = await prepareOk(graph);
|
|
137
139
|
const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
|
|
138
|
-
const adopt = agents.find((v) => String(v.appendPrompt).
|
|
139
|
-
const plain = agents.find((v) => String(v.appendPrompt).
|
|
140
|
+
const adopt = agents.find((v) => String(v.appendPrompt).includes("adopt the package"));
|
|
141
|
+
const plain = agents.find((v) => String(v.appendPrompt).includes("just implement it"));
|
|
140
142
|
|
|
141
|
-
// The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact
|
|
143
|
+
// The emit-declaring node keeps its authored prompt AND gains the emit contract naming its fact,
|
|
144
|
+
// both AFTER the unconditional idempotency preflight (#551).
|
|
142
145
|
assert(adopt, "the emit-declaring agent node must be seeded");
|
|
143
146
|
const adoptPrompt = String(adopt?.appendPrompt);
|
|
144
|
-
assert(adoptPrompt.startsWith(
|
|
147
|
+
assert(adoptPrompt.startsWith(renderIdempotencyPreamble()), "the idempotency preflight leads every agent prompt");
|
|
148
|
+
assert(adoptPrompt.includes("adopt the package"), "the authored prompt is preserved after the preflight");
|
|
145
149
|
assert(adoptPrompt.includes("Classifier emit contract"), `the emit contract must be threaded in, got: ${adoptPrompt}`);
|
|
146
150
|
assert(adoptPrompt.includes("`result`") && adoptPrompt.includes("(string)"), "the declared fact name + type must be surfaced to the agent");
|
|
147
151
|
assert(adoptPrompt.includes("breaking | compatible"), "the fact's optional description rides the contract");
|
|
148
152
|
assert(adoptPrompt.includes("AGENT_RESULT_FILE"), "the contract names the completion channel the fact rides");
|
|
149
153
|
|
|
150
|
-
// A node that declares NO facts
|
|
151
|
-
|
|
154
|
+
// A node that declares NO facts still carries the preflight, then exactly the authored prompt — the
|
|
155
|
+
// emit contract contributes nothing.
|
|
156
|
+
assertEquals(plain?.appendPrompt, renderIdempotencyPreamble() + "just implement it");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
test("agent node idempotency preflight (#551): every agent prompt leads with adopt-and-report guidance; non-agent nodes are untouched", async () => {
|
|
161
|
+
// #551: a delivery agent node dispatches a raw retry-carrying `senior:feature` job with no
|
|
162
|
+
// PR-existence guard, so a re-dispatch opened a DUPLICATE PR (instance 43077 n0 → #979/#980). The
|
|
163
|
+
// fix threads an unconditional idempotency preflight into every agent node's `appendPrompt` telling
|
|
164
|
+
// the agent to CHECK for an existing claim/open PR and ADOPT-AND-REPORT it rather than open a second.
|
|
165
|
+
// Pin: (a) both agent nodes lead with the preflight regardless of emits, (b) it names the check +
|
|
166
|
+
// the adopt-and-report contract, and (c) wait/human/connector nodes never carry it.
|
|
167
|
+
const graph: DeliveryGraph = {
|
|
168
|
+
name: "idempotency",
|
|
169
|
+
nodes: [
|
|
170
|
+
{ id: "emitter", kind: "agent", agent: { jobType: "senior:feature", prompt: "do X" }, emits: [{ name: "result", type: "string" }] },
|
|
171
|
+
{ id: "plain", kind: "agent", agent: { jobType: "senior:feature", prompt: "do Y" } },
|
|
172
|
+
{ id: "gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" } } },
|
|
173
|
+
],
|
|
174
|
+
edges: [{ from: "emitter", to: "plain" }, { from: "plain", to: "gate" }],
|
|
175
|
+
};
|
|
176
|
+
const p = await prepareOk(graph);
|
|
177
|
+
const preamble = renderIdempotencyPreamble();
|
|
178
|
+
|
|
179
|
+
// Every agent node leads with the preflight, ahead of its authored prompt (and any emit contract).
|
|
180
|
+
const agents = Object.values(p.nodeInputs).filter((v) => "jobType" in v) as Array<Record<string, unknown>>;
|
|
181
|
+
assertEquals(agents.length, 2, "both agent nodes are seeded");
|
|
182
|
+
for (const a of agents) {
|
|
183
|
+
const prompt = String(a.appendPrompt);
|
|
184
|
+
assert(prompt.startsWith(preamble), "the preflight is the leading prefix of every agent prompt");
|
|
185
|
+
assert(prompt.includes("Idempotency preflight"), "the preflight heading is present");
|
|
186
|
+
assert(prompt.includes("DO NOT open a second PR") || prompt.includes("do not open a second"), "it forbids a duplicate PR");
|
|
187
|
+
assert(prompt.includes("adopt and report"), "it names the adopt-and-report contract");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// The preflight is unconditional but AGENT-ONLY — a wait node's seed carries no prompt at all.
|
|
191
|
+
const wait = Object.values(p.nodeInputs).find((v) => "gateKey" in v) as Record<string, unknown> | undefined;
|
|
192
|
+
assert(wait, "the wait node is seeded");
|
|
193
|
+
assert(!("appendPrompt" in wait!), "a non-agent node never carries the agent idempotency preflight");
|
|
152
194
|
});
|
|
153
195
|
|
|
154
196
|
|
package/app/deliveryRunner.ts
CHANGED
|
@@ -178,6 +178,48 @@ function rewriteProcessId(bpmn: string, processDefinitionId: string): string {
|
|
|
178
178
|
.replace(`bpmnElement="${DELIVERY_GRAPH_PROCESS_ID}"`, `bpmnElement="${processDefinitionId}"`);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
/** The idempotency preflight prepended to EVERY `agent` node's `appendPrompt` (issue #551). A delivery
|
|
182
|
+
* agent node dispatches a raw `senior:feature` job with no `feature_runs` idempotency row and no
|
|
183
|
+
* PR-existence guard, and the job carries retries — so an idle/timeout re-dispatch hands the SAME
|
|
184
|
+
* "implement #N" prompt to another worker, who (in a fresh worktree, blind to the first) opens a SECOND
|
|
185
|
+
* PR on the same issue. That is exactly how instance 43077's node n0 (`Magikcraft/nano-bpm#977`) spawned
|
|
186
|
+
* the #979/#980 duplicate. The advisory AGENTS.md claim protocol did not prevent it because nothing tells
|
|
187
|
+
* the *agent* to look first. This block does: a preflight that makes the agent **adopt-and-report** an
|
|
188
|
+
* existing PR instead of opening a duplicate. Adopt-and-report (not "escalate") because a delivery agent
|
|
189
|
+
* node has NO in-band escalate route — an in-flight agent can only complete (job done) or fail (which
|
|
190
|
+
* raises an incident, the stuck state we are avoiding); adopting completes the completion-barrier node
|
|
191
|
+
* cleanly, with no duplicate and no incident. Fixed wording (no derived data), so identical graphs still
|
|
192
|
+
* compile+seed deterministically; it is unconditional because every agent node that opens a PR is exposed
|
|
193
|
+
* to the same re-dispatch race. This is an advisory guard — the categorical fix (an engine-level
|
|
194
|
+
* preflight guard, or routing the node through the idempotent feature cell) is tracked as a follow-up. */
|
|
195
|
+
export function renderIdempotencyPreamble(): string {
|
|
196
|
+
return [
|
|
197
|
+
"## Idempotency preflight (delivery graph) — check BEFORE you implement",
|
|
198
|
+
"",
|
|
199
|
+
"This node may be re-dispatched (a retry after a timeout) or run in parallel with another worker.",
|
|
200
|
+
"BEFORE you write ANY code, confirm nobody is already delivering the issue you were asked to implement:",
|
|
201
|
+
"",
|
|
202
|
+
"1. Read that issue's comments for an existing **claim** (a comment beginning `Claimed —`, an",
|
|
203
|
+
" assignee, or a referenced in-progress branch/worktree).",
|
|
204
|
+
"2. List the repository's OPEN pull requests for one that already references the issue (a `Closes",
|
|
205
|
+
" #N`, the issue number in its title/body, or a branch named for it).",
|
|
206
|
+
"",
|
|
207
|
+
"If an existing claim OR an open PR already covers this issue, DO NOT open a second PR — a duplicate",
|
|
208
|
+
"PR is a defect: it splits review and collides in the same files. Instead **adopt and report**:",
|
|
209
|
+
"complete WITHOUT making any changes and return the EXISTING PR as your result — put it in your `pr`",
|
|
210
|
+
"field (a URL or `owner/repo#N`) and complete with your normal success status, with a `summary` that",
|
|
211
|
+
"names the PR you adopted. This satisfies the node cleanly; a downstream door (or a human) drives the",
|
|
212
|
+
"existing PR the rest of the way.",
|
|
213
|
+
"",
|
|
214
|
+
"Only implement — and open your own PR — when NO claim and NO open PR exist for the issue.",
|
|
215
|
+
"",
|
|
216
|
+
"---",
|
|
217
|
+
"",
|
|
218
|
+
"",
|
|
219
|
+
].join("\n");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
181
223
|
/** Render the classifier-emit contract appended to an `agent` node's `appendPrompt` (issue #506) — the
|
|
182
224
|
* instruction that turns a declared `emits[]` into completion variables a downstream guarded split (S7)
|
|
183
225
|
* can route on. A `senior:*` fleet agent completes with the Output-contract envelope (`status`,
|
|
@@ -231,7 +273,7 @@ function buildNodeInput(
|
|
|
231
273
|
// truth. A no-emit node appends nothing, so a plain implementation node is unchanged.
|
|
232
274
|
const basePrompt = node.agent.prompt ?? "";
|
|
233
275
|
const emits = Array.isArray(node.emits) ? node.emits.map((f) => ({ ...f })) : [];
|
|
234
|
-
return { jobType: node.agent.jobType, appendPrompt: basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
|
|
276
|
+
return { jobType: node.agent.jobType, appendPrompt: renderIdempotencyPreamble() + basePrompt + renderEmitContract(emits), timeout: isoDuration(node.agent.timeout, ctx.nodeTimeout) };
|
|
235
277
|
}
|
|
236
278
|
case "wait": {
|
|
237
279
|
const probe = parseProbe(node.wait);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.143.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",
|