@nanobpm/nano-workforce 0.174.0 → 0.175.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/agentic/families/relay.family.test.ts +192 -0
- package/app/agentic/families/relay.family.ts +140 -67
- package/app/reconcile.test.ts +234 -4
- package/app/reconcile.ts +299 -44
- package/main.ts +8 -6
- package/openapi.yaml +10 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [0.175.0](https://github.com/nanobpm/nano-workforce/compare/v0.174.1...v0.175.0) (2026-09-02)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **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)
|
|
6
|
+
|
|
7
|
+
## [0.174.1](https://github.com/nanobpm/nano-workforce/compare/v0.174.0...v0.174.1) (2026-09-02)
|
|
8
|
+
|
|
9
|
+
### Bug Fixes
|
|
10
|
+
|
|
11
|
+
* **reconcile:** fold vanished engine instances to orphaned, unwedging inflight runs ([#706](https://github.com/nanobpm/nano-workforce/issues/706)) ([68461da](https://github.com/nanobpm/nano-workforce/commit/68461dae90fba5ab6caaa8eef595e63f3995dc88)), closes [#2](https://github.com/nanobpm/nano-workforce/issues/2) [#630](https://github.com/nanobpm/nano-workforce/issues/630) [#627](https://github.com/nanobpm/nano-workforce/issues/627)
|
|
12
|
+
|
|
1
13
|
## [0.174.0](https://github.com/nanobpm/nano-workforce/compare/v0.173.0...v0.174.0) (2026-09-02)
|
|
2
14
|
|
|
3
15
|
### Features
|
|
@@ -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}.
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
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
|
-
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
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 —
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
//
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
|
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
|
|
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
|
|
772
|
-
* job). The
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
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 (
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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?
|
|
890
|
-
// disconnect-driven reconcile
|
|
891
|
-
//
|
|
892
|
-
//
|
|
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
|
package/app/reconcile.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Red/green coverage for the app-side engine-reset reconciliation surface (
|
|
1
|
+
// Red/green coverage for the app-side engine-reset reconciliation surface (issues #622 and #630).
|
|
2
2
|
//
|
|
3
3
|
// The core scenario the incident (Magikcraft/nano-bpm#1065) demanded a supported remedy for: the
|
|
4
4
|
// engine is reset and its incarnation epoch REGRESSES, while `app.db` still projects engine-backed
|
|
@@ -13,21 +13,55 @@ import { test } from "node:test";
|
|
|
13
13
|
import { assertEquals } from "#test-assert";
|
|
14
14
|
import { freshData } from "../test/reconcileDb.ts";
|
|
15
15
|
import {
|
|
16
|
+
DEFAULT_VANISHED_GRACE_MS,
|
|
16
17
|
ORPHANED_STATUS,
|
|
17
18
|
parseEngineEpoch,
|
|
18
19
|
RECONCILE_ORPHAN_REASON,
|
|
20
|
+
RECONCILE_VANISHED_REASON,
|
|
19
21
|
reconcileEngineBackedWork,
|
|
22
|
+
reconcileVanishedInstances,
|
|
23
|
+
runEngineReconcile,
|
|
20
24
|
} from "./reconcile.ts";
|
|
21
25
|
|
|
22
26
|
const AT = () => new Date("2026-02-02T00:00:00.000Z");
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
/** The canonical `_urban_instance_state` DDL (urban's framework projection, `_urban_`-prefixed so it
|
|
29
|
+
* is provisioned by the runtime — NOT our migrations). Mirrors `InstanceStateStore`'s schema so the
|
|
30
|
+
* vanished-instance reconcile is exercised against exactly the table it reads in production. */
|
|
31
|
+
function ensureInstanceState(raw: DatabaseSync): void {
|
|
32
|
+
raw.exec(
|
|
33
|
+
`CREATE TABLE IF NOT EXISTS _urban_instance_state (
|
|
34
|
+
process_instance_key TEXT NOT NULL,
|
|
35
|
+
state TEXT NOT NULL,
|
|
36
|
+
waiting_on_human INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
updated_at TEXT NOT NULL,
|
|
38
|
+
PRIMARY KEY (process_instance_key)
|
|
39
|
+
);`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function seedInstanceState(raw: DatabaseSync, processKey: string, state: string): void {
|
|
44
|
+
raw
|
|
45
|
+
.prepare(
|
|
46
|
+
`INSERT INTO _urban_instance_state (process_instance_key, state, waiting_on_human, updated_at)
|
|
47
|
+
VALUES (?, ?, 0, '2026-01-15')`,
|
|
48
|
+
)
|
|
49
|
+
.run(processKey, state);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function seedFeatureRun(
|
|
53
|
+
raw: DatabaseSync,
|
|
54
|
+
key: string,
|
|
55
|
+
status: string,
|
|
56
|
+
processKey: string | null,
|
|
57
|
+
updatedAt = "2026-01-01",
|
|
58
|
+
): void {
|
|
25
59
|
raw
|
|
26
60
|
.prepare(
|
|
27
61
|
`INSERT INTO feature_runs (feature_key, repo, issue_number, issue_url, base_branch, status, process_key, created_at, updated_at)
|
|
28
|
-
VALUES (?, 'o/r', 1, 'https://x', 'main', ?, ?, '2026-01-01',
|
|
62
|
+
VALUES (?, 'o/r', 1, 'https://x', 'main', ?, ?, '2026-01-01', ?)`,
|
|
29
63
|
)
|
|
30
|
-
.run(key, status, processKey);
|
|
64
|
+
.run(key, status, processKey, updatedAt);
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
function seedDeliveryGraphRun(raw: DatabaseSync, runKey: string, status: string, processKey: string | null): void {
|
|
@@ -210,3 +244,199 @@ test("RED→GREEN: orphaning stamps updated_at so the transition timestamp isn't
|
|
|
210
244
|
assertEquals(dg.status, ORPHANED_STATUS);
|
|
211
245
|
assertEquals(dg.updated_at, at);
|
|
212
246
|
});
|
|
247
|
+
|
|
248
|
+
// --- Vanished-instance reconciliation (issue #630) --------------------------------------------
|
|
249
|
+
// The "instance absent/unknown" gap, DISTINCT from the epoch-regression reset above: when an engine
|
|
250
|
+
// instance VANISHES from the read model (`_urban_instance_state` row pruned/never re-created after a
|
|
251
|
+
// clean reset), the derived terminal edge has no `TERMINATED` row to match, so the run freezes at its
|
|
252
|
+
// last worker-owned status (`escalated`) and wedges Active forever. `reconcileVanishedInstances`
|
|
253
|
+
// drives those orphaned-in-truth rows to `orphaned` WITH PROVENANCE — gated on a grace window so a
|
|
254
|
+
// still-starting run (not yet projected) is spared.
|
|
255
|
+
|
|
256
|
+
test("RED→GREEN: a vanished instance (no _urban_instance_state row, past grace) is orphaned", async () => {
|
|
257
|
+
const { data, raw } = freshData();
|
|
258
|
+
ensureInstanceState(raw);
|
|
259
|
+
// The pre-reset orphan from the incident: escalated, keyed on a HIGH pre-reset process_key whose
|
|
260
|
+
// instance is absent from the current read model. Its updated_at is ~32 days before AT() (past grace).
|
|
261
|
+
seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
|
|
262
|
+
// A live sibling: still ACTIVE in the projection — must be left untouched.
|
|
263
|
+
seedFeatureRun(raw, "o/r#live", "running", "200");
|
|
264
|
+
seedInstanceState(raw, "200", "ACTIVE");
|
|
265
|
+
|
|
266
|
+
// RED (pre-fix): the orphan reads `escalated` (Active) indefinitely — no terminal edge fires.
|
|
267
|
+
const before = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'").get() as {
|
|
268
|
+
status: string;
|
|
269
|
+
};
|
|
270
|
+
assertEquals(before.status, "escalated");
|
|
271
|
+
|
|
272
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
|
|
273
|
+
|
|
274
|
+
assertEquals(res.reason, "instance-vanished");
|
|
275
|
+
assertEquals(res.orphanedCount, 1);
|
|
276
|
+
|
|
277
|
+
const orphan = raw
|
|
278
|
+
.prepare("SELECT status, updated_at FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'")
|
|
279
|
+
.get() as { status: string; updated_at: string };
|
|
280
|
+
assertEquals(orphan.status, ORPHANED_STATUS);
|
|
281
|
+
// The transition refreshes updated_at like every other status transition (not left stale).
|
|
282
|
+
assertEquals(orphan.updated_at, AT().toISOString());
|
|
283
|
+
|
|
284
|
+
// The live instance (ACTIVE row present) is never touched.
|
|
285
|
+
const live = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#live'").get() as { status: string };
|
|
286
|
+
assertEquals(live.status, "running");
|
|
287
|
+
|
|
288
|
+
const prov = raw
|
|
289
|
+
.prepare("SELECT * FROM reconcile_provenance WHERE source_table='feature_runs'")
|
|
290
|
+
.get() as Record<string, unknown>;
|
|
291
|
+
assertEquals(prov.to_status, ORPHANED_STATUS);
|
|
292
|
+
assertEquals(prov.from_status, "escalated");
|
|
293
|
+
assertEquals(prov.reason, RECONCILE_VANISHED_REASON);
|
|
294
|
+
assertEquals(prov.key_value, "71506");
|
|
295
|
+
assertEquals(prov.run_id, "van-1");
|
|
296
|
+
assertEquals(prov.observed_epoch, null);
|
|
297
|
+
|
|
298
|
+
const run = raw.prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id='van-1'").get() as {
|
|
299
|
+
reason: string;
|
|
300
|
+
orphaned_count: number;
|
|
301
|
+
};
|
|
302
|
+
assertEquals(run.reason, "instance-vanished");
|
|
303
|
+
assertEquals(run.orphaned_count, 1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("a still-starting run within the grace window is NOT prematurely folded", async () => {
|
|
307
|
+
const { data, raw } = freshData();
|
|
308
|
+
ensureInstanceState(raw);
|
|
309
|
+
// Dispatched moments ago — its process_key is set but the reconciler has not yet projected the
|
|
310
|
+
// instance into _urban_instance_state. updated_at is 30s before AT(), inside the grace window.
|
|
311
|
+
const justNow = new Date(AT().getTime() - 30_000).toISOString();
|
|
312
|
+
seedFeatureRun(raw, "o/r#starting", "running", "999", justNow);
|
|
313
|
+
|
|
314
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
|
|
315
|
+
|
|
316
|
+
assertEquals(res.orphanedCount, 0);
|
|
317
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#starting'").get() as {
|
|
318
|
+
status: string;
|
|
319
|
+
};
|
|
320
|
+
assertEquals(row.status, "running");
|
|
321
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
322
|
+
// A generous grace window is the point — the default comfortably exceeds a poll cycle.
|
|
323
|
+
assertEquals(DEFAULT_VANISHED_GRACE_MS >= 60_000, true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("RED→GREEN: a row whose updated_at is null/unparseable is spared, never orphaned", async () => {
|
|
327
|
+
const { data, raw } = freshData();
|
|
328
|
+
ensureInstanceState(raw);
|
|
329
|
+
// A tracked table's `updated_at` can be nullable (e.g. delivery_units.updated_at,
|
|
330
|
+
// db/migrations/088_delivery_units.sql) or carry an unparseable value. Its instance is absent from
|
|
331
|
+
// the read model, so without a usable age we cannot tell a genuinely-vanished row from a live one.
|
|
332
|
+
// RED (pre-fix): withinGrace treated an unestablishable age as "old enough" and folded the row.
|
|
333
|
+
// GREEN: we err toward sparing — an ageless row is treated as within grace and left untouched.
|
|
334
|
+
seedFeatureRun(raw, "o/r#ageless", "running", "888", "not-a-timestamp");
|
|
335
|
+
|
|
336
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
|
|
337
|
+
|
|
338
|
+
assertEquals(res.orphanedCount, 0);
|
|
339
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#ageless'").get() as {
|
|
340
|
+
status: string;
|
|
341
|
+
};
|
|
342
|
+
assertEquals(row.status, "running");
|
|
343
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test("terminal history, keyless rows, and rows with a live instance are never folded as vanished", async () => {
|
|
347
|
+
const { data, raw } = freshData();
|
|
348
|
+
ensureInstanceState(raw);
|
|
349
|
+
seedFeatureRun(raw, "term#1", "merged", "88"); // terminal — not in activeStatuses
|
|
350
|
+
seedFeatureRun(raw, "nokeed#1", "running", null); // active but never dispatched (no engine key)
|
|
351
|
+
seedFeatureRun(raw, "live#1", "escalated", "89"); // active, but its instance is still present
|
|
352
|
+
seedInstanceState(raw, "89", "ACTIVE");
|
|
353
|
+
|
|
354
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
|
|
355
|
+
|
|
356
|
+
assertEquals(res.orphanedCount, 0);
|
|
357
|
+
const statuses = raw.prepare("SELECT feature_key, status FROM feature_runs ORDER BY feature_key").all() as {
|
|
358
|
+
feature_key: string;
|
|
359
|
+
status: string;
|
|
360
|
+
}[];
|
|
361
|
+
assertEquals(statuses.find((r) => r.feature_key === "term#1")?.status, "merged");
|
|
362
|
+
assertEquals(statuses.find((r) => r.feature_key === "nokeed#1")?.status, "running");
|
|
363
|
+
assertEquals(statuses.find((r) => r.feature_key === "live#1")?.status, "escalated");
|
|
364
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 0);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("no-op when the _urban_instance_state projection is absent (never orphan on its absence)", async () => {
|
|
368
|
+
const { data, raw } = freshData();
|
|
369
|
+
// NOTE: no ensureInstanceState — the framework projection has not been provisioned.
|
|
370
|
+
seedFeatureRun(raw, "o/r#1", "escalated", "71506");
|
|
371
|
+
|
|
372
|
+
const res = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
|
|
373
|
+
|
|
374
|
+
assertEquals(res.reason, "no-op");
|
|
375
|
+
assertEquals(res.orphanedCount, 0);
|
|
376
|
+
const row = raw.prepare("SELECT status FROM feature_runs WHERE feature_key='o/r#1'").get() as { status: string };
|
|
377
|
+
assertEquals(row.status, "escalated");
|
|
378
|
+
const run = raw.prepare("SELECT reason FROM reconcile_runs WHERE run_id='van-1'").get() as { reason: string };
|
|
379
|
+
assertEquals(run.reason, "no-op");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("idempotent: a second vanished pass is a no-op (the orphaned row left activeStatuses)", async () => {
|
|
383
|
+
const { data, raw } = freshData();
|
|
384
|
+
ensureInstanceState(raw);
|
|
385
|
+
seedFeatureRun(raw, "o/r#1", "escalated", "71506");
|
|
386
|
+
|
|
387
|
+
const first = await reconcileVanishedInstances(data, { now: AT, runId: "van-1" });
|
|
388
|
+
assertEquals(first.orphanedCount, 1);
|
|
389
|
+
|
|
390
|
+
const second = await reconcileVanishedInstances(data, { now: AT, runId: "van-2" });
|
|
391
|
+
assertEquals(second.reason, "no-op");
|
|
392
|
+
assertEquals(second.orphanedCount, 0);
|
|
393
|
+
assertEquals((raw.prepare("SELECT COUNT(*) c FROM reconcile_provenance").get() as { c: number }).c, 1);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// --- Merged seam: runEngineReconcile (both passes, one result) --------------------------------
|
|
397
|
+
// The operator/startup seam merges the epoch-regression and vanished-instance passes into ONE
|
|
398
|
+
// result. This guards the merged behavior the two per-pass suites above don't reach: run-id
|
|
399
|
+
// correlation (the vanished pass's provenance must be locatable from the returned `runId`) and
|
|
400
|
+
// `reason` selection when the epoch pass is `engine-unreachable` yet vanished instances are orphaned.
|
|
401
|
+
|
|
402
|
+
test("runEngineReconcile: engine-unreachable epoch pass still folds vanished instances, with a correlatable run id", async () => {
|
|
403
|
+
const { data, raw } = freshData();
|
|
404
|
+
ensureInstanceState(raw);
|
|
405
|
+
// A vanished orphan (escalated, past grace, instance absent from the read model).
|
|
406
|
+
seedFeatureRun(raw, "Magikcraft/nano-bpm#1051", "escalated", "71506");
|
|
407
|
+
|
|
408
|
+
// The engine is unreachable — the epoch probe fails, so the epoch pass reports `engine-unreachable`
|
|
409
|
+
// and orphans nothing; the vanished pass must still act.
|
|
410
|
+
const fetchImpl = (() => Promise.reject(new Error("ECONNREFUSED"))) as unknown as typeof fetch;
|
|
411
|
+
const res = await runEngineReconcile(
|
|
412
|
+
data,
|
|
413
|
+
{ restAddress: "http://engine.invalid" },
|
|
414
|
+
{ now: AT, fetchImpl },
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
// The vanished pass acted even though the epoch pass could not reach the engine.
|
|
418
|
+
assertEquals(res.reason, "instance-vanished");
|
|
419
|
+
assertEquals(res.orphanedCount, 1);
|
|
420
|
+
const orphan = raw
|
|
421
|
+
.prepare("SELECT status FROM feature_runs WHERE feature_key='Magikcraft/nano-bpm#1051'")
|
|
422
|
+
.get() as { status: string };
|
|
423
|
+
assertEquals(orphan.status, ORPHANED_STATUS);
|
|
424
|
+
|
|
425
|
+
// The vanished pass's provenance is stamped with the DERIVED, correlatable id `<runId>-vanished`
|
|
426
|
+
// (the boot path omits opts.runId, so a bare random UUID would be non-locatable from the result).
|
|
427
|
+
const prov = raw
|
|
428
|
+
.prepare("SELECT run_id FROM reconcile_provenance WHERE source_table='feature_runs'")
|
|
429
|
+
.get() as { run_id: string };
|
|
430
|
+
assertEquals(prov.run_id, `${res.runId}-vanished`);
|
|
431
|
+
|
|
432
|
+
// Both passes recorded their own reconcile_runs row under correlatable ids.
|
|
433
|
+
const epochRun = raw.prepare("SELECT reason FROM reconcile_runs WHERE run_id=?").get(res.runId) as
|
|
434
|
+
| { reason: string }
|
|
435
|
+
| undefined;
|
|
436
|
+
assertEquals(epochRun?.reason, "engine-unreachable");
|
|
437
|
+
const vanishedRun = raw
|
|
438
|
+
.prepare("SELECT reason, orphaned_count FROM reconcile_runs WHERE run_id=?")
|
|
439
|
+
.get(`${res.runId}-vanished`) as { reason: string; orphaned_count: number } | undefined;
|
|
440
|
+
assertEquals(vanishedRun?.reason, "instance-vanished");
|
|
441
|
+
assertEquals(vanishedRun?.orphaned_count, 1);
|
|
442
|
+
});
|
package/app/reconcile.ts
CHANGED
|
@@ -29,12 +29,24 @@
|
|
|
29
29
|
// engine is a no-op too: reconcile NEVER orphans when it could not confirm a reset (a 401/5xx or
|
|
30
30
|
// a network error yields `reachable:false`, not a false "engine missing").
|
|
31
31
|
//
|
|
32
|
+
// A second, complementary pass covers the "instance absent/unknown" gap (issue #630) the epoch signal
|
|
33
|
+
// alone cannot: an inflight run whose engine instance has VANISHED from the read model — its
|
|
34
|
+
// `keyField` (process_key) has NO `_urban_instance_state` row at all (engine clean-reset, cluster
|
|
35
|
+
// rebuild, or read-model pruning removed it), so the derived tracking edge has no `TERMINATED` row to
|
|
36
|
+
// match and the run freezes at its last worker-owned status, wedging Active forever with no
|
|
37
|
+
// reconciliation path (the observed pre-reset orphan `Magikcraft/nano-bpm#1051`, process_key 71506).
|
|
38
|
+
// `reconcileVanishedInstances` drives every such row — active, dispatched, its key absent from
|
|
39
|
+
// `_urban_instance_state`, and PAST A GRACE WINDOW (so a still-starting run not yet projected is
|
|
40
|
+
// spared) — to the same `orphaned` terminal, with a DISTINCT provenance reason so an operator can
|
|
41
|
+
// tell a vanished-instance orphan apart from an epoch-regression one. `runEngineReconcile` runs BOTH
|
|
42
|
+
// passes, so startup and the operator command converge both failure modes in one call.
|
|
43
|
+
//
|
|
32
44
|
// The provenance is app-owned (not urban's `_urban_write_provenance`, which is a domain-free
|
|
33
45
|
// insert-join sidecar written only inside a job): reconcile runs at boot / over HTTP, outside any
|
|
34
46
|
// job, and needs to record the REASON + epoch + run id — which the app-owned `reconcile_provenance`
|
|
35
47
|
// table carries, and the existing `app.db` backup convention makes the whole mutation reversible.
|
|
36
48
|
|
|
37
|
-
import type { DataLayer, GatewayDataSource as DataSource } from "@nanobpm/urban";
|
|
49
|
+
import type { DataLayer, GatewayDataSource as DataSource, InstanceTracking } from "@nanobpm/urban";
|
|
38
50
|
import type { TopologyProbe } from "./enginePreflight.ts";
|
|
39
51
|
import { activeStatusesFor, baseStatusFieldFor, engineBackedBindings, keyFieldFor } from "./instanceTracking.ts";
|
|
40
52
|
|
|
@@ -49,6 +61,28 @@ export const ORPHANED_STATUS = "orphaned";
|
|
|
49
61
|
* recorded incarnation epoch regressed (the #1065 signature). */
|
|
50
62
|
export const RECONCILE_ORPHAN_REASON = "engine-reset/epoch-regression";
|
|
51
63
|
|
|
64
|
+
/** The provenance reason stamped when a row is orphaned because its engine instance VANISHED from the
|
|
65
|
+
* read model — the run's `keyField` (process_key) has NO `_urban_instance_state` row at all, so the
|
|
66
|
+
* instance is absent/unknown in engine truth (engine clean-reset, cluster rebuild, or read-model
|
|
67
|
+
* pruning removed the instance-state row entirely — issue #630). Deliberately DISTINCT from
|
|
68
|
+
* {@link RECONCILE_ORPHAN_REASON} so an operator can tell an epoch-regression orphan apart from a
|
|
69
|
+
* vanished-instance orphan, even though both land on the same `orphaned` terminal. */
|
|
70
|
+
export const RECONCILE_VANISHED_REASON = "engine-instance/vanished";
|
|
71
|
+
|
|
72
|
+
/** The default grace window (ms) a dispatched-but-instance-less row is spared before it is considered
|
|
73
|
+
* vanished. A run dispatched moments ago (its `process_key` set) has not yet been polled into
|
|
74
|
+
* `_urban_instance_state` by the instanceTracking reconciler (`pollMs` 5s + engine search latency),
|
|
75
|
+
* so it transiently looks "vanished". This window (comfortably larger than a poll cycle) keeps a
|
|
76
|
+
* legitimately-still-starting run from being folded to terminal prematurely (issue #630 AC #2). */
|
|
77
|
+
export const DEFAULT_VANISHED_GRACE_MS = 5 * 60_000;
|
|
78
|
+
|
|
79
|
+
/** The framework's canonical per-instance engine-lifecycle projection table (urban's
|
|
80
|
+
* `_urban_instance_state`, keyed by `process_instance_key`). The vanished-instance reconcile joins
|
|
81
|
+
* each engine-backed row's `keyField` against it: a run whose key has NO row here has no backing
|
|
82
|
+
* instance in engine truth. `_urban_` prefixed (framework bookkeeping) so it is provisioned by the
|
|
83
|
+
* runtime, not our migrations — the reconcile guards on its existence before acting. */
|
|
84
|
+
const INSTANCE_STATE_TABLE = "_urban_instance_state";
|
|
85
|
+
|
|
52
86
|
/** The single-row epoch ledger + its append-only run/provenance sidecars (migration 092). */
|
|
53
87
|
const INCARNATION_TABLE = "engine_incarnation";
|
|
54
88
|
const RUNS_TABLE = "reconcile_runs";
|
|
@@ -71,7 +105,12 @@ export interface EngineEpochObservation {
|
|
|
71
105
|
}
|
|
72
106
|
|
|
73
107
|
/** Why a reconcile pass acted (or did not). */
|
|
74
|
-
export type ReconcileReason =
|
|
108
|
+
export type ReconcileReason =
|
|
109
|
+
| "epoch-regression"
|
|
110
|
+
| "seed-epoch"
|
|
111
|
+
| "no-op"
|
|
112
|
+
| "engine-unreachable"
|
|
113
|
+
| "instance-vanished";
|
|
75
114
|
|
|
76
115
|
/** One orphaned engine-backed row. */
|
|
77
116
|
export interface OrphanedRow {
|
|
@@ -107,6 +146,14 @@ export interface ReconcileOptions {
|
|
|
107
146
|
log?: ReconcileLog;
|
|
108
147
|
}
|
|
109
148
|
|
|
149
|
+
/** Options for the vanished-instance reconcile pass ({@link reconcileVanishedInstances}). */
|
|
150
|
+
export interface VanishedReconcileOptions extends ReconcileOptions {
|
|
151
|
+
/** How long (ms) a dispatched-but-instance-less row is spared before it is folded to terminal, so a
|
|
152
|
+
* still-starting run (not yet projected into `_urban_instance_state`) is not orphaned prematurely.
|
|
153
|
+
* Defaults to {@link DEFAULT_VANISHED_GRACE_MS}. */
|
|
154
|
+
graceMs?: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
110
157
|
/** Read the incarnation epoch out of a `/v2/topology` body — `nano.incarnation` (or its `epoch`
|
|
111
158
|
* alias), coerced from a number or a numeric string. Any other shape (absent, non-numeric) yields
|
|
112
159
|
* null: "the engine exposes no epoch". A null is a no-op ONLY when no epoch was previously recorded;
|
|
@@ -175,6 +222,78 @@ async function persistEpoch(src: DataSource, epoch: number, at: string): Promise
|
|
|
175
222
|
);
|
|
176
223
|
}
|
|
177
224
|
|
|
225
|
+
/** The resolved schema + tracking selectors for one engine-backed binding, or `null` when the binding
|
|
226
|
+
* carries no `activeStatuses` selector (it cannot classify "in-flight", so it is skipped). */
|
|
227
|
+
interface BindingShape {
|
|
228
|
+
table: string;
|
|
229
|
+
active: readonly string[];
|
|
230
|
+
statusField: string;
|
|
231
|
+
keyField: string;
|
|
232
|
+
pkCol: string;
|
|
233
|
+
hasUpdatedAt: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The shape of a row selected for possible orphaning. */
|
|
237
|
+
interface OrphanCandidate {
|
|
238
|
+
__pk: unknown;
|
|
239
|
+
__key: unknown;
|
|
240
|
+
__status: unknown;
|
|
241
|
+
__updated?: unknown;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Resolve a binding's tracking selectors + physical schema, or `null` to skip a selector-less
|
|
245
|
+
* binding (activeStatusesFor would throw; we tolerate it here). */
|
|
246
|
+
async function resolveShape(src: DataSource, binding: InstanceTracking): Promise<BindingShape | null> {
|
|
247
|
+
if (!binding.activeStatuses?.length) return null;
|
|
248
|
+
const table = binding.table;
|
|
249
|
+
const { pkCol, hasUpdatedAt } = await tableShape(src, table);
|
|
250
|
+
return {
|
|
251
|
+
table,
|
|
252
|
+
active: activeStatusesFor(table),
|
|
253
|
+
statusField: baseStatusFieldFor(table),
|
|
254
|
+
keyField: keyFieldFor(table),
|
|
255
|
+
pkCol,
|
|
256
|
+
hasUpdatedAt,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Drive ONE candidate row to `orphaned` with provenance, GUARDED: the UPDATE re-asserts the exact
|
|
261
|
+
* status read AND a populated key (plus any `extraGuardSql`, e.g. the still-vanished re-check), so a
|
|
262
|
+
* writer that flipped the row to a newer terminal status (or an instance that reappeared) between the
|
|
263
|
+
* SELECT and this UPDATE wins the race — we never clobber that history back to `orphaned`. Only a row
|
|
264
|
+
* we actually transitioned (`res.changed > 0`) gets provenance and is returned. `updated_at` is
|
|
265
|
+
* stamped (when the table has one) so the transition refreshes the row's timestamp like every other
|
|
266
|
+
* status transition in the codebase. Runs inside the caller's transaction. */
|
|
267
|
+
async function orphanRow(
|
|
268
|
+
src: DataSource,
|
|
269
|
+
shape: BindingShape,
|
|
270
|
+
row: OrphanCandidate,
|
|
271
|
+
reason: string,
|
|
272
|
+
observedEpoch: number | null,
|
|
273
|
+
runId: string,
|
|
274
|
+
at: string,
|
|
275
|
+
extraGuardSql = "",
|
|
276
|
+
): Promise<OrphanedRow | null> {
|
|
277
|
+
const { table, pkCol, statusField, keyField, hasUpdatedAt } = shape;
|
|
278
|
+
const pk = String(row.__pk);
|
|
279
|
+
const key = row.__key == null ? null : String(row.__key);
|
|
280
|
+
const fromStatus = String(row.__status);
|
|
281
|
+
const res = await src.exec(
|
|
282
|
+
`UPDATE ${q(table)} SET ${q(statusField)} = ?` +
|
|
283
|
+
(hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} = ?` : "") +
|
|
284
|
+
` WHERE ${q(pkCol)} = ? AND ${q(statusField)} = ? AND ${q(keyField)} IS NOT NULL${extraGuardSql}`,
|
|
285
|
+
hasUpdatedAt ? [ORPHANED_STATUS, at, row.__pk, fromStatus] : [ORPHANED_STATUS, row.__pk, fromStatus],
|
|
286
|
+
);
|
|
287
|
+
if (res.changed <= 0) return null;
|
|
288
|
+
await src.exec(
|
|
289
|
+
`INSERT INTO ${PROVENANCE_TABLE} ` +
|
|
290
|
+
`(run_id, source_table, pk_value, key_value, from_status, to_status, reason, observed_epoch, at) ` +
|
|
291
|
+
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
292
|
+
[runId, table, pk, key, fromStatus, ORPHANED_STATUS, reason, observedEpoch, at],
|
|
293
|
+
);
|
|
294
|
+
return { table, pk, key, fromStatus };
|
|
295
|
+
}
|
|
296
|
+
|
|
178
297
|
/** Orphan every NON-terminal, engine-backed row across all instanceTracking bindings, recording one
|
|
179
298
|
* `reconcile_provenance` row per transition. Runs inside the caller's transaction. */
|
|
180
299
|
async function orphanEngineBackedRows(
|
|
@@ -185,52 +304,87 @@ async function orphanEngineBackedRows(
|
|
|
185
304
|
): Promise<OrphanedRow[]> {
|
|
186
305
|
const orphaned: OrphanedRow[] = [];
|
|
187
306
|
for (const binding of engineBackedBindings()) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const { pkCol, hasUpdatedAt } = await tableShape(src, table);
|
|
196
|
-
const placeholders = active.map(() => "?").join(", ");
|
|
197
|
-
const rows = await src.query<{ __pk: unknown; __key: unknown; __status: unknown }>(
|
|
198
|
-
`SELECT ${q(pkCol)} AS __pk, ${q(keyField)} AS __key, ${q(statusField)} AS __status ` +
|
|
199
|
-
`FROM ${q(table)} WHERE ${q(statusField)} IN (${placeholders}) AND ${q(keyField)} IS NOT NULL`,
|
|
200
|
-
[...active],
|
|
307
|
+
const shape = await resolveShape(src, binding);
|
|
308
|
+
if (!shape) continue;
|
|
309
|
+
const placeholders = shape.active.map(() => "?").join(", ");
|
|
310
|
+
const rows = await src.query<OrphanCandidate>(
|
|
311
|
+
`SELECT ${q(shape.pkCol)} AS __pk, ${q(shape.keyField)} AS __key, ${q(shape.statusField)} AS __status ` +
|
|
312
|
+
`FROM ${q(shape.table)} WHERE ${q(shape.statusField)} IN (${placeholders}) AND ${q(shape.keyField)} IS NOT NULL`,
|
|
313
|
+
[...shape.active],
|
|
201
314
|
);
|
|
202
315
|
for (const row of rows) {
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
const fromStatus = String(row.__status);
|
|
206
|
-
// GUARDED update: re-assert the exact status we read AND a populated key, so a writer that
|
|
207
|
-
// flipped the row to a newer terminal status (or cleared its key) between the SELECT above and
|
|
208
|
-
// this UPDATE wins the race — we never clobber that terminal history back to `orphaned`. Only a
|
|
209
|
-
// row we actually transitioned (`res.changed > 0`) gets provenance and is counted. We also stamp
|
|
210
|
-
// `updated_at` (when the table has one) so the transition to `orphaned` refreshes the row's
|
|
211
|
-
// timestamp the same way every other status transition in the codebase does — leaving it stale
|
|
212
|
-
// would misrepresent the orphaning moment to the UI/audits.
|
|
213
|
-
const res = await src.exec(
|
|
214
|
-
`UPDATE ${q(table)} SET ${q(statusField)} = ?` +
|
|
215
|
-
(hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} = ?` : "") +
|
|
216
|
-
` WHERE ${q(pkCol)} = ? AND ${q(statusField)} = ? AND ${q(keyField)} IS NOT NULL`,
|
|
217
|
-
hasUpdatedAt
|
|
218
|
-
? [ORPHANED_STATUS, at, row.__pk, fromStatus]
|
|
219
|
-
: [ORPHANED_STATUS, row.__pk, fromStatus],
|
|
220
|
-
);
|
|
221
|
-
if (res.changed <= 0) continue;
|
|
222
|
-
await src.exec(
|
|
223
|
-
`INSERT INTO ${PROVENANCE_TABLE} ` +
|
|
224
|
-
`(run_id, source_table, pk_value, key_value, from_status, to_status, reason, observed_epoch, at) ` +
|
|
225
|
-
`VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
226
|
-
[runId, table, pk, key, fromStatus, ORPHANED_STATUS, RECONCILE_ORPHAN_REASON, observedEpoch, at],
|
|
227
|
-
);
|
|
228
|
-
orphaned.push({ table, pk, key, fromStatus });
|
|
316
|
+
const o = await orphanRow(src, shape, row, RECONCILE_ORPHAN_REASON, observedEpoch, runId, at);
|
|
317
|
+
if (o) orphaned.push(o);
|
|
229
318
|
}
|
|
230
319
|
}
|
|
231
320
|
return orphaned;
|
|
232
321
|
}
|
|
233
322
|
|
|
323
|
+
/** Whether a row's `updated_at` is younger than the grace window — i.e. it was (re)dispatched too
|
|
324
|
+
* recently to have been projected into `_urban_instance_state` yet, so it must NOT be folded. A
|
|
325
|
+
* null/unparseable timestamp is treated as "within grace" (spared): when we cannot establish a row's
|
|
326
|
+
* age we must NOT orphan it — a nullable `updated_at` (e.g. `delivery_units.updated_at`,
|
|
327
|
+
* db/migrations/088_delivery_units.sql) would otherwise fold a live row. Erring toward sparing at
|
|
328
|
+
* worst leaves a genuinely-vanished ageless row for a later pass once it carries a usable timestamp;
|
|
329
|
+
* erring the other way wedges/destroys a live run, so we choose the safe default. */
|
|
330
|
+
function withinGrace(updated: unknown, nowMs: number, graceMs: number): boolean {
|
|
331
|
+
if (updated == null) return true;
|
|
332
|
+
const t = Date.parse(String(updated));
|
|
333
|
+
if (!Number.isFinite(t)) return true;
|
|
334
|
+
return nowMs - t < graceMs;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Orphan every NON-terminal, engine-backed row whose `keyField` (process instance key) has NO
|
|
338
|
+
* `_urban_instance_state` row — the instance is absent/unknown in engine truth (vanished, issue
|
|
339
|
+
* #630) — and whose last transition is older than the grace window. Records one
|
|
340
|
+
* `reconcile_provenance` row per transition (reason {@link RECONCILE_VANISHED_REASON}). Runs inside
|
|
341
|
+
* the caller's transaction. */
|
|
342
|
+
async function orphanVanishedRows(
|
|
343
|
+
src: DataSource,
|
|
344
|
+
runId: string,
|
|
345
|
+
at: string,
|
|
346
|
+
nowMs: number,
|
|
347
|
+
graceMs: number,
|
|
348
|
+
): Promise<OrphanedRow[]> {
|
|
349
|
+
const orphaned: OrphanedRow[] = [];
|
|
350
|
+
for (const binding of engineBackedBindings()) {
|
|
351
|
+
const shape = await resolveShape(src, binding);
|
|
352
|
+
if (!shape) continue;
|
|
353
|
+
const placeholders = shape.active.map(() => "?").join(", ");
|
|
354
|
+
const updatedSel = shape.hasUpdatedAt ? `, ${q(UPDATED_AT_COLUMN)} AS __updated` : "";
|
|
355
|
+
// Active, dispatched (key populated) rows whose engine instance key has NO matching
|
|
356
|
+
// `_urban_instance_state` row — absent/unknown in engine truth.
|
|
357
|
+
const rows = await src.query<OrphanCandidate>(
|
|
358
|
+
`SELECT ${q(shape.pkCol)} AS __pk, ${q(shape.keyField)} AS __key, ${q(shape.statusField)} AS __status${updatedSel} ` +
|
|
359
|
+
`FROM ${q(shape.table)} b WHERE ${q(shape.statusField)} IN (${placeholders}) AND ${q(shape.keyField)} IS NOT NULL ` +
|
|
360
|
+
`AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s WHERE s.process_instance_key = b.${q(shape.keyField)})`,
|
|
361
|
+
[...shape.active],
|
|
362
|
+
);
|
|
363
|
+
// Re-assert "still no instance-state row" in the guarded UPDATE too, so an instance that reappears
|
|
364
|
+
// (the poller records it) between the SELECT above and the UPDATE wins the race.
|
|
365
|
+
const stillVanishedGuard =
|
|
366
|
+
` AND NOT EXISTS (SELECT 1 FROM ${q(INSTANCE_STATE_TABLE)} s ` +
|
|
367
|
+
`WHERE s.process_instance_key = ${q(shape.table)}.${q(shape.keyField)})`;
|
|
368
|
+
for (const row of rows) {
|
|
369
|
+
if (shape.hasUpdatedAt && withinGrace(row.__updated, nowMs, graceMs)) continue;
|
|
370
|
+
const o = await orphanRow(src, shape, row, RECONCILE_VANISHED_REASON, null, runId, at, stillVanishedGuard);
|
|
371
|
+
if (o) orphaned.push(o);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return orphaned;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Whether the framework `_urban_instance_state` projection exists in this source. When it does NOT,
|
|
378
|
+
* the vanished-instance pass is a hard no-op: without the projection every dispatched row would look
|
|
379
|
+
* "vanished", so we must never orphan on its absence. */
|
|
380
|
+
async function instanceStateTableExists(src: DataSource): Promise<boolean> {
|
|
381
|
+
const rows = await src.query<{ n: number }>(
|
|
382
|
+
`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
383
|
+
[INSTANCE_STATE_TABLE],
|
|
384
|
+
);
|
|
385
|
+
return rows.length > 0 && Number(rows[0].n) > 0;
|
|
386
|
+
}
|
|
387
|
+
|
|
234
388
|
/**
|
|
235
389
|
* Reconcile the app's engine-backed projections against one epoch observation. Pure of I/O beyond the
|
|
236
390
|
* data layer (the topology probe is {@link probeEngineEpoch}, injected as `observation`), so the
|
|
@@ -300,6 +454,75 @@ export async function reconcileEngineBackedWork(
|
|
|
300
454
|
return { runId, reason: result.reason, observedEpoch, recordedEpoch, orphanedCount: result.orphaned.length, orphaned: result.orphaned };
|
|
301
455
|
}
|
|
302
456
|
|
|
457
|
+
/**
|
|
458
|
+
* Reconcile engine-backed inflight work against the framework's canonical per-instance projection
|
|
459
|
+
* (`_urban_instance_state`) — the "instance absent/unknown" gap (issue #630), DISTINCT from the
|
|
460
|
+
* epoch-regression reset the {@link reconcileEngineBackedWork} pass handles.
|
|
461
|
+
*
|
|
462
|
+
* When an engine instance VANISHES from the read model — engine clean-reset, cluster rebuild, or
|
|
463
|
+
* read-model pruning removes the `_urban_instance_state` row entirely — there is no `TERMINATED` row
|
|
464
|
+
* for the derived tracking edge to match, so the run freezes at its last worker-owned status
|
|
465
|
+
* (`escalated`/`awaiting_operator`) and wedges the Active list forever with no reconciliation path.
|
|
466
|
+
* "The instance backing this run no longer exists in engine truth" is a terminal condition: this pass
|
|
467
|
+
* drives every such row (active, dispatched, its `keyField` absent from `_urban_instance_state`, and
|
|
468
|
+
* past the grace window) to the defined `orphaned` terminal WITH PROVENANCE.
|
|
469
|
+
*
|
|
470
|
+
* Safety:
|
|
471
|
+
* • GRACE WINDOW — a just-dispatched run has not yet been polled into `_urban_instance_state`; only
|
|
472
|
+
* rows whose last transition is older than `graceMs` are folded, so a still-starting run is never
|
|
473
|
+
* prematurely orphaned (AC #2).
|
|
474
|
+
* • PROJECTION-PRESENT — if `_urban_instance_state` does not exist (the runtime has not provisioned
|
|
475
|
+
* it), every dispatched row would look vanished, so the pass is a hard no-op.
|
|
476
|
+
* • GUARDED — the same status-re-assert as the epoch pass, plus a still-vanished re-check, so a
|
|
477
|
+
* concurrent terminal write or a reappearing instance wins the race.
|
|
478
|
+
* • This pass reads the app's OWN last-known projection, not a live probe, so it acts correctly on a
|
|
479
|
+
* genuinely-vanished instance regardless of transient engine reachability (a live instance keeps
|
|
480
|
+
* its persisted ACTIVE row across a restart, so it is never mistaken for vanished).
|
|
481
|
+
*/
|
|
482
|
+
export async function reconcileVanishedInstances(
|
|
483
|
+
data: DataLayer,
|
|
484
|
+
opts: VanishedReconcileOptions = {},
|
|
485
|
+
): Promise<ReconcileResult> {
|
|
486
|
+
const src = data.open(opts.sourceName);
|
|
487
|
+
const clock = opts.now?.() ?? new Date();
|
|
488
|
+
const at = clock.toISOString();
|
|
489
|
+
const nowMs = clock.getTime();
|
|
490
|
+
const runId = opts.runId ?? crypto.randomUUID();
|
|
491
|
+
const graceMs = opts.graceMs ?? DEFAULT_VANISHED_GRACE_MS;
|
|
492
|
+
|
|
493
|
+
// Without the framework projection we cannot tell a vanished instance from a live one — every
|
|
494
|
+
// dispatched row would look vanished. NO-OP rather than orphan live work.
|
|
495
|
+
if (!(await instanceStateTableExists(src))) {
|
|
496
|
+
await recordRun(src, { runId, at, observedEpoch: null, recordedEpoch: null, reason: "no-op", orphanedCount: 0 });
|
|
497
|
+
opts.log?.info(`reconcile(vanished): instance-state projection absent — no-op [run ${runId}].`);
|
|
498
|
+
return { runId, reason: "no-op", observedEpoch: null, recordedEpoch: null, orphanedCount: 0, orphaned: [] };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const orphaned = await src.tx(async (t) => {
|
|
502
|
+
const rows = await orphanVanishedRows(t, runId, at, nowMs, graceMs);
|
|
503
|
+
const reason: ReconcileReason = rows.length > 0 ? "instance-vanished" : "no-op";
|
|
504
|
+
await recordRun(t, { runId, at, observedEpoch: null, recordedEpoch: null, reason, orphanedCount: rows.length });
|
|
505
|
+
return rows;
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
if (orphaned.length > 0) {
|
|
509
|
+
opts.log?.warn(
|
|
510
|
+
`reconcile(vanished): orphaned ${orphaned.length} inflight row(s) whose engine instance ` +
|
|
511
|
+
`vanished from the read model [run ${runId}].`,
|
|
512
|
+
);
|
|
513
|
+
} else {
|
|
514
|
+
opts.log?.info(`reconcile(vanished): no vanished instances — no-op [run ${runId}].`);
|
|
515
|
+
}
|
|
516
|
+
return {
|
|
517
|
+
runId,
|
|
518
|
+
reason: orphaned.length > 0 ? "instance-vanished" : "no-op",
|
|
519
|
+
observedEpoch: null,
|
|
520
|
+
recordedEpoch: null,
|
|
521
|
+
orphanedCount: orphaned.length,
|
|
522
|
+
orphaned,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
303
526
|
async function recordRun(
|
|
304
527
|
src: DataSource,
|
|
305
528
|
run: { runId: string; at: string; observedEpoch: number | null; recordedEpoch: number | null; reason: ReconcileReason; orphanedCount: number },
|
|
@@ -312,15 +535,47 @@ async function recordRun(
|
|
|
312
535
|
}
|
|
313
536
|
|
|
314
537
|
/** Probe the engine's incarnation epoch, then reconcile — the wiring both startup (main.ts) and the
|
|
315
|
-
* `reconcileEngineState` operator command share, so the two paths can never diverge.
|
|
538
|
+
* `reconcileEngineState` operator command share, so the two paths can never diverge. Runs BOTH the
|
|
539
|
+
* epoch-regression pass (engine reset/rewind) and the vanished-instance pass (an inflight run whose
|
|
540
|
+
* instance is absent/unknown in `_urban_instance_state` — issue #630), returning ONE merged result.
|
|
541
|
+
* Reason precedence: `epoch-regression` always wins; otherwise, if the vanished pass orphaned any
|
|
542
|
+
* rows the reason is `instance-vanished` (even when the epoch pass reported a non-regression state
|
|
543
|
+
* such as `engine-unreachable`); otherwise the epoch pass's reason stands. `orphanedCount` /
|
|
544
|
+
* `orphaned` cover both passes. The two passes never double-fold a row: once the epoch pass orphans a
|
|
545
|
+
* row it leaves `activeStatuses`, so the vanished pass no longer selects it. */
|
|
316
546
|
export async function runEngineReconcile(
|
|
317
547
|
data: DataLayer,
|
|
318
548
|
engineRest: { restAddress: string; token?: string },
|
|
319
|
-
opts:
|
|
549
|
+
opts: VanishedReconcileOptions & { fetchImpl?: typeof fetch } = {},
|
|
320
550
|
): Promise<ReconcileResult> {
|
|
321
551
|
const observation = await probeEngineEpoch(engineRest.restAddress, {
|
|
322
552
|
token: engineRest.token,
|
|
323
553
|
fetchImpl: opts.fetchImpl,
|
|
324
554
|
});
|
|
325
|
-
|
|
555
|
+
const epoch = await reconcileEngineBackedWork(data, observation, opts);
|
|
556
|
+
// A distinct run id so the vanished pass's `reconcile_runs`/provenance rows never collide with the
|
|
557
|
+
// epoch pass's (run_id is a PRIMARY KEY). DERIVE it from the epoch pass's resolved run id — which is
|
|
558
|
+
// also the merged result's `runId` — so it is `<runId>-vanished` on EVERY path, including the boot
|
|
559
|
+
// path where `opts.runId` is omitted (a bare random UUID here would be non-correlatable to the
|
|
560
|
+
// returned `runId`). Operators can always locate the vanished pass's provenance from the reported id.
|
|
561
|
+
const vanished = await reconcileVanishedInstances(data, {
|
|
562
|
+
...opts,
|
|
563
|
+
runId: `${epoch.runId}-vanished`,
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
const orphaned = [...epoch.orphaned, ...vanished.orphaned];
|
|
567
|
+
const reason: ReconcileReason =
|
|
568
|
+
epoch.reason === "epoch-regression"
|
|
569
|
+
? "epoch-regression"
|
|
570
|
+
: vanished.orphanedCount > 0
|
|
571
|
+
? "instance-vanished"
|
|
572
|
+
: epoch.reason;
|
|
573
|
+
return {
|
|
574
|
+
runId: epoch.runId,
|
|
575
|
+
reason,
|
|
576
|
+
observedEpoch: epoch.observedEpoch,
|
|
577
|
+
recordedEpoch: epoch.recordedEpoch,
|
|
578
|
+
orphanedCount: orphaned.length,
|
|
579
|
+
orphaned,
|
|
580
|
+
};
|
|
326
581
|
}
|
package/main.ts
CHANGED
|
@@ -103,12 +103,14 @@ if (httpServer instanceof Server) {
|
|
|
103
103
|
app.log.warn("agentic channel not mounted: app.httpServer is not a node:http Server on this host");
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
// Engine-reset reconciliation (
|
|
107
|
-
// the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
|
|
108
|
-
// Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
|
|
109
|
-
// terminal WITH PROVENANCE
|
|
110
|
-
//
|
|
111
|
-
//
|
|
106
|
+
// Engine-reset reconciliation (issues #622, #630). On boot, compare the engine's incarnation epoch
|
|
107
|
+
// against the last-seen value; on a REGRESSION (the engine was reset/restored/rewound and re-minted
|
|
108
|
+
// its keys, Magikcraft/nano-bpm#1065) drive every dangling engine-backed inflight row to the defined
|
|
109
|
+
// `orphaned` terminal WITH PROVENANCE. A second pass also folds any run whose engine instance has
|
|
110
|
+
// VANISHED from the read model (no `_urban_instance_state` row, past a grace window — issue #630),
|
|
111
|
+
// which the epoch signal alone can't catch — BEFORE the pollers below start projecting off stale,
|
|
112
|
+
// dead instances. Guarded: an unreachable engine / an absent projection is a no-op (never orphans
|
|
113
|
+
// live work), and any failure degrades to a warn so reconcile can never block boot.
|
|
112
114
|
if (app.data) {
|
|
113
115
|
try {
|
|
114
116
|
const reconciled = await runEngineReconcile(
|
package/openapi.yaml
CHANGED
|
@@ -1042,7 +1042,10 @@ components:
|
|
|
1042
1042
|
type: integer
|
|
1043
1043
|
ReconcileReport:
|
|
1044
1044
|
type: object
|
|
1045
|
-
description: The result of
|
|
1045
|
+
description: "The merged result of the engine-reconcile invocation — an aggregate of both the
|
|
1046
|
+
epoch-regression (engine reset/rewind) pass and the vanished-instance pass (issues #622 and
|
|
1047
|
+
#630). `runId` is the epoch pass's run id; the vanished pass records its own provenance under
|
|
1048
|
+
the derived id `<runId>-vanished`."
|
|
1046
1049
|
additionalProperties: false
|
|
1047
1050
|
required:
|
|
1048
1051
|
- runId
|
|
@@ -1054,15 +1057,19 @@ components:
|
|
|
1054
1057
|
properties:
|
|
1055
1058
|
runId:
|
|
1056
1059
|
type: string
|
|
1057
|
-
description: The reconcile run id
|
|
1060
|
+
description: The reconcile run id the epoch pass's orphaned-transition provenance is stamped
|
|
1061
|
+
with. The vanished-instance pass's provenance is stamped with the derived, deterministic id
|
|
1062
|
+
`<runId>-vanished`, so operators can locate the provenance rows for either pass from this id.
|
|
1058
1063
|
reason:
|
|
1059
1064
|
type: string
|
|
1060
|
-
description: Why this pass acted (or did not).
|
|
1065
|
+
description: Why this pass acted (or did not). `instance-vanished` — a run whose engine
|
|
1066
|
+
instance is absent/unknown in the read model was orphaned (issue #630).
|
|
1061
1067
|
enum:
|
|
1062
1068
|
- epoch-regression
|
|
1063
1069
|
- seed-epoch
|
|
1064
1070
|
- no-op
|
|
1065
1071
|
- engine-unreachable
|
|
1072
|
+
- instance-vanished
|
|
1066
1073
|
observedEpoch:
|
|
1067
1074
|
type: integer
|
|
1068
1075
|
nullable: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/nano-workforce",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.175.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",
|