@nanobpm/nano-workforce 0.174.1 → 0.175.1

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.175.1](https://github.com/nanobpm/nano-workforce/compare/v0.175.0...v0.175.1) (2026-09-02)
2
+
3
+ ### Bug Fixes
4
+
5
+ * **intake:** route feature-run admission idempotency through derived_status ([#707](https://github.com/nanobpm/nano-workforce/issues/707)) ([eab2c9c](https://github.com/nanobpm/nano-workforce/commit/eab2c9ce3d4b22bad38da62170f13fda647eb21f)), closes [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#503](https://github.com/nanobpm/nano-workforce/issues/503) [#704](https://github.com/nanobpm/nano-workforce/issues/704)
6
+
7
+ ## [0.175.0](https://github.com/nanobpm/nano-workforce/compare/v0.174.1...v0.175.0) (2026-09-02)
8
+
9
+ ### Features
10
+
11
+ * **agentic:** engine/poller-owned relay transcript completion (retire the presence heuristic) ([#708](https://github.com/nanobpm/nano-workforce/issues/708)) ([74f42f6](https://github.com/nanobpm/nano-workforce/commit/74f42f6fa3f1b07a6608ccf50979693eecb8ead4)), closes [689/#690](https://github.com/689/nano-workforce/issues/690) [#661](https://github.com/nanobpm/nano-workforce/issues/661) [#691](https://github.com/nanobpm/nano-workforce/issues/691) [#691](https://github.com/nanobpm/nano-workforce/issues/691) [#691](https://github.com/nanobpm/nano-workforce/issues/691) [#unlink](https://github.com/nanobpm/nano-workforce/issues/unlink)
12
+
1
13
  ## [0.174.1](https://github.com/nanobpm/nano-workforce/compare/v0.174.0...v0.174.1) (2026-09-02)
2
14
 
3
15
  ### Bug Fixes
@@ -956,6 +956,198 @@ test("#689 mid-job reconnect: with no isInstanceLive wired, a producer disconnec
956
956
  service.teardown();
957
957
  });
958
958
 
959
+ test("#691 engine-owned disconnect: a mid-job reconnect whose engine job is still parked keeps the correlation live", async () => {
960
+ const registry = new ConnectionRegistry();
961
+ const correlation = new CorrelationRegistry();
962
+ const byConnection = new Map([["conn-old", "worker-E"]]);
963
+ // The engine is the completion authority (#691): while the harness holds the lease the job stays
964
+ // parked (resolver returns an element-instance key), so a producer disconnect must NOT complete it.
965
+ let parked = true;
966
+ const resolveElementInstance = (jobKey: string) =>
967
+ Promise.resolve(parked && jobKey === "9001" ? "ei-9001" : undefined);
968
+ // isInstanceLive is deliberately NOT wired (static () => false): the engine job-state — not the
969
+ // presence heuristic — must be what spares the reconnect, proving presence is no longer the authority.
970
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
971
+ resolveElementInstance,
972
+ });
973
+ const p = connect("conn-old", registry);
974
+ hub.handler?.(produce(jobStream("9001"), 1, "booting agent"), p.conn);
975
+ assertEquals(correlation.jobKeysFor("worker-E"), ["9001"], "the job links on first produce");
976
+
977
+ // The producer WS blips: the old connection drops (no new one yet — presence would report the
978
+ // instance NOT live). A frame drives #reconcile → drops the dead producer + kicks the engine reconcile.
979
+ registry.remove("conn-old");
980
+ const other = connect("cons", registry);
981
+ hub.handler?.(grant(0), other.conn);
982
+ await tick(); // let the fire-and-forget engine reconcile settle
983
+ assertEquals(correlation.jobKeysFor("worker-E"), ["9001"], "a still-parked job survives the disconnect");
984
+ assertEquals(service.transcriptOf(jobStream("9001")), undefined, "the still-active stream is NOT archived");
985
+ assertEquals(service.liveFallback(jobStream("9001"))?.ring !== undefined, true, "the transcript is still live");
986
+
987
+ // The worker resumes producing on a NEW connection over the same job → re-attributed, still one job.
988
+ byConnection.set("conn-new", "worker-E");
989
+ const p2 = connect("conn-new", registry);
990
+ hub.handler?.(produce(jobStream("9001"), 1, "resumed output"), p2.conn);
991
+ assertEquals(correlation.jobKeysFor("worker-E"), ["9001"], "the resumed producer stays linked to the same job");
992
+
993
+ // The job genuinely ends: the engine park vanishes. The periodic backstop pass completes + archives it.
994
+ parked = false;
995
+ await service.reconcileEngineCorrelations();
996
+ assertEquals(correlation.jobKeysFor("worker-E"), [], "the ended job is released");
997
+ assertEquals(service.transcriptOf(jobStream("9001"))?.status, "completed", "and its transcript is archived");
998
+ service.teardown();
999
+ });
1000
+
1001
+ test("#691 engine-owned disconnect: a true exit whose engine job is gone completes + archives the stream", async () => {
1002
+ const registry = new ConnectionRegistry();
1003
+ const correlation = new CorrelationRegistry();
1004
+ const byConnection = new Map([["prod", "worker-X"]]);
1005
+ // The engine reports the job GONE at disconnect time (a clean completion whose terminal lifecycle
1006
+ // event was missed, or an unclean exit): the disconnect-driven engine reconcile completes it.
1007
+ const resolveElementInstance = () => Promise.resolve(undefined);
1008
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
1009
+ resolveElementInstance,
1010
+ });
1011
+ const p = connect("prod", registry);
1012
+ hub.handler?.(produce(jobStream("kX"), 1, "x"), p.conn);
1013
+ assertEquals(correlation.jobKeysFor("worker-X"), ["kX"]);
1014
+
1015
+ registry.remove("prod");
1016
+ const other = connect("cons", registry);
1017
+ hub.handler?.(grant(0), other.conn);
1018
+ await tick(); // the fire-and-forget engine reconcile resolves "gone" → completes
1019
+ assertEquals(correlation.jobKeysFor("worker-X"), [], "a truly-ended job is released on disconnect");
1020
+ assertEquals(service.transcriptOf(jobStream("kX"))?.status, "completed", "and its transcript is archived");
1021
+ service.teardown();
1022
+ });
1023
+
1024
+ test("#691 engine-owned disconnect: an UNLINKED job stream (register/produce race) whose engine job is still parked is NOT archived on disconnect", async () => {
1025
+ const registry = new ConnectionRegistry();
1026
+ const correlation = new CorrelationRegistry();
1027
+ // The connection→instance map is deliberately EMPTY at first produce: the producer's presence
1028
+ // instance is not yet resolvable (the documented register/produce race), so #link leaves the job
1029
+ // stream UNLINKED (`state.linked` stays false) even though it IS a job stream and a resolver is
1030
+ // wired. The engine — not `state.linked` — must own the disconnect completion decision, otherwise
1031
+ // a mid-job reconnect that raced the link would wrongly fall through to the presence fallback and
1032
+ // archive a still-parked job.
1033
+ const byConnection = new Map<string, string>();
1034
+ let parked = true;
1035
+ const resolveElementInstance = (jobKey: string) =>
1036
+ Promise.resolve(parked && jobKey === "7742" ? "ei-7742" : undefined);
1037
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
1038
+ resolveElementInstance,
1039
+ });
1040
+ const p = connect("conn-old", registry);
1041
+ hub.handler?.(produce(jobStream("7742"), 1, "booting agent"), p.conn);
1042
+ assertEquals(correlation.count(), 0, "the stream did NOT link — the instance was not resolvable at produce time");
1043
+
1044
+ // The old producer connection blips before a later produce could retry the link. A frame drives
1045
+ // #reconcile → it must defer to the engine (job still parked) rather than archive on presence.
1046
+ registry.remove("conn-old");
1047
+ const other = connect("cons", registry);
1048
+ hub.handler?.(grant(0), other.conn);
1049
+ await tick(); // let the fire-and-forget engine reconcile settle
1050
+ assertEquals(
1051
+ service.transcriptOf(jobStream("7742")),
1052
+ undefined,
1053
+ "an unlinked-but-still-parked job is NOT archived on disconnect",
1054
+ );
1055
+ assertEquals(service.liveFallback(jobStream("7742"))?.ring !== undefined, true, "its transcript is still live");
1056
+
1057
+ // The worker resumes on a NEW connection; the instance now resolves → the late link finally lands.
1058
+ byConnection.set("conn-new", "worker-R");
1059
+ const p2 = connect("conn-new", registry);
1060
+ hub.handler?.(produce(jobStream("7742"), 1, "resumed output"), p2.conn);
1061
+ assertEquals(correlation.jobKeysFor("worker-R"), ["7742"], "the late link succeeds on reconnect once the instance resolves");
1062
+
1063
+ // The job genuinely ends: the engine park vanishes → the backstop pass completes + archives it.
1064
+ parked = false;
1065
+ await service.reconcileEngineCorrelations();
1066
+ assertEquals(correlation.jobKeysFor("worker-R"), [], "the ended job is released");
1067
+ assertEquals(service.transcriptOf(jobStream("7742"))?.status, "completed", "and its transcript is archived");
1068
+ service.teardown();
1069
+ });
1070
+
1071
+ test("#708 periodic backstop covers an UNLINKED job stream: an unlinked-but-parked disconnect that never reconnects is completed once the engine park ends", async () => {
1072
+ const registry = new ConnectionRegistry();
1073
+ const correlation = new CorrelationRegistry();
1074
+ // Same register/produce race as #691: the connection→instance map is empty, so #link leaves the
1075
+ // job stream UNLINKED. The producer then drops while the engine job is still parked, so the
1076
+ // disconnect path keeps the stream live and clears `state.producer` (so #reconcile does not
1077
+ // re-trigger). Critically the worker NEVER reconnects — no later `produce` ever links it. The
1078
+ // periodic backstop is now the ONLY actor that can retire it, so it must include unlinked job
1079
+ // streams in its reconcile snapshot; otherwise the stream leaks live forever once the engine job
1080
+ // becomes terminal.
1081
+ const byConnection = new Map<string, string>();
1082
+ let parked = true;
1083
+ const resolveElementInstance = (jobKey: string) =>
1084
+ Promise.resolve(parked && jobKey === "7708" ? "ei-7708" : undefined);
1085
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
1086
+ resolveElementInstance,
1087
+ });
1088
+ const p = connect("conn-old", registry);
1089
+ hub.handler?.(produce(jobStream("7708"), 1, "booting agent"), p.conn);
1090
+ assertEquals(correlation.count(), 0, "the stream did NOT link — the instance was not resolvable at produce time");
1091
+
1092
+ // Producer blips; the disconnect engine-reconcile keeps the still-parked stream live and clears
1093
+ // the producer so #reconcile will not revisit it on later frames.
1094
+ registry.remove("conn-old");
1095
+ const other = connect("cons", registry);
1096
+ hub.handler?.(grant(0), other.conn);
1097
+ await tick();
1098
+ assertEquals(
1099
+ service.transcriptOf(jobStream("7708")),
1100
+ undefined,
1101
+ "an unlinked-but-still-parked job is NOT archived on disconnect",
1102
+ );
1103
+
1104
+ // The engine job genuinely ends. With NO reconnect to link it, only the periodic backstop can
1105
+ // retire it — and it must, even though the stream is unlinked.
1106
+ parked = false;
1107
+ await service.reconcileEngineCorrelations();
1108
+ assertEquals(
1109
+ service.transcriptOf(jobStream("7708"))?.status,
1110
+ "completed",
1111
+ "the periodic backstop completes an unlinked job stream once the engine park disappears",
1112
+ );
1113
+ assertEquals(service.liveFallback(jobStream("7708")), undefined, "and its live ring is retired");
1114
+ service.teardown();
1115
+ });
1116
+
1117
+ test("#691 engine-owned disconnect: a transient engine read at disconnect never falsely completes; the backstop retries", async () => {
1118
+ const registry = new ConnectionRegistry();
1119
+ const correlation = new CorrelationRegistry();
1120
+ const byConnection = new Map([["prod", "worker-T"]]);
1121
+ // The disconnect-time engine read throws (unavailable). A transient fault must be treated as
1122
+ // "unknown — keep it linked", NEVER as "job gone": the stream stays live until a later pass resolves.
1123
+ let failing = true;
1124
+ let gone = false;
1125
+ const resolveElementInstance = () => {
1126
+ if (failing) return Promise.reject(new Error("engine unavailable"));
1127
+ return Promise.resolve(gone ? undefined : "ei-T");
1128
+ };
1129
+ const { service, hub } = mkCorrelatedService(registry, memoryDb(), correlation, byConnection, {
1130
+ resolveElementInstance,
1131
+ });
1132
+ const p = connect("prod", registry);
1133
+ hub.handler?.(produce(jobStream("kT"), 1, "x"), p.conn);
1134
+
1135
+ registry.remove("prod");
1136
+ const other = connect("cons", registry);
1137
+ hub.handler?.(grant(0), other.conn);
1138
+ await tick(); // the engine read rejects → the stream must stay linked, not complete
1139
+ assertEquals(correlation.jobKeysFor("worker-T"), ["kT"], "a transient engine failure leaves the job linked");
1140
+ assertEquals(service.transcriptOf(jobStream("kT")), undefined, "and its transcript is NOT archived");
1141
+
1142
+ // The engine recovers and now reports the job gone: the periodic backstop pass completes it.
1143
+ failing = false;
1144
+ gone = true;
1145
+ await service.reconcileEngineCorrelations();
1146
+ assertEquals(correlation.jobKeysFor("worker-T"), [], "the backstop pass releases the ended job");
1147
+ assertEquals(service.transcriptOf(jobStream("kT"))?.status, "completed", "and archives its transcript");
1148
+ service.teardown();
1149
+ });
1150
+
959
1151
  test("H6 correlation write-side: non-job streams are never linked; a link retries until the instance resolves", () => {
960
1152
  const registry = new ConnectionRegistry();
961
1153
  const correlation = new CorrelationRegistry();
@@ -254,15 +254,18 @@ export interface RelayTranscriptServiceOptions {
254
254
  readonly instanceForConnection?: (connectionId: string) => string | undefined;
255
255
  /**
256
256
  * Whether a worker instance is still live on any hub connection (#689). Wired to the presence
257
- * registry's {@link PresenceRegistry.isInstanceLive}. The disconnect-driven reconcile uses it to
258
- * spare a still-active job's stream when its worker merely RECONNECTED mid-job (old producer
259
- * connection dropped, a new one re-registered under the same instance): completing then would
260
- * archive a live job's transcript and release its correlation, wedging the cockpit (the reconnected
261
- * worker's produce frames hit a terminal `completed` stream and are ignored) while the harness
262
- * keeps the engine lease. When wired to presence, a true worker-exit still completes: presence
263
- * has dropped the instance, so this returns false. Omitted (`() => false`, the static default) →
264
- * the prior always-complete-on-disconnect behaviour every disconnect completes, reconnect
265
- * included (the seam never consults presence, so its value does not vary).
257
+ * registry's {@link PresenceRegistry.isInstanceLive}. Since #691 the disconnect-driven reconcile
258
+ * defers to the engine job-state (the poller-owned completion authority) whenever an engine view
259
+ * ({@link resolveElementInstance}) is wired, so this presence signal is consulted ONLY as the
260
+ * engine-less fallback: on a host with no engine read-model (or a non-job stream no jobKey, so
261
+ * nothing for the engine to reconcile against; an unlinked *job* stream still has a jobKey and DOES
262
+ * reconcile against the engine since #691) it spares a still-active job's stream when its worker merely
263
+ * RECONNECTED mid-job (old producer connection dropped, a new one re-registered under the same
264
+ * instance) completing then would archive a live job's transcript and release its correlation,
265
+ * wedging the cockpit (the reconnected worker's produce frames hit a terminal `completed` stream and
266
+ * are ignored). A true worker-exit still completes (presence has dropped the instance → false).
267
+ * Omitted (`() => false`, the static default) → the prior always-complete-on-disconnect behaviour in
268
+ * that fallback path.
266
269
  */
267
270
  readonly isInstanceLive?: (instance: string) => boolean;
268
271
  /**
@@ -725,27 +728,56 @@ export class RelayTranscriptService {
725
728
  }
726
729
 
727
730
  /**
728
- * Flush + complete every ephemeral stream whose producer connection is no longer live (the S1
729
- * registry dropped it on close or liveness timeout), and release its job correlation (H6, #149).
730
- * Lazy, like the relay hub's own dead-subscriber prune: it runs on each inbound frame, and shutdown
731
- * covers the quiescent tail via {@link teardown}. The correlation release is store-independent (it
732
- * runs even for an unpersisted relay), so a dropped worker's `jobKeys` always clear.
731
+ * Reconcile every ephemeral stream whose producer connection is no longer live (the S1 registry
732
+ * dropped it on close or liveness timeout). For a job stream with an engine view wired this
733
+ * defers the completion decision to the engine job-state (#691) the poller-owned authority
734
+ * regardless of whether the stream is linked yet (an unlinked job stream can still be mid-reconnect
735
+ * on the register/produce race), so a mid-job reconnect never archives a still-active job; for an
736
+ * engine-less host (or a non-job stream with no engine job to reconcile against) it flushes +
737
+ * completes the ephemeral stream and releases its correlation (H6, #149), with
738
+ * presence liveness (#689/#690) sparing a still-live instance as the only fallback signal. Lazy, like
739
+ * the relay hub's own dead-subscriber prune: it runs on each inbound frame, and shutdown covers the
740
+ * quiescent tail via {@link teardown}. The correlation release is store-independent (it runs even for
741
+ * an unpersisted relay), so a dropped worker's `jobKeys` always clear.
733
742
  */
734
743
  #reconcile(): void {
735
744
  for (const [stream, state] of this.#streams) {
736
745
  if (state.producer !== undefined && !this.#registry.has(state.producer)) {
737
- // Producer connection gone. Normally that means the job it was relaying ended — release the
738
- // correlation and flush+complete its ephemeral transcript. BUT a worker that merely
739
- // RECONNECTED mid-job (#689) also loses its old producer connection while its job keeps
740
- // running (the harness holds the engine lease and extends it). Presence re-registers the SAME
741
- // instance under the new connection, so the instance stays live even though this specific
742
- // producer connection is gone. Completing then would archive a still-active job's transcript
743
- // to `historical` and drop its correlation and because a completed stream is terminal
744
- // (`#observe` ignores later frames), the reconnected worker could NEVER re-correlate: the
745
- // cockpit shows the worker idle with a frozen transcript while the job is genuinely running.
746
- // So spare the stream while its instance is still live; the next `produce` re-attributes the
747
- // live connection (via `#observe`), a NEW job supersedes it (via `#link`), or — once the
748
- // worker truly exits presence drops the instance and a later reconcile completes it.
746
+ // Producer connection gone. Normally that means the job it was relaying ended — but a worker
747
+ // that merely RECONNECTED mid-job (#689) also loses its old producer connection while its job
748
+ // keeps running (the harness holds the engine lease and extends it). Inferring job-end from the
749
+ // relay-connection layer (the old presence heuristic, #690) conflates two independent liveness
750
+ // signals (engine lease vs. WS connection); the authoritative one is the engine job-state the
751
+ // poller already reconciles (#691).
752
+ if (this.#resolveElementInstance !== undefined && jobKeyOfStream(stream) !== undefined) {
753
+ // Engine/poller-owned completion (#691): drop the dead producer so `#reconcile` does not
754
+ // re-trigger on every subsequent frame, then reconcile THIS stream against the engine's view
755
+ // of its job (fire-and-forget the sync frame handler must not await an engine read). A
756
+ // reconnected worker's job is still parked kept live (the reconnect's next `produce`
757
+ // re-attributes the live connection via `#observe`); a genuine exit's job is gone
758
+ // completed + archived. The periodic {@link reconcileEngineCorrelations} pass is the backstop
759
+ // if this read faults transiently. Presence liveness is no longer consulted here — the
760
+ // engine job-state is the sole completion authority whenever an engine view is wired.
761
+ //
762
+ // NB: this deliberately does NOT require `state.linked`. A job stream can still be UNLINKED
763
+ // during the documented register/produce race (the producer's presence instance was not yet
764
+ // resolvable at `produce` time, so `#link` deferred). Gating the engine path on `linked`
765
+ // would fall an unlinked-but-engine-wired job through to the presence fallback below and
766
+ // archive a still-parked job mid-reconnect — the very heuristic #691 removes. The engine is
767
+ // resolvable by jobKey alone (as at link time), so defer to it regardless of `linked`;
768
+ // `#unlink` inside `completeStream` stays a no-op for a stream that never linked.
769
+ state.producer = undefined;
770
+ void this.#reconcileStreamAgainstEngine(stream).catch((err: unknown) => {
771
+ this.#log.warn("agentic relay disconnect engine-reconcile failed", { stream, err: String(err) });
772
+ });
773
+ continue;
774
+ }
775
+ // Engine-less fallback (no engine view wired — e.g. an engine-less host, or a non-job stream
776
+ // with no engine job to reconcile against): presence-liveness spares a still-live
777
+ // instance's stream (#689/#690) and a truly-gone instance completes. Presence remains a
778
+ // completion signal ONLY in this degraded path where there is no engine job-state to consult —
779
+ // never the sole authority on the engine-wired path above. (A job stream with an engine view
780
+ // wired always took the engine path above, linked or not — it never reaches here.)
749
781
  if (state.instance !== undefined && this.#isInstanceLive(state.instance)) continue;
750
782
  // Producer connection gone → the job it was relaying ended: release its correlation.
751
783
  this.#unlink(stream, state);
@@ -756,58 +788,98 @@ export class RelayTranscriptService {
756
788
  }
757
789
 
758
790
  /**
759
- * Defensive engine-reconcile safety net (#661): release any linked jobKey whose engine JOB park is
760
- * no longer live. The precise, fast release is the terminal `lifecycle` event
791
+ * Defensive engine-reconcile safety net (#661): release any job stream linked OR unlinked (#708) —
792
+ * whose engine JOB park is no longer live. The precise, fast release is the terminal `lifecycle` event
761
793
  * ({@link #observeTerminalLifecycle}), but an UNCLEAN worker exit (crash/kill) can skip that event —
762
794
  * and because the worker's relay connection is persistent across jobs, the disconnect release never
763
795
  * fires either, so the finished job would linger as a phantom active job on the worker's supply row.
764
796
  * This periodic pass asks the engine read model (the same {@link ElementInstanceResolver} the link
765
- * path uses at #544) whether each linked job is still parked; a job the engine no longer parks
797
+ * path uses at #544) whether each job is still parked; a job the engine no longer parks
766
798
  * (resolver returns `undefined`) is released and its transcript completed. Bounds staleness regardless
767
799
  * of whether the worker emitted a clean terminal event — and also covers a worker that emitted nothing
768
- * and merely went quiet.
800
+ * and merely went quiet. It covers UNLINKED job streams too (#708): the disconnect path (#691)
801
+ * can leave a job stream unlinked-but-still-parked with no producer, and jobKey alone resolves
802
+ * terminality, so this backstop is the only actor that can retire one whose worker never reconnects.
769
803
  *
770
804
  * Advisory and best-effort: a no-op with no resolver wired, and a resolver THROW / REJECTION for a
771
- * given job is treated as "unknown — keep it linked" (never a false release of a genuinely active
772
- * job). The linked set is snapshotted before any await so a concurrent completion (a terminal
805
+ * given job is treated as "unknown — keep it" (never a false release of a genuinely active
806
+ * job). The job-stream set is snapshotted before any await so a concurrent completion (a terminal
773
807
  * lifecycle event landing mid-pass) cannot corrupt iteration, and each release re-checks the current
774
808
  * stream state so a job already released between snapshot and resolution is not double-completed.
775
809
  */
776
810
  async reconcileEngineCorrelations(): Promise<void> {
777
- const resolve = this.#resolveElementInstance;
778
- if (resolve === undefined) return;
779
- const linked: { stream: string; jobKey: string; processInstanceKey?: string }[] = [];
811
+ if (this.#resolveElementInstance === undefined) return;
812
+ // Snapshot the job-stream ids BEFORE any await so a concurrent completion (a terminal lifecycle
813
+ // event, a supersede, or a disconnect-triggered engine reconcile landing mid-pass) cannot corrupt
814
+ // iteration; the per-stream helper re-reads live state, so a job released between snapshot and
815
+ // resolution is not double-completed.
816
+ const streams: string[] = [];
780
817
  for (const [stream, state] of this.#streams) {
781
- if (!state.linked || state.completed) continue;
782
- const jobKey = jobKeyOfStream(stream);
783
- if (jobKey === undefined) continue;
784
- const processInstanceKey = this.#correlation()?.resolve?.(jobKey)?.processInstanceKey;
785
- linked.push({ stream, jobKey, processInstanceKey });
818
+ if (state.completed) continue;
819
+ if (jobKeyOfStream(stream) === undefined) continue;
820
+ // Include UNLINKED job streams too (#708). The disconnect path (#691) routes an unlinked
821
+ // job stream through the engine reconcile and clears `state.producer`, so if the engine
822
+ // reports "still parked" at disconnect (or the read faults transiently) and the worker never
823
+ // reconnects to `produce` a link, this periodic pass is the ONLY actor left that can retire
824
+ // it. jobKey alone resolves terminality (as at link time), so gating this snapshot on
825
+ // `state.linked` would leak such a stream live forever once its engine job becomes terminal.
826
+ streams.push(stream);
786
827
  }
787
- for (const { stream, jobKey, processInstanceKey } of linked) {
788
- let activeKey: string | undefined;
789
- try {
790
- activeKey = await resolve(jobKey, processInstanceKey);
791
- } catch (err) {
792
- // A transient engine read failure must NOT be read as "job gone" leave the job linked; a
793
- // later pass (or the terminal lifecycle event) releases it. Advisory, never a false release.
794
- this.#log.warn("agentic relay engine-reconcile read failed leaving correlation linked", {
795
- stream,
796
- jobKey,
797
- err: String(err),
798
- });
799
- continue;
800
- }
801
- // A live JOB park (a resolved element-instance key) means the job is genuinely active keep it.
802
- if (activeKey !== undefined) continue;
803
- // The engine no longer parks this job → it ended (possibly via an unclean exit that skipped the
804
- // terminal lifecycle event). Re-check the current state a concurrent completion may already
805
- // have released it then release its correlation and flush its transcript.
806
- const current = this.#streams.get(stream);
807
- if (current === undefined || current.completed || !current.linked) continue;
808
- this.#log.info("agentic relay engine-reconcile released a stale correlation", { stream, jobKey });
809
- this.completeStream(stream);
828
+ for (const stream of streams) await this.#reconcileStreamAgainstEngine(stream);
829
+ }
830
+
831
+ /**
832
+ * Reconcile ONE `job:<jobKey>` stream against the engine's view of its job (the poller-owned
833
+ * completion authority, #691). Asks the engine read model (the {@link ElementInstanceResolver} the
834
+ * #544 link path uses) whether the job is still parked: still parked genuinely active, kept live;
835
+ * gone → ended (a clean completion whose terminal lifecycle event was missed, or an unclean exit) →
836
+ * released + its transcript flushed/archived. This is the single canonical engine-reconcile step,
837
+ * shared by BOTH the periodic safety-net pass ({@link reconcileEngineCorrelations}) and the
838
+ * disconnect-driven `#reconcile` — so a producer disconnect no longer completes a stream by inferring
839
+ * job-end from relay-connection/presence liveness (#689/#690), but by the authoritative engine
840
+ * job-state. A no-op with no resolver wired, and a resolver THROW / REJECTION is treated as
841
+ * "unknown — keep it" so a transient engine read never falsely releases a genuinely active
842
+ * job (a later pass, or the terminal lifecycle event, releases it). Re-reads live state before
843
+ * completing so a job released between the read and the completion is not double-completed.
844
+ *
845
+ * Does NOT require `state.linked`: the disconnect path (#691) also routes an UNLINKED job stream
846
+ * here (a job stream whose link deferred on the register/produce race), so the engine — not the
847
+ * link flag — owns its completion. The engine resolves by jobKey alone (as at link time); an
848
+ * unlinked stream the engine says is gone is still flushed/archived, and `#unlink` inside
849
+ * `completeStream` stays a no-op for it.
850
+ */
851
+ async #reconcileStreamAgainstEngine(stream: string): Promise<void> {
852
+ const resolve = this.#resolveElementInstance;
853
+ if (resolve === undefined) return;
854
+ const before = this.#streams.get(stream);
855
+ if (before === undefined || before.completed) return;
856
+ const jobKey = jobKeyOfStream(stream);
857
+ if (jobKey === undefined) return;
858
+ const processInstanceKey = this.#correlation()?.resolve?.(jobKey)?.processInstanceKey;
859
+ let activeKey: string | undefined;
860
+ try {
861
+ activeKey = await resolve(jobKey, processInstanceKey);
862
+ } catch (err) {
863
+ // A transient engine read failure must NOT be read as "job gone" — keep the stream live; a
864
+ // later pass (or the terminal lifecycle event) releases it. Advisory, never a false release.
865
+ this.#log.warn("agentic relay engine-reconcile read failed — keeping stream live", {
866
+ stream,
867
+ jobKey,
868
+ err: String(err),
869
+ });
870
+ return;
810
871
  }
872
+ // A live JOB park (a resolved element-instance key) means the job is genuinely active — keep it.
873
+ // This is what spares a mid-job RECONNECT: the harness still holds the engine lease, so the job is
874
+ // still parked even though the old producer connection dropped.
875
+ if (activeKey !== undefined) return;
876
+ // The engine no longer parks this job → it ended (possibly via an unclean exit that skipped the
877
+ // terminal lifecycle event). Re-check the current state — a concurrent completion may already
878
+ // have released it — then release its correlation and flush its transcript.
879
+ const current = this.#streams.get(stream);
880
+ if (current === undefined || current.completed) return;
881
+ this.#log.info("agentic relay engine-reconcile released a stale correlation", { stream, jobKey });
882
+ this.completeStream(stream);
811
883
  }
812
884
 
813
885
  /**
@@ -886,10 +958,11 @@ export function createRelayFamily(options: {
886
958
  // are read per call, so this works regardless of family mount order (relay may mount before
887
959
  // presence/correlation). Absent registries → no linking, still advisory-correct.
888
960
  instanceForConnection: (connectionId) => currentPresenceRegistry()?.instanceForConnection(connectionId),
889
- // #689: is the producing worker instance still live on ANY connection? Gates the
890
- // disconnect-driven reconcile so a mid-job RECONNECT (old producer connection dropped, the
891
- // same instance re-registered) does not archive a still-active job's stream and wedge its
892
- // correlation. Read per call for the same mount-order independence as the resolvers above.
961
+ // #689/#691: is the producing worker instance still live on ANY connection? Since #691 the
962
+ // disconnect-driven reconcile decides completion by the engine job-state (below) whenever the
963
+ // element-instance resolver is wired; this presence signal is the engine-LESS fallback that
964
+ // spares a mid-job RECONNECT's still-active stream where no engine view is available. Read per
965
+ // call for the same mount-order independence as the resolvers around it.
893
966
  isInstanceLive: (instance) => currentPresenceRegistry()?.isInstanceLive(instance) ?? false,
894
967
  // #485: resolve a completed job's worker attribution (presence identity/host) from the live
895
968
  // presence registry, read per call for the same mount-order independence. Absent → attribution
@@ -7,6 +7,7 @@
7
7
  // process variables (the single `task` slice + the base-branch brief).
8
8
  import { after, test } from "node:test";
9
9
  import { assertEquals } from "#test-assert";
10
+ import { withTrackingViews } from "../test/trackingViews.ts";
10
11
  import { FEATURE_PROCESS_ID, FEATURE_TERMINAL_STATUSES, featureTaskId, startFeature } from "./feature.ts";
11
12
 
12
13
  // `startFeature` now fetches the issue title (issue #248) via the GitHub transport. Force the token
@@ -49,7 +50,11 @@ function memTable(rows: any[], key: string) {
49
50
 
50
51
  function memData(stores: Record<string, { rows: any[]; key: string }>) {
51
52
  return {
52
- table: (name: string, key: string) => memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key),
53
+ // Serve the ADR-0065 derived tracking VIEW (`feature_runs__tracking`) off the base store so the
54
+ // intake idempotency reader (which reads `derived_status`, issue #704) resolves against the same
55
+ // rows — a base row with no explicitly-seeded `derived_status` folds `derived_status := status`.
56
+ table: withTrackingViews((name: string, key: string) =>
57
+ memTable(stores[name]?.rows ?? [], stores[name]?.key ?? key)),
53
58
  } as any;
54
59
  }
55
60
 
@@ -74,6 +79,8 @@ test("startFeature: inserts a running feature_runs row and persists the process
74
79
 
75
80
  assertEquals(result.featureKey, "owner/repo#42");
76
81
  assertEquals(result.processKey, "PI-9");
82
+ assertEquals(result.outcome, "started");
83
+ assertEquals(result.alreadyRunning, false);
77
84
  const row = stores.feature_runs.rows[0];
78
85
  assertEquals(row.feature_key, "owner/repo#42");
79
86
  assertEquals(row.repo, "owner/repo");
@@ -214,6 +221,7 @@ test("startFeature: an already-running run short-circuits (no new instance)", as
214
221
  const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
215
222
  assertEquals(created, 0);
216
223
  assertEquals("alreadyRunning" in result && (result as any).alreadyRunning, true);
224
+ assertEquals(result.outcome, "already-active");
217
225
  assertEquals(result.processKey, "PI-OLD");
218
226
  });
219
227
 
@@ -293,7 +301,138 @@ test("startFeature: an in-place restart clears a stale acknowledged_at (re-earn
293
301
  assertEquals(row.acknowledged_at, null);
294
302
  });
295
303
 
296
- // Issue #248: the human-readable identity for the feature grids. Every start persists a non-blank
304
+ // Issue #704 (RED first): the feature-side twin of the #503 `submitPr`/epic re-admission wedges. Under
305
+ // urban 0.81.0 the `instanceTracking` reconciler is a SOURCE, not a writer: on cancel/terminate it no
306
+ // longer stamps the terminal `abandoned` onto base `feature_runs.status` — the terminal is recomputed
307
+ // on read as `feature_runs__tracking.derived_status`. A terminated run therefore keeps its base
308
+ // `status` frozen at its last worker transient (`running`/`escalated`/`awaiting_operator`) while
309
+ // `derived_status` reads `abandoned`. The intake idempotency reader MUST classify on `derived_status`,
310
+ // or a terminated run wedges `already-active` forever — a green success that dispatches NO instance.
311
+ //
312
+ // (a) present + TERMINATED: base row present, `status` frozen, `derived_status: "abandoned"` → the
313
+ // resubmit dispatches a FRESH instance and reports `started` with a new processKey.
314
+ test("startFeature: resubmits a derive-only-terminated run (base 'running', derived 'abandoned') — started, not already-active", async () => {
315
+ const stores = {
316
+ feature_runs: {
317
+ rows: [{
318
+ feature_key: "owner/repo#42",
319
+ repo: "owner/repo",
320
+ issue_number: 42,
321
+ status: "running", // base transient FROZEN — the reconciler no longer writes the terminal
322
+ derived_status: "abandoned", // ADR-0065 derive-only terminal
323
+ process_key: "PI-DEAD",
324
+ pr_key: null,
325
+ converge: 0,
326
+ auto_merge: 0,
327
+ }],
328
+ key: "feature_key",
329
+ },
330
+ };
331
+ let created = 0;
332
+ const engine = {
333
+ createInstance: () => {
334
+ created += 1;
335
+ return Promise.resolve({ processInstanceKey: "PI-FRESH" });
336
+ },
337
+ } as any;
338
+ const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
339
+ assertEquals(created, 1); // NOT wedged — a fresh incarnation was dispatched
340
+ assertEquals(result.outcome, "started");
341
+ assertEquals(result.alreadyRunning, false);
342
+ assertEquals(result.processKey, "PI-FRESH"); // a NEW instance key, not the dead PI-DEAD
343
+ const row = stores.feature_runs.rows[0];
344
+ assertEquals(row.status, "running"); // restarted in place
345
+ assertEquals(row.process_key, "PI-FRESH");
346
+ });
347
+
348
+ // (b) vanished instance (feature_runs row absent — a clean reset dropped it): the intake sees no prior
349
+ // run at all and inserts a fresh one. Resubmit dispatches a fresh instance and reports `started`.
350
+ // (The present-but-instance-state-vanished shape — where derived_status cannot fold — is #630's edge;
351
+ // this asserts the row-absent boundary is resubmittable here.)
352
+ test("startFeature: resubmits when the prior run row has vanished (row absent) — started", async () => {
353
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
354
+ let created = 0;
355
+ const engine = {
356
+ createInstance: () => {
357
+ created += 1;
358
+ return Promise.resolve({ processInstanceKey: "PI-REBORN" });
359
+ },
360
+ } as any;
361
+ const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
362
+ assertEquals(created, 1);
363
+ assertEquals(result.outcome, "started");
364
+ assertEquals(result.processKey, "PI-REBORN");
365
+ assertEquals(stores.feature_runs.rows[0].process_key, "PI-REBORN");
366
+ });
367
+
368
+ // A genuinely-live prior run (non-terminal derived edge) still short-circuits `already-active` — a
369
+ // live run cannot be double-started.
370
+ test("startFeature: a non-terminal derived run still short-circuits (already-active, no new instance)", async () => {
371
+ const stores = {
372
+ feature_runs: {
373
+ rows: [{
374
+ feature_key: "owner/repo#42",
375
+ status: "running",
376
+ derived_status: "running", // still live
377
+ process_key: "PI-LIVE",
378
+ }],
379
+ key: "feature_key",
380
+ },
381
+ };
382
+ let created = 0;
383
+ const engine = {
384
+ createInstance: () => {
385
+ created += 1;
386
+ return Promise.resolve({ processInstanceKey: "PI-NEW" });
387
+ },
388
+ } as any;
389
+ const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
390
+ assertEquals(created, 0);
391
+ assertEquals(result.outcome, "already-active");
392
+ assertEquals(result.alreadyRunning, true);
393
+ assertEquals(result.processKey, "PI-LIVE");
394
+ });
395
+
396
+ // The silent-success contract (#704 fix #3): when the engine returns NO instance key the intake
397
+ // dispatched nothing — it must report `noop-terminal` (a distinct non-success), never a green
398
+ // `started`. The operation maps this to a 502 so the page renders "nothing started" distinctly.
399
+ test("startFeature: a start that dispatches no instance reports noop-terminal (not a green success)", async () => {
400
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
401
+ const engine = { createInstance: () => Promise.resolve({ processInstanceKey: null }) } as any;
402
+ const result = await startFeature(memData(stores), engine, PARSED, "main", false, false);
403
+ assertEquals(result.outcome, "noop-terminal");
404
+ assertEquals(result.processKey, null);
405
+ assertEquals(result.alreadyRunning, false);
406
+ });
407
+
408
+ // #704 follow-up (review): a `noop-terminal` start leaves a `feature_runs` row at base `running` with
409
+ // `process_key: null` — NO engine instance was ever dispatched. That phantom row's `derived_status`
410
+ // folds to the non-terminal base `running` (there is no terminated instance to fold to `abandoned`),
411
+ // so without the null-`process_key` admission guard a retry would short-circuit `already-active` and
412
+ // wedge resubmission forever. Assert the retry stays RESUBMITTABLE and re-dispatches.
413
+ test("startFeature: retry after a noop-terminal re-dispatches (a null process_key row is not already-active)", async () => {
414
+ const stores = { feature_runs: { rows: [] as any[], key: "feature_key" } };
415
+ // Round 1: the engine dispatches nothing → noop-terminal, leaving a running/process_key:null row.
416
+ const noopEngine = { createInstance: () => Promise.resolve({ processInstanceKey: null }) } as any;
417
+ const first = await startFeature(memData(stores), noopEngine, PARSED, "main", false, false);
418
+ assertEquals(first.outcome, "noop-terminal");
419
+ assertEquals(stores.feature_runs.rows[0].status, "running");
420
+ assertEquals(stores.feature_runs.rows[0].process_key, null);
421
+ // Round 2: a healthy engine now dispatches — the retry must NOT wedge as already-active.
422
+ let created = 0;
423
+ const engine = {
424
+ createInstance: () => {
425
+ created += 1;
426
+ return Promise.resolve({ processInstanceKey: "PI-RETRY" });
427
+ },
428
+ } as any;
429
+ const second = await startFeature(memData(stores), engine, PARSED, "main", false, false);
430
+ assertEquals(created, 1);
431
+ assertEquals(second.outcome, "started");
432
+ assertEquals(second.alreadyRunning, false);
433
+ assertEquals(second.processKey, "PI-RETRY");
434
+ });
435
+
297
436
  // `title` — the fetched issue title when available, else the `owner/repo#N` key — on BOTH the insert
298
437
  // (new run) and update (in-place restart) paths, so the title-led grid never renders a blank cell.
299
438
  test("startFeature: coalesces title to the key when the fetch yields nothing (insert path)", async () => {
package/app/feature.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import type { DataLayer, EngineClient } from "@nanobpm/urban";
18
18
  import { TRANSCRIPT_URL_BASE_VAR, transcriptUrlBaseFor } from "./agentic/transcript-url.ts";
19
19
  import { coalesceTitle, fetchIssueTitle } from "./github.ts";
20
+ import { derivedTrackingTable } from "./instanceTracking.ts";
20
21
  import { ESCALATION_SLA_TIMEOUT, normalizeBaseBranch, type ParsedIssue, renderBaseBranchBrief } from "./plan.ts";
21
22
  import type { ReadinessProbe } from "./readiness.ts";
22
23
  import { repoEnvelopeVars } from "./repoEnvelope.ts";
@@ -235,6 +236,50 @@ export const FEATURE_BLOCKED_ELEMENT = "feature-blocked";
235
236
  * that reproduces the reconciler bypass). */
236
237
  export const featureRuns = (data: DataLayer) => data.table<FeatureRun>("feature_runs", "feature_key");
237
238
 
239
+ /** A `feature_runs` row as seen through its derived tracking VIEW (`feature_runs__tracking`): the base
240
+ * columns plus urban's ADR-0065 `derived_status`, which FOLDS the reconciler's terminal edge
241
+ * (out-of-band terminate / in-app cancel → `abandoned`) over the worker-owned transient. Since urban
242
+ * 0.81.0 the `instanceTracking` reconciler is a SOURCE, not a writer: it no longer stamps the terminal
243
+ * onto base `status` on cancel/terminate — the terminal is recomputed on read as `derived_status`. */
244
+ export type TrackedFeatureRun = FeatureRun & { derived_status: string };
245
+
246
+ /** Read-only accessor over the feature-run derived tracking VIEW (`feature_runs__tracking`). Use this —
247
+ * and read `derived_status`, NOT the base `status` — for the intake/admission idempotency
248
+ * classification (issue #704), so a run whose engine instance was terminated out-of-band (base row
249
+ * frozen at its last transient `running`/`escalated`/`awaiting_operator`) is correctly seen as
250
+ * `abandoned` and RESUBMITTABLE. The view name + `derived_status` column resolve through
251
+ * `derivedTrackingTable` (app/instanceTracking.ts), never a hard-coded name — mirroring `prsTracking`
252
+ * (app/service.ts) and `plansTracking` (app/plan.ts). Writes stay on `featureRuns`. */
253
+ export const featureRunsTracking = (data: DataLayer) =>
254
+ derivedTrackingTable<TrackedFeatureRun>(data, "feature_runs", "feature_key");
255
+
256
+ /** The discriminated outcome of a feature-run intake (issue #704). It distinguishes a genuine dispatch
257
+ * from a short-circuit and from a no-op, so a submit that started NOTHING can never render as a bare
258
+ * green success:
259
+ * - `started` — a fresh engine instance was dispatched (`processKey` set).
260
+ * - `already-active` — a non-terminal prior run for the same issue is still live, so the start
261
+ * short-circuited; no new instance was created and `processKey` is the live run's.
262
+ * - `noop-terminal` — the intake attempted a start but the engine returned NO instance key, so
263
+ * nothing was dispatched. Surfaced by the operation as a distinct non-success (a submit that
264
+ * dispatches no instance is not "Done"). */
265
+ export type FeatureStartOutcome = "started" | "already-active" | "noop-terminal";
266
+
267
+ /** The result of {@link startFeature} — a discriminated intake outcome (issue #704). `alreadyRunning`
268
+ * is retained as a back-compat flag for existing callers/telemetry (true iff `outcome` is
269
+ * `already-active`). A `type` alias (not an `interface`) because an object-literal type alias — with a
270
+ * fully-known, final set of properties — is assignable to the generated OpenAPI response shape's index
271
+ * signature, whereas an `interface` (open to declaration-merging) is not; this mirrors the inferred
272
+ * sibling results. */
273
+ export type StartFeatureResult = {
274
+ featureKey: string;
275
+ outcome: FeatureStartOutcome;
276
+ /** The dispatched instance key (`started`), the live run's key (`already-active`), or null
277
+ * (`noop-terminal`). */
278
+ processKey: string | null;
279
+ /** True iff a non-terminal prior run short-circuited the start (`outcome === "already-active"`). */
280
+ alreadyRunning: boolean;
281
+ };
282
+
238
283
  /** The deterministic task id for a single-issue run — the implementation agent branches
239
284
  * `feat/<task.id>` (see resources/prompts/feature.md), so it MUST be derivable from the issue alone
240
285
  * and stable across a resume. The PR is opened on the target repo, so the issue number
@@ -257,7 +302,7 @@ export async function startFeature(
257
302
  autoMerge: boolean,
258
303
  customInstructions: string | null = null,
259
304
  readiness: FeatureReadinessOptions = {},
260
- ) {
305
+ ): Promise<StartFeatureResult> {
261
306
  // Intake-time readiness gate (issue #295): the probes the run must satisfy before it implements,
262
307
  // and the bound its preflight escalation timers fire off. Both are load-bearing together —
263
308
  // `pr.readiness-probe` rejects a blank `probeTimeout` and the preflight timers read `=probeTimeout`
@@ -286,9 +331,38 @@ export async function startFeature(
286
331
  ? customInstructions.trim()
287
332
  : null;
288
333
  const table = featureRuns(data);
289
- const existing = await table.get(parsed.planKey);
290
- if (existing && !FEATURE_TERMINAL_STATUSES.includes(existing.status)) {
291
- return { featureKey: parsed.planKey, alreadyRunning: true, processKey: existing.process_key };
334
+ // ADR-0065: classify "already running" on the DERIVED terminal edge, not the base transient. A
335
+ // feature run whose engine instance was terminated out-of-band (or by an ordinary in-app cancel —
336
+ // derive-only under urban 0.81.0) has a base row frozen at its last worker transient
337
+ // (`running`/`escalated`/`awaiting_operator`) but a `feature_runs__tracking.derived_status` of
338
+ // `abandoned`. Reading the base `status` here wedged a terminated run `alreadyRunning` forever —
339
+ // returning a green success while dispatching NO instance (issue #704, the feature-side twin of the
340
+ // `submitPr` / epic re-admission wedges #503 fixed; this intake reader was the one #503 omitted).
341
+ // Route the idempotency gate through the derived view so a terminated run is correctly seen terminal
342
+ // and RESUBMITTABLE; a non-terminal derived edge still short-circuits (a genuinely-live run cannot
343
+ // be double-started). ONE read through the tracking VIEW serves both this classification and the
344
+ // insert-vs-update decision below: it re-exports every base column (so it doubles as the "exists?"
345
+ // check and the `process_key` source) plus `derived_status`, so a second base-table round trip is
346
+ // redundant.
347
+ const trackedExisting = await featureRunsTracking(data).get(parsed.planKey);
348
+ // A row with a null `process_key` never had an engine instance dispatched, so it is NEVER a live
349
+ // run — treat it as non-active for admission. This is load-bearing for the `noop-terminal` path
350
+ // (issue #704): a start the engine accepted without returning an instance key leaves a base row at
351
+ // `running`/`process_key: null` whose `derived_status` folds to the non-terminal base `running`
352
+ // (there is no terminated instance to fold to `abandoned`). Without this guard that phantom row
353
+ // would short-circuit every retry as `already-active`, wedging resubmission forever even though
354
+ // nothing was ever dispatched.
355
+ if (
356
+ trackedExisting &&
357
+ trackedExisting.process_key != null &&
358
+ !FEATURE_TERMINAL_STATUSES.some((s) => s === trackedExisting.derived_status)
359
+ ) {
360
+ return {
361
+ featureKey: parsed.planKey,
362
+ outcome: "already-active",
363
+ processKey: trackedExisting.process_key,
364
+ alreadyRunning: true,
365
+ };
292
366
  }
293
367
  const base = normalizeBaseBranch(baseBranch);
294
368
  const ts = now();
@@ -300,7 +374,7 @@ export async function startFeature(
300
374
  await fetchIssueTitle(parsed.repo, parsed.number, process.env.GITHUB_TOKEN ?? ""),
301
375
  parsed.planKey,
302
376
  );
303
- if (existing) {
377
+ if (trackedExisting) {
304
378
  await table.update(parsed.planKey, {
305
379
  status: "running",
306
380
  base_branch: base,
@@ -425,6 +499,10 @@ export async function startFeature(
425
499
  const processKey = processInstanceKey == null ? null : String(processInstanceKey);
426
500
  if (processKey != null) {
427
501
  await table.update(parsed.planKey, { process_key: processKey, updated_at: now() });
502
+ return { featureKey: parsed.planKey, outcome: "started", processKey, alreadyRunning: false };
428
503
  }
429
- return { featureKey: parsed.planKey, processKey };
504
+ // The engine accepted the start but returned NO instance key — nothing was actually dispatched.
505
+ // Report a discriminated no-op (issue #704) so the caller can surface it as a distinct non-success
506
+ // instead of a bare green "Done": a submit that dispatches no instance is not "started".
507
+ return { featureKey: parsed.planKey, outcome: "noop-terminal", processKey: null, alreadyRunning: false };
430
508
  }
@@ -1,4 +1,4 @@
1
- // Class guard for the ADR-0065 terminal-edge reader migration (issue #503).
1
+ // Class guard for the ADR-0065 terminal-edge reader migration (issues #503, #704).
2
2
  //
3
3
  // Since ADR-0065 (`@nanobpm/urban@0.81.0`) the `instanceTracking` reconciler is a SOURCE, not a
4
4
  // writer: on cancel/terminate it feeds urban's instance projection and the terminal edge
@@ -6,14 +6,20 @@
6
6
  // the terminal (`abandoned`/`failed`/`reviewed`) onto the base `status` column. A classifying reader
7
7
  // that inspects the BASE `status` column of a DERIVE-ONLY tracked table therefore sees a row frozen at
8
8
  // its last worker-owned transient after the instance ends → phantom-active / wedged-idempotency bugs
9
- // (the #497 / #503 class).
9
+ // (the #497 / #503 / #704 class).
10
10
  //
11
- // This is a SOURCE-SCAN guard over the defect CLASS, not a single instance: it asserts that every
12
- // terminal/active classification (`TERMINAL_STATUSES.includes` / `=== ABANDONED_STATUS` /
13
- // `PLAN_TERMINAL_STATUSES` / the feature read model's status DSL) for the three derive-only tracked
14
- // tables (`pull_requests`, `plans`, `feature_runs`) reads the DERIVED effective status
15
- // (`.derived_status`), never the frozen base `.status`. A future reader that silently re-drifts onto
16
- // the base column fails here.
11
+ // This is a SOURCE-SCAN guard over the defect CLASS, not a single instance. It is PARAMETRIZED over
12
+ // the three derive-only tracked tables and their admission/idempotency/classifier readers
13
+ // (derivation-over-duplication in the test itself, so the whole class is unrepresentable):
14
+ // - `pull_requests` → service.ts, `TERMINAL_STATUSES` (submitPr idempotency, activePrs, incidents,
15
+ // merge lanes, wave gates)
16
+ // - `plans` → plan.ts, `PLAN_TERMINAL_STATUSES` (startPlan re-admission, active-by-base)
17
+ // - `feature_runs` → feature.ts, `FEATURE_TERMINAL_STATUSES` (startFeature INTAKE idempotency — the
18
+ // reader #503 omitted, closed by #704)
19
+ // Every terminal-set classification (both the `.includes(x)` and `.some((s) => s === x)` forms) MUST
20
+ // read the DERIVED effective status (`.derived_status`), never the frozen base `.status`. A future
21
+ // reader — on ANY of the three tables — that silently re-drifts onto the base column fails here; the
22
+ // pre-#704 feature intake (`FEATURE_TERMINAL_STATUSES.includes(existing.status)`) would have been red.
17
23
  //
18
24
  // Worker-owned terminals that PASS THROUGH the derive edge unchanged (`merged`) are exempt: a base
19
25
  // `=== "merged"` read is legitimate (see `isDepMerged` / `classifyWaveTarget` / `mergeLaneDecisionForPr`
@@ -36,23 +42,61 @@ function stripComments(src: string): string {
36
42
  return src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
37
43
  }
38
44
 
39
- test("class guard: every TERMINAL_STATUSES classification in service.ts reads derived_status, not base status", () => {
40
- const code = stripComments(SRC("service.ts"));
41
- const calls = [...code.matchAll(/TERMINAL_STATUSES\.includes\(([^)]*)\)/g)];
42
- assert(calls.length > 0, "expected TERMINAL_STATUSES.includes classifications in service.ts");
43
- for (const m of calls) {
44
- const arg = m[1];
45
- assert(
46
- /\.derived_status\b/.test(arg),
47
- `TERMINAL_STATUSES.includes(${arg}) classifies on the BASE status of a derive-only tracked table — ` +
48
- `route it through prsTracking and read \`.derived_status\` (ADR-0065, #503)`,
49
- );
50
- assert(
51
- !/[A-Za-z0-9_)\]]\.status\b/.test(arg),
52
- `TERMINAL_STATUSES.includes(${arg}) still reads a base \`.status\` — the terminal edge is derive-only (#503)`,
53
- );
45
+ /** Every expression a `<SET>_TERMINAL_STATUSES` classifies, across BOTH idioms the app uses:
46
+ * `SET.includes(<expr>)` and `SET.some((s) => s === <expr>)` (and the mirrored `<expr> === s`). One
47
+ * extractor for both forms so a reader can't dodge the guard by switching idiom. For the `.some`
48
+ * form we capture the CLASSIFIED OPERAND the side of `===` that is NOT the arrow parameter — on
49
+ * either orientation, and assert against that operand rather than the whole arrow body, so an
50
+ * incidental `.derived_status` reference elsewhere in the body can't mask a base-`.status`
51
+ * classification. */
52
+ function classifiedExprs(code: string, setName: string): string[] {
53
+ const exprs: string[] = [];
54
+ for (const m of code.matchAll(new RegExp(`${setName}\\.includes\\(([^)]*)\\)`, "g"))) {
55
+ exprs.push(m[1]);
54
56
  }
55
- });
57
+ for (const m of code.matchAll(new RegExp(`${setName}\\.some\\(\\(\\s*(\\w+)\\s*\\)\\s*=>\\s*([^)]*)\\)`, "g"))) {
58
+ const param = m[1]; // the arrow parameter, e.g. `s`
59
+ const body = m[2]; // `s === <expr>` or the mirrored `<expr> === s`
60
+ const sides = body.split("===").map((x) => x.trim());
61
+ // Capture the operand compared against the loop parameter, on either side of `===`; fall back to
62
+ // the whole body for any shape we don't recognise so the guard errs toward stricter, not looser.
63
+ if (sides.length === 2 && (sides[0] === param || sides[1] === param)) {
64
+ exprs.push(sides[0] === param ? sides[1] : sides[0]);
65
+ } else {
66
+ exprs.push(body);
67
+ }
68
+ }
69
+ return exprs;
70
+ }
71
+
72
+ /** The derive-only tracked tables and the reader module + terminal-status set that classifies each.
73
+ * ONE registry drives the whole guard so adding a fourth derive-only admission reader is a one-line
74
+ * change, never a copy-pasted test. */
75
+ const DERIVE_ONLY_READERS = [
76
+ { table: "pull_requests", file: "service.ts", set: "TERMINAL_STATUSES" },
77
+ { table: "plans", file: "plan.ts", set: "PLAN_TERMINAL_STATUSES" },
78
+ { table: "feature_runs", file: "feature.ts", set: "FEATURE_TERMINAL_STATUSES" },
79
+ ] as const;
80
+
81
+ for (const { table, file, set } of DERIVE_ONLY_READERS) {
82
+ test(`class guard: every ${set} classification (${table} admission/idempotency) reads derived_status, not base status`, () => {
83
+ const code = stripComments(SRC(file));
84
+ const exprs = classifiedExprs(code, set);
85
+ assert(exprs.length > 0, `expected ${set} classifications in ${file}`);
86
+ for (const expr of exprs) {
87
+ assert(
88
+ /\.derived_status\b/.test(expr),
89
+ `${set} classifies on \`${expr}\` — the BASE status of the derive-only tracked table \`${table}\`. ` +
90
+ `Route it through the derived tracking view and read \`.derived_status\` (ADR-0065, #503/#704)`,
91
+ );
92
+ assert(
93
+ !/[A-Za-z0-9_)\]]\.status\b/.test(expr),
94
+ `${set} classifies on \`${expr}\` which still reads a base \`.status\` — the terminal edge is ` +
95
+ `derive-only for \`${table}\` (ADR-0065, #503/#704)`,
96
+ );
97
+ }
98
+ });
99
+ }
56
100
 
57
101
  test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"abandoned\"` read classification in service.ts", () => {
58
102
  const code = stripComments(SRC("service.ts"));
@@ -71,21 +115,6 @@ test("class guard: no base `.status === ABANDONED_STATUS` / `.status === \"aband
71
115
  );
72
116
  });
73
117
 
74
- test("class guard: every PLAN_TERMINAL_STATUSES classification in plan.ts reads derived_status, not base status", () => {
75
- const code = stripComments(SRC("plan.ts"));
76
- // Match the `.some((s) => s === <ref>)` classification form used at both admission sites.
77
- const calls = [...code.matchAll(/PLAN_TERMINAL_STATUSES\.some\(\([^)]*\)\s*=>\s*[^)]*===\s*([A-Za-z0-9_.]+)\)/g)];
78
- assert(calls.length > 0, "expected PLAN_TERMINAL_STATUSES classifications in plan.ts");
79
- for (const m of calls) {
80
- const ref = m[1];
81
- assert(
82
- /\.derived_status$/.test(ref),
83
- `PLAN_TERMINAL_STATUSES classification reads \`${ref}\` — route it through plansTracking and read ` +
84
- `\`.derived_status\` so a derive-only-terminated epic is seen terminal (ADR-0065, #503)`,
85
- );
86
- }
87
- });
88
-
89
118
  test("class guard: the feature read model classifies on derived_status, never the base status column", () => {
90
119
  // The feature history read model (app/featureReadModel.ts) buckets a run's pipeline `stage`/
91
120
  // `list_bucket` off its status. Under ADR-0065 that must be the terminal-folded `derived_status`
package/openapi.yaml CHANGED
@@ -1281,15 +1281,29 @@ components:
1281
1281
  type: object
1282
1282
  required:
1283
1283
  - featureKey
1284
+ - outcome
1284
1285
  properties:
1285
1286
  featureKey:
1286
1287
  type: string
1288
+ outcome:
1289
+ type: string
1290
+ enum:
1291
+ - started
1292
+ - already-active
1293
+ - noop-terminal
1294
+ description: >-
1295
+ Discriminated intake outcome (issue #704). `started` — a fresh engine instance was
1296
+ dispatched (`processKey` set). `already-active` — a non-terminal prior run for this issue is
1297
+ still live, so the start short-circuited (no new instance; `processKey` is the live run's).
1298
+ `noop-terminal` — the engine returned no instance key, so NOTHING was dispatched; the
1299
+ operation surfaces this as a 502 (never on a 202 body), so a submit that dispatched no
1300
+ instance is never reported as success.
1287
1301
  processKey:
1288
1302
  type: string
1289
1303
  nullable: true
1290
1304
  alreadyRunning:
1291
1305
  type: boolean
1292
- description: True when a non-terminal feature run for this issue already exists; no new instance was started.
1306
+ description: True iff a non-terminal feature run for this issue already existed and short-circuited the start (outcome `already-active`); no new instance was started.
1293
1307
  EpicSetStart:
1294
1308
  description: >-
1295
1309
  The set/batch admission request body (issue #292, slice S2). Submits a SET of epics plus the
@@ -5618,6 +5632,15 @@ paths:
5618
5632
  application/json:
5619
5633
  schema:
5620
5634
  $ref: "#/components/schemas/ErrorBody"
5635
+ "502":
5636
+ description: >-
5637
+ The engine accepted the start but returned no instance key — nothing was dispatched (issue
5638
+ #704). A submit that starts no instance is surfaced here as a distinct non-success, never a
5639
+ green 202.
5640
+ content:
5641
+ application/json:
5642
+ schema:
5643
+ $ref: "#/components/schemas/ErrorBody"
5621
5644
  /actions/message:
5622
5645
  post:
5623
5646
  operationId: postMessage
@@ -177,14 +177,31 @@ export default defineOperation("startFeature", async ({ body }, app) => {
177
177
  customInstructions,
178
178
  { probes: readiness.probes, probeTimeout: readiness.probeTimeout, probePollEvery: readiness.probePollEvery },
179
179
  );
180
- app.log.info("feature run started", {
180
+ app.log.info("feature run intake", {
181
181
  featureKey: parsed.planKey,
182
182
  requestedBaseBranch: normalizedBase,
183
183
  converge,
184
184
  autoMerge,
185
185
  hasCustomInstructions: typeof customInstructions === "string" && customInstructions.trim() !== "",
186
186
  readinessProbes: readiness.probes.length,
187
- alreadyRunning: "alreadyRunning" in result && result.alreadyRunning === true,
187
+ outcome: result.outcome,
188
+ alreadyRunning: result.alreadyRunning,
188
189
  });
190
+ // Discriminated intake outcome (issue #704): a `noop-terminal` means the engine accepted the start
191
+ // but returned no instance key, so NOTHING was dispatched. That is not a success — surface it as a
192
+ // 502 so the feature page renders "nothing started" distinctly (a red error banner), never a bare
193
+ // green success for a submit that started no instance. `started` / `already-active` are both
194
+ // legitimate 202s (the latter short-circuited a genuinely-live run).
195
+ if (result.outcome === "noop-terminal") {
196
+ app.log.error("feature run intake dispatched no engine instance", { featureKey: parsed.planKey });
197
+ return {
198
+ status: 502,
199
+ body: {
200
+ error:
201
+ `feature run for ${parsed.planKey} started no engine instance — the engine returned no ` +
202
+ `instance key, so nothing was dispatched. Retry, or check the engine.`,
203
+ },
204
+ };
205
+ }
189
206
  return { status: 202, body: result };
190
207
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.174.1",
3
+ "version": "0.175.1",
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",
@@ -84,7 +84,7 @@
84
84
  "props": {
85
85
  "title": "Implement one issue",
86
86
  "submitLabel": "Implement & raise PR",
87
- "action": { "path": "/app/api/actions/start/feature", "body": "{{form}}" },
87
+ "action": { "path": "/app/api/actions/start/feature", "body": "{{form}}", "successLabel": "Feature run dispatched \u2014 track it in the runs below. (A run already live for this issue is re-used, not double-started; if nothing is dispatched you\u2019ll see an error here, never a silent success.)" },
88
88
  "fields": [
89
89
  { "key": "issue", "label": "owner/repo#123 or a GitHub issue URL", "type": "text", "required": true, "requiredMessage": "An issue reference is required (owner/repo#123 or an issue URL)" },
90
90
  { "key": "baseBranch", "label": "Base branch: the branch the PR targets, e.g. main. A missing epic/* branch is auto-created off default HEAD; a non-epic/* branch must already exist.", "type": "text", "required": true, "requiredMessage": "Name the branch the PR targets (e.g. main or epic/agent-protocol)" },