@nanobpm/nano-workforce 0.180.0 → 0.182.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.182.0](https://github.com/nanobpm/nano-workforce/compare/v0.181.0...v0.182.0) (2026-09-04)
2
+
3
+ ### Features
4
+
5
+ * stamp each agent node's repository for per-node delivery-graph isolation ([#742](https://github.com/nanobpm/nano-workforce/issues/742)) ([e8dcdfc](https://github.com/nanobpm/nano-workforce/commit/e8dcdfce2122bdb2a7e881a0900ca1f607d6d402)), closes [#739](https://github.com/nanobpm/nano-workforce/issues/739)
6
+
7
+ ## [0.181.0](https://github.com/nanobpm/nano-workforce/compare/v0.180.0...v0.181.0) (2026-09-04)
8
+
9
+ ### Features
10
+
11
+ * delivery-graph MCP DX — interleaved sequence gates, paginated guide sections, stage-supersede visibility ([#741](https://github.com/nanobpm/nano-workforce/issues/741)) ([6f10e8a](https://github.com/nanobpm/nano-workforce/commit/6f10e8a25721645af4e5f932f2cbaa9ad6025b70)), closes [#740](https://github.com/nanobpm/nano-workforce/issues/740) [#460](https://github.com/nanobpm/nano-workforce/issues/460) [#740](https://github.com/nanobpm/nano-workforce/issues/740)
12
+
1
13
  ## [0.180.0](https://github.com/nanobpm/nano-workforce/compare/v0.179.2...v0.180.0) (2026-09-04)
2
14
 
3
15
  ### Features
@@ -10,9 +10,11 @@ import { test } from "node:test";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
  import {
12
12
  GUIDE_SECTIONS,
13
+ GUIDE_SECTION_PAGE_DEFAULT,
13
14
  guideToc,
14
15
  renderAgentGuide,
15
16
  renderGuideSection,
17
+ renderGuideSectionChunk,
16
18
  splitGuideSections,
17
19
  } from "./agentGuide.ts";
18
20
 
@@ -105,6 +107,91 @@ test("an unknown section id resolves to undefined (the op turns that into a 400)
105
107
  assertEquals(renderGuideSection("does-not-exist", "https://x/app/api"), undefined);
106
108
  });
107
109
 
110
+ // ── Paginated section retrieval (issue #740) ────────────────────────────────────────────────────
111
+
112
+ test("renderGuideSectionChunk: paging through a section reassembles it exactly, chunk by chunk", () => {
113
+ const base = "https://x/app/api";
114
+ const full = renderGuideSection("delivery-graphs", base)!;
115
+ const totalChars = Array.from(full).length;
116
+ const PAGE = 4000;
117
+ let start = 0;
118
+ let assembled = "";
119
+ let pages = 0;
120
+ let lastTotal = -1;
121
+ // Follow the nextStart cursor until it is null — the classic pagination loop.
122
+ for (;;) {
123
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, start, PAGE)!;
124
+ assert(chunk, "a known section must page");
125
+ assert(Array.from(chunk.instructions).length <= PAGE, "a page must be bounded by the window");
126
+ assertEquals(chunk.start, start);
127
+ assertEquals(chunk.totalLength, totalChars);
128
+ if (lastTotal !== -1) assertEquals(chunk.totalLength, lastTotal);
129
+ lastTotal = chunk.totalLength;
130
+ assembled += chunk.instructions;
131
+ pages++;
132
+ if (chunk.nextStart === null) break;
133
+ start = chunk.nextStart;
134
+ assert(pages < 100, "pagination must terminate");
135
+ }
136
+ assert(pages > 1, "a large section must span multiple pages at this window");
137
+ assertEquals(assembled, full, "the reassembled pages must equal the whole section");
138
+ });
139
+
140
+ test("renderGuideSectionChunk: a window past the end returns an empty last page with nextStart null", () => {
141
+ const base = "https://x/app/api";
142
+ const full = renderGuideSection("orient", base)!;
143
+ const total = Array.from(full).length;
144
+ const chunk = renderGuideSectionChunk("orient", base, total + 500, 100)!;
145
+ assertEquals(chunk.start, total);
146
+ assertEquals(chunk.length, 0);
147
+ assertEquals(chunk.instructions, "");
148
+ assertEquals(chunk.nextStart, null);
149
+ });
150
+
151
+ test("renderGuideSectionChunk: the first page of a fitting section equals the whole section (nextStart null)", () => {
152
+ const base = "https://x/app/api";
153
+ const full = renderGuideSection("orient", base)!;
154
+ const chunk = renderGuideSectionChunk("orient", base, 0, GUIDE_SECTION_PAGE_DEFAULT)!;
155
+ // `orient` fits well under the default page, so one page carries it whole with no continuation.
156
+ assertEquals(chunk.instructions, full);
157
+ assertEquals(chunk.nextStart, null);
158
+ });
159
+
160
+ test("renderGuideSectionChunk: a zero-length window on a non-empty section terminates (nextStart null, no loop)", () => {
161
+ const base = "https://x/app/api";
162
+ const full = renderGuideSection("delivery-graphs", base)!;
163
+ assert(Array.from(full).length > 0, "precondition: the section has content");
164
+ // A caller (or a direct invoker bypassing the op's `length >= 1` validation) that asks for a
165
+ // zero-length window gets an empty page — but `nextStart` MUST be null so a "page until nextStart
166
+ // is null" loop cannot spin forever on a cursor that never advances.
167
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, 0, 0)!;
168
+ assertEquals(chunk.start, 0);
169
+ assertEquals(chunk.length, 0);
170
+ assertEquals(chunk.instructions, "");
171
+ assertEquals(chunk.nextStart, null);
172
+ });
173
+
174
+ test("renderGuideSectionChunk: an unknown section id is undefined (the op turns that into a 400)", () => {
175
+ assertEquals(renderGuideSectionChunk("does-not-exist", "https://x/app/api", 0, 100), undefined);
176
+ });
177
+
178
+ test("renderGuideSectionChunk: chunk boundaries never split a multi-byte character", () => {
179
+ const base = "https://x/app/api";
180
+ const full = renderGuideSection("delivery-graphs", base)!;
181
+ // Page at every 1-char window across a stretch that contains the guide's arrows/emoji; each
182
+ // reassembled result must round-trip losslessly (no U+FFFD replacement from a split code point).
183
+ let assembled = "";
184
+ let start = 0;
185
+ for (let i = 0; i < 200 && start < Array.from(full).length; i++) {
186
+ const chunk = renderGuideSectionChunk("delivery-graphs", base, start, 7)!;
187
+ assembled += chunk.instructions;
188
+ if (chunk.nextStart === null) break;
189
+ start = chunk.nextStart;
190
+ }
191
+ assert(!assembled.includes("\uFFFD"), "no replacement characters — code points were never split");
192
+ assertEquals(assembled, Array.from(full).slice(0, Array.from(assembled).length).join(""));
193
+ });
194
+
108
195
  test("no content regression: each section body is a verbatim slice of the raw guide", () => {
109
196
  // Rendering keys examples to an instance; the UN-substituted section bodies must be exact
110
197
  // substrings of the authored doc, so the addressable surface never rewrites guide content.
package/app/agentGuide.ts CHANGED
@@ -174,3 +174,64 @@ export function renderGuideSection(id: string, apiBase: string): string | undefi
174
174
  const base = apiBase.replace(/\/+$/, "");
175
175
  return section.body.replaceAll("__BASE__", base).replaceAll("__ENGINE__", resolveEngineBase());
176
176
  }
177
+
178
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
179
+ // Paginated section retrieval (issue #740).
180
+ //
181
+ // The addressable guide exists to avoid the ~43KB monolith, but a single section can ITSELF overflow
182
+ // a typical MCP tool-result limit — `delivery-graphs` alone renders to ~25KB, the one section an
183
+ // author most needs. So a section is additionally retrievable in BOUNDED CHUNKS via a `start`/`length`
184
+ // window with a `nextStart` continuation cursor. Offsets are UNICODE CHARACTER (code-point) offsets —
185
+ // NOT raw byte offsets — so a chunk boundary never splits a multi-byte character (the arrows/emoji in
186
+ // the guide) into mojibake. A plain `renderGuideSection` (no window) is untouched, so existing
187
+ // `section=<id>` calls that already fit stay byte-for-byte identical.
188
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
189
+
190
+ /** The default window size (characters) when a caller engages pagination without an explicit
191
+ * `length` — comfortably under a typical MCP tool-result limit so a single page never overflows. */
192
+ export const GUIDE_SECTION_PAGE_DEFAULT = 12_000;
193
+
194
+ /** One page of a section's markdown: the `instructions` slice plus the cursor state so a caller can
195
+ * page through with `nextStart` until it is `null`. Offsets/lengths are CHARACTER counts. */
196
+ export interface GuideSectionChunk {
197
+ readonly instructions: string;
198
+ /** The (clamped) character offset this page starts at. */
199
+ readonly start: number;
200
+ /** The number of characters actually returned in this page. */
201
+ readonly length: number;
202
+ /** The total number of characters in the fully-rendered section. */
203
+ readonly totalLength: number;
204
+ /** The character offset to pass as `start` for the next page, or `null` when this is the last page. */
205
+ readonly nextStart: number | null;
206
+ }
207
+
208
+ /** Render a bounded WINDOW of a single section's markdown for a given app control-API base, with
209
+ * `__BASE__`/`__ENGINE__` substituted exactly as {@link renderGuideSection} does. `start` and
210
+ * `length` are CHARACTER offsets (clamped to the section bounds; a non-positive `length` yields an
211
+ * empty page). Returns `undefined` for an unknown id (the caller turns that into a 400). */
212
+ export function renderGuideSectionChunk(
213
+ id: string,
214
+ apiBase: string,
215
+ start: number,
216
+ length: number,
217
+ ): GuideSectionChunk | undefined {
218
+ const full = renderGuideSection(id, apiBase);
219
+ if (full === undefined) return undefined;
220
+ const chars = Array.from(full);
221
+ const total = chars.length;
222
+ const from = Math.min(Math.max(0, Math.trunc(start)), total);
223
+ const take = Math.max(0, Math.trunc(length));
224
+ const slice = chars.slice(from, from + take);
225
+ const end = from + slice.length;
226
+ return {
227
+ instructions: slice.join(""),
228
+ start: from,
229
+ length: slice.length,
230
+ totalLength: total,
231
+ // Only advance when this page actually consumed characters. A zero-length window (`length <= 0`)
232
+ // returns an empty page that made NO progress, so it must terminate the cursor (`null`) rather
233
+ // than hand back a `nextStart` equal to `start` — a non-advancing cursor would loop a caller
234
+ // that pages until `nextStart` is null forever.
235
+ nextStart: slice.length > 0 && end < total ? end : null,
236
+ };
237
+ }
@@ -19,8 +19,10 @@
19
19
  // Every error carries a JSON-path-qualified `path` (`nodes[2].kind`, `edges[1].from`, …) so the
20
20
  // caller can point the author straight at the offending input.
21
21
 
22
+ import { isPlausibleBranchName } from "./baseBranch.ts";
22
23
  import { isConvergeTarget } from "./convergeTargets.ts";
23
24
  import { isRawConvergeMergeJobType, NODE_COMPLETION_POLICIES } from "./nodePolicy.ts";
25
+ import { isResolvableRepo } from "./repoEnvelope.ts";
24
26
 
25
27
  /** The CLOSED node-kind allowlist (ADR 0005 Decision 2) — the trust boundary. Extensible only by a
26
28
  * deliberate ADR/PR (add the openapi variant + a case here), never by a graph author. Kept as the
@@ -94,6 +96,8 @@ export type DeliveryGraphErrorCode =
94
96
  | "raw-converge-node"
95
97
  | "merge-requires-converge"
96
98
  | "converge-merge-type"
99
+ | "invalid-node-repository"
100
+ | "invalid-node-base-branch"
97
101
  | "unbound-pr";
98
102
 
99
103
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
@@ -164,7 +168,7 @@ const GRAPH_NAME_MAX_LENGTH = 255;
164
168
  * like the library import/save doors' `graphJson`, where the schema never touches the parsed value)
165
169
  * would otherwise let an oversized-but-compilable graph reach the layout/compiler and be persisted —
166
170
  * both violating the declared contract and exposing the import path to avoidable CPU/memory growth. */
167
- const GRAPH_MAX_NODES = 256;
171
+ export const GRAPH_MAX_NODES = 256;
168
172
  const GRAPH_MAX_EDGES = 1024;
169
173
  const NODE_MAX_EMITS = 32;
170
174
 
@@ -428,6 +432,31 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
428
432
  code: "merge-requires-converge",
429
433
  });
430
434
  }
435
+ // #739: an `agent` node may declare its OWN `repository` (`owner/repo`) + `baseBranch`, so a
436
+ // cross-repo graph provisions each cell's isolation envelope from that node's own repo (no
437
+ // uniform run-level repo, no `repoless`). Both are OPTIONAL (absent → the run-level fallback),
438
+ // but a PRESENT value must pass the SAME allowlists the dispatch door / `repoEnvelopeVars` apply
439
+ // — a plain `owner/repo` (no `.git`, no host/query chars) and a plausible git branch name — so a
440
+ // graph that bypassed OpenAPI shape validation cannot smuggle a malformed clone URL / ref past
441
+ // this gate into the per-node envelope. Rejected path-qualified rather than silently dropped.
442
+ if (kind === "agent" && config.repository !== undefined && !isResolvableRepo(config.repository)) {
443
+ errors.push({
444
+ path: `${path}.${configKey}.repository`,
445
+ message:
446
+ "`agent.repository`, when present, must be an `owner/repo` reference (no trailing `.git`, no " +
447
+ `host/query characters) — got ${JSON.stringify(config.repository)} (#739)`,
448
+ code: "invalid-node-repository",
449
+ });
450
+ }
451
+ if (kind === "agent" && config.baseBranch !== undefined && (typeof config.baseBranch !== "string" || !isPlausibleBranchName(config.baseBranch))) {
452
+ errors.push({
453
+ path: `${path}.${configKey}.baseBranch`,
454
+ message:
455
+ "`agent.baseBranch`, when present, must be a plausible git branch name (no whitespace, shell " +
456
+ `metacharacters, leading \`-\`, \`..\`/\`//\`, etc.) — got ${JSON.stringify(config.baseBranch)} (#739)`,
457
+ code: "invalid-node-base-branch",
458
+ });
459
+ }
431
460
  // #548: register a converge-connector / pr-wait as a PR-binding consumer (pass 4 validates the
432
461
  // binding once edges are resolved). Only when the id is usable so pass 4 can key by node id.
433
462
  if (typeof id === "string" && id.length > 0) {
@@ -45,6 +45,18 @@ import {
45
45
  validateDeliveryGraph,
46
46
  } from "./deliveryGraph.ts";
47
47
  import { DELIVERY_HUMAN_ELEMENT, GENERIC_HUMAN_FORM } from "./deliveryHuman.ts";
48
+ import { AGENT_TASK_NS } from "./repoEnvelope.ts";
49
+
50
+ /** The task-header key that carries an `agent` node's DECLARED per-node repository spec (#739) into the
51
+ * compiled BPMN. It is a DIGEST-STABLE, env-free marker — pure graph content — so two graphs differing
52
+ * only in a node's declared `repository`/`baseBranch` content-address differently (they are different
53
+ * graphs), while the env-dependent parts of the real envelope (`cloneTimeoutMs`) and the run-level
54
+ * FALLBACK repo are NOT baked here (they are injected by the runner POST-digest, so the same graph in
55
+ * two environments / two runs still shares one id). The runner replaces this single marker header on
56
+ * every agent service task with the flattened EFFECTIVE `io.nanobpm.agentTask.*` envelope headers
57
+ * (declared ?? run-level), or strips it for an unresolved/`repoless` cell. The `__` prefix marks it as
58
+ * an internal marker the harness never reads. */
59
+ export const AGENT_REPO_SPEC_HEADER = `${AGENT_TASK_NS}.__repoSpec`;
48
60
 
49
61
  /** The engine-native BODY every node kind delegates to (Decision 2 — the graph SCHEDULES, it does not
50
62
  * re-implement execution). Each node compiles to an EMBEDDED `bpmn:subProcess` (call activities are a
@@ -1027,7 +1039,7 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
1027
1039
  // as a required data dependency. A broken producer (returns `in_progress`, or omits a required
1028
1040
  // emit) escalates AT this node instead of threading an incomplete result onward.
1029
1041
  const contractGate = { requiredEmits: normaliseEmits(node).filter((f) => requiredEmits.has(f.name)) };
1030
- return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate);
1042
+ return serviceBodyLines(el, node.id, attr("type", node.agent.jobType), [], node.agent.jobType, contractGate, agentRepoSpecHeaderLines(node));
1031
1043
  }
1032
1044
  case "connector":
1033
1045
  return serviceBodyLines(el, node.id, `type="${DELEGATE_TASK_TYPE.connector}"`, [], `connector → ${node.connector.target}`);
@@ -1040,11 +1052,25 @@ function innerBodyLines(w: NodeWiring, requiredEmits: ReadonlySet<string>): stri
1040
1052
  }
1041
1053
  }
1042
1054
 
1055
+ /** Render the DECLARED per-node repository-spec marker task header (#739) for an `agent` node — a single
1056
+ * `<zeebe:taskHeaders>` block carrying {@link AGENT_REPO_SPEC_HEADER} with a compact JSON of the node's
1057
+ * DECLARED `{ repository, baseBranch }` (each `null` when absent). It is emitted on EVERY agent service
1058
+ * task (even one with no declared repo → `{"repository":null,"baseBranch":null}`) so the runner has a
1059
+ * single, uniform anchor to replace with the effective envelope on every cell. Digest-stable and
1060
+ * env-free — only the declared values (pure graph content) appear here; the run-level fallback and the
1061
+ * env-dependent `cloneTimeoutMs` are injected by the runner POST-digest. Declared values pass the
1062
+ * `owner/repo` + branch-name allowlists (validator/OpenAPI), so the JSON carries no XML-hostile chars. */
1063
+ function agentRepoSpecHeaderLines(node: Extract<DeliveryNode, { kind: "agent" }>): string[] {
1064
+ const trimOrNull = (v: unknown): string | null => (typeof v === "string" && v.trim() !== "" ? v.trim() : null);
1065
+ const spec = JSON.stringify({ repository: trimOrNull(node.agent.repository), baseBranch: trimOrNull(node.agent.baseBranch) });
1066
+ return [
1067
+ " <zeebe:taskHeaders>",
1068
+ ` <zeebe:header key="${AGENT_REPO_SPEC_HEADER}" ${attr("value", spec)} />`,
1069
+ " </zeebe:taskHeaders>",
1070
+ ];
1071
+ }
1072
+
1043
1073
  /** `agent`/`connector` body: `start → serviceTask → end`, with a bounded `=nodeTimeout` boundary that
1044
- * escalates the stalled node onto a human-completable user task. `taskDefAttr` is the pre-rendered
1045
- * `type="…"` attribute; `taskProps` are optional `<zeebe:property>` envelope lines; `descriptor`
1046
- * names the stalled work (job type / connector target) for the escalation task's context line (#499). */
1047
- /** The FEEL boolean an `agent` node's producer-contract gate (issue #731) evaluates on its `_gate`
1048
1074
  * exclusive split's SUCCESS flow: the completion proceeds onward only when the self-reported `status`
1049
1075
  * is a terminal success (or absent/null) AND every required-data-dependency emit is populated non-null.
1050
1076
  * Reads the job's returned variables from the subProcess scope (the emit source var for an agent fact
@@ -1095,23 +1121,16 @@ function serviceBodyLines(
1095
1121
  taskProps: readonly string[],
1096
1122
  descriptor: string,
1097
1123
  contractGate?: { requiredEmits: readonly DeliveryFact[] },
1124
+ taskHeaders: readonly string[] = [],
1098
1125
  ): string[] {
1099
1126
  const esc = escalationTaskElement(el);
1100
- const taskExt =
1101
- taskProps.length > 0
1102
- ? [
1103
- " <bpmn:extensionElements>",
1104
- ` <zeebe:taskDefinition ${taskDefAttr} />`,
1105
- " <zeebe:properties>",
1106
- ...taskProps,
1107
- " </zeebe:properties>",
1108
- " </bpmn:extensionElements>",
1109
- ]
1110
- : [
1111
- " <bpmn:extensionElements>",
1112
- ` <zeebe:taskDefinition ${taskDefAttr} />`,
1113
- " </bpmn:extensionElements>",
1114
- ];
1127
+ const taskExt = [
1128
+ " <bpmn:extensionElements>",
1129
+ ` <zeebe:taskDefinition ${taskDefAttr} />`,
1130
+ ...(taskProps.length > 0 ? [" <zeebe:properties>", ...taskProps, " </zeebe:properties>"] : []),
1131
+ ...taskHeaders,
1132
+ " </bpmn:extensionElements>",
1133
+ ];
1115
1134
  const timeoutEscalation = escalationTaskLines(
1116
1135
  esc,
1117
1136
  nodeId,
@@ -178,6 +178,47 @@ test("stageProposal: proposals with DIFFERENT logical keys coexist — supersede
178
178
  });
179
179
  });
180
180
 
181
+ // ── StageOutcome visibility (issue #740) ────────────────────────────────────────────────────────
182
+ test("stageProposal outcome: a FIRST stage reports nothing superseded and no live siblings", async () => {
183
+ await withData(async (data) => {
184
+ const outcome = await stageProposal(data, row({ digest: "d1" }));
185
+ assertEquals(outcome.row.digest, "d1");
186
+ assertEquals(outcome.superseded, []);
187
+ assertEquals(outcome.siblingsStaged, 0);
188
+ });
189
+ });
190
+
191
+ test("stageProposal outcome: a same-logical-key re-stage reports the retired digest in `superseded`", async () => {
192
+ await withData(async (data) => {
193
+ await stageProposal(data, row({ digest: "d1" }));
194
+ const outcome = await stageProposal(data, row({ digest: "d2" })); // same logical_key "runbook"
195
+ assertEquals(outcome.row.digest, "d2");
196
+ assertEquals(outcome.superseded, ["d1"], "the prior same-key staged digest was retired");
197
+ assertEquals(outcome.siblingsStaged, 0, "no OTHER-logical-key live proposal exists");
198
+ });
199
+ });
200
+
201
+ test("stageProposal outcome: an idempotent re-stage of the SAME live digest supersedes nothing", async () => {
202
+ await withData(async (data) => {
203
+ await stageProposal(data, row({ digest: "d1", createdAt: "2999-01-01T00:00:00.000Z" }));
204
+ const outcome = await stageProposal(data, row({ digest: "d1", createdAt: "2999-01-01T00:00:00.000Z" }));
205
+ assertEquals(outcome.row.digest, "d1");
206
+ assertEquals(outcome.superseded, [], "re-staging the identical live digest retires nothing");
207
+ assertEquals(outcome.siblingsStaged, 0);
208
+ });
209
+ });
210
+
211
+ test("stageProposal outcome: an orphan sibling under a DIFFERENT logical key is counted in `siblingsStaged`", async () => {
212
+ await withData(async (data) => {
213
+ // The footgun: a renamed graph stages under a new logical key without superseding the old one.
214
+ await stageProposal(data, row({ digest: "d1", logicalKey: "runbook-old" }));
215
+ const outcome = await stageProposal(data, row({ digest: "d2", logicalKey: "runbook-new" }));
216
+ assertEquals(outcome.row.digest, "d2");
217
+ assertEquals(outcome.superseded, [], "a different logical key supersedes nothing");
218
+ assertEquals(outcome.siblingsStaged, 1, "the still-live orphan under the old key is visible");
219
+ });
220
+ });
221
+
181
222
  test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage whose supersede pass runs AFTER a newer stage neither clobbers it (zero) nor coexists with it (two)", async () => {
182
223
  await withData(async (data) => {
183
224
  const table = deliveryGraphProposals(data);
@@ -200,6 +241,25 @@ test("stageProposal: reconciles to EXACTLY ONE live proposal — an older stage
200
241
  });
201
242
  });
202
243
 
244
+ test("stageProposal outcome: `row` reflects the POST-reconcile status — a stage immediately superseded by a newer sibling reports its own row as `superseded`, not the pre-reconcile `staged`", async () => {
245
+ await withData(async (data) => {
246
+ const table = deliveryGraphProposals(data);
247
+ // A newer staged sibling (d2) is already committed for logical_key "runbook".
248
+ const newer = row({ digest: "d2" });
249
+ newer.updated_at = "2999-01-01T00:00:00.000Z";
250
+ await table.insert(newer);
251
+ // The older stage (d1) runs last; the reconcile immediately supersedes its own row.
252
+ const outcome = await stageProposal(data, row({ digest: "d1" }));
253
+ assertEquals(outcome.row.digest, "d1");
254
+ assertEquals(
255
+ outcome.row.status,
256
+ "superseded",
257
+ "the returned row is re-read post-reconcile, matching what is actually persisted",
258
+ );
259
+ assertEquals((await table.get("d1"))?.status, "superseded");
260
+ });
261
+ });
262
+
203
263
  test("getStagedProposal: an EXPIRED staged proposal is not live", async () => {
204
264
  await withData(async (data) => {
205
265
  await stageProposal(data, row());
@@ -153,6 +153,27 @@ export function buildProposalRow(input: {
153
153
  };
154
154
  }
155
155
 
156
+ /** The outcome of a {@link stageProposal} — the written row PLUS a supersede/sibling summary the stage
157
+ * doors surface so an agent can warn the operator precisely about what its stage did to the cockpit's
158
+ * Delivery Graphs list (issue #740). Supersede keys on the LOGICAL graph key (derived from the graph
159
+ * `name`), so re-staging the "same" runbook under a CHANGED `name` creates a sibling with a different
160
+ * logical key that is NOT superseded — a silent footgun. Making the collision VISIBLE here (rather than
161
+ * silent) lets the agent tell the operator exactly which digests it retired and how many other live
162
+ * proposals remain (potential orphaned siblings). */
163
+ export interface StageOutcome {
164
+ /** The row this stage wrote, RE-READ after the supersede reconcile so it reflects the actually-
165
+ * persisted state (a concurrent newer stage can leave this digest `superseded` immediately). */
166
+ row: DeliveryGraphProposal;
167
+ /** Digests of OTHER proposals sharing this row's logical key that this stage flipped to
168
+ * `superseded` — the same-logical-graph proposals it cleanly replaced. Empty on a first stage. */
169
+ superseded: string[];
170
+ /** How many OTHER live `staged` proposals remain after this stage (a DIFFERENT logical key from this
171
+ * row) — i.e. proposals this stage did NOT supersede. A non-zero count flags possible orphaned
172
+ * siblings (e.g. an earlier stage of the same runbook under a different `name`) cluttering the
173
+ * operator's list, so the agent can name them for cleanup. */
174
+ siblingsStaged: number;
175
+ }
176
+
156
177
  /** Persist a compiled graph as a `staged` proposal and SUPERSEDE any prior staged proposal for the
157
178
  * same logical graph. Idempotent on `digest` (a re-stage of an identical, still-live digest refreshes
158
179
  * `updated_at` but preserves `created_at`, so the TTL stays anchored to the first stage; a re-stage of
@@ -162,10 +183,20 @@ export function buildProposalRow(input: {
162
183
  * exactly one live proposal per logical graph (the latest digest the operator would dispatch). The
163
184
  * supersede RECONCILES to the globally-newest staged row (`updated_at`, `digest`-tie-broken) rather than
164
185
  * flipping only rows older than the just-written one, so concurrent stages of two different digests
165
- * converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order. */
166
- export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<DeliveryGraphProposal> {
186
+ * converge to EXACTLY ONE live proposal — never zero, and never two — regardless of arrival order.
187
+ *
188
+ * Returns a {@link StageOutcome} — the written row plus the supersede/sibling summary (issue #740) the
189
+ * stage doors surface so an agent can warn the operator precisely about siblings it did / did not
190
+ * retire. */
191
+ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal): Promise<StageOutcome> {
167
192
  const table = deliveryGraphProposals(data);
168
193
  const existing = await table.get(row.digest);
194
+ // Capture the same-logical-key staged siblings that exist BEFORE this stage writes (excluding our own
195
+ // digest) — the supersede reconcile below normally flips them all to `superseded`; we re-read them
196
+ // after to report exactly which digests this stage retired.
197
+ const priorSameKey = (await table.find({ status: "staged" })).filter(
198
+ (r) => r.logical_key === row.logical_key && r.digest !== row.digest && isLiveStaged(r),
199
+ );
169
200
  const toWrite = existing
170
201
  ? buildProposalRow({
171
202
  digest: row.digest,
@@ -208,7 +239,34 @@ export async function stageProposal(data: DataLayer, row: DeliveryGraphProposal)
208
239
  `UPDATE "delivery_graph_proposals" SET "status" = 'superseded', "updated_at" = ? WHERE "logical_key" = ? AND "status" = 'staged' AND EXISTS (SELECT 1 FROM "delivery_graph_proposals" AS "newer" WHERE "newer"."logical_key" = "delivery_graph_proposals"."logical_key" AND "newer"."status" = 'staged' AND ("newer"."updated_at" > "delivery_graph_proposals"."updated_at" OR ("newer"."updated_at" = "delivery_graph_proposals"."updated_at" AND "newer"."digest" > "delivery_graph_proposals"."digest")))`,
209
240
  [now(), row.logical_key],
210
241
  );
211
- return toWrite;
242
+
243
+ // Report what the stage did to siblings (issue #740). `superseded` = the same-logical-key proposals
244
+ // this stage retired (re-read post-reconcile so a concurrent newer stage that kept ITS row live —
245
+ // leaving ours superseded — is reported honestly). `siblingsStaged` = OTHER live staged proposals
246
+ // with a DIFFERENT logical key that remain (the orphaned-sibling footgun: a re-stage under a changed
247
+ // `name` never supersedes them).
248
+ // Re-check ONLY the pre-stage staged siblings captured above (typically zero or a handful of
249
+ // concurrently-live same-key rows), reporting exactly those this reconcile flipped to `superseded`.
250
+ // Re-reading each `priorSameKey` digest keeps staging O(live same-key siblings) — a global
251
+ // `find({ status: "superseded" })` scans EVERY superseded row ever written (proposals are never
252
+ // deleted) and grows without bound. Matching strictly on `status === "superseded"` (not merely
253
+ // `!== "staged"`) avoids misreporting a sibling that concurrently went `dispatched`/`dismissed`/
254
+ // `expired`.
255
+ const superseded: string[] = [];
256
+ for (const prev of priorSameKey) {
257
+ const after = await table.get(prev.digest);
258
+ if (after?.status === "superseded") superseded.push(prev.digest);
259
+ }
260
+ const siblingsStaged = (await listStagedProposals(data)).filter((r) => r.logical_key !== row.logical_key).length;
261
+
262
+ // Re-read the just-written row AFTER the reconcile so `row` reports the ACTUALLY-persisted state, not
263
+ // the pre-reconcile `toWrite`: a concurrent newer stage of a different digest can flip THIS digest to
264
+ // `superseded` in the reconcile above, so returning `toWrite` (still `staged`) would report a status
265
+ // that doesn't match the DB. Fall back to `toWrite` only if the row somehow vanished (it shouldn't —
266
+ // we just wrote it), keeping the return non-null.
267
+ const persisted = (await table.get(toWrite.digest)) ?? toWrite;
268
+
269
+ return { row: persisted, superseded, siblingsStaged };
212
270
  }
213
271
 
214
272
  /** The ONE definition of "a live, dispatchable-RIGHT-NOW staged proposal" (issue #608): the row is
@@ -39,6 +39,10 @@ export interface StagedResult {
39
39
  nodeCount: number;
40
40
  humanNodeCount: number;
41
41
  sideEffectCount: number;
42
+ /** Digests of same-logical-graph proposals this stage superseded (issue #740). */
43
+ superseded: string[];
44
+ /** How many OTHER live staged proposals remain after this stage — potential orphaned siblings. */
45
+ siblingsStaged: number;
42
46
  }
43
47
 
44
48
  /** A rejected compile — the `CompileDeliveryGraphErrors` body, verbatim from the compiler. */
@@ -75,7 +79,7 @@ export async function compileAndStageDeliveryGraph(
75
79
  ? result.resolved.name.trim()
76
80
  : null;
77
81
  const preview = buildProposalPreview(result);
78
- await stageProposal(
82
+ const outcome = await stageProposal(
79
83
  data,
80
84
  buildProposalRow({
81
85
  digest,
@@ -99,10 +103,16 @@ export async function compileAndStageDeliveryGraph(
99
103
  digest,
100
104
  preview,
101
105
  reviewUrl: proposalReviewUrl(digest, origin),
106
+ // Supersede/sibling visibility (issue #740): the agent surfaces these so the operator learns
107
+ // precisely which prior proposals this stage retired and whether other live proposals remain.
108
+ superseded: outcome.superseded,
109
+ siblingsStaged: outcome.siblingsStaged,
102
110
  },
103
111
  digest,
104
112
  nodeCount: result.resolved.nodes.length,
105
113
  humanNodeCount: result.humanNodes.length,
106
114
  sideEffectCount: result.sideEffects.length,
115
+ superseded: outcome.superseded,
116
+ siblingsStaged: outcome.siblingsStaged,
107
117
  };
108
118
  }