@nanobpm/nano-workforce 0.139.3 → 0.140.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.140.0](https://github.com/nanobpm/nano-workforce/compare/v0.139.4...v0.140.0) (2026-08-25)
2
+
3
+ ### Features
4
+
5
+ * **agentic:** emit transcriptUrl as a job-output variable (Stage 0, [#543](https://github.com/nanobpm/nano-workforce/issues/543)) ([#547](https://github.com/nanobpm/nano-workforce/issues/547)) ([26b8410](https://github.com/nanobpm/nano-workforce/commit/26b84105405f283acad35ea4026e247fc92d607c)), closes [#544](https://github.com/nanobpm/nano-workforce/issues/544) [#486](https://github.com/nanobpm/nano-workforce/issues/486) [#486](https://github.com/nanobpm/nano-workforce/issues/486)
6
+
7
+ ## [0.139.4](https://github.com/nanobpm/nano-workforce/compare/v0.139.3...v0.139.4) (2026-08-25)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **delivery-graph:** honor a wait node's per-node poll.timeoutMs + onTimeout ([#462](https://github.com/nanobpm/nano-workforce/issues/462)) ([#545](https://github.com/nanobpm/nano-workforce/issues/545)) ([f91d456](https://github.com/nanobpm/nano-workforce/commit/f91d4561736e0d7b4bbcaf55a7a4ff61a602b82d)), closes [Magikcraft/nano-bpm#978](https://github.com/Magikcraft/nano-bpm/issues/978)
12
+
1
13
  ## [0.139.3](https://github.com/nanobpm/nano-workforce/compare/v0.139.2...v0.139.3) (2026-08-25)
2
14
 
3
15
  ### Documentation
@@ -292,6 +292,37 @@ test("retention: a disconnected producer auto-completes its ephemeral stream on
292
292
  service.teardown();
293
293
  });
294
294
 
295
+ test("#486 live fallback: an uncompleted stream's ring is served pre-flush, then yields to the durable store", () => {
296
+ const registry = new ConnectionRegistry();
297
+ const hub = capturingHub();
298
+ const service = new RelayTranscriptService({
299
+ hub,
300
+ registry,
301
+ db: memoryDb(),
302
+ log: noopLog(),
303
+ now: () => "2026-03-04T05:06:07.000Z",
304
+ });
305
+ const p = connect("prod", registry);
306
+ for (let i = 0; i < 3; i++) hub.handler?.(produce(jobStream("Lk1"), 1, `r${i}`), p.conn);
307
+
308
+ // The job has completed and emitted its transcriptUrl, but this multiplexing worker is still live so
309
+ // no disconnect/supersede flushed the ring — the durable store still 404s (#486). The live fallback
310
+ // exposes the captured ring + its opened-at instant so the read path can serve it immediately.
311
+ assertEquals(service.transcriptOf(jobStream("Lk1")), undefined, "not yet flushed while the worker is live");
312
+ const live = service.liveFallback(jobStream("Lk1"));
313
+ assert(live !== undefined, "a live, unflushed stream has a serveable ring");
314
+ assertEquals(live.createdAt, "2026-03-04T05:06:07.000Z", "createdAt is the stream's opened-at instant");
315
+ assertEquals(live.ring.since(0).entries.length, 3, "the whole captured window is available");
316
+ assertEquals(live.ring.nextOffset, 3);
317
+
318
+ // Once the stream completes (flushed to durable), the durable store is the source of truth and the
319
+ // live fallback steps aside so a completed transcript is never double-sourced.
320
+ service.completeStream(jobStream("Lk1"));
321
+ assertEquals(service.transcriptOf(jobStream("Lk1"))?.status, "completed");
322
+ assertEquals(service.liveFallback(jobStream("Lk1")), undefined, "a completed stream no longer falls back to the ring");
323
+ service.teardown();
324
+ });
325
+
295
326
  test("H6 correlation write-side: a produce on job:<k> links instance→[k]; stream completion releases it", () => {
296
327
  const registry = new ConnectionRegistry();
297
328
  const correlation = new CorrelationRegistry();
@@ -90,6 +90,14 @@ interface StreamState {
90
90
  * instance is not yet resolvable (a register/produce race), so a later `produce` frame retries.
91
91
  */
92
92
  linked: boolean;
93
+ /**
94
+ * When this stream's in-memory state was first opened, ISO-8601 (stamped from the service clock).
95
+ * The durable transcript row carries its own `created_at` (stamped at flush/`open`), but a
96
+ * still-live ephemeral stream has no durable row yet (#486): its ring holds the captured bytes but
97
+ * the store 404s until a producer disconnect / supersede flushes it. This is the authoritative
98
+ * "when opened" for {@link RelayTranscriptService.liveFallback} to serve the pre-flush ring.
99
+ */
100
+ createdAt: string;
93
101
  /**
94
102
  * The worker instance a `job:<jobKey>` stream was linked under (H6). Recorded so a stream's release
95
103
  * (completion / disconnect) can tidy the {@link RelayTranscriptService.#jobStreamByInstance}
@@ -536,10 +544,30 @@ export class RelayTranscriptService {
536
544
  }
537
545
  }
538
546
 
547
+ /**
548
+ * The still-live relay ring for a stream, for the read path to serve BEFORE a durable flush (#486).
549
+ *
550
+ * A multiplexing worker relays every job over one long-lived connection, one job at a time, so an
551
+ * ephemeral job stream is only flushed to the durable store when the worker disconnects or a NEW
552
+ * job supersedes it — NOT when the job itself completes. In the window between "job completed
553
+ * (`transcriptUrl` emitted)" and that flush, {@link TranscriptStore.get} returns undefined and the
554
+ * transcript endpoint would 404 the freshly-emitted URL. This exposes the live ring (+ its opened-at
555
+ * instant) so {@link readTranscriptFrom} can serve the captured bytes directly, making the URL
556
+ * readable the moment it is emitted. Returns undefined when there is no live ring, or once the
557
+ * stream has been completed (the durable store is then the source of truth).
558
+ */
559
+ liveFallback(stream: string): { ring: TranscriptRing; createdAt: string } | undefined {
560
+ const ring = this.relay.ring(stream);
561
+ if (ring === undefined) return undefined;
562
+ const state = this.#streams.get(stream);
563
+ if (state?.completed) return undefined;
564
+ return { ring, createdAt: state?.createdAt ?? this.#now() };
565
+ }
566
+
539
567
  #stateFor(stream: string): StreamState {
540
568
  let state = this.#streams.get(stream);
541
569
  if (state === undefined) {
542
- state = { lifecycle: "ephemeral", completed: false, linked: false };
570
+ state = { lifecycle: "ephemeral", completed: false, linked: false, createdAt: this.#now() };
543
571
  this.#streams.set(stream, state);
544
572
  }
545
573
  return state;
@@ -6,10 +6,10 @@
6
6
  // interaction with a missing/invalid createdAt — where a hand-built store lets us fix exact timestamps.
7
7
  import { test } from "node:test";
8
8
  import { DatabaseSync } from "node:sqlite";
9
- import type { SqliteDb, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
9
+ import type { SqliteDb, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
10
10
  import { assert, assertEquals } from "#test-assert";
11
11
  import { AgenticCorrelationStore } from "./correlation-store.ts";
12
- import { correlationFieldsFor, listTranscripts } from "./transcript-read.ts";
12
+ import { correlationFieldsFor, listTranscripts, readTranscriptFrom } from "./transcript-read.ts";
13
13
 
14
14
  /** A read-only TranscriptStore double: list() returns the seeded metas; read() has no retained chunks. */
15
15
  function fakeStore(metas: TranscriptStream[]): TranscriptStore {
@@ -122,3 +122,96 @@ test("listTranscripts: the instance filter returns only sessions the durable sto
122
122
  );
123
123
  assert(out.every((t) => t.instance === "worker-A"), "each row is attributed to worker-A");
124
124
  });
125
+
126
+ // --- #486: the still-live-ring read fallback that makes a freshly-emitted transcriptUrl readable ---
127
+ //
128
+ // A multiplexing worker relays every job over one long-lived connection and only flushes a job's ring
129
+ // to the durable store when it disconnects or a NEW job supersedes it — NOT when the job completes. In
130
+ // the window between "job completed (transcriptUrl emitted)" and that flush, the durable store has no
131
+ // row, so the transcript endpoint must serve the live ring or it would 404 the URL it just emitted.
132
+
133
+ /** A minimal live ring double satisfying {@link TranscriptRing}: the whole retained window from `from`. */
134
+ function fakeRing(entries: { offset: number; chunk: string }[]): TranscriptRing {
135
+ const nextOffset = entries.length === 0 ? 0 : entries[entries.length - 1].offset + 1;
136
+ return {
137
+ since: (from: number) => ({ entries: entries.filter((e) => e.offset >= from) }),
138
+ nextOffset,
139
+ };
140
+ }
141
+
142
+ /** A store double whose `get` returns a seeded row (or undefined), with the matching `since` window. */
143
+ function getStore(row: TranscriptStream | undefined, entries: { offset: number; chunk: string }[] = []): TranscriptStore {
144
+ return {
145
+ get: (_stream: string) => row,
146
+ since: (_stream: string, from: number) => ({
147
+ entries: entries.filter((e) => e.offset >= from),
148
+ gap: false,
149
+ nextOffset: row?.nextOffset ?? 0,
150
+ }),
151
+ } as unknown as TranscriptStore;
152
+ }
153
+
154
+ test("readTranscriptFrom: falls back to the live ring when the durable store has no row (#486)", () => {
155
+ const ring = fakeRing([
156
+ { offset: 0, chunk: "hello " },
157
+ { offset: 1, chunk: "world" },
158
+ ]);
159
+ const out = readTranscriptFrom("job:live1", 0, getStore(undefined), undefined, undefined, {
160
+ ring,
161
+ createdAt: mid,
162
+ });
163
+ assert(out !== undefined, "a live-but-unflushed stream is readable, not a 404");
164
+ assertEquals(out.status, "open", "an unflushed live stream reads as open");
165
+ assertEquals(out.lifecycle, "ephemeral");
166
+ assertEquals(out.createdAt, mid);
167
+ assertEquals(out.nextOffset, 2);
168
+ assertEquals(out.chunkCount, 2);
169
+ assertEquals(
170
+ out.entries.map((e) => e.chunk).join(""),
171
+ "hello world",
172
+ "the captured bytes are served straight from the ring",
173
+ );
174
+ assertEquals(out.jobKey, "live1", "the jobKey is still decoded from the stream id");
175
+ });
176
+
177
+ test("readTranscriptFrom: the live-ring fallback honours the resume-from offset", () => {
178
+ const ring = fakeRing([
179
+ { offset: 0, chunk: "a" },
180
+ { offset: 1, chunk: "b" },
181
+ { offset: 2, chunk: "c" },
182
+ ]);
183
+ const out = readTranscriptFrom("job:live2", 2, getStore(undefined), undefined, undefined, { ring, createdAt: mid });
184
+ assert(out !== undefined);
185
+ assertEquals(out.from, 2);
186
+ assertEquals(
187
+ out.entries.map((e) => e.chunk).join(""),
188
+ "c",
189
+ "only chunks at/after the requested offset are replayed",
190
+ );
191
+ });
192
+
193
+ test("readTranscriptFrom: prefers the durable store once the ring has been flushed", () => {
194
+ const row: TranscriptStream = {
195
+ stream: "job:flushed",
196
+ lifecycle: "ephemeral",
197
+ status: "completed",
198
+ createdAt: early,
199
+ completedAt: late,
200
+ nextOffset: 1,
201
+ };
202
+ const store = getStore(row, [{ offset: 0, chunk: "durable" }]);
203
+ // A live ring is ALSO provided, but the flushed durable row wins (it is the source of truth once flushed).
204
+ const out = readTranscriptFrom("job:flushed", 0, store, undefined, undefined, {
205
+ ring: fakeRing([{ offset: 0, chunk: "stale-ring" }]),
206
+ createdAt: mid,
207
+ });
208
+ assert(out !== undefined);
209
+ assertEquals(out.status, "completed", "the flushed durable row is served, not the live ring");
210
+ assertEquals(out.completedAt, late);
211
+ assertEquals(out.entries.map((e) => e.chunk).join(""), "durable");
212
+ });
213
+
214
+ test("readTranscriptFrom: returns undefined when neither the store nor a live ring has the stream", () => {
215
+ const out = readTranscriptFrom("job:gone", 0, getStore(undefined), undefined, undefined, undefined);
216
+ assertEquals(out, undefined);
217
+ });
@@ -14,7 +14,7 @@
14
14
  // Pure and side-effect-free apart from reading the store: no I/O beyond the injected store, so it is
15
15
  // unit-testable on the injected env (Node, no browser), and never touches the engine or a BPMN flow.
16
16
 
17
- import type { TranscriptChunk, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
17
+ import type { TranscriptChunk, TranscriptRing, TranscriptStore, TranscriptStream } from "@nanobpm/agentic/transcript";
18
18
  import type { AgenticTranscript, AgenticTranscriptData } from "../../nano-generated/api-io.d.ts";
19
19
  import { type CorrelationRegistry, jobKeyOfStream } from "./correlation.ts";
20
20
  import type { AgenticCorrelationStore } from "./correlation-store.ts";
@@ -163,33 +163,64 @@ export function listTranscripts(
163
163
  /**
164
164
  * Fetch a stored transcript's bytes from offset `from` (inclusive), projected onto the range/offset
165
165
  * wire shape — the SAME resume-from-offset contract the live terminal renders, so the cockpit replays
166
- * a closed stream through its existing renderer. Returns undefined when the stream has no transcript.
166
+ * a closed stream through its existing renderer.
167
+ *
168
+ * Reads the durable {@link TranscriptStore} first; when the store has no row for the stream (it was
169
+ * never persisted, or — the #486 caveat — the job completed on a still-live multiplexing worker whose
170
+ * ring has not been flushed yet) it falls back to the injected live ring so a freshly-emitted
171
+ * `transcriptUrl` is readable the moment it is emitted. Returns undefined only when neither the store
172
+ * nor a live ring has the stream.
167
173
  */
168
174
  export function readTranscriptFrom(
169
175
  stream: string,
170
176
  from: number,
171
- store: TranscriptStore,
177
+ store: TranscriptStore | undefined,
172
178
  correlation: CorrelationRegistry | undefined,
173
179
  durable?: AgenticCorrelationStore | undefined,
180
+ live?: { ring: TranscriptRing; createdAt: string } | undefined,
174
181
  ): AgenticTranscriptData | undefined {
175
- const meta = store.get(stream);
176
- if (meta === undefined) return undefined;
177
- const slice = store.since(stream, from);
178
- const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
179
- const out: AgenticTranscriptData = {
180
- stream: meta.stream,
181
- lifecycle: meta.lifecycle,
182
- status: meta.status,
183
- createdAt: meta.createdAt,
184
- nextOffset: slice.nextOffset,
185
- byteLength: byteLengthOf(slice.entries),
186
- chunkCount: entries.length,
187
- from,
188
- gap: slice.gap,
189
- entries,
190
- };
191
- if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
192
- const fields = correlationFieldsFor(meta.stream, correlation, durable);
182
+ const meta = store?.get(stream);
183
+ let out: AgenticTranscriptData;
184
+ if (store !== undefined && meta !== undefined) {
185
+ const slice = store.since(stream, from);
186
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
187
+ out = {
188
+ stream: meta.stream,
189
+ lifecycle: meta.lifecycle,
190
+ status: meta.status,
191
+ createdAt: meta.createdAt,
192
+ nextOffset: slice.nextOffset,
193
+ byteLength: byteLengthOf(slice.entries),
194
+ chunkCount: entries.length,
195
+ from,
196
+ gap: slice.gap,
197
+ entries,
198
+ };
199
+ if (meta.completedAt !== undefined) out.completedAt = meta.completedAt;
200
+ } else if (live !== undefined) {
201
+ // #486: no durable row yet — the job completed on a still-live multiplexing worker whose ring has
202
+ // not been flushed. Serve the captured bytes straight from the live ring so a freshly-emitted
203
+ // `transcriptUrl` is readable immediately (an open ephemeral stream), rather than 404-ing until the
204
+ // worker disconnects. `gap` is derived structurally: the first returned entry sitting past `from`
205
+ // means retention already evicted the requested prefix.
206
+ const slice = live.ring.since(from);
207
+ const entries = slice.entries.map((c) => ({ offset: c.offset, chunk: c.chunk }));
208
+ out = {
209
+ stream,
210
+ lifecycle: "ephemeral",
211
+ status: "open",
212
+ createdAt: live.createdAt,
213
+ nextOffset: live.ring.nextOffset,
214
+ byteLength: byteLengthOf(slice.entries),
215
+ chunkCount: entries.length,
216
+ from,
217
+ gap: entries.length > 0 && entries[0].offset > from,
218
+ entries,
219
+ };
220
+ } else {
221
+ return undefined;
222
+ }
223
+ const fields = correlationFieldsFor(out.stream, correlation, durable);
193
224
  if (fields.jobKey !== undefined) out.jobKey = fields.jobKey;
194
225
  if (fields.processInstanceKey !== undefined) out.processInstanceKey = fields.processInstanceKey;
195
226
  if (fields.bpmnProcessId !== undefined) out.bpmnProcessId = fields.bpmnProcessId;
@@ -0,0 +1,35 @@
1
+ // Unit coverage for the transcript-URL SSOT (#543): the base a worker prepends and the full per-job
2
+ // URL it emits must derive from ONE path string and the ONE jobStream() encoder, so the seed the
3
+ // dispatcher hands each agent job, the endpoint route, and the tests can never drift apart.
4
+ import { test } from "node:test";
5
+ import { assertEquals } from "#test-assert";
6
+ import { jobStream } from "./correlation.ts";
7
+ import {
8
+ TRANSCRIPT_URL_BASE_VAR,
9
+ TRANSCRIPT_URL_VAR,
10
+ transcriptUrlBaseFor,
11
+ transcriptUrlForJob,
12
+ } from "./transcript-url.ts";
13
+
14
+ const BASE = "https://nano.example.com";
15
+
16
+ test("the variable names are the stable wire contract Explorer and the worker agree on", () => {
17
+ assertEquals(TRANSCRIPT_URL_VAR, "transcriptUrl");
18
+ assertEquals(TRANSCRIPT_URL_BASE_VAR, "transcriptUrlBase");
19
+ });
20
+
21
+ test("transcriptUrlBaseFor: the seeded base is the app mount's transcript endpoint, trailing-slashed", () => {
22
+ assertEquals(transcriptUrlBaseFor(BASE), `${BASE}/app/api/agentic/transcripts/`);
23
+ });
24
+
25
+ test("transcriptUrlForJob: the full URL is the base + the jobKey-scoped stream id", () => {
26
+ const jobKey = "2251799813685249";
27
+ assertEquals(transcriptUrlForJob(jobKey, BASE), `${BASE}/app/api/agentic/transcripts/${jobStream(jobKey)}`);
28
+ });
29
+
30
+ test("derivation: transcriptUrlForJob is exactly transcriptUrlBaseFor + jobStream — no second path source", () => {
31
+ // This is the anti-drift invariant: the value a worker emits and the base the dispatcher seeds share
32
+ // a single origin, so a route change in one place can never leave the other pointing at a 404.
33
+ const jobKey = "job-abc";
34
+ assertEquals(transcriptUrlForJob(jobKey, BASE), `${transcriptUrlBaseFor(BASE)}${jobStream(jobKey)}`);
35
+ });
@@ -0,0 +1,54 @@
1
+ // nano-workforce — the agent-job transcript URL contract (ADR 0006 §4b, Stage 0 / #543).
2
+ //
3
+ // The zero-infra correlation slice: on agentic job completion the completing worker emits a
4
+ // `transcriptUrl` output variable pointing at the durable HTTP transcript endpoint for its own
5
+ // `jobKey`, so Nano Explorer's variables panel links a process run to its agent transcript with no
6
+ // Explorer change and no new correlation infra (that sharper element-instance keying is Stage 1,
7
+ // #544). This module is the SINGLE SOURCE OF TRUTH for that URL's shape: the seed the dispatcher hands
8
+ // each agent job, the value the worker emits, and the tests all derive from it, so they cannot drift
9
+ // from one another. It is authored to match the endpoint route the `getAgenticTranscript` operation
10
+ // serves (`GET /app/api/agentic/transcripts/{stream}` in `openapi.yaml`) — see {@link TRANSCRIPT_STREAM_PATH}.
11
+ //
12
+ // The value is worker-supplied because only the completing worker knows its own `jobKey` (the engine
13
+ // exposes no job/element/process key to an output-mapping FEEL context — it is job metadata, not a
14
+ // variable). So the app SEEDS the base URL onto the agent job (`transcriptUrlBase`) and the worker
15
+ // appends its jobKey-scoped stream id, keeping the fleet worker app-agnostic (a bare concatenation)
16
+ // while this module owns every path segment.
17
+
18
+ import { publicBaseUrl } from "../blackboard.ts";
19
+ import { jobStream } from "./correlation.ts";
20
+
21
+ /** The job-output variable a completed agent job carries its transcript URL on (rendered by Explorer). */
22
+ export const TRANSCRIPT_URL_VAR = "transcriptUrl";
23
+
24
+ /** The input variable the dispatcher seeds onto an agent job so the worker can build {@link TRANSCRIPT_URL_VAR}
25
+ * by appending its own jobKey-scoped stream — the worker never needs to know the app's mount path. */
26
+ export const TRANSCRIPT_URL_BASE_VAR = "transcriptUrlBase";
27
+
28
+ /** The control-API path (under the app mount) the transcript stream endpoint is served at, ending in a
29
+ * trailing slash so a `{stream}` id appends cleanly. This is the app-tier authoring used to BUILD the
30
+ * transcript URL (the seed the dispatcher hands each agent job, the value the worker emits, and the
31
+ * tests) — the single origin those derive from, so they cannot drift from one another. It is NOT the
32
+ * sole authoring of the route string itself: the endpoint route is declared by the `getAgenticTranscript`
33
+ * operation (`GET /app/api/agentic/transcripts/{stream}` in `openapi.yaml`), and the cockpit embed
34
+ * carries its own module-anchored default (`pages/cockpit/mount.js`). Keep this value in step with that
35
+ * route. */
36
+ const TRANSCRIPT_STREAM_PATH = "/app/api/agentic/transcripts/";
37
+
38
+ /**
39
+ * The externally-reachable base a worker prepends to its jobKey-scoped stream id to form the
40
+ * transcript URL: `<publicBaseUrl>/app/api/agentic/transcripts/`. The worker appends `job:<jobKey>`
41
+ * ({@link jobStream}). Trailing slash included so the concatenation is a bare append.
42
+ */
43
+ export function transcriptUrlBaseFor(base: string = publicBaseUrl()): string {
44
+ return `${base}${TRANSCRIPT_STREAM_PATH}`;
45
+ }
46
+
47
+ /**
48
+ * The full durable transcript URL for a completed job's `jobKey` — the value a worker emits on
49
+ * {@link TRANSCRIPT_URL_VAR}. Derived from {@link transcriptUrlBaseFor} + {@link jobStream} so it can
50
+ * never disagree with the base the dispatcher seeds or the endpoint route it resolves to.
51
+ */
52
+ export function transcriptUrlForJob(jobKey: string, base: string = publicBaseUrl()): string {
53
+ return `${transcriptUrlBaseFor(base)}${jobStream(jobKey)}`;
54
+ }
@@ -718,3 +718,16 @@ test("S7 single-target guarded fan-out is NOT an exclusive split — a node whos
718
718
  });
719
719
  assertEquals(errors, []);
720
720
  });
721
+
722
+ test("a wait node's onTimeout: fail is rejected (unsupported-on-timeout) while continue/escalate validate (#462)", () => {
723
+ const waitWith = (onTimeout: string) => ({
724
+ name: "onTimeout",
725
+ nodes: [{ id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout } }],
726
+ edges: [],
727
+ });
728
+ const err = hasCode(validateDeliveryGraph(waitWith("fail")), "unsupported-on-timeout");
729
+ assertEquals(err.path, "nodes[0].wait.onTimeout");
730
+ // continue + escalate are honored — they must NOT raise the unsupported-on-timeout error.
731
+ assertEquals(validateDeliveryGraph(waitWith("continue")), []);
732
+ assertEquals(validateDeliveryGraph(waitWith("escalate")), []);
733
+ });
@@ -83,7 +83,8 @@ export type DeliveryGraphErrorCode =
83
83
  | "mixed-fan-out"
84
84
  | "multiple-defaults"
85
85
  | "non-exhaustive-split"
86
- | "exclusive-merge-parity";
86
+ | "exclusive-merge-parity"
87
+ | "unsupported-on-timeout";
87
88
 
88
89
  /** A single semantic validation failure. `path` is a JSON-path-qualified pointer at the offending
89
90
  * input (`nodes[2].kind`, `edges[1].from`, `nodes[0].emits[1].name`), `message` is human-actionable,
@@ -342,6 +343,21 @@ export function validateDeliveryGraph(graph: unknown): DeliveryGraphError[] {
342
343
  });
343
344
  }
344
345
  }
346
+ // A `wait` node's `onTimeout: fail` cannot be honored yet: the compiler would emit a terminate
347
+ // end on the not-ready-at-boundary path, but the engine treats terminate-end events as
348
+ // parsed-not-executed (Magikcraft/nano-bpm bpmn.rs), so `fail` would silently degrade to a plain
349
+ // end — the "declared knob silently ignored" defect class. Reject it loudly (path-qualified)
350
+ // until engine parity lands (Magikcraft/nano-bpm#978), rather than mis-compile it. `escalate`
351
+ // (default) and `continue` ARE honored.
352
+ if (kind === "wait" && config.onTimeout === "fail") {
353
+ errors.push({
354
+ path: `${path}.${configKey}.onTimeout`,
355
+ message:
356
+ "`onTimeout: fail` on a `wait` node is not yet supported (blocked on engine terminate-end " +
357
+ "execution, Magikcraft/nano-bpm#978); use `escalate` (default) or `continue`",
358
+ code: "unsupported-on-timeout",
359
+ });
360
+ }
345
361
  }
346
362
  } else if (rawNode.human !== undefined && !isRecord(rawNode.human)) {
347
363
  // `human` config is OPTIONAL (formKey/prompt both resolve to a generic fallback in S3), but
@@ -81,6 +81,33 @@ test("happy path: a well-formed graph compiles to a full preview with no side ef
81
81
  assertEquals(r.sideEffects.length, 2);
82
82
  });
83
83
 
84
+ test("#543 transcript correlation: only an agent node seeds transcriptUrlBase and emits transcriptUrl", async () => {
85
+ const r = await compileOk(RELEASE_RUNBOOK);
86
+ // The agent node's subProcess ioMapping threads the seeded base IN (so the completing worker can
87
+ // build its own jobKey-scoped URL) and propagates the worker-set transcriptUrl OUT to the instance.
88
+ assert(
89
+ /<zeebe:input source="=if \(is defined\(transcriptUrlBase\)\) then transcriptUrlBase else null" target="transcriptUrlBase"/.test(
90
+ r.bpmn,
91
+ ),
92
+ "an agent node seeds transcriptUrlBase",
93
+ );
94
+ assert(
95
+ /<zeebe:output source="=if \(is defined\(transcriptUrl\)\) then transcriptUrl else null" target="transcriptUrl"/.test(r.bpmn),
96
+ "an agent node propagates the worker-emitted transcriptUrl up to the instance scope",
97
+ );
98
+ // RELEASE_RUNBOOK has exactly ONE agent node — wait/human/connector must NOT carry the mapping.
99
+ assertEquals(
100
+ (r.bpmn.match(/target="transcriptUrl"/g) ?? []).length,
101
+ 1,
102
+ "only the agent node emits transcriptUrl (non-agent kinds do not)",
103
+ );
104
+ assertEquals(
105
+ (r.bpmn.match(/target="transcriptUrlBase"/g) ?? []).length,
106
+ 1,
107
+ "only the agent node seeds transcriptUrlBase",
108
+ );
109
+ });
110
+
84
111
  test("determinism: the same JSON always yields byte-identical bpmn/diagram/resolved", async () => {
85
112
  const a = await compileOk(RELEASE_RUNBOOK);
86
113
  const b = await compileOk(RELEASE_RUNBOOK);
@@ -605,3 +632,45 @@ test("S7 compiler: a post-merge node with an extra always-firing producer joins
605
632
  assert(/<bpmn:parallelGateway id="gwj\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize joins its always-firing producers on a parallel gateway");
606
633
  assert(!/<bpmn:exclusiveGateway id="gwm\d+"[^>]*name="join into finalize"/.test(r.bpmn), "finalize is NOT compiled as an exclusive merge");
607
634
  });
635
+
636
+ test("a wait node's onTimeout: continue proceeds past the gate with NO escalation task; escalate (default) keeps the human stop (#462)", async () => {
637
+ // AC (#462): `onTimeout: continue` routes the not-ready-at-boundary branch straight to the node end
638
+ // — no `__esc` escalation user task, no human stop — while the default (`escalate`) parks it on the
639
+ // escalation task. Two sibling wait nodes, one of each, isolate the difference.
640
+ const graph = {
641
+ name: "continue vs escalate",
642
+ nodes: [
643
+ { id: "soft", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout: "continue" } },
644
+ { id: "hard", kind: "wait", wait: { kind: "pr", target: "acme/repo#2", match: { prState: "merged" }, onTimeout: "escalate" } },
645
+ ],
646
+ edges: [{ from: "soft", to: "hard" }],
647
+ };
648
+ const r = await compileOk(graph);
649
+ const softEl = elementForNode(r.bpmn, "soft");
650
+ const hardEl = elementForNode(r.bpmn, "hard");
651
+ // continue: no escalation twin for `soft`, and its not-ready boundary flow lands on the node end.
652
+ assert(!r.bpmn.includes(`delivery-human-task__${softEl}__esc`), "continue emits no escalation user task");
653
+ assert(
654
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${softEl}_i4" name="not ready" sourceRef="${softEl}_lastGw" targetRef="${softEl}_end" />`),
655
+ "continue routes the not-ready boundary branch to the node end",
656
+ );
657
+ // escalate: `hard` keeps its escalation twin and routes not-ready to it.
658
+ assert(r.bpmn.includes(`delivery-human-task__${hardEl}__esc`), "escalate keeps the escalation user task");
659
+ assert(
660
+ r.bpmn.includes(`<bpmn:sequenceFlow id="${hardEl}_i4" name="not ready" sourceRef="${hardEl}_lastGw" targetRef="delivery-human-task__${hardEl}__esc" />`),
661
+ "escalate routes the not-ready boundary branch to the escalation task",
662
+ );
663
+ });
664
+
665
+ test("a wait node's onTimeout: fail is rejected at compile with a path-qualified error (blocked on engine terminate-end, #462/#978)", async () => {
666
+ const errors = await compileFail({
667
+ name: "fail not yet supported",
668
+ nodes: [
669
+ { id: "g", kind: "wait", wait: { kind: "pr", target: "acme/repo#1", match: { prState: "merged" }, onTimeout: "fail" } },
670
+ ],
671
+ edges: [],
672
+ });
673
+ const hit = errors.find((e) => e.path === "nodes[0].wait.onTimeout");
674
+ assert(hit, `expected a path-qualified onTimeout error, got ${JSON.stringify(errors)}`);
675
+ assert(hit?.message.includes("#978"), `the error names the blocking engine issue, got ${hit?.message}`);
676
+ });
@@ -35,6 +35,7 @@ import type {
35
35
  ResolvedDeliveryEdge,
36
36
  ResolvedDeliveryNode,
37
37
  } from "../nano-generated/api-io.d.ts";
38
+ import { TRANSCRIPT_URL_BASE_VAR, TRANSCRIPT_URL_VAR } from "./agentic/transcript-url.ts";
38
39
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
39
40
  import {
40
41
  analyzeExclusiveTopology,
@@ -815,6 +816,11 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
815
816
  inputs.push({ source: cfg("jobType"), target: "jobType" });
816
817
  inputs.push({ source: cfg("appendPrompt"), target: "appendPrompt" });
817
818
  inputs.push({ source: cfg("timeout"), target: "nodeTimeout" });
819
+ // Stage 0 transcript correlation (#543): seed the transcript URL base so the completing fleet
820
+ // worker can append its own jobKey-scoped stream and emit `transcriptUrl` (below). `transcriptUrlBase`
821
+ // is a top-level launch variable (deliveryRunner) — guarded so a hand-seeded instance without it
822
+ // threads null rather than raising a FEEL error.
823
+ inputs.push({ source: guarded(TRANSCRIPT_URL_BASE_VAR), target: TRANSCRIPT_URL_BASE_VAR });
818
824
  break;
819
825
  case "wait":
820
826
  inputs.push({ source: cfg("gateKey"), target: "gateKey" });
@@ -862,6 +868,14 @@ function ioMappingLines(w: NodeWiring, boundInputs: readonly BoundInput[]): stri
862
868
  outputs.push({ source: guarded(factSourceVar(node.kind, fact)), target: `${el}_${fact.name}` });
863
869
  }
864
870
 
871
+ // Stage 0 transcript correlation (#543): propagate the completing worker's `transcriptUrl` (built
872
+ // from the seeded base + its jobKey) up to the process-instance scope, where Nano Explorer's
873
+ // variables panel renders it as the link from this run to the agent's transcript. Guarded so a job
874
+ // completed without it (an older fleet worker) threads null instead of raising a FEEL error.
875
+ if (node.kind === "agent") {
876
+ outputs.push({ source: guarded(TRANSCRIPT_URL_VAR), target: TRANSCRIPT_URL_VAR });
877
+ }
878
+
865
879
  const lines: string[] = [" <zeebe:ioMapping>"];
866
880
  for (const i of inputs) lines.push(` <zeebe:input ${attr("source", i.source)} target="${i.target}" />`);
867
881
  for (const o of outputs) lines.push(` <zeebe:output ${attr("source", o.source)} target="${o.target}" />`);
@@ -953,10 +967,16 @@ function serviceBodyLines(
953
967
  /** `wait` body: `start → pr.readiness-probe (poll) → ready? → end`, escalating on not-ready or on the
954
968
  * `=probeTimeout` engine bound. The probe polls its OWN target, so an unrelated upstream event can
955
969
  * never flip it to ready (#274/S2 concurrency-correctness); the `pr` kind (S2) binds `mergedSha`. */
956
- function waitBodyLines(el: string, node: DeliveryNode): string[] {
970
+ function waitBodyLines(el: string, node: Extract<DeliveryNode, { kind: "wait" }>): string[] {
957
971
  const nodeId = node.id;
958
972
  const esc = escalationTaskElement(el);
959
973
  const emits = normaliseEmits(node);
974
+ // `onTimeout` routing (#462): `escalate` (default) parks the not-ready-at-boundary token on a
975
+ // human-completable escalation task; `continue` proceeds past the gate as not-ready WITHOUT a human
976
+ // stop (a documented sharp edge — the downstream side-effecting node then runs without the awaited
977
+ // fact). `fail` is rejected earlier at validation (blocked on engine terminate-end, #978), so it
978
+ // never reaches here.
979
+ const continueOnTimeout = node.wait?.onTimeout === "continue";
960
980
  // Defect A: read-only probe diagnostics seeded onto the escalation task so the operator/agent can
961
981
  // tell a genuine "not published yet" from a transient false-negative — the probe's last detail, the
962
982
  // resolved target/match, and a compact summary of the candidate releases the probe observed.
@@ -1033,22 +1053,27 @@ function waitBodyLines(el: string, node: DeliveryNode): string[] {
1033
1053
  ` <bpmn:outgoing>${el}_i7</bpmn:outgoing>`,
1034
1054
  ` <bpmn:outgoing>${el}_i4</bpmn:outgoing>`,
1035
1055
  " </bpmn:exclusiveGateway>",
1036
- ...escalationTaskLines(
1037
- esc,
1038
- nodeId,
1039
- [`${el}_i4`],
1040
- `${el}_i5`,
1041
- waitEscalationContextFeel(nodeId),
1042
- { resume: { kind: node.kind, emits }, diagnosticInputs },
1043
- ),
1044
- ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming><bpmn:incoming>${el}_i5</bpmn:incoming><bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
1056
+ ...(continueOnTimeout
1057
+ ? []
1058
+ : escalationTaskLines(
1059
+ esc,
1060
+ nodeId,
1061
+ [`${el}_i4`],
1062
+ `${el}_i5`,
1063
+ waitEscalationContextFeel(nodeId),
1064
+ { resume: { kind: node.kind, emits }, diagnosticInputs },
1065
+ )),
1066
+ // On `continue`, the not-ready-at-boundary branch (`_i4`) proceeds straight to the node end (no
1067
+ // human stop, no `_i5` escalation-return flow); on `escalate` it parks on the escalation task,
1068
+ // which returns via `_i5`.
1069
+ ` <bpmn:endEvent id="${el}_end"><bpmn:incoming>${el}_i1</bpmn:incoming>${continueOnTimeout ? `<bpmn:incoming>${el}_i4</bpmn:incoming>` : `<bpmn:incoming>${el}_i5</bpmn:incoming>`}<bpmn:incoming>${el}_i7</bpmn:incoming></bpmn:endEvent>`,
1045
1070
  flow(`${el}_i0`, `${el}_start`, `${el}_probeLoop`),
1046
1071
  flow(`${el}_i1`, `${el}_probeLoop`, `${el}_end`),
1047
1072
  flow(`${el}_i2`, `${el}_be`, `${el}_lastAttempt`),
1048
1073
  flow(`${el}_i6`, `${el}_lastAttempt`, `${el}_lastGw`),
1049
1074
  ` <bpmn:sequenceFlow id="${el}_i7" name="ready" sourceRef="${el}_lastGw" targetRef="${el}_end"><bpmn:conditionExpression xsi:type="bpmn:tFormalExpression">=ready = true</bpmn:conditionExpression></bpmn:sequenceFlow>`,
1050
- ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${esc}" />`,
1051
- flow(`${el}_i5`, esc, `${el}_end`),
1075
+ ` <bpmn:sequenceFlow id="${el}_i4" name="not ready" sourceRef="${el}_lastGw" targetRef="${continueOnTimeout ? `${el}_end` : esc}" />`,
1076
+ ...(continueOnTimeout ? [] : [flow(`${el}_i5`, esc, `${el}_end`)]),
1052
1077
  ];
1053
1078
  }
1054
1079
 
@@ -22,6 +22,13 @@ import { assert, assertEquals } from "#test-assert";
22
22
  import { DELIVERY_CONNECTOR_TASK_TYPE } from "./deliveryConnector.ts";
23
23
  import { compileDeliveryGraph } from "./deliveryGraphCompiler.ts";
24
24
  import { runDeliveryGraph } from "./deliveryRunner.ts";
25
+ import { jobStream } from "./agentic/correlation.ts";
26
+ import {
27
+ TRANSCRIPT_URL_BASE_VAR,
28
+ TRANSCRIPT_URL_VAR,
29
+ transcriptUrlBaseFor,
30
+ transcriptUrlForJob,
31
+ } from "./agentic/transcript-url.ts";
25
32
  import type { DeliveryGraph } from "../nano-generated/api-io.d.ts";
26
33
 
27
34
  /** A graph exercising the full node-kind matrix: `agent` (a named `senior:*` job), `wait` (the
@@ -208,6 +215,78 @@ function escapeRe(s: string): string {
208
215
  return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
209
216
  }
210
217
 
218
+ /** Read a running process instance's scope variables out of the wasm engine's raw snapshot, by key. */
219
+ function instanceVariables(engine: { snapshot(): Record<string, unknown> }, key: string): Record<string, unknown> {
220
+ const snap = engine.snapshot();
221
+ const instances = snap.instances;
222
+ assert(Array.isArray(instances), "snapshot.instances is an array of instance rows");
223
+ const row = instances.find((i): i is { key: string; variables: Record<string, unknown> } => {
224
+ return typeof i === "object" && i !== null && (i as { key?: unknown }).key === key;
225
+ });
226
+ assert(row !== undefined, `no snapshot instance row for ${key}`);
227
+ return row.variables ?? {};
228
+ }
229
+
230
+ test("#543 transcript correlation: a completed agent job exposes a resolvable instance-scope transcriptUrl", async () => {
231
+ const engine = await createWasmEngineClient();
232
+ try {
233
+ // A minimal agent→human graph: the agent job completes (emitting its transcript URL the way the
234
+ // real fleet worker does — the seeded base + its own jobKey-scoped stream), then the instance parks
235
+ // on the human node so its scope variables are still inspectable (a bare agent graph would COMPLETE
236
+ // and drop them). The worker captures its jobKey so the test can assert the exact URL the SSOT
237
+ // builder yields for it.
238
+ let workerJobKey = "";
239
+ let seededBase: unknown;
240
+ await engine.registerWorker(
241
+ "senior:feature",
242
+ async (job) => {
243
+ workerJobKey = String(job.jobKey);
244
+ seededBase = job.variables?.transcriptUrlBase;
245
+ // Mirror the harness: append the jobKey-scoped stream id to the app-seeded base (#486/#543).
246
+ return { transcriptUrl: `${String(job.variables?.transcriptUrlBase)}${jobStream(workerJobKey)}` };
247
+ },
248
+ { fetchVariables: [TRANSCRIPT_URL_BASE_VAR] },
249
+ );
250
+
251
+ const graph: DeliveryGraph = {
252
+ name: "transcript correlation",
253
+ nodes: [
254
+ { id: "impl", kind: "agent", agent: { jobType: "senior:feature", prompt: "ship it" } },
255
+ { id: "review", kind: "human", human: { prompt: "review the run" } },
256
+ ],
257
+ edges: [{ from: "impl", to: "review" }],
258
+ };
259
+ const run = await runDeliveryGraph(engine, graph);
260
+ assert(run.ok, `runDeliveryGraph failed: ${JSON.stringify(run)}`);
261
+ const key = run.handle.processInstanceKey;
262
+
263
+ // Drive until the agent node has completed and the instance parks on the human user task.
264
+ let parked = false;
265
+ for (let round = 0; round < MAX_ROUNDS; round++) {
266
+ await engine.drain();
267
+ const open = await engine.searchUserTasks({ processInstanceKey: key, state: "CREATED" });
268
+ if (open.length > 0) {
269
+ parked = true;
270
+ break;
271
+ }
272
+ }
273
+ assert(parked, "the agent node must complete and the instance park on the human node");
274
+
275
+ // The app seeded the transcript endpoint base onto the agent job (input mapping)...
276
+ assertEquals(seededBase, transcriptUrlBaseFor(), "the agent job receives the seeded transcriptUrlBase");
277
+ // ...and the worker-emitted transcriptUrl propagated up to the process-instance scope (output
278
+ // mapping), resolving to EXACTLY the SSOT URL for that jobKey — the link Nano Explorer renders.
279
+ const vars = instanceVariables(engine, key);
280
+ assertEquals(
281
+ vars[TRANSCRIPT_URL_VAR],
282
+ transcriptUrlForJob(workerJobKey),
283
+ "the completed agent job exposes a resolvable, correct transcriptUrl on the instance",
284
+ );
285
+ } finally {
286
+ await engine.close();
287
+ }
288
+ });
289
+
211
290
  // ── S7: guarded (conditional) routing DEPLOYS and ROUTES on the real engine (ADR 0005 S7) ──────────
212
291
  // The compiler tests prove a guarded split emits an exclusiveGateway with FEEL conditions; only a live
213
292
  // deploy proves the engine EVALUATES those conditions and takes exactly ONE branch. Here the `bump`
@@ -258,6 +258,51 @@ test("a RUN-LEVEL timeout is normalized (lower-case → canonical) and a malform
258
258
  }
259
259
  });
260
260
 
261
+ test("a wait node's per-node poll.timeoutMs drives its escalation boundary while a sibling keeps the run/default (#462)", async () => {
262
+ // AC (#462): a `wait` node declaring `poll.timeoutMs` seeds nodeInputs.<el>.probeTimeout derived
263
+ // from that budget (the compiled `=probeTimeout` boundary), while a sibling wait WITHOUT a declared
264
+ // timeout keeps the run-level value. Mirrors the per-node `everyMs → probePollEvery` override that
265
+ // already exists — the escalation boundary is the one budget that was silently ignored, so a 7-day
266
+ // gate escalated at the 30-minute run default.
267
+ const graph: DeliveryGraph = {
268
+ name: "per-node wait timeout",
269
+ nodes: [
270
+ { id: "long-gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" }, poll: { timeoutMs: 604_800_000 } } },
271
+ { id: "default-gate", kind: "wait", wait: { kind: "pr", target: "owner/repo#2", match: { prState: "merged" } } },
272
+ ],
273
+ edges: [{ from: "long-gate", to: "default-gate" }],
274
+ };
275
+ const p = await prepareOk(graph, { probeTimeout: "PT20M", runKey: "run-462" });
276
+ const waits = Object.values(p.nodeInputs).filter((v) => "gateKey" in v) as Array<{ gateKey: string; probeTimeout: string }>;
277
+ const byGate = (suffix: string) => waits.find((w) => w.gateKey.endsWith(suffix));
278
+ // Element ids are positional by sorted node id: default-gate → n0, long-gate → n1.
279
+ assertEquals(byGate(":n1")?.probeTimeout, "PT604800S"); // 7 days in seconds — the per-node budget wins
280
+ assertEquals(byGate(":n0")?.probeTimeout, "PT20M"); // sibling keeps the run-level value
281
+ });
282
+
283
+ test("an invalid per-node poll.timeoutMs/everyMs falls back to the run-level ctx.* override, not the built-in default (#462)", async () => {
284
+ // Guard against the JS-truthiness gap: a negative `poll.timeoutMs` (`-1`) is truthy, so a bare
285
+ // `probe.poll?.timeoutMs ? readinessTimeout(probe, {}) : ctx.probeTimeout` would route to
286
+ // `readinessTimeout(probe, {})`, which rejects `< 1` and — with `env: {}` — returns the built-in
287
+ // PT30M default, silently discarding the run/dispatch override. An invalid value must fall through
288
+ // to `ctx.*` (the run-level value) instead. Same for `everyMs`.
289
+ const graph: DeliveryGraph = {
290
+ name: "invalid per-node budget",
291
+ nodes: [
292
+ {
293
+ id: "bad-gate",
294
+ kind: "wait",
295
+ wait: { kind: "pr", target: "owner/repo#1", match: { prState: "merged" }, poll: { timeoutMs: -1, everyMs: -5 } },
296
+ },
297
+ ],
298
+ edges: [],
299
+ };
300
+ const p = await prepareOk(graph, { probeTimeout: "PT20M", probePollEvery: "PT42S", runKey: "run-462b" });
301
+ const wait = Object.values(p.nodeInputs).find((v) => "gateKey" in v) as { probeTimeout: string; probePollEvery: string } | undefined;
302
+ assertEquals(wait?.probeTimeout, "PT20M"); // run-level override, NOT the built-in PT30M default
303
+ assertEquals(wait?.probePollEvery, "PT42S"); // run-level cadence, NOT DEFAULT_EVERY_MS
304
+ });
305
+
261
306
  test("a malformed graph returns the S1 compile errors and prepares nothing", async () => {
262
307
  const r = await prepareDeliveryGraph({ nodes: [{ id: "a", kind: "agent", agent: { jobType: "j" } }], edges: [{ from: "a", to: "ghost" }] } as unknown as DeliveryGraph);
263
308
  assert(!r.ok, "a dangling edge fails to prepare");
@@ -16,8 +16,9 @@
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import type { EngineClient } from "@nanobpm/urban";
18
18
  import type { DeliveryFact, DeliveryGraph, DeliveryNode } from "../nano-generated/api-io.d.ts";
19
+ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
19
20
  import { assertNever, compileDeliveryGraph, DELIVERY_GRAPH_PROCESS_ID } from "./deliveryGraphCompiler.ts";
20
- import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery } from "./readiness.ts";
21
+ import { DEFAULT_EVERY_MS, msToIsoDuration, parseProbe, readinessPollEvery, readinessTimeout } from "./readiness.ts";
21
22
  import { isoDuration } from "./reviewWait.ts";
22
23
 
23
24
  /** The content digest of a compiled graph — `sha256(bpmn)[:12]` — the single source of truth for the
@@ -147,7 +148,14 @@ export async function runDeliveryGraph(
147
148
  await engine.deployResources([{ name: `${processDefinitionId}.bpmn`, content: bpmn, contentType: "application/xml" }]);
148
149
  const { processInstanceKey } = await engine.createInstance({
149
150
  processDefinitionId,
150
- variables: { nodeInputs },
151
+ variables: {
152
+ nodeInputs,
153
+ // Stage 0 transcript correlation (#543): the transcript-endpoint base every agent node's
154
+ // completing worker appends its jobKey-scoped stream to, to emit `transcriptUrl` (see the agent
155
+ // node ioMapping in deliveryGraphCompiler). Seeded once at the run root — the same value for
156
+ // every node — and read down into each agent job via `=transcriptUrlBase`.
157
+ [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
158
+ },
151
159
  });
152
160
  // The engine can yield a numeric key; `DeliveryRunHandle.processInstanceKey` is typed `string` and
153
161
  // downstream consumers expect a string — coerce (codebase-wide `String(...)` pattern, e.g. app/plan.ts).
@@ -227,11 +235,25 @@ function buildNodeInput(
227
235
  }
228
236
  case "wait": {
229
237
  const probe = parseProbe(node.wait);
238
+ // Only a VALID, positive per-node budget overrides the run level. Match the `>= 1` predicate
239
+ // `readinessTimeout`/`readinessPollEvery` apply internally, rather than a bare JS-truthiness
240
+ // check on `poll.timeoutMs`/`everyMs`: a negative (`-1`) value is truthy, so a truthiness gate
241
+ // would route to `readinessTimeout(probe, {})`, which then rejects it (`< 1`) and — because
242
+ // `env` is `{}` — falls back to the *built-in* default (PT30M / DEFAULT_EVERY_MS), silently
243
+ // discarding the run/dispatch override in `ctx.*`. Gating on the same validity predicate here
244
+ // makes an invalid per-node value fall through to `ctx.probeTimeout`/`ctx.probePollEvery`.
245
+ const declaredTimeout = typeof probe.poll?.timeoutMs === "number" && probe.poll.timeoutMs >= 1;
246
+ const declaredEvery = typeof probe.poll?.everyMs === "number" && probe.poll.everyMs >= 1;
230
247
  return {
231
248
  gateKey: `${ctx.runKey}:${ctx.element}`,
232
249
  probe: node.wait,
233
- probeTimeout: ctx.probeTimeout,
234
- probePollEvery: probe.poll?.everyMs ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
250
+ // Per-node escalation boundary (#462): a `wait` node's declared `poll.timeoutMs` drives its
251
+ // compiled `=probeTimeout` bound, mirroring the `everyMs → probePollEvery` override below —
252
+ // otherwise a node's poll budget is honored for the interval but silently ignored for the
253
+ // boundary (a 7-day gate escalated at the 30-minute run default). Falls back to the run-level
254
+ // `ctx.probeTimeout` (which itself honors the dispatch override / default) when undeclared.
255
+ probeTimeout: declaredTimeout ? readinessTimeout(probe, {}) : ctx.probeTimeout,
256
+ probePollEvery: declaredEvery ? readinessPollEvery(probe, {}) : ctx.probePollEvery,
235
257
  };
236
258
  }
237
259
  case "human":
package/app/feature.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  // Data access goes through the record gateway (`data.table`), never hand-written
16
16
  // SQL — matching app/plan.ts and app/service.ts.
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
+ import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
18
19
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
19
20
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
20
21
  import type { ReadinessProbe } from "./readiness.ts";
@@ -400,6 +401,11 @@ export async function startFeature(
400
401
  probePollEvery: readinessProbes ? (readiness.probePollEvery ?? null) : null,
401
402
  gateKey: readinessProbes ? `feature-readiness:${parsed.planKey}` : null,
402
403
  resolvedArtifacts: null,
404
+ // Stage 0 transcript correlation (#543): the transcript-endpoint base the `implement-task` agent
405
+ // worker appends its jobKey-scoped stream to, to emit a `transcriptUrl` output variable that
406
+ // links this feature run to its agent transcript in Nano Explorer (feature.bpmn `implement-task`
407
+ // ioMapping). Read down into the job via `=transcriptUrlBase`.
408
+ [TRANSCRIPT_URL_BASE_VAR]: transcriptUrlBaseFor(),
403
409
  },
404
410
  });
405
411
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
package/openapi.yaml CHANGED
@@ -1430,7 +1430,14 @@ components:
1430
1430
  description: >-
1431
1431
  A `wait` node — a durable `ReadinessProbe` (ADR 0001 §2) watching an external fact. Reuses the
1432
1432
  existing `ReadinessProbe` shape verbatim (Decision 3 — never a second wait loop); the `pr`
1433
- merge-state kind is added to that shape by slice S2 and flows in here automatically.
1433
+ merge-state kind is added to that shape by slice S2 and flows in here automatically. The
1434
+ probe's `poll.timeoutMs` sets THIS node's escalation boundary (how long the gate waits before
1435
+ it acts on `onTimeout`), falling back to the run/default when absent (#462). `onTimeout`
1436
+ `escalate` (default) parks the elapsed gate on a human-completable task; `continue` proceeds
1437
+ past the gate as not-ready with NO human stop (a sharp edge — the downstream side-effecting
1438
+ node then runs without the awaited fact); `fail` is NOT yet supported on a delivery `wait`
1439
+ node (blocked on engine terminate-end execution, Magikcraft/nano-bpm#978) and is rejected at
1440
+ compile with a path-qualified error rather than silently degrading.
1434
1441
  allOf:
1435
1442
  - $ref: "#/components/schemas/DeliveryNodeCommon"
1436
1443
  - type: object
@@ -29,13 +29,21 @@ export default defineOperation("getAgenticTranscript", async ({ params, query, r
29
29
  }
30
30
 
31
31
  const service = currentRelayTranscriptService();
32
- const store = service?.store;
33
- if (!store) {
34
- // No transcript store mounted (relay unmounted or unpersisted) - nothing to replay.
32
+ if (!service) {
33
+ // No relay/transcript service mounted at all - nothing to replay.
35
34
  return { status: 404, body: { error: "no transcript for stream" } };
36
35
  }
37
36
 
38
- const data = readTranscriptFrom(params.stream, from, store, currentCorrelation(), service?.correlationStore);
37
+ // Read the durable store first, falling back to the still-live relay ring (#486) so a `transcriptUrl`
38
+ // emitted by a job on a still-live multiplexing worker is readable before its ring is flushed.
39
+ const data = readTranscriptFrom(
40
+ params.stream,
41
+ from,
42
+ service.store,
43
+ currentCorrelation(),
44
+ service.correlationStore,
45
+ service.liveFallback(params.stream),
46
+ );
39
47
  if (data === undefined) {
40
48
  return { status: 404, body: { error: "no transcript for stream" } };
41
49
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.139.3",
3
+ "version": "0.140.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",
@@ -177,8 +177,10 @@
177
177
  </zeebe:linkedResources>
178
178
  <zeebe:ioMapping>
179
179
  <zeebe:input source="=&#34;&#10;&#10;---&#10;&#10;&#34; + task.prompt + (if (baseBranchBrief = null) then &#34;&#34; else baseBranchBrief) + (if (resolvedArtifacts = null or count(resolvedArtifacts[item != null]) = 0) then &#34;&#34; else (&#34;&#10;&#10;---&#10;&#10;**Bound upstream readiness (intake gate).** This feature was scheduled to wait until an upstream landed and published. Build/install/clone against EXACTLY these resolved published versions (the ones first carrying the awaited capability), and bump the consumer dependency to them — never merely the newest:&#10;&#10;&#34; + string join(resolvedArtifacts[item != null], &#34;&#10;&#34;))) + (if (customInstructions = null) then &#34;&#34; else &#34;&#10;&#10;---&#10;&#10;## Operator custom instructions&#10;&#10;The operator supplied these instructions for this run — follow them:&#10;&#10;&#34; + customInstructions)" target="appendPrompt" />
180
+ <zeebe:input source="=if (is defined(transcriptUrlBase)) then transcriptUrlBase else null" target="transcriptUrlBase" />
180
181
  <zeebe:output source="=status" target="status" />
181
182
  <zeebe:output source="=question" target="question" />
183
+ <zeebe:output source="=if (is defined(transcriptUrl)) then transcriptUrl else null" target="transcriptUrl" />
182
184
  </zeebe:ioMapping>
183
185
  </bpmn:extensionElements>
184
186
  <bpmn:incoming>f_toImplement</bpmn:incoming>