@nanobpm/nano-workforce 0.179.2 → 0.181.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.
@@ -19,8 +19,9 @@
19
19
  // `pr`-typed emits, threaded fact edges, a DAG. It is validated against the S3 vocabulary
20
20
  // (`deliveryGraphVocabulary`) so an unknown connector target / probe kind is rejected at the door with
21
21
  // `issues[{path,message}]` rather than only surfacing at compile time.
22
- import type { DeliveryEdge, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
22
+ import type { DeliveryEdge, DeliveryGraph, DeliveryNode, ReadinessProbe } from "../nano-generated/api-io.d.ts";
23
23
  import { CONVERGE_MERGE_TARGET } from "./convergeTargets.ts";
24
+ import { GRAPH_MAX_NODES } from "./deliveryGraph.ts";
24
25
  import { deliveryGraphVocabulary } from "./deliveryGraphVocabulary.ts";
25
26
  import { type ParsedIssue, parseIssue } from "./plan.ts";
26
27
 
@@ -31,10 +32,42 @@ export interface SequenceIssueError {
31
32
  readonly message: string;
32
33
  }
33
34
 
34
- /** The `sequenceIssues` intent body an optional leading `behind` gate plus the ordered `issues`. */
35
+ /** An interleaved `wait` GATE that must go green before a given issue's agent starts (issue #740).
36
+ * Reuses the exact probe schema `wait` nodes already accept — the `npm`/`github-check`/`http`/…
37
+ * vocabulary — so an author can insert e.g. "wait for `@nanobpm/agentic@0.13.0` to publish" between
38
+ * two sequence steps without falling off the intent onto raw node/edge JSON. */
39
+ export interface SequenceGate {
40
+ /** The wait-probe kind (must be a known kind in the S3 vocabulary — `npm`, `github-check`, `http`,
41
+ * `command`, `capability`, `pr`, `epic`). */
42
+ kind: string;
43
+ /** The probe target — kind-specific (`pkg@version` for `npm`, `owner/repo@ref` for `github-check`,
44
+ * a URL for `http`, …). */
45
+ target: string;
46
+ /** Optional kind-specific readiness `match` fields (e.g. `{ version }` for `npm`). */
47
+ match?: Record<string, unknown>;
48
+ /** Optional poll budget; defaults to the bounded merge-gate budget ({@link MERGE_POLL}) so a gate
49
+ * never falls into the 30-minute default trap. */
50
+ poll?: { everyMs?: number; timeoutMs?: number };
51
+ /** What to do when the gate never goes green within its budget — `escalate` (default) or `continue`.
52
+ * `fail` is rejected (not yet supported by the compiler/engine). */
53
+ onTimeout?: "escalate" | "continue";
54
+ /** Optional env-key name supplying a credential for the probe (`http`/`capability`). */
55
+ credentialEnv?: string;
56
+ }
57
+
58
+ /** One `issues[]` entry with an OPTIONAL leading {@link SequenceGate} — the object form. A bare string
59
+ * entry (no gate) keeps today's behaviour byte-for-byte. */
60
+ export interface SequenceIssueEntry {
61
+ gate?: SequenceGate;
62
+ issue: string;
63
+ }
64
+
65
+ /** The `sequenceIssues` intent body — an optional leading `behind` gate plus the ordered `issues`,
66
+ * each of which may be a bare `owner/repo#N` string OR a `{ gate?, issue }` object interleaving a
67
+ * `wait` gate before that issue's agent (issue #740). */
35
68
  export interface SequenceIssuesIntent {
36
69
  behind?: string;
37
- issues: string[];
70
+ issues: (string | SequenceIssueEntry)[];
38
71
  }
39
72
 
40
73
  /** The result of {@link buildSequenceGraph}: either the constructed graph, or the path-qualified
@@ -63,6 +96,29 @@ export const MAX_SEQUENCE_ISSUES = 64;
63
96
  * downstream connector / `wait[pr]` node late-binds its target PR from the fact (§9.4, issue #548). */
64
97
  const PR_EMIT = { name: "pr", type: "pr" as const };
65
98
 
99
+ /** A validated, ready-to-emit interleaved gate: `poll` and `onTimeout` are resolved to concrete values
100
+ * (defaults applied) so {@link assembleGraph} can drop it straight into a `wait` node's config. */
101
+ interface BuiltGate {
102
+ kind: ReadinessProbe["kind"];
103
+ target: string;
104
+ match?: Record<string, unknown>;
105
+ poll: { everyMs?: number; timeoutMs?: number };
106
+ onTimeout: "escalate" | "continue";
107
+ credentialEnv?: string;
108
+ }
109
+
110
+ /** A parsed `issues[]` entry: the normalised issue plan-key plus its optional interleaved gate. */
111
+ interface ParsedEntry {
112
+ issueKey: string;
113
+ gate: BuiltGate | null;
114
+ }
115
+
116
+ /** Narrow a validated string to the closed `ReadinessProbe["kind"]` union — backed by the runtime
117
+ * vocabulary set so the type guard and the door validation share one source of truth. */
118
+ function isProbeKind(kind: string, probeKinds: Set<string>): kind is ReadinessProbe["kind"] {
119
+ return probeKinds.has(kind);
120
+ }
121
+
66
122
  /** Parse a ref into an issue target ONLY if its number is a positive, safe integer. `parseIssue`'s
67
123
  * `\d+` accepts `#0` and precision-overflowing numbers (e.g. `#99999999999999999999`, which coerces
68
124
  * past `Number.MAX_SAFE_INTEGER`), but such a target can never resolve to a real issue/PR — staging a
@@ -74,12 +130,111 @@ function parseIssueRef(ref: unknown): ParsedIssue | null {
74
130
  return Number.isSafeInteger(parsed.number) && parsed.number >= 1 ? parsed : null;
75
131
  }
76
132
 
133
+ /** Validate one interleaved `gate` spec against the probe vocabulary, applying defaults. Returns the
134
+ * built gate or path-qualified `issues[{path,message}]` rejections (keyed under `path`, e.g.
135
+ * `issues[1].gate.kind`). Reuses the same probe schema `wait` nodes accept: `{ kind, target, match?,
136
+ * poll?, onTimeout?, credentialEnv? }`. */
137
+ function parseGate(raw: unknown, path: string, probeKinds: Set<string>): { gate?: BuiltGate; errors: SequenceIssueError[] } {
138
+ const errors: SequenceIssueError[] = [];
139
+ if (!isRecord(raw)) {
140
+ return { errors: [{ path, message: "`gate` must be an object carrying a `wait` probe (`{ kind, target, … }`)." }] };
141
+ }
142
+
143
+ // Trim before validating AND before persisting: harmless surrounding whitespace must neither
144
+ // trip a confusing "unknown kind" rejection nor survive into the generated `wait` node, where a
145
+ // stray trailing space silently probes the wrong target (`pkg@1.0.0 ` → a gate that never goes
146
+ // green). The persisted value is always the trimmed one.
147
+ let validKind: ReadinessProbe["kind"] | null = null;
148
+ const kind = raw.kind;
149
+ if (typeof kind !== "string" || kind.trim() === "") {
150
+ errors.push({ path: `${path}.kind`, message: "`gate.kind` is required and must be a non-empty wait-probe kind." });
151
+ } else {
152
+ const trimmedKind = kind.trim();
153
+ if (!isProbeKind(trimmedKind, probeKinds)) {
154
+ errors.push({ path: `${path}.kind`, message: `wait-probe kind \`${trimmedKind}\` is not in the delivery-graph vocabulary.` });
155
+ } else {
156
+ validKind = trimmedKind;
157
+ }
158
+ }
159
+
160
+ let validTarget: string | null = null;
161
+ const target = raw.target;
162
+ if (typeof target !== "string" || target.trim() === "") {
163
+ errors.push({ path: `${path}.target`, message: "`gate.target` is required and must be a non-empty string." });
164
+ } else {
165
+ validTarget = target.trim();
166
+ }
167
+
168
+ let match: Record<string, unknown> | undefined;
169
+ if (raw.match !== undefined) {
170
+ if (!isRecord(raw.match)) {
171
+ errors.push({ path: `${path}.match`, message: "`gate.match`, when present, must be an object of readiness fields." });
172
+ } else {
173
+ match = raw.match;
174
+ }
175
+ }
176
+
177
+ const poll: { everyMs?: number; timeoutMs?: number } = { ...MERGE_POLL };
178
+ if (raw.poll !== undefined) {
179
+ if (!isRecord(raw.poll)) {
180
+ errors.push({ path: `${path}.poll`, message: "`gate.poll`, when present, must be `{ everyMs?, timeoutMs? }`." });
181
+ } else {
182
+ for (const key of ["everyMs", "timeoutMs"] as const) {
183
+ const v = raw.poll[key];
184
+ if (v === undefined) continue;
185
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 1) {
186
+ errors.push({ path: `${path}.poll.${key}`, message: `\`gate.poll.${key}\`, when present, must be a positive integer (milliseconds).` });
187
+ } else {
188
+ poll[key] = v;
189
+ }
190
+ }
191
+ }
192
+ }
193
+
194
+ let onTimeout: "escalate" | "continue" = "escalate";
195
+ if (raw.onTimeout !== undefined) {
196
+ if (raw.onTimeout !== "escalate" && raw.onTimeout !== "continue") {
197
+ errors.push({
198
+ path: `${path}.onTimeout`,
199
+ message: "`gate.onTimeout` must be `escalate` (default) or `continue` — `fail` is not supported on a `wait` node.",
200
+ });
201
+ } else {
202
+ onTimeout = raw.onTimeout;
203
+ }
204
+ }
205
+
206
+ let credentialEnv: string | undefined;
207
+ if (raw.credentialEnv !== undefined) {
208
+ if (typeof raw.credentialEnv !== "string" || raw.credentialEnv.trim() === "") {
209
+ errors.push({ path: `${path}.credentialEnv`, message: "`gate.credentialEnv`, when present, must be a non-empty env-key name." });
210
+ } else {
211
+ credentialEnv = raw.credentialEnv;
212
+ }
213
+ }
214
+
215
+ if (errors.length > 0 || validKind === null || validTarget === null) return { errors };
216
+ return {
217
+ gate: {
218
+ kind: validKind,
219
+ target: validTarget,
220
+ ...(match ? { match } : {}),
221
+ poll,
222
+ onTimeout,
223
+ ...(credentialEnv ? { credentialEnv } : {}),
224
+ },
225
+ errors: [],
226
+ };
227
+ }
228
+
77
229
  /**
78
230
  * Build the canonical delivery graph for a `sequenceIssues` intent, or return path-qualified
79
231
  * `issues[{path,message}]` rejections for invalid input. Validates:
80
232
  * - `issues` is a non-empty array within {@link MAX_SEQUENCE_ISSUES};
81
- * - every `issues[i]` and the optional `behind` parse as an `owner/repo#N` reference;
82
- * - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard).
233
+ * - every `issues[i]` (a bare ref, or a `{ gate?, issue }` object) and the optional `behind` parse
234
+ * as an `owner/repo#N` reference;
235
+ * - each interleaved `gate` names a wait-probe kind known to the S3 vocabulary (issue #740);
236
+ * - the connector target and wait-probe kinds it emits are known to the S3 vocabulary (drift guard);
237
+ * - the generated graph stays within the compiler's node ceiling.
83
238
  * Pure — no I/O. The constructed graph passes `validateDeliveryGraph` by construction.
84
239
  */
85
240
  export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
@@ -89,8 +244,12 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
89
244
  const rawIssues = body.issues;
90
245
  const rawBehind = body.behind;
91
246
 
92
- // ── `issues`: a non-empty, bounded array of parseable refs ───────────────────────────────────
93
- const parsedIssues: string[] = [];
247
+ // The vocabulary is needed up front to validate interleaved gate `kind`s as each entry is parsed.
248
+ const vocab = deliveryGraphVocabulary();
249
+ const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
250
+
251
+ // ── `issues`: a non-empty, bounded array of parseable refs, each optionally carrying a gate ────
252
+ const parsedEntries: ParsedEntry[] = [];
94
253
  if (!Array.isArray(rawIssues)) {
95
254
  issues.push({ path: "issues", message: "`issues` must be a non-empty array of `owner/repo#N` issue references." });
96
255
  } else if (rawIssues.length === 0) {
@@ -101,16 +260,26 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
101
260
  message: `\`issues\` has too many entries (${rawIssues.length}) — the limit is ${MAX_SEQUENCE_ISSUES}.`,
102
261
  });
103
262
  } else {
104
- rawIssues.forEach((ref, i) => {
105
- const parsed = parseIssueRef(ref);
263
+ rawIssues.forEach((entry, i) => {
264
+ // A bare string entry is a gate-less issue (today's behaviour, byte-for-byte). An object entry
265
+ // carries a required `issue` ref plus an OPTIONAL leading `gate` (issue #740).
266
+ const rawRef = isRecord(entry) ? entry.issue : entry;
267
+ const parsed = parseIssueRef(rawRef);
106
268
  if (!parsed) {
269
+ const shown = isRecord(entry) ? String(rawRef) : String(entry);
107
270
  issues.push({
108
- path: `issues[${i}]`,
109
- message: `\`${String(ref)}\` is not a valid \`owner/repo#N\` issue reference.`,
271
+ path: isRecord(entry) ? `issues[${i}].issue` : `issues[${i}]`,
272
+ message: `\`${shown}\` is not a valid \`owner/repo#N\` issue reference.`,
110
273
  });
111
274
  return;
112
275
  }
113
- parsedIssues.push(parsed.planKey);
276
+ let gate: BuiltGate | null = null;
277
+ if (isRecord(entry) && entry.gate !== undefined && entry.gate !== null) {
278
+ const parsedGate = parseGate(entry.gate, `issues[${i}].gate`, probeKinds);
279
+ issues.push(...parsedGate.errors);
280
+ gate = parsedGate.gate ?? null;
281
+ }
282
+ parsedEntries.push({ issueKey: parsed.planKey, gate });
114
283
  });
115
284
  }
116
285
 
@@ -135,7 +304,6 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
135
304
  // ── Vocabulary drift guard (S3): the target/probe kinds this generator emits MUST be known to the
136
305
  // structured vocabulary. This can only trip if the closed vocabulary changes underneath us — it is
137
306
  // surfaced as a door `issue` (not a throw) so the failure mode is a clean rejection, not a 500. ──
138
- const vocab = deliveryGraphVocabulary();
139
307
  const realTargets = new Set(vocab.connectorTargets.filter((t) => t.status === "real").map((t) => t.target));
140
308
  if (!realTargets.has(CONVERGE_MERGE_TARGET)) {
141
309
  issues.push({
@@ -143,28 +311,43 @@ export function buildSequenceGraph(intent: unknown): SequenceIssuesResult {
143
311
  message: `connector target \`${CONVERGE_MERGE_TARGET}\` is not a real target in the delivery-graph vocabulary.`,
144
312
  });
145
313
  }
146
- const probeKinds = new Set(vocab.waitProbeKinds.map((p) => p.kind));
147
314
  for (const kind of behindKey ? [PR_PROBE_KIND, EPIC_PROBE_KIND] : [PR_PROBE_KIND]) {
148
315
  if (!probeKinds.has(kind)) {
149
316
  issues.push({ path: "issues", message: `wait-probe kind \`${kind}\` is not in the delivery-graph vocabulary.` });
150
317
  }
151
318
  }
152
319
 
320
+ // ── Node-budget guard: interleaved gates add a node per gated issue, so a fully-gated max-length
321
+ // sequence (plus the optional `behind` gate) can push past the compiler's node ceiling. Reject it
322
+ // at the door so a SUCCESSFULLY generated graph always validates `by construction`. ──
323
+ if (issues.length === 0) {
324
+ const projectedNodes = (behindKey ? 1 : 0) + parsedEntries.reduce((n, e) => n + 3 + (e.gate ? 1 : 0), 0);
325
+ if (projectedNodes > GRAPH_MAX_NODES) {
326
+ issues.push({
327
+ path: "issues",
328
+ message: `the generated graph would have too many nodes (${projectedNodes}) — the limit is ${GRAPH_MAX_NODES}; use fewer issues or interleaved gates.`,
329
+ });
330
+ }
331
+ }
332
+
153
333
  if (issues.length > 0) return { ok: false, issues };
154
334
 
155
- return { ok: true, graph: assembleGraph(parsedIssues, behindKey) };
335
+ return { ok: true, graph: assembleGraph(parsedEntries, behindKey) };
156
336
  }
157
337
 
158
- /** Assemble the canonical node/edge chain for the (already-validated) issue keys + optional gate. */
159
- function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryGraph {
338
+ /** Assemble the canonical node/edge chain for the (already-validated) parsed entries + optional gate.
339
+ * Each entry emits `agent → connector[converge-merge] → wait[pr, merged]`; an entry with an
340
+ * interleaved `gate` gets a leading `wait[<gate.kind>]` node spliced between the prior sequence step
341
+ * and its agent, so the agent starts only once the prior issue merged AND the gate went green. */
342
+ function assembleGraph(entries: ParsedEntry[], behindKey: string | null): DeliveryGraph {
160
343
  const nodes: DeliveryNode[] = [];
161
344
  const edges: DeliveryEdge[] = [];
162
345
 
163
346
  // Optional leading `wait[epic]` gate — the whole sequence waits for `behind` to be fully merged.
164
- const GATE_ID = "gate-epic";
347
+ const EPIC_GATE_ID = "gate-epic";
165
348
  if (behindKey) {
166
349
  nodes.push({
167
- id: GATE_ID,
350
+ id: EPIC_GATE_ID,
168
351
  kind: "wait",
169
352
  wait: {
170
353
  kind: EPIC_PROBE_KIND,
@@ -177,12 +360,40 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
177
360
  });
178
361
  }
179
362
 
180
- issueKeys.forEach((issueKey, i) => {
363
+ entries.forEach((entry, i) => {
181
364
  const n = i + 1;
182
365
  const openId = `open-${n}`;
183
366
  const landId = `land-${n}`;
184
367
  const mergedId = `merged-${n}`;
185
368
  const prRef = `${openId}.pr`;
369
+ const issueKey = entry.issueKey;
370
+
371
+ // The sequence predecessor whose completion releases THIS issue: the prior issue's merge, else the
372
+ // leading epic gate for the first issue (null when the first issue is ungated by `behind`).
373
+ const predecessor = i === 0 ? (behindKey ? EPIC_GATE_ID : null) : `merged-${i}`;
374
+
375
+ // Interleaved gate (issue #740): a `wait[<kind>]` node the issue's agent waits on. It inherits the
376
+ // sequence predecessor's edge (so it only starts probing once the prior issue merged) and the
377
+ // agent then waits on the gate — gating the agent on the prior merge AND the gate condition.
378
+ let agentPredecessor = predecessor;
379
+ if (entry.gate) {
380
+ const gateId = `gate-${n}`;
381
+ const g = entry.gate;
382
+ nodes.push({
383
+ id: gateId,
384
+ kind: "wait",
385
+ wait: {
386
+ kind: g.kind,
387
+ target: g.target,
388
+ ...(g.match ? { match: g.match } : {}),
389
+ poll: { ...g.poll },
390
+ onTimeout: g.onTimeout,
391
+ ...(g.credentialEnv ? { credentialEnv: g.credentialEnv } : {}),
392
+ },
393
+ });
394
+ if (predecessor) edges.push({ from: predecessor, to: gateId });
395
+ agentPredecessor = gateId;
396
+ }
186
397
 
187
398
  // agent → opens the PR, emits it as a typed `pr` fact the downstream nodes late-bind.
188
399
  nodes.push({
@@ -214,18 +425,16 @@ function assembleGraph(issueKeys: string[], behindKey: string | null): DeliveryG
214
425
  edges.push({ from: prRef, to: landId });
215
426
  edges.push({ from: prRef, to: mergedId });
216
427
 
217
- // Sequence: this issue's agent starts once the PRIOR issue merged; the first waits on the gate.
218
- if (i === 0) {
219
- if (behindKey) edges.push({ from: GATE_ID, to: openId });
220
- } else {
221
- edges.push({ from: `merged-${i}`, to: openId });
222
- }
428
+ // Sequence: this issue's agent starts once its predecessor (prior merge / epic gate / interleaved
429
+ // gate) released. A first ungated issue has no predecessor edge (it starts immediately).
430
+ if (agentPredecessor) edges.push({ from: agentPredecessor, to: openId });
223
431
  });
224
432
 
433
+ const gateCount = entries.filter((e) => e.gate).length;
225
434
  const name =
226
- issueKeys.length === 1
227
- ? `sequence ${issueKeys[0]}`
228
- : `sequence ${issueKeys.length} issues${behindKey ? ` behind ${behindKey}` : ""}`;
435
+ entries.length === 1
436
+ ? `sequence ${entries[0].issueKey}`
437
+ : `sequence ${entries.length} issues${gateCount > 0 ? ` with ${gateCount} gate${gateCount === 1 ? "" : "s"}` : ""}${behindKey ? ` behind ${behindKey}` : ""}`;
229
438
 
230
439
  return { name, nodes, edges };
231
440
  }
@@ -69,6 +69,49 @@ describe("S5 — the addressable operator guide over MCP (#611)", () => {
69
69
  assert(res.text.length < 30000, "one section must fit a typical tool-result budget in a single call");
70
70
  });
71
71
 
72
+ test("section pagination: start/length pages a section and nextStart walks it to completion (#740)", async () => {
73
+ const whole = await h.callTool("getAgentGuide", { section: "delivery-graphs" });
74
+ const wholeSection = (whole.json as { section?: { instructions: string } }).section!;
75
+ const wholeChars = Array.from(wholeSection.instructions);
76
+
77
+ const PAGE = 4000;
78
+ let start = 0;
79
+ let assembled = "";
80
+ let pages = 0;
81
+ let total = -1;
82
+ for (;;) {
83
+ const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start, length: PAGE });
84
+ assert(!res.isError, `a paged fetch must not error: ${res.text}`);
85
+ const body = res.json as {
86
+ kind?: string;
87
+ section?: { id: string; instructions: string; start: number; length: number; totalLength: number; nextStart: number | null };
88
+ };
89
+ assert.equal(body.kind, "section");
90
+ const s = body.section!;
91
+ assert.equal(s.id, "delivery-graphs");
92
+ assert(Array.from(s.instructions).length <= PAGE, "a page must be bounded by `length`");
93
+ assert.equal(s.start, start);
94
+ if (total !== -1) assert.equal(s.totalLength, total, "totalLength is stable across pages");
95
+ total = s.totalLength;
96
+ assembled += s.instructions;
97
+ pages++;
98
+ if (s.nextStart === null) break;
99
+ start = s.nextStart;
100
+ assert(pages < 100, "pagination must terminate");
101
+ }
102
+ assert(pages > 1, "a large section must span multiple pages at this window");
103
+ assert.equal(total, wholeChars.length, "totalLength equals the full section length");
104
+ assert.equal(assembled, wholeSection.instructions, "the reassembled pages equal the whole section");
105
+ });
106
+
107
+ test("section pagination: a non-integer/negative start is a uniform issues[{path,message}] 400 (#740)", async () => {
108
+ const res = await h.callTool("getAgentGuide", { section: "delivery-graphs", start: -5 });
109
+ assert(res.isError, "an invalid pagination arg must surface as a tool-level error");
110
+ const body = res.json as { issues?: { path: string; message: string }[] };
111
+ assert(Array.isArray(body.issues) && body.issues.length >= 1, "must answer with issues[]");
112
+ assert(body.issues!.some((i) => i.path.includes("start")), "the issue must name the `start` argument");
113
+ });
114
+
72
115
  test("an unknown section id is rejected with issues[{path,message}]", async () => {
73
116
  const res = await h.callTool("getAgentGuide", { section: "no-such-section" });
74
117
  assert(res.isError, "an unknown section id must surface as a tool-level error");
@@ -185,6 +185,67 @@ describe("delivery-graph runner — engine-native execution (S4)", () => {
185
185
  assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the late-bound wait resolved and the graph reached End");
186
186
  });
187
187
 
188
+ test("#731 producer contract gate: an agent that completes with status=in_progress and a null required emit escalates AT the producer, does NOT thread null downstream, and resumes", async () => {
189
+ const app = track(await boot(freshDir()));
190
+
191
+ // The instance-10746 failure mode: the agent's job COMPLETES, but it broke its node contract —
192
+ // it self-reports `status: "in_progress"` and never opened the PR, so its required `pr` emit is
193
+ // null. Before #731 this threaded `open_pr = null` through to the connector, which then failed with
194
+ // a mis-attributed CONSUMER incident. The producer gate must instead park THIS node.
195
+ let agentFired = 0;
196
+ await app.engine.registerWorker("senior:demo", async () => {
197
+ agentFired++;
198
+ return { status: "in_progress", summary: "delegated to a background agent; PR not opened." };
199
+ });
200
+ let connectorFired = 0;
201
+ await app.engine.registerWorker(
202
+ "pr.delivery-connector",
203
+ async (job) => {
204
+ connectorFired++;
205
+ const vars = job.variables as Record<string, unknown>;
206
+ const { target, payload, boundFacts } = readConnectorInput(vars as Parameters<typeof readConnectorInput>[0]);
207
+ const dedupeKey = connectorDedupeKey({
208
+ dedupeKey: (vars.dedupeKey as string | null | undefined) ?? null,
209
+ processInstanceKey: job.processInstanceKey ?? null,
210
+ elementId: job.elementId ?? null,
211
+ });
212
+ return await dispatchConnector(app.db, { dedupeKey: dedupeKey ?? "x", target, payload, boundFacts }, new Date().toISOString());
213
+ },
214
+ { fetchVariables: ["boundFacts", "target", "dedupeKey", "payload"] },
215
+ );
216
+
217
+ const graph: DeliveryGraph = {
218
+ name: "e2e producer gate",
219
+ nodes: [
220
+ { id: "open", kind: "agent", agent: { jobType: "senior:demo" }, emits: [{ name: "pr", type: "pr" }] },
221
+ { id: "land", kind: "connector", connector: { target: "slack", payload: { pr: "open.pr" }, dedupeKey: "land-731" } },
222
+ ],
223
+ edges: [{ from: "open.pr", to: "land" }],
224
+ };
225
+
226
+ const run = await runDeliveryGraph(app.engine, graph, { escalationSlaTimeout: "PT1H", repoless: true });
227
+ assert.ok(run.ok, `graph should deploy + run, got ${JSON.stringify(run)}`);
228
+ await app.settle();
229
+
230
+ // The agent job fired and COMPLETED — but the node did NOT succeed: it parked on its producer
231
+ // contract escalation, the graph never reached End, and the downstream connector never fired on null.
232
+ assert.equal(agentFired, 1, "the agent node's job fired and completed");
233
+ assert.ok(!takenFlows(app).some((f) => f.endsWith("->End")), "the broken producer did NOT thread its result to End");
234
+ assert.equal(connectorFired, 0, "the downstream connector never fired on a null required emit");
235
+ const open = await app.engine.searchUserTasks({ state: "CREATED" });
236
+ const contract = open.find((t) => t.elementId?.startsWith("delivery-human-task__") && t.elementId?.endsWith("__contract"));
237
+ assert.ok(contract, `the producer escalates AT its node on its __contract task, got ${JSON.stringify(open.map((t) => t.elementId))}`);
238
+
239
+ // Resumable (the issue's manual unblock): a human/agent supplies the eventually-created PR on the
240
+ // contract task; the subProcess output mapping republishes `open_pr` non-null and the connector runs.
241
+ await app.engine.completeUserTask(contract.userTaskKey, { value: "owner/repo#42", humanOutcome: "completed" });
242
+ await app.settle();
243
+ assert.equal(connectorFired, 1, "resuming the contract escalation with the missing PR unblocks the downstream connector");
244
+ const rows = await deliveryConnectorDispatches(app.db).find({ dedupe_key: "land-731" });
245
+ assert.equal(rows.length, 1, "the connector fired exactly once after resume");
246
+ assert.ok(takenFlows(app).some((f) => f.endsWith("->End")), "the resumed producer's result reaches End");
247
+ });
248
+
188
249
  test("resume never double-fires: an at-least-once redelivery of the connector dedupes", async () => {
189
250
  const app = track(await boot(freshDir()));
190
251
  // The connector fired once above's-style; here prove the idempotency directly against the ledger a