@nanobpm/nano-workforce 0.175.1 → 0.176.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 +12 -0
- package/app/abandon.test.ts +26 -0
- package/app/abandon.ts +14 -1
- package/app/agentic/families/relay.family.test.ts +95 -1
- package/app/agentic/families/relay.family.ts +62 -20
- package/app/contracts.ts +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.176.0](https://github.com/nanobpm/nano-workforce/compare/v0.175.2...v0.176.0) (2026-09-02)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **agentic:** flush durable transcript on a worker phase:close event (fixes truncated tails) ([#711](https://github.com/nanobpm/nano-workforce/issues/711)) ([e02ff9b](https://github.com/nanobpm/nano-workforce/commit/e02ff9b4f8c2b7452dc40b0b8532736c5d418cc0)), closes [#710](https://github.com/nanobpm/nano-workforce/issues/710)
|
|
6
|
+
|
|
7
|
+
## [0.175.2](https://github.com/nanobpm/nano-workforce/compare/v0.175.1...v0.175.2) (2026-09-02)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **abandon:** don't let the cancel-check deadlock a shell-less agent ([#678](https://github.com/nanobpm/nano-workforce/issues/678)) ([#709](https://github.com/nanobpm/nano-workforce/issues/709)) ([f5fed93](https://github.com/nanobpm/nano-workforce/commit/f5fed93bdd0e98cbb24f7dff4b0f9a650c235a69)), closes [#76](https://github.com/nanobpm/nano-workforce/issues/76) [#76](https://github.com/nanobpm/nano-workforce/issues/76)
|
|
12
|
+
|
|
1
13
|
## [0.175.1](https://github.com/nanobpm/nano-workforce/compare/v0.175.0...v0.175.1) (2026-09-02)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/app/abandon.test.ts
CHANGED
|
@@ -83,6 +83,32 @@ test("renderAbandonBrief embeds the concrete URL and the stop contract", () => {
|
|
|
83
83
|
assertEquals(brief.includes("curl -fsS"), true);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
+
test("renderAbandonBrief forbids delegating the check to a sub-agent (issue #678)", () => {
|
|
87
|
+
// A non-Claude agent (e.g. qwen) that hands this shell command to a shell-less
|
|
88
|
+
// `general-purpose` sub-agent deadlocks: the sub-agent has no shell, produces no
|
|
89
|
+
// output, and the worker's idle timeout kills the round. The brief must steer the
|
|
90
|
+
// agent to run the check inline with its OWN shell and never delegate it.
|
|
91
|
+
const brief = renderAbandonBrief("https://host/app/api/hooks/abandon?token=tok");
|
|
92
|
+
// Not merely that it *mentions* sub-agents/inline — it must explicitly PROHIBIT delegation, so a
|
|
93
|
+
// future edit that softened the brief into *encouraging* delegation would fail this test.
|
|
94
|
+
assertEquals(/do \*\*not\*\* delegate it to a\s+sub-?agent/i.test(brief), true, "explicitly forbids delegating to a sub-agent");
|
|
95
|
+
assertEquals(/inline/i.test(brief), true, "tells the agent to run it inline");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("renderAbandonBrief degrades gracefully when the agent has no shell (issue #678)", () => {
|
|
99
|
+
// The check is ADVISORY over an airtight harness job-fence (issue #76 layer 2). An
|
|
100
|
+
// agent that cannot run a shell command at all must be told to SKIP and proceed
|
|
101
|
+
// rather than thrash/hang — the harness job-fence (issue #76 layer 2) still fences
|
|
102
|
+
// a cancelled run, so a skipped check degrades safely rather than promising full
|
|
103
|
+
// independent enforcement.
|
|
104
|
+
const brief = renderAbandonBrief("https://host/app/api/hooks/abandon?token=tok");
|
|
105
|
+
assertEquals(/skip this check/i.test(brief), true, "offers a skip path");
|
|
106
|
+
// Assert the actual anti-deadlock contract, not an explanatory word: a shell-less agent must be
|
|
107
|
+
// told its lack of a shell is NOT a failed check, and must be forbidden from stalling/hanging.
|
|
108
|
+
assertEquals(/not (a )?failed check/i.test(brief), true, "no shell is not treated as a failed check");
|
|
109
|
+
assertEquals(/never stall,.*hang/i.test(brief), true, "forbids stalling/hanging when it can't run curl");
|
|
110
|
+
});
|
|
111
|
+
|
|
86
112
|
test("prKeyForAbandonToken resolves a known token and rejects unknowns", async () => {
|
|
87
113
|
const data = memData();
|
|
88
114
|
await seedPr(data, "o/r#1", "tok", "converging");
|
package/app/abandon.ts
CHANGED
|
@@ -148,5 +148,18 @@ down), instead of silently printing an error body with exit 0.
|
|
|
148
148
|
try to complete the job is EXPECTED after a cancel — do not treat it as an error to retry.
|
|
149
149
|
- **Proceed** only when the command **succeeds** AND reports \`"abandoned": false\` — and re-check
|
|
150
150
|
right before the push, since a cancel can land at any moment. Checking as late as possible keeps
|
|
151
|
-
the window tiny
|
|
151
|
+
the window tiny.
|
|
152
|
+
|
|
153
|
+
**Run this check yourself, inline, with your own shell tool.** Do **not** delegate it to a
|
|
154
|
+
sub-agent, helper, or a separate "run this command" task: a sub-agent may have **no shell**, in which
|
|
155
|
+
case it cannot run \`curl\`, produces no output, and the worker's idle timeout will kill the round.
|
|
156
|
+
One inline \`curl\` in your own turn is all this needs.
|
|
157
|
+
|
|
158
|
+
**Having no way to run a shell command at all is NOT a failed check — SKIP this check and proceed.**
|
|
159
|
+
The "abort on non-zero exit" rule above is about \`curl\` *running and reporting* a torn-down run; it
|
|
160
|
+
does **not** mean "abort because you have no shell to run \`curl\` with". This check is a best-effort
|
|
161
|
+
optimisation, not a hard gate: the harness that runs your job also fences a cancelled run's
|
|
162
|
+
completion, so a skipped check only widens a tiny race — it does not by itself create an orphaned
|
|
163
|
+
run. Never stall, thrash, hang, or fail the round trying to run \`curl\` through a tool you do not
|
|
164
|
+
have; a skipped check is strictly better than a hung one.`;
|
|
152
165
|
}
|
|
@@ -16,7 +16,13 @@ import { fileURLToPath } from "node:url";
|
|
|
16
16
|
import { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
17
17
|
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
18
18
|
import { RELAY_FAMILY } from "@nanobpm/agentic/relay";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
encodeTranscriptEvent,
|
|
21
|
+
type SqliteDb,
|
|
22
|
+
TRANSCRIPT_EVENT_MARKER,
|
|
23
|
+
TRANSCRIPT_EVENT_VERSION,
|
|
24
|
+
TRANSCRIPT_SCHEMA_SQL,
|
|
25
|
+
} from "@nanobpm/agentic/transcript";
|
|
20
26
|
import { assert, assertEquals, assertThrows } from "#test-assert";
|
|
21
27
|
import { noopLog } from "../../../test/log.ts";
|
|
22
28
|
import { CorrelationRegistry, jobStream } from "../correlation.ts";
|
|
@@ -551,6 +557,21 @@ test("#544 element-instance enrichment: an unresolved job (never parked) leaves
|
|
|
551
557
|
const lifecycle = (stream: string, incarnation: number, phase: "open" | "completed" | "exited"): Frame =>
|
|
552
558
|
produce(stream, incarnation, encodeTranscriptEvent({ kind: "lifecycle", phase, offset: 0 }));
|
|
553
559
|
|
|
560
|
+
/**
|
|
561
|
+
* A `produce` frame carrying the harness's job-end `phase:"close"` transcript lifecycle marker
|
|
562
|
+
* (#710 / jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:"open"` RELAY_OPEN_CHUNK the
|
|
563
|
+
* harness emits at relay-session open. The typed `encodeTranscriptEvent` cannot express the `close`
|
|
564
|
+
* phase (the agentic `LifecycleEvent` union is `open|completed|exited`), so the on-wire envelope is
|
|
565
|
+
* built through the SAME marker/version constants the encoder stamps — mirroring exactly what the
|
|
566
|
+
* harness's untyped `encodeTranscriptEvent({ kind: "lifecycle", phase: "close" })` puts on the wire,
|
|
567
|
+
* without a forked wire shape. */
|
|
568
|
+
const closeMarker = JSON.stringify({
|
|
569
|
+
[TRANSCRIPT_EVENT_MARKER]: TRANSCRIPT_EVENT_VERSION,
|
|
570
|
+
kind: "lifecycle",
|
|
571
|
+
phase: "close",
|
|
572
|
+
});
|
|
573
|
+
const close = (stream: string, incarnation: number): Frame => produce(stream, incarnation, closeMarker);
|
|
574
|
+
|
|
554
575
|
test("#661 primary release: a terminal lifecycle event clears an idle-but-connected worker's finished job", () => {
|
|
555
576
|
const registry = new ConnectionRegistry();
|
|
556
577
|
const correlation = new CorrelationRegistry();
|
|
@@ -607,6 +628,79 @@ test("#661 primary release: an `exited` lifecycle also releases; a non-terminal
|
|
|
607
628
|
service.teardown();
|
|
608
629
|
});
|
|
609
630
|
|
|
631
|
+
test("#710 close release: a job-end `phase:close` marker flushes the durable transcript with ALL bytes (no truncated tail)", () => {
|
|
632
|
+
const registry = new ConnectionRegistry();
|
|
633
|
+
const correlation = new CorrelationRegistry();
|
|
634
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
635
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
636
|
+
const p = connect("prod", registry);
|
|
637
|
+
|
|
638
|
+
// The worker relays N job frames on a live connection → linked as active. It then goes idle after the
|
|
639
|
+
// job WITHOUT a supersede (no next job) and WITHOUT a disconnect (the channel persists across jobs),
|
|
640
|
+
// so the ONLY job-end signal is the harness's drained `phase:close` marker.
|
|
641
|
+
const N = 4;
|
|
642
|
+
for (let i = 0; i < N; i++) hub.handler?.(produce(jobStream("k1"), 1, `job-1 line ${i}`), p.conn);
|
|
643
|
+
assertEquals(correlation.jobKeysFor("worker-A"), ["k1"], "the job is active while it runs");
|
|
644
|
+
assertEquals(service.transcriptOf(jobStream("k1")), undefined, "not flushed while the job runs");
|
|
645
|
+
|
|
646
|
+
// The job completes: the harness drains its outbound relay buffer and emits `phase:close` on the SAME
|
|
647
|
+
// live connection, then goes idle. Before #710 this was NOT a flush trigger — the durable transcript
|
|
648
|
+
// was snapshotted late (or not at all) by a later supersede/disconnect, truncating the tail. Now the
|
|
649
|
+
// close event flushes it deterministically at job-completion time.
|
|
650
|
+
hub.handler?.(close(jobStream("k1"), 1), p.conn);
|
|
651
|
+
|
|
652
|
+
assertEquals(correlation.jobKeysFor("worker-A"), [], "the finished job is released on the close marker");
|
|
653
|
+
assertEquals(correlation.count(), 0, "no phantom active job after close");
|
|
654
|
+
|
|
655
|
+
const meta = service.transcriptOf(jobStream("k1"));
|
|
656
|
+
assertEquals(meta?.status, "completed", "the job becomes a completed past session at close time");
|
|
657
|
+
// ALL N produced frames PLUS the close marker itself are in the durable transcript — no truncated
|
|
658
|
+
// tail (the close event rides the ring append before the release, like the #661 terminal path).
|
|
659
|
+
assertEquals(
|
|
660
|
+
service.reattach(jobStream("k1"), 0)?.entries.length,
|
|
661
|
+
N + 1,
|
|
662
|
+
"every relayed byte (and the close marker) is flushed — the tail is not truncated",
|
|
663
|
+
);
|
|
664
|
+
service.teardown();
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("#710 idempotent: a close then a duplicate close then a producer disconnect neither double-flushes nor throws", () => {
|
|
668
|
+
const registry = new ConnectionRegistry();
|
|
669
|
+
const correlation = new CorrelationRegistry();
|
|
670
|
+
const byConnection = new Map([["prod", "worker-A"]]);
|
|
671
|
+
const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection);
|
|
672
|
+
const p = connect("prod", registry);
|
|
673
|
+
|
|
674
|
+
hub.handler?.(produce(jobStream("k1"), 1, "job-1 line"), p.conn);
|
|
675
|
+
hub.handler?.(close(jobStream("k1"), 1), p.conn);
|
|
676
|
+
const afterClose = service.reattach(jobStream("k1"), 0)?.entries.length;
|
|
677
|
+
assertEquals(service.transcriptOf(jobStream("k1"))?.status, "completed", "flushed on the first close");
|
|
678
|
+
assertEquals(correlation.count(), 0, "correlation released on the first close");
|
|
679
|
+
|
|
680
|
+
// A late DUPLICATE close (a harness retry, or a redelivered frame) is a no-op — the completed stream
|
|
681
|
+
// is terminal, so it neither re-flushes nor throws.
|
|
682
|
+
hub.handler?.(close(jobStream("k1"), 1), p.conn);
|
|
683
|
+
assertEquals(
|
|
684
|
+
service.reattach(jobStream("k1"), 0)?.entries.length,
|
|
685
|
+
afterClose,
|
|
686
|
+
"a duplicate close does not append or re-flush",
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// The producer later disconnects (the belt-and-suspenders fallback). #reconcile runs on the next
|
|
690
|
+
// inbound frame; the already-completed stream is skipped, so the fallback neither double-flushes nor
|
|
691
|
+
// throws — the additive close trigger composes cleanly with the disconnect fallback.
|
|
692
|
+
registry.remove("prod");
|
|
693
|
+
const q = connect("other", registry);
|
|
694
|
+
hub.handler?.(produce(jobStream("k2"), 1, "unrelated"), q.conn);
|
|
695
|
+
assertEquals(
|
|
696
|
+
service.reattach(jobStream("k1"), 0)?.entries.length,
|
|
697
|
+
afterClose,
|
|
698
|
+
"the disconnect fallback does not re-flush the already-closed stream",
|
|
699
|
+
);
|
|
700
|
+
assertEquals(service.transcriptOf(jobStream("k1"))?.status, "completed", "still exactly one completed past session");
|
|
701
|
+
service.teardown();
|
|
702
|
+
});
|
|
703
|
+
|
|
610
704
|
test("#661 no-regression: an ordinary (non-lifecycle) chunk never clears a genuinely active job", () => {
|
|
611
705
|
const registry = new ConnectionRegistry();
|
|
612
706
|
const correlation = new CorrelationRegistry();
|
|
@@ -24,6 +24,8 @@ import type { ConnectionRegistry } from "@nanobpm/agentic/channel";
|
|
|
24
24
|
import type { Frame } from "@nanobpm/agentic/protocol";
|
|
25
25
|
import { RELAY_FAMILY, RelayHub, type RelayHubOptions } from "@nanobpm/agentic/relay";
|
|
26
26
|
import {
|
|
27
|
+
CORE_TRANSCRIPT_VOCAB,
|
|
28
|
+
mergeTranscriptVocab,
|
|
27
29
|
parseTranscriptEvent,
|
|
28
30
|
type SqliteDb,
|
|
29
31
|
type TranscriptLifecycle,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
TranscriptStore,
|
|
33
35
|
type TranscriptStoreOptions,
|
|
34
36
|
type TranscriptStream,
|
|
37
|
+
type TranscriptVocab,
|
|
35
38
|
} from "@nanobpm/agentic/transcript";
|
|
36
39
|
import type { Logger } from "@nanobpm/urban";
|
|
37
40
|
import { currentCorrelation, type JobContext, type JobCorrelation, jobKeyOfStream } from "../correlation.ts";
|
|
@@ -128,17 +131,50 @@ function readProp(value: unknown, key: string): unknown {
|
|
|
128
131
|
return Object.hasOwn(value, key) ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined;
|
|
129
132
|
}
|
|
130
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The harness's job-end lifecycle phase (#710 / jwulf/c8ctl-plugin-nano#150): the closing twin of the
|
|
136
|
+
* `phase:"open"` `RELAY_OPEN_CHUNK` the harness emits at relay-session open. The harness emits it —
|
|
137
|
+
* through agentic's own `encodeTranscriptEvent`, never hand-rolled — right before `job.complete`/
|
|
138
|
+
* `job.fail`, once it has DRAINED its outbound relay buffer, so it is the deterministic "this job's
|
|
139
|
+
* bytes are all here, it is done" signal that supersede/disconnect only ever approximated.
|
|
140
|
+
*/
|
|
141
|
+
const HARNESS_CLOSE_PHASE = "close";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The relay family's terminal-detection vocabulary: the ONE canonical transcript vocab, additively
|
|
145
|
+
* EXTENDED (via the sanctioned {@link mergeTranscriptVocab} extension point, never a forked parser) to
|
|
146
|
+
* ALSO classify the harness's job-end `phase:"close"` marker (#710). The agentic `LifecycleEvent`
|
|
147
|
+
* contract's phases are `open|completed|exited` — `close` is a fourth, job-scoped termination marker
|
|
148
|
+
* agentic 0.10.0 does not yet know, so the core `lifecycle` decoder drops it to a raw `stream-chunk`.
|
|
149
|
+
* This override recognizes it and maps it onto the contract's terminal `completed` phase, so
|
|
150
|
+
* {@link parseTranscriptEvent} classifies a close envelope as a terminal lifecycle WITHOUT a second
|
|
151
|
+
* wire shape. It is scoped to {@link isTerminalLifecycleChunk} (the correlation-release detector); the
|
|
152
|
+
* cockpit derive/read fold keeps using the CORE vocab, so the close chunk stays byte-faithful in the
|
|
153
|
+
* durable transcript and this remap never leaks into the derived view.
|
|
154
|
+
*/
|
|
155
|
+
const RELAY_TERMINAL_VOCAB: TranscriptVocab = mergeTranscriptVocab(CORE_TRANSCRIPT_VOCAB, {
|
|
156
|
+
lifecycle: (body, offset) => {
|
|
157
|
+
const phase = typeof body.phase === "string" ? body.phase : undefined;
|
|
158
|
+
if (phase === "open" || phase === "completed" || phase === "exited") return { kind: "lifecycle", offset, phase };
|
|
159
|
+
if (phase === HARNESS_CLOSE_PHASE) return { kind: "lifecycle", offset, phase: "completed" };
|
|
160
|
+
return undefined;
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
131
164
|
/**
|
|
132
165
|
* Decode a single relay chunk through the ONE canonical transcript parser and report whether it is a
|
|
133
|
-
* TERMINAL `lifecycle` event
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
166
|
+
* TERMINAL `lifecycle` event — the authoritative "job end" signal that flushes the durable transcript
|
|
167
|
+
* and releases correlation. Two shapes are terminal: the agent run's own `phase` `completed`/`exited`
|
|
168
|
+
* (#661), and the harness's drained job-end `phase:"close"` marker (#710), which
|
|
169
|
+
* {@link RELAY_TERMINAL_VOCAB} decodes onto the contract's `completed` phase. Anything else — raw
|
|
170
|
+
* terminal bytes, a non-envelope JSON value, a `phase: "open"` lifecycle, any other event kind — is
|
|
171
|
+
* not terminal. Reusing {@link parseTranscriptEvent} (with the additively-extended vocab) keeps
|
|
172
|
+
* transcript-vocab knowledge out of the content-agnostic relay ring and off any forked decoder
|
|
173
|
+
* (Derivation Over Duplication): the ring still sees opaque bytes; only this narrow seam classifies
|
|
174
|
+
* them.
|
|
139
175
|
*/
|
|
140
176
|
function isTerminalLifecycleChunk(chunk: string): boolean {
|
|
141
|
-
const event = parseTranscriptEvent({ offset: 0, chunk });
|
|
177
|
+
const event = parseTranscriptEvent({ offset: 0, chunk }, RELAY_TERMINAL_VOCAB);
|
|
142
178
|
return event.kind === "lifecycle" && (event.phase === "completed" || event.phase === "exited");
|
|
143
179
|
}
|
|
144
180
|
|
|
@@ -516,8 +552,8 @@ export class RelayTranscriptService {
|
|
|
516
552
|
}
|
|
517
553
|
|
|
518
554
|
/** Handle one inbound `relay` frame: reconcile dead producers, observe ownership, delegate, then
|
|
519
|
-
* release on a terminal `lifecycle` event (#661 — AFTER the hub appended the chunk, so the
|
|
520
|
-
* event itself is captured in the flushed transcript). */
|
|
555
|
+
* release on a terminal `lifecycle` event (#661/#710 — AFTER the hub appended the chunk, so the
|
|
556
|
+
* terminal event itself is captured in the flushed transcript). */
|
|
521
557
|
#onFrame(frame: Frame, conn: RelayConnectionCtx): void {
|
|
522
558
|
this.#reconcile();
|
|
523
559
|
this.#observe(frame, conn);
|
|
@@ -526,17 +562,23 @@ export class RelayTranscriptService {
|
|
|
526
562
|
}
|
|
527
563
|
|
|
528
564
|
/**
|
|
529
|
-
* Primary job-end release (#661): when a `produce` frame carries
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
538
|
-
* `
|
|
539
|
-
*
|
|
565
|
+
* Primary job-end release (#661, #710): when a `produce` frame carries a terminal `lifecycle` event —
|
|
566
|
+
* the agent run's own `phase` `completed`/`exited` (#661), OR the harness's drained job-end
|
|
567
|
+
* `phase:"close"` marker (#710) — complete its stream so the worker's job⇄instance correlation is
|
|
568
|
+
* released, and the durable "past session" transcript is flushed, the moment the job ends. This runs
|
|
569
|
+
* even though the worker's relay connection stays open across jobs (the disconnect/supersede release
|
|
570
|
+
* paths miss that idle-after-last-job tail, so an idle worker's finished job would otherwise linger as
|
|
571
|
+
* a phantom active job on its supply row, and its transcript would be snapshotted late → truncated
|
|
572
|
+
* tail). The `close` trigger is ADDITIVE — supersede + disconnect remain as belt-and-suspenders
|
|
573
|
+
* fallbacks for a harness that predates the close event or a crash that never sends one — and the
|
|
574
|
+
* `state.completed` guard below makes a close-then-later-disconnect (or a duplicate close) an
|
|
575
|
+
* idempotent no-op. Runs AFTER the hub appends the chunk to the ring, so the terminal event is part
|
|
576
|
+
* of the flushed transcript. A narrow, self-contained decode at the correlation seam that reuses the
|
|
577
|
+
* ONE canonical {@link parseTranscriptEvent} ({@link isTerminalLifecycleChunk}) — the content-agnostic
|
|
578
|
+
* relay ring keeps treating chunks as opaque bytes, and no transcript-vocab knowledge is forked into
|
|
579
|
+
* it. Non-terminal chunks (raw bytes, a `phase: "open"` lifecycle, any other event kind) never
|
|
580
|
+
* complete a live stream, so a genuinely active job is never cleared. Advisory — never throws into
|
|
581
|
+
* the frame handler.
|
|
540
582
|
*/
|
|
541
583
|
#observeTerminalLifecycle(frame: Frame): void {
|
|
542
584
|
if (readProp(frame.payload, "op") !== "produce") return;
|
package/app/contracts.ts
CHANGED
|
@@ -463,6 +463,14 @@ export const WIRE_CONTRACTS = {
|
|
|
463
463
|
shape:
|
|
464
464
|
'{ nwfTranscriptEvent: 1, kind: "permission", phase: "request", callId: string, policy: "escalate"|"yolo", options: [{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }, ...Array<{ optionId: string, name: string, kind: "allow-once"|"allow-always"|"reject-once"|"reject-always" }>], toolName?: string, title?: string, reason?: string } | { nwfTranscriptEvent: 1, kind: "permission", phase: "resolution", callId: string, optionId: string, allowed: boolean, by?: "operator"|"auto" }',
|
|
465
465
|
},
|
|
466
|
+
"transcript.lifecycleClose": {
|
|
467
|
+
category: "wire",
|
|
468
|
+
name: "transcript.lifecycleClose",
|
|
469
|
+
owner: "app/agentic/families/relay.family.ts",
|
|
470
|
+
semantics:
|
|
471
|
+
"The harness's job-end `phase:\"close\"` transcript `lifecycle` event (issue #710, harness half jwulf/c8ctl-plugin-nano#150) — the closing twin of the `phase:\"open\"` RELAY_OPEN_CHUNK the harness emits at relay-session open. The harness emits it on the `job:<jobKey>` relay stream through agentic's own `encodeTranscriptEvent` (never hand-rolled), right before `job.complete`/`job.fail` and AFTER draining its outbound relay buffer, so it is the deterministic \"all this job's bytes are here, it is done\" signal. The app recognizes it in `isTerminalLifecycleChunk` (relay.family.ts) to `completeStream()` — flush the durable past-session transcript and release job⇄instance correlation — at job-completion time, fixing the truncated-tail defect where the flush waited for a supersede/disconnect. `close` is NOT a core agentic `LifecycleEvent` phase (the contract's phases are open|completed|exited); the app decodes it via an ADDITIVE `mergeTranscriptVocab` extension (`RELAY_TERMINAL_VOCAB`) that maps it onto the terminal `completed` phase — never a forked wire shape, and scoped to the relay job-end detector so the cockpit derive (CORE vocab) keeps the close chunk byte-faithful. The `close` trigger is ADDITIVE: supersede + disconnect remain as fallbacks and the `state.completed` guard keeps a close-then-disconnect (or duplicate close) idempotent. Consume this ONE marker — do not re-declare a synonym or a second job-end signal.",
|
|
472
|
+
shape: '{ nwfTranscriptEvent: 1, kind: "lifecycle", phase: "close" }',
|
|
473
|
+
},
|
|
466
474
|
} as const satisfies Record<string, WireContract>;
|
|
467
475
|
|
|
468
476
|
export const TYPE_CONTRACTS = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.176.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",
|