@evoclock/pi-agentic-driver 0.9.0 → 0.9.2
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/.agentic-driver/router.defaults.json +64 -0
- package/LICENSE +11 -8
- package/README.md +94 -29
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +9 -2
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +5 -4
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +105 -0
- package/extensions/task-board.ts +1 -1
- package/lib/adapters/diff-scope.mjs +1 -1
- package/lib/adapters/evidence.mjs +1 -1
- package/lib/adapters/narrative.mjs +1 -1
- package/lib/adapters/review-feedback.mjs +1 -1
- package/lib/adapters/visualization.mjs +1 -1
- package/lib/code-phage-core.mjs +1 -1
- package/lib/python_ast_metrics.py +1 -1
- package/lib/typescript_ast_metrics.mjs +1 -1
- package/package.json +17 -3
- package/scripts/aidr_writing_review.js +1 -1
- package/scripts/enforcement/attended_authority_guard.js +1 -1
- package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +66 -4
- package/scripts/enforcement/herdr_communication_pi.js +55 -19
- package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
- package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
- package/scripts/enforcement/native_tui_context.js +1 -1
- package/scripts/enforcement/pulse_core_pi.js +292 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +1083 -0
- package/scripts/enforcement/router_engine_pi.js +483 -0
- package/scripts/enforcement/router_ranking_pi.js +146 -0
- package/scripts/enforcement/router_replacement_pi.js +98 -0
- package/scripts/enforcement/router_runtime_pi.js +299 -0
- package/scripts/enforcement/router_schemas_pi.js +604 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +597 -48
- package/skills/pulse/SKILL.md +39 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Replacement and lineage (ROUTER_DESIGN_TASK68 §7): the same-seat vs
|
|
5
|
+
// different-seat replacement rules and the dead-route rule.
|
|
6
|
+
//
|
|
7
|
+
// - Same-seat replacement (worker died, same route healthy): reuses the
|
|
8
|
+
// envelope; pinned route.
|
|
9
|
+
// - Different-seat replacement (route dead, quota exhausted): requires a NEW
|
|
10
|
+
// route-decision record AND a new claim/envelope — an envelope
|
|
11
|
+
// authenticated for the old seat is never reused for a new seat. The new
|
|
12
|
+
// record carries parentDecisionId lineage.
|
|
13
|
+
// - Dead-route rule: a seat whose collector reports failure or whose health
|
|
14
|
+
// probe fails can never be used for replacement.
|
|
15
|
+
//
|
|
16
|
+
// Pure decision logic; the caller performs the actual replaceAttempt /
|
|
17
|
+
// claim transitions.
|
|
18
|
+
|
|
19
|
+
import { sha256Hex } from "./router_schemas_pi.js";
|
|
20
|
+
|
|
21
|
+
function isPlainObject(value) {
|
|
22
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// §4 freshness at read time, duplicated here for the health/quota checks the
|
|
26
|
+
// replacement decision needs (single source of truth lives in the schemas
|
|
27
|
+
// module; this re-derivation keeps this module dependency-light).
|
|
28
|
+
function fresh(record, now) {
|
|
29
|
+
if (!isPlainObject(record)) return false;
|
|
30
|
+
if (record.sourceStatus !== "ok") return false;
|
|
31
|
+
const at = typeof now === "string" ? Date.parse(now) : now;
|
|
32
|
+
const expiry = Date.parse(record?.expiresAt);
|
|
33
|
+
return Number.isFinite(at) && Number.isFinite(expiry) && at < expiry;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The dead-route rule (§7.2): a seat is DEAD for replacement when its
|
|
37
|
+
// collector reports failure (sourceStatus not ok, or stale observation) or
|
|
38
|
+
// its health probe fails (no fresh ok health observation for local seats).
|
|
39
|
+
// A dead seat can never be used for replacement — not even the same seat.
|
|
40
|
+
export function isDeadRoute({ seat, healthObservations = {}, quotaObservations = [], now }) {
|
|
41
|
+
if (!isPlainObject(seat)) return { dead: true, reason: "seat record missing" };
|
|
42
|
+
if (seat.enabled !== true) return { dead: true, reason: "seat is disabled in config" };
|
|
43
|
+
if (seat.kind === "local") {
|
|
44
|
+
const health = isPlainObject(healthObservations) ? healthObservations[seat.endpointRef] : undefined;
|
|
45
|
+
if (!fresh(health, now)) {
|
|
46
|
+
return { dead: true, reason: `endpoint "${seat.endpointRef}" has no fresh ok health observation` };
|
|
47
|
+
}
|
|
48
|
+
return { dead: false, reason: null };
|
|
49
|
+
}
|
|
50
|
+
// Subscription seats: collector failure is fatal for replacement (§10:
|
|
51
|
+
// stale/malformed/unknown excludes the route; fail closed).
|
|
52
|
+
const observations = (Array.isArray(quotaObservations) ? quotaObservations : [])
|
|
53
|
+
.filter((obs) => obs?.seatId === seat.seatId);
|
|
54
|
+
if (seat.quotaCollector !== null) {
|
|
55
|
+
const anyOk = observations.some((obs) => obs.sourceStatus === "ok");
|
|
56
|
+
if (!anyOk) {
|
|
57
|
+
return { dead: true, reason: `collector for seat "${seat.seatId}" reports failure (fail closed)` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { dead: false, reason: null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The replacement decision (§7.2). Inputs: the authenticated envelope's
|
|
64
|
+
// route identity, the candidate replacement seat (from a NEW route decision
|
|
65
|
+
// when the seat differs), and current health/quota observations.
|
|
66
|
+
// Returns one of:
|
|
67
|
+
// { action: "reuse-envelope" } — same seat, alive
|
|
68
|
+
// { action: "new-route-decision" } — different seat required
|
|
69
|
+
// { action: "refused", reason } — dead route / invalid
|
|
70
|
+
export function replacementAction({ envelope, replacementSeatId = null, seatsById, healthObservations = {}, quotaObservations = [], now }) {
|
|
71
|
+
const currentSeatId = typeof envelope?.seatId === "string" ? envelope.seatId : null;
|
|
72
|
+
if (currentSeatId === null) {
|
|
73
|
+
return { action: "refused", reason: "the envelope carries no seat identity (fails closed)" };
|
|
74
|
+
}
|
|
75
|
+
const targetSeatId = replacementSeatId ?? currentSeatId;
|
|
76
|
+
const seat = seatsById?.get?.(targetSeatId) ?? null;
|
|
77
|
+
|
|
78
|
+
// Dead-route rule first: the target seat (even the same seat) must be alive.
|
|
79
|
+
const dead = isDeadRoute({ seat, healthObservations, quotaObservations, now });
|
|
80
|
+
if (dead.dead) {
|
|
81
|
+
return { action: "refused", reason: `dead route: ${dead.reason} (a dead route can never be used for replacement)` };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (targetSeatId === currentSeatId) {
|
|
85
|
+
// Same-seat replacement: the envelope is reused; the route is pinned.
|
|
86
|
+
return { action: "reuse-envelope", seatId: currentSeatId };
|
|
87
|
+
}
|
|
88
|
+
// Different-seat replacement: a NEW route-decision record AND a new
|
|
89
|
+
// claim/envelope are required (the old envelope is never re-authenticated
|
|
90
|
+
// for another seat). The new decision carries parentDecisionId lineage.
|
|
91
|
+
return { action: "new-route-decision", fromSeatId: currentSeatId, toSeatId: targetSeatId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Lineage digest for the replacement chain: binds parent decision, old
|
|
95
|
+
// envelope, and attempt index into the new decision's audit trail.
|
|
96
|
+
export function lineageDigest({ parentDecisionId, oldEnvelopeId, attemptIndex }) {
|
|
97
|
+
return sha256Hex(JSON.stringify({ parentDecisionId, oldEnvelopeId, attemptIndex }));
|
|
98
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// Production router runtime loader. Configuration precedence is the closed
|
|
5
|
+
// user profile over repository defaults. SQLite remains operational state;
|
|
6
|
+
// authenticated claims remain authority and are used only to repair it.
|
|
7
|
+
//
|
|
8
|
+
// Two run modes:
|
|
9
|
+
// - write mode (default): a dispatch path. Expires stale reserved rows,
|
|
10
|
+
// reconciles claimed reservations from the authenticated claims file,
|
|
11
|
+
// settles terminal claims, heartbeats live claims, and ingests
|
|
12
|
+
// observations/receipts.
|
|
13
|
+
// - read-only mode (W4): the `check` observation path. It reads the
|
|
14
|
+
// collector cache and the store without a single write, and never creates
|
|
15
|
+
// a store that does not exist. `persisted: false` is then true.
|
|
16
|
+
//
|
|
17
|
+
// The localHealth probe (W2) produces endpoint-keyed, read-time-fresh health
|
|
18
|
+
// observations for `kind: "local"` seats. A failed probe is never "ok".
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
21
|
+
import { join, resolve } from "node:path";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { mergeRouterConfig, validateRouterConfig, computeConfigDigest, readCollectorCache } from "./router_schemas_pi.js";
|
|
24
|
+
import {
|
|
25
|
+
openRouterStore, closeRouterStore, activeReservations, consumptionSamples,
|
|
26
|
+
expireStaleReservations, insertQuotaObservation, insertConsumptionSample,
|
|
27
|
+
reconcileReservationFromClaim, followClaimConsumption, renewReservationLease,
|
|
28
|
+
} from "./router_store_pi.js";
|
|
29
|
+
import { readClaimsState } from "./task_board_core_pi.js";
|
|
30
|
+
|
|
31
|
+
export const ROUTER_DEFAULTS_RELATIVE_PATH = ".agentic-driver/router.defaults.json";
|
|
32
|
+
export const ROUTER_PROFILE_DEFAULT_PATH = join(homedir(), ".config", "agentic-driver", "router", "profile.json");
|
|
33
|
+
export const ROUTER_CACHE_ROOT_DEFAULT_PATH = join(homedir(), ".local", "share", "agentic-driver", "quota");
|
|
34
|
+
export const HEALTH_OBSERVATION_SCHEMA = "agentic-driver.health-observation.v1";
|
|
35
|
+
export const LOCAL_HEALTH_PATH = "/health";
|
|
36
|
+
|
|
37
|
+
function readJson(path, { optional = false } = {}) {
|
|
38
|
+
if (!existsSync(path)) {
|
|
39
|
+
if (optional) return null;
|
|
40
|
+
throw new Error(`router configuration is missing: ${path}`);
|
|
41
|
+
}
|
|
42
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function expandHome(path) {
|
|
46
|
+
return path === "~" ? homedir() : path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function loadRouterConfig({ repoRoot = process.cwd(), defaultsPath = null, profilePath = null } = {}) {
|
|
50
|
+
const resolvedDefaults = resolve(defaultsPath ?? join(repoRoot, ROUTER_DEFAULTS_RELATIVE_PATH));
|
|
51
|
+
const resolvedProfile = resolve(profilePath ?? process.env.AGENTIC_DRIVER_ROUTER_PROFILE ?? ROUTER_PROFILE_DEFAULT_PATH);
|
|
52
|
+
const defaults = readJson(resolvedDefaults);
|
|
53
|
+
const profile = readJson(resolvedProfile, { optional: true });
|
|
54
|
+
const config = mergeRouterConfig(defaults, profile);
|
|
55
|
+
if (config === null) throw new Error("router defaults/profile merge is invalid (fails closed)");
|
|
56
|
+
const validation = validateRouterConfig(config);
|
|
57
|
+
if (!validation.ok) throw new Error(`router configuration is invalid: ${validation.errors.join("; ")}`);
|
|
58
|
+
return { config, configDigest: computeConfigDigest(config), configRevision: config.revision,
|
|
59
|
+
defaultsPath: resolvedDefaults, profilePath: resolvedProfile, profileLoaded: profile !== null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// localHealth probe adapter (W2, design §8.12)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function healthBase({ endpointRef, at, expiresAt }) {
|
|
67
|
+
return {
|
|
68
|
+
schema: HEALTH_OBSERVATION_SCHEMA,
|
|
69
|
+
endpointRef,
|
|
70
|
+
capturedAt: at,
|
|
71
|
+
expiresAt,
|
|
72
|
+
sourceStatus: "unknown",
|
|
73
|
+
reason: null,
|
|
74
|
+
warm: null,
|
|
75
|
+
memoryHeadroomBytes: null,
|
|
76
|
+
concurrency: null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Probe ONE endpoint. The probe fails closed: a missing endpoint, a missing
|
|
81
|
+
// transport, a non-2xx response, a malformed body, a body status other than
|
|
82
|
+
// "ok", an exhausted memory headroom, or a full concurrency limit all yield a
|
|
83
|
+
// non-"ok" sourceStatus. Only a verified successful probe is ever "ok".
|
|
84
|
+
async function probeEndpoint({ endpointRef, endpoint, at, expiresAt, fetchFn, timeoutMs, warmStateTracking }) {
|
|
85
|
+
const base = healthBase({ endpointRef, at, expiresAt });
|
|
86
|
+
if (endpoint === null || typeof endpoint !== "object" || typeof endpoint.url !== "string" || endpoint.url === "") {
|
|
87
|
+
return { ...base, sourceStatus: "unknown", reason: `endpoint "${endpointRef}" does not resolve in endpoints{}` };
|
|
88
|
+
}
|
|
89
|
+
if (typeof fetchFn !== "function") {
|
|
90
|
+
return { ...base, sourceStatus: "unknown", reason: "no health probe transport is available (fails closed)" };
|
|
91
|
+
}
|
|
92
|
+
const controller = typeof AbortController === "function" ? new AbortController() : null;
|
|
93
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
94
|
+
let response;
|
|
95
|
+
try {
|
|
96
|
+
response = await fetchFn(`${endpoint.url.replace(/\/$/, "")}${LOCAL_HEALTH_PATH}`, { method: "GET", signal: controller?.signal });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return { ...base, sourceStatus: "unreachable", reason: `health probe failed: ${String(error?.message || error).slice(0, 128)}` };
|
|
99
|
+
} finally {
|
|
100
|
+
if (timer) clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
if (response === null || typeof response !== "object" || response.ok !== true) {
|
|
103
|
+
return { ...base, sourceStatus: "unreachable", reason: `health endpoint returned status ${response?.status ?? "no response"}` };
|
|
104
|
+
}
|
|
105
|
+
let body = null;
|
|
106
|
+
try { body = typeof response.json === "function" ? await response.json() : null; } catch (error) {
|
|
107
|
+
return { ...base, sourceStatus: "parse-error", reason: `health body is not JSON: ${String(error?.message || error).slice(0, 128)}` };
|
|
108
|
+
}
|
|
109
|
+
if (body === null || typeof body !== "object" || Array.isArray(body) || body.status !== "ok") {
|
|
110
|
+
return { ...base, sourceStatus: "unreachable", reason: `health status "${body?.status ?? "absent"}" is not ok` };
|
|
111
|
+
}
|
|
112
|
+
let memoryHeadroomBytes = null;
|
|
113
|
+
const memory = body.memory ?? null;
|
|
114
|
+
if (memory !== null && typeof memory === "object" && Number.isFinite(memory.free)) {
|
|
115
|
+
memoryHeadroomBytes = memory.free;
|
|
116
|
+
if (memory.free <= 0) return { ...base, sourceStatus: "unreachable", reason: "no memory headroom on the endpoint" };
|
|
117
|
+
}
|
|
118
|
+
let concurrency = null;
|
|
119
|
+
const slots = body.concurrency ?? null;
|
|
120
|
+
if (slots !== null && typeof slots === "object" && Number.isFinite(slots.active) && Number.isFinite(slots.limit)) {
|
|
121
|
+
concurrency = { active: slots.active, limit: slots.limit };
|
|
122
|
+
if (slots.limit <= 0 || slots.active >= slots.limit) {
|
|
123
|
+
return { ...base, sourceStatus: "unreachable", reason: `endpoint concurrency is full (${slots.active}/${slots.limit})` };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const warm = typeof body.warm === "boolean" ? body.warm
|
|
127
|
+
: warmStateTracking && typeof body.model_loaded === "boolean" ? body.model_loaded : null;
|
|
128
|
+
return { ...base, sourceStatus: "ok", reason: null, warm, memoryHeadroomBytes, concurrency };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Probe every endpoint a local seat needs. For a spanning cluster seat the
|
|
132
|
+
// seat's endpointRef observation is "ok" only when BOTH nodes are ready; a
|
|
133
|
+
// single failed node fails the seat closed (design §8.12).
|
|
134
|
+
export async function probeLocalHealth({ config, now = null, fetchFn = null, timeoutMs = 3000 } = {}) {
|
|
135
|
+
const at = now ?? new Date().toISOString();
|
|
136
|
+
const probeSeconds = Number.isInteger(config?.localHealth?.probeSeconds) && config.localHealth.probeSeconds > 0
|
|
137
|
+
? config.localHealth.probeSeconds : 60;
|
|
138
|
+
const expiresAt = new Date(Date.parse(at) + probeSeconds * 1000).toISOString();
|
|
139
|
+
const warmStateTracking = config?.localHealth?.warmStateTracking === true;
|
|
140
|
+
const doFetch = fetchFn ?? globalThis.fetch?.bind(globalThis) ?? null;
|
|
141
|
+
const localSeats = (config?.seats ?? []).filter((seat) => seat?.kind === "local" && seat.enabled === true);
|
|
142
|
+
const requiredBySeat = new Map();
|
|
143
|
+
const endpointsToProbe = new Set();
|
|
144
|
+
for (const seat of localSeats) {
|
|
145
|
+
const refs = new Set([seat.endpointRef]);
|
|
146
|
+
if (seat.clusterMembership?.spansNodes === true) {
|
|
147
|
+
for (const node of Array.isArray(seat.clusterMembership.nodes) ? seat.clusterMembership.nodes : []) refs.add(node);
|
|
148
|
+
}
|
|
149
|
+
requiredBySeat.set(seat.seatId, [...refs]);
|
|
150
|
+
for (const ref of refs) endpointsToProbe.add(ref);
|
|
151
|
+
}
|
|
152
|
+
const probes = {};
|
|
153
|
+
for (const ref of endpointsToProbe) {
|
|
154
|
+
probes[ref] = await probeEndpoint({ endpointRef: ref, endpoint: config?.endpoints?.[ref], at, expiresAt, fetchFn: doFetch, timeoutMs, warmStateTracking });
|
|
155
|
+
}
|
|
156
|
+
const observations = { ...probes };
|
|
157
|
+
for (const [seatId, refs] of requiredBySeat) {
|
|
158
|
+
if (refs.length <= 1) continue;
|
|
159
|
+
const seat = localSeats.find((candidate) => candidate.seatId === seatId);
|
|
160
|
+
const failed = refs.filter((ref) => probes[ref]?.sourceStatus !== "ok");
|
|
161
|
+
if (failed.length > 0) {
|
|
162
|
+
observations[seat.endpointRef] = {
|
|
163
|
+
...(probes[seat.endpointRef] ?? healthBase({ endpointRef: seat.endpointRef, at, expiresAt })),
|
|
164
|
+
sourceStatus: "unreachable",
|
|
165
|
+
reason: `spanning cluster seat "${seatId}" is not ready: endpoint(s) ${failed.join(", ")} did not report ok`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { observations, probes, probedAt: at, expiresAt };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Store acquisition (W5): cache the write store per db path so a tick loop
|
|
174
|
+
// reuses one handle instead of leaking a DatabaseSync per invocation. A
|
|
175
|
+
// read-only store is opened only for its call and closed immediately.
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
const writeStoreCache = new Map();
|
|
179
|
+
|
|
180
|
+
function acquireWriteStore(dbPath) {
|
|
181
|
+
const key = dbPath ?? "__default__";
|
|
182
|
+
const cached = writeStoreCache.get(key);
|
|
183
|
+
if (cached !== undefined && cached.closed !== true && cached.db !== null) return cached;
|
|
184
|
+
const store = openRouterStore({ dbPath });
|
|
185
|
+
writeStoreCache.set(key, store);
|
|
186
|
+
return store;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function closeCachedRouterStores() {
|
|
190
|
+
for (const store of writeStoreCache.values()) closeRouterStore(store);
|
|
191
|
+
writeStoreCache.clear();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Terminal-claim settlement + heartbeat (L1). The authenticated claims file
|
|
196
|
+
// is authority: a reservation bound to a consumed claim settles to
|
|
197
|
+
// consumed/released, a reservation bound to a still-active claim is
|
|
198
|
+
// heartbeated, and a reservation bound to no claim is released. SQLite is
|
|
199
|
+
// repaired FROM the claims file, never the reverse.
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
export function settleTerminalReservations(store, { claimsState, now }) {
|
|
203
|
+
const activeEnvelopeIds = new Set((claimsState?.claims ?? []).map((claim) => claim.envelopeId));
|
|
204
|
+
const consumedByEnvelopeId = new Map((claimsState?.consumedClaims ?? []).map((entry) => [entry.envelopeId, entry.reason]));
|
|
205
|
+
const reservations = activeReservations(store, { now });
|
|
206
|
+
const settled = [];
|
|
207
|
+
const seen = new Set();
|
|
208
|
+
for (const reservation of reservations) {
|
|
209
|
+
if (reservation.state !== "claimed" || typeof reservation.envelopeId !== "string" || reservation.envelopeId === "") continue;
|
|
210
|
+
if (seen.has(reservation.envelopeId)) continue;
|
|
211
|
+
seen.add(reservation.envelopeId);
|
|
212
|
+
if (activeEnvelopeIds.has(reservation.envelopeId)) {
|
|
213
|
+
renewReservationLease(store, { reservationId: reservation.reservationId, now });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const reason = consumedByEnvelopeId.get(reservation.envelopeId) ?? "reclaimed";
|
|
217
|
+
const followed = followClaimConsumption(store, { envelopeId: reservation.envelopeId, reason, now });
|
|
218
|
+
settled.push(...followed.followed);
|
|
219
|
+
}
|
|
220
|
+
return settled;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Runtime assembly
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
export function prepareRouterRuntime({ repoRoot = process.cwd(), boardPath = null, defaultsPath = null,
|
|
228
|
+
profilePath = null, dbPath = null, cacheRoot = null, now = null, consumptionReceipts = [], readOnly = false } = {}) {
|
|
229
|
+
const loaded = loadRouterConfig({ repoRoot, defaultsPath, profilePath });
|
|
230
|
+
const at = now ?? new Date().toISOString();
|
|
231
|
+
const store = readOnly
|
|
232
|
+
? openRouterStore({ dbPath, readOnly: true })
|
|
233
|
+
: acquireWriteStore(dbPath);
|
|
234
|
+
|
|
235
|
+
let claimsState = { claims: [], consumedClaims: [] };
|
|
236
|
+
// Terminal settlement and heartbeat repair SQLite only from an
|
|
237
|
+
// authoritative claims file. Without a board path there is no authority to
|
|
238
|
+
// repair against, so reservations are left untouched (fail closed).
|
|
239
|
+
const hasClaimsAuthority = boardPath && existsSync(boardPath);
|
|
240
|
+
if (hasClaimsAuthority) {
|
|
241
|
+
const read = readClaimsState(boardPath);
|
|
242
|
+
if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
|
|
243
|
+
claimsState = read.state;
|
|
244
|
+
if (!readOnly) {
|
|
245
|
+
for (const claim of claimsState.claims) reconcileReservationFromClaim(store, { claim, now: at });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const trustedRoot = resolve(cacheRoot ?? process.env.AGENTIC_DRIVER_ROUTER_CACHE_ROOT ?? ROUTER_CACHE_ROOT_DEFAULT_PATH);
|
|
250
|
+
const quotaObservations = [];
|
|
251
|
+
for (const [collectorId, collector] of Object.entries(loaded.config.collectors)) {
|
|
252
|
+
const cachePath = expandHome(collector.cachePath);
|
|
253
|
+
const read = readCollectorCache({ cachePath, trustedRoot });
|
|
254
|
+
if (!read.ok || read.observation.collector !== collectorId) continue;
|
|
255
|
+
if (!readOnly) insertQuotaObservation(store, read.observation);
|
|
256
|
+
quotaObservations.push(read.observation);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try {
|
|
260
|
+
if (!readOnly) {
|
|
261
|
+
expireStaleReservations(store, { now: at });
|
|
262
|
+
// L1: settle reservations whose claim is terminal (consumed/gone) and
|
|
263
|
+
// heartbeat reservations whose claim is still active, but only from an
|
|
264
|
+
// authoritative claims file.
|
|
265
|
+
if (hasClaimsAuthority) settleTerminalReservations(store, { claimsState, now: at });
|
|
266
|
+
for (const receipt of Array.isArray(consumptionReceipts) ? consumptionReceipts : []) {
|
|
267
|
+
if (typeof receipt?.seatId !== "string" || typeof receipt?.unit !== "string"
|
|
268
|
+
|| !Number.isFinite(receipt?.quantity) || receipt.quantity < 0) continue;
|
|
269
|
+
insertConsumptionSample(store, { seatId: receipt.seatId, unit: receipt.unit,
|
|
270
|
+
quantity: receipt.quantity, claimId: receipt.claimId ?? null, capturedAt: receipt.capturedAt ?? at });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const snapshot = {
|
|
274
|
+
quotaObservations,
|
|
275
|
+
consumptionSamples: store.missing === true ? [] : consumptionSamples(store),
|
|
276
|
+
existingReservations: store.missing === true ? [] : activeReservations(store, { now: at }),
|
|
277
|
+
};
|
|
278
|
+
return {
|
|
279
|
+
...loaded, routerConfig: loaded.config, routerStore: readOnly ? null : store, routerNow: at,
|
|
280
|
+
routerSnapshot: snapshot, cacheRoot: trustedRoot,
|
|
281
|
+
routerReadOnly: readOnly, routerStorePath: store.path,
|
|
282
|
+
};
|
|
283
|
+
} finally {
|
|
284
|
+
// Read-only runtimes never outlive the call; the handle is closed here so
|
|
285
|
+
// the pure `check` path cannot leak a store.
|
|
286
|
+
if (readOnly) closeRouterStore(store);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The production entry: assemble the runtime and attach endpoint-keyed
|
|
291
|
+
// localHealth observations to the snapshot (W2). In read-only mode this is
|
|
292
|
+
// still pure: probes are network reads and the store is opened read-only.
|
|
293
|
+
export async function prepareRouterRuntimeWithHealth({ fetchFn = null, healthTimeoutMs = 3000, ...options } = {}) {
|
|
294
|
+
const runtime = prepareRouterRuntime(options);
|
|
295
|
+
const health = await probeLocalHealth({ config: runtime.routerConfig, now: runtime.routerNow, fetchFn, timeoutMs: healthTimeoutMs });
|
|
296
|
+
runtime.routerSnapshot.healthObservations = health.observations;
|
|
297
|
+
runtime.healthProbes = health.probes;
|
|
298
|
+
return runtime;
|
|
299
|
+
}
|