@evoclock/pi-agentic-driver 0.9.1 → 0.9.3
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 +116 -32
- package/extensions/attended-authority-guard.ts +1 -1
- package/extensions/code-phage.js +1 -1
- package/extensions/herdr-communication.ts +1 -1
- package/extensions/herdr-dispatch.ts +1 -1
- package/extensions/herdr-lifecycle.ts +1 -1
- package/extensions/linux-microvm.ts +1 -1
- package/extensions/pulse.ts +33 -2
- package/extensions/task-board.ts +1 -1
- package/janus/README.draft.md +210 -0
- package/janus/README.md +196 -0
- package/janus/janus/audit.ts +213 -0
- package/janus/janus/budget.ts +101 -0
- package/janus/janus/budget_breaker.ts +105 -0
- package/janus/janus/config.ts +332 -0
- package/janus/janus/errors.ts +90 -0
- package/janus/janus/evaluate.ts +230 -0
- package/janus/janus/keychain.ts +119 -0
- package/janus/janus/providers.ts +544 -0
- package/janus/janus/schema.ts +344 -0
- package/janus/janus/server.ts +267 -0
- package/janus/janus/single_flight.ts +99 -0
- package/janus/janus/verdict.ts +192 -0
- package/janus/package-lock.json +616 -0
- package/janus/package.json +29 -0
- package/janus/tests/accounting.test.ts +229 -0
- package/janus/tests/allowlist.test.ts +97 -0
- package/janus/tests/config.test.ts +151 -0
- package/janus/tests/errors.test.ts +147 -0
- package/janus/tests/evaluate_pipeline.test.ts +358 -0
- package/janus/tests/helpers.ts +217 -0
- package/janus/tests/providers.test.ts +292 -0
- package/janus/tests/redaction_budget.test.ts +227 -0
- package/janus/tests/schema.test.ts +356 -0
- package/janus/tests/server.test.ts +288 -0
- package/janus/tests/single_flight.test.ts +84 -0
- package/janus/tsconfig.json +19 -0
- package/janus/user.janus.plist +51 -0
- 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 +14 -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 +1 -1
- package/scripts/enforcement/herdr_communication_pi.js +13 -8
- 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 +38 -14
- package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
- 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 +654 -0
- package/scripts/enforcement/router_store_pi.js +438 -0
- package/scripts/enforcement/task_board_core_pi.js +255 -44
- package/skills/pulse/SKILL.md +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
-
// SPDX-License-Identifier: AGPL-3.0-
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
3
|
|
|
4
4
|
// Pulse scheduler (PULSE_DESIGN_v3 §2.3, §3.3, §6, §7, §8, §12): session-scoped
|
|
5
5
|
// scheduling composition. Owns the manual `check` action (pure observation),
|
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
automationPolicyPath, PLACEMENTS,
|
|
23
23
|
} from "./task_board_core_pi.js";
|
|
24
24
|
import { scanBoard } from "./pulse_core_pi.js";
|
|
25
|
+
import { routeDecision } from "./router_engine_pi.js";
|
|
26
|
+
import { createReservation, claimReservation, renewReservationLease, consumeReservation, releaseReservation, insertRouteDecision } from "./router_store_pi.js";
|
|
25
27
|
import { isNativeTuiContext } from "./native_tui_context.js";
|
|
26
28
|
|
|
27
29
|
// ---------------------------------------------------------------------------
|
|
@@ -98,7 +100,7 @@ export function observeModelAvailability({ pulsePolicy = null, modelRegistry = n
|
|
|
98
100
|
|
|
99
101
|
// One pure board scan for the manual check. Returns a structured result; it
|
|
100
102
|
// never claims and never mutates anything.
|
|
101
|
-
export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = null, observedAt = null }) {
|
|
103
|
+
export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = null, observedAt = null, activeSessions = [], providerLimit = null }) {
|
|
102
104
|
const base = observeBoardForPulse({ boardPath });
|
|
103
105
|
if (!base.ok) return base;
|
|
104
106
|
const { validated, pulsePolicy, effectivePolicy } = base;
|
|
@@ -115,8 +117,32 @@ export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = nul
|
|
|
115
117
|
declaredModels,
|
|
116
118
|
observedAt,
|
|
117
119
|
placement: pulsePolicy ? effectivePolicy.placement : null,
|
|
120
|
+
activeSessions, providerLimit,
|
|
118
121
|
});
|
|
119
|
-
return { ok: true, code: null, reason: null, scan, diagnosticHash, validated, effectivePolicy, pulsePolicy, activeClaims };
|
|
122
|
+
return { ok: true, code: null, reason: null, scan, diagnosticHash, validated, effectivePolicy, pulsePolicy, activeClaims, activeSessions, providerLimit };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function routePulseCheck(check, { routerConfig, quotaObservations = [], healthObservations = {}, consumptionSamples = [], existingReservations = [], modelInstalled = null, janusRank = null, now = null } = {}) {
|
|
126
|
+
if (!check?.ok || !routerConfig) return { ok: false, code: "router-unavailable", reason: "validated router configuration is required" };
|
|
127
|
+
const decisions = [];
|
|
128
|
+
const proposed = new Set((check.scan.proposedDispatches ?? []).map((p) => p.title));
|
|
129
|
+
for (const card of check.validated.cards) {
|
|
130
|
+
if (!proposed.has(card.title)) continue;
|
|
131
|
+
const role = check.scan.cards.find((c) => c.title === card.title)?.role ?? "implementer";
|
|
132
|
+
const routeModels = [...(check.pulsePolicy?.routing?.[role]?.preferred ?? []), ...(check.pulsePolicy?.routing?.[role]?.fallback ?? [])].map((r) => r.model);
|
|
133
|
+
const policyRouteSeatIds = routerConfig.seats.filter((s) => routeModels.includes(s.model)).map((s) => s.seatId);
|
|
134
|
+
const freeCapacityBySeat = Object.fromEntries(routerConfig.seats.map((s) => [s.seatId,
|
|
135
|
+
check.scan.capacity.find((c) => c.role === role && c.model === s.model)?.free ?? 0]));
|
|
136
|
+
const decision = await routeDecision({ card: { ...card, role }, config: routerConfig,
|
|
137
|
+
snapshotDigest: check.diagnosticHash, policyRouteSeatIds, healthObservations,
|
|
138
|
+
quotaObservations, consumptionSamples, existingReservations, activeSessions: check.activeSessions,
|
|
139
|
+
freeCapacityBySeat, modelInstalled, janusRank, now: now ?? new Date().toISOString() });
|
|
140
|
+
if (!decision.ok) {
|
|
141
|
+
return { ok: false, code: "route-unavailable", reason: decision.reason ?? `no eligible route for card ${card.cardId}` };
|
|
142
|
+
}
|
|
143
|
+
decisions.push({ ...decision, cardId: card.cardId });
|
|
144
|
+
}
|
|
145
|
+
return { ok: true, decisions };
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
// Shared trusted resolution: board parse/validation + policy read/validate.
|
|
@@ -158,7 +184,7 @@ const BATCH_INTERACTIVE_TTL_MS = 5 * 60 * 1000;
|
|
|
158
184
|
// set covers the FULL currently eligible candidate list (every READY_FOR_NEXT
|
|
159
185
|
// card), not only initially free slots; refill consumes it lazily. It is
|
|
160
186
|
// extension-memory state, never model input or durable authority.
|
|
161
|
-
export function mintBatchContext({ scan, cards, policy, instruction, now = null, batchId = null }) {
|
|
187
|
+
export function mintBatchContext({ scan, cards, policy, instruction, now = null, batchId = null, routeDecisions = null }) {
|
|
162
188
|
if (scan?.schema !== "agentic-driver.board-pulse.v1") {
|
|
163
189
|
throw new Error("mintBatchContext requires a pulse scan");
|
|
164
190
|
}
|
|
@@ -176,14 +202,25 @@ export function mintBatchContext({ scan, cards, policy, instruction, now = null,
|
|
|
176
202
|
else byTitle.set(card.title, card);
|
|
177
203
|
}
|
|
178
204
|
const proposalByTitle = new Map((scan.proposedDispatches ?? []).map((p) => [p.title, p]));
|
|
205
|
+
// Route decisions (router design §1/§8.1): keyed by cardId. The scheduler
|
|
206
|
+
// carries the decision's identity (seatId, provider, model, digest,
|
|
207
|
+
// reservationId) into the batch entry — it never recomputes a route.
|
|
208
|
+
const decisionByCardId = new Map(
|
|
209
|
+
(routeDecisions ?? []).filter((d) => d?.ok && typeof d?.record?.decisionId === "string")
|
|
210
|
+
.map((d) => [d.cardId, d]),
|
|
211
|
+
);
|
|
179
212
|
const entries = [];
|
|
180
213
|
for (const cardScan of scan.cards) {
|
|
181
214
|
if (cardScan.result !== "READY_FOR_NEXT") continue;
|
|
182
215
|
const card = byTitle.get(cardScan.title);
|
|
183
216
|
if (!card) continue; // duplicate or missing title fails closed
|
|
184
217
|
const proposal = proposalByTitle.get(cardScan.title);
|
|
185
|
-
const
|
|
186
|
-
if (!
|
|
218
|
+
const decision = decisionByCardId.get(card.cardId) ?? null;
|
|
219
|
+
if (!decision || typeof decision.digest !== "string" || typeof decision.model !== "string"
|
|
220
|
+
|| typeof decision.provider !== "string" || typeof decision.selectedSeatId !== "string") {
|
|
221
|
+
throw new Error(`missing complete route decision for card ${card.cardId} (fails closed)`);
|
|
222
|
+
}
|
|
223
|
+
const model = decision.model;
|
|
187
224
|
const repositories = Array.isArray(card.repositories) ? card.repositories.filter((r) => typeof r === "string" && r !== "") : [];
|
|
188
225
|
const repository = repositories.length === 1 ? repositories[0]
|
|
189
226
|
: repositories.length === 0 && Array.isArray(policy?.acceptedRepositories) && policy.acceptedRepositories.length === 1
|
|
@@ -199,6 +236,18 @@ export function mintBatchContext({ scan, cards, policy, instruction, now = null,
|
|
|
199
236
|
repository,
|
|
200
237
|
placement: proposal?.placement ?? policy?.placement ?? null,
|
|
201
238
|
state: "unused",
|
|
239
|
+
seatId: decision.selectedSeatId,
|
|
240
|
+
accountId: decision.accountId,
|
|
241
|
+
provider: decision.provider,
|
|
242
|
+
endpointRef: decision.endpointRef,
|
|
243
|
+
effort: decision.effort,
|
|
244
|
+
containmentTier: decision.containmentTier,
|
|
245
|
+
phase: decision.record.phase,
|
|
246
|
+
routeDecisionId: decision.record.decisionId,
|
|
247
|
+
routeDecisionRecord: decision.record,
|
|
248
|
+
routeDecisionDigest: decision.digest,
|
|
249
|
+
reservationId: decision.reservationId,
|
|
250
|
+
reservationVectors: decision.reservationVectors,
|
|
202
251
|
});
|
|
203
252
|
}
|
|
204
253
|
// Interactive expiry is the earlier of policy expiry or five minutes.
|
|
@@ -278,7 +327,7 @@ const AUTOMATED_HOST_DENIED =
|
|
|
278
327
|
// consume after verified start. A claim race removes the card from the landed
|
|
279
328
|
// batch and refreshes capacity; an ambiguous spawn leaves the entry reserved
|
|
280
329
|
// and triggers reconciliation, never reuse or another start.
|
|
281
|
-
async function executeBatch({ batch, boardPath, spawnWorker = null, context = null, now = null, allowHostDispatch = false }) {
|
|
330
|
+
export async function executeBatch({ batch, boardPath, spawnWorker = null, context = null, now = null, allowHostDispatch = false, routerStore = null }) {
|
|
282
331
|
const landed = [];
|
|
283
332
|
const capacityTaken = new Set();
|
|
284
333
|
let terminalReason = "no-eligible-card";
|
|
@@ -300,7 +349,13 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
|
|
|
300
349
|
continue;
|
|
301
350
|
}
|
|
302
351
|
const reserved = reserveBatchEntry(batch, entry.cardId, { now });
|
|
303
|
-
if (!reserved.ok) continue;
|
|
352
|
+
if (!reserved.ok) continue;
|
|
353
|
+
if (entry.reservationId !== null) {
|
|
354
|
+
if (!routerStore) { releaseBatchEntry(batch, entry.cardId); throw new Error("router store is required for quota reservation"); }
|
|
355
|
+
createReservation(routerStore, { reservationId: entry.reservationId, decisionId: entry.routeDecisionId,
|
|
356
|
+
seatId: entry.seatId, accountId: entry.accountId, vectors: entry.reservationVectors, now: now ?? new Date().toISOString() });
|
|
357
|
+
}
|
|
358
|
+
if (routerStore) insertRouteDecision(routerStore, { record: entry.routeDecisionRecord, digest: entry.routeDecisionDigest });
|
|
304
359
|
// Host entries claim ONLY through the trusted confirmed-batch path; the
|
|
305
360
|
// batch was registered after the one native confirmation, and each entry
|
|
306
361
|
// is single-use. Contained entries use the ordinary contained-only claim.
|
|
@@ -308,6 +363,7 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
|
|
|
308
363
|
? claimCardForConfirmedBatch({ boardPath, cardId: entry.cardId, role: entry.role, policy: null, now, confirmedBatch: batch })
|
|
309
364
|
: claimCard({ boardPath, cardId: entry.cardId, role: entry.role, policy: null });
|
|
310
365
|
if (!claim.ok) {
|
|
366
|
+
if (routerStore && entry.reservationId) releaseReservation(routerStore, { reservationId: entry.reservationId, now: now ?? new Date().toISOString() });
|
|
311
367
|
releaseBatchEntry(batch, entry.cardId);
|
|
312
368
|
landed.push({
|
|
313
369
|
title: entry.title, role: entry.role, model: entry.model, placement: entry.placement,
|
|
@@ -317,6 +373,14 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
|
|
|
317
373
|
continue;
|
|
318
374
|
}
|
|
319
375
|
capacityTaken.add(entry.cardId);
|
|
376
|
+
if (routerStore && entry.reservationId) {
|
|
377
|
+
const bound = claimReservation(routerStore, { reservationId: entry.reservationId,
|
|
378
|
+
claimId: claim.envelopeId, envelopeId: claim.envelopeId, now: now ?? new Date().toISOString() });
|
|
379
|
+
if (!bound.ok) throw new Error(bound.reason);
|
|
380
|
+
// L1: lease renewal runs through the production dispatch loop hook, not
|
|
381
|
+
// only from tests. The authenticated envelope is the reservation identity.
|
|
382
|
+
heartbeatBatchReservation({ routerStore, envelope: claim.envelope, now });
|
|
383
|
+
}
|
|
320
384
|
if (typeof spawnWorker !== "function") {
|
|
321
385
|
// No guarded spawn seam in this context: the claim landed and the entry
|
|
322
386
|
// is consumed as a claimed-but-not-started assignment; never spawn
|
|
@@ -331,10 +395,13 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
|
|
|
331
395
|
spawned = await spawnWorker({
|
|
332
396
|
role: entry.role,
|
|
333
397
|
repository: entry.repository,
|
|
334
|
-
model:
|
|
398
|
+
model: claim.envelope.model,
|
|
399
|
+
provider: claim.envelope.provider,
|
|
400
|
+
seatId: claim.envelope.seatId,
|
|
401
|
+
endpointRef: entry.endpointRef,
|
|
402
|
+
envelope: claim.envelope,
|
|
335
403
|
placement: entry.placement,
|
|
336
|
-
context,
|
|
337
|
-
signal: null,
|
|
404
|
+
context, signal: null,
|
|
338
405
|
});
|
|
339
406
|
} catch (error) {
|
|
340
407
|
spawned = { ok: false, reason: String(error?.message || error).slice(0, 256) };
|
|
@@ -345,7 +412,9 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
|
|
|
345
412
|
} else {
|
|
346
413
|
// Delivery-unknown, partial start, or ambiguous read-back: the entry
|
|
347
414
|
// stays reserved and triggers reconciliation — never reuse or another
|
|
348
|
-
// start (§6.2).
|
|
415
|
+
// start (§6.2). The reservation follows the claim through the terminal
|
|
416
|
+
// settlement hook (production caller for settleBatchReservation).
|
|
417
|
+
if (routerStore && entry.reservationId) settleBatchReservation({ routerStore, envelope: claim.envelope, outcome: "released", now });
|
|
349
418
|
landed.push({
|
|
350
419
|
title: entry.title, role: entry.role, model: entry.model, placement: entry.placement,
|
|
351
420
|
status: "denied",
|
|
@@ -357,6 +426,19 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
|
|
|
357
426
|
return { landed, terminalReason };
|
|
358
427
|
}
|
|
359
428
|
|
|
429
|
+
// Dispatch-loop completion/heartbeat hooks. The authenticated envelope is
|
|
430
|
+
// the only accepted reservation identity; terminal transitions are idempotent.
|
|
431
|
+
export function heartbeatBatchReservation({ routerStore, envelope, now = null }) {
|
|
432
|
+
if (!routerStore || typeof envelope?.reservationId !== "string") return { ok: true, skipped: true };
|
|
433
|
+
return renewReservationLease(routerStore, { reservationId: envelope.reservationId, now: now ?? new Date().toISOString() });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function settleBatchReservation({ routerStore, envelope, outcome, now = null }) {
|
|
437
|
+
if (!routerStore || typeof envelope?.reservationId !== "string") return { ok: true, skipped: true };
|
|
438
|
+
const input = { reservationId: envelope.reservationId, now: now ?? new Date().toISOString() };
|
|
439
|
+
return outcome === "completed" ? consumeReservation(routerStore, input) : releaseReservation(routerStore, input);
|
|
440
|
+
}
|
|
441
|
+
|
|
360
442
|
// ---------------------------------------------------------------------------
|
|
361
443
|
// Interactive run (§6.2)
|
|
362
444
|
// ---------------------------------------------------------------------------
|
|
@@ -376,12 +458,15 @@ export async function pulseRun({ boardPath, instruction, context = null, spawnWo
|
|
|
376
458
|
if (!pulsePolicy || pulsePolicy.enabled !== true) {
|
|
377
459
|
return { ok: false, code: "pulse-disabled", reason: "Pulse is not enabled for this board (fails closed)", scan: null, landedAssignments: [], batchTerminalReason: "pulse-disabled" };
|
|
378
460
|
}
|
|
379
|
-
const check = pulseCheck({ boardPath, modelRegistry: context?.modelRegistry ?? null, scopedModels: context?.scopedModels ?? null, observedAt: now
|
|
461
|
+
const check = pulseCheck({ boardPath, modelRegistry: context?.modelRegistry ?? null, scopedModels: context?.scopedModels ?? null, observedAt: now,
|
|
462
|
+
activeSessions: context?.activeSessions ?? [], providerLimit: context?.providerLimit ?? null });
|
|
380
463
|
if (!check.ok) {
|
|
381
464
|
return { ok: false, code: check.code, reason: check.reason, scan: null, landedAssignments: [], batchTerminalReason: check.code };
|
|
382
465
|
}
|
|
383
466
|
const { scan } = check;
|
|
384
|
-
const
|
|
467
|
+
const routed = await routePulseCheck(check, { ...(context?.routerSnapshot ?? {}), routerConfig: context?.routerConfig, now });
|
|
468
|
+
if (!routed.ok) return { ok: false, code: routed.code, reason: routed.reason, scan, landedAssignments: [], batchTerminalReason: routed.code };
|
|
469
|
+
const batch = mintBatchContext({ scan, cards: validated.cards, policy: effectivePolicy, instruction, now, routeDecisions: routed.decisions });
|
|
385
470
|
// One semantic batch preview and one native confirmation for the exact
|
|
386
471
|
// batch (§6.2 step 3-4). Headless contexts fail closed.
|
|
387
472
|
if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
|
|
@@ -410,7 +495,7 @@ export async function pulseRun({ boardPath, instruction, context = null, spawnWo
|
|
|
410
495
|
// batch context is registered for the trusted in-memory confirmed-batch
|
|
411
496
|
// claim path; each host entry is claimable exactly once from it.
|
|
412
497
|
registerConfirmedBatchForClaims(batch);
|
|
413
|
-
const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: true });
|
|
498
|
+
const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: true, routerStore: context?.routerStore ?? null });
|
|
414
499
|
return { ok: true, cancelled: false, scan, landedAssignments: landed, batchTerminalReason: terminalReason, batchId: batch.batchId };
|
|
415
500
|
}
|
|
416
501
|
|
|
@@ -419,17 +504,33 @@ export async function pulseRun({ boardPath, instruction, context = null, spawnWo
|
|
|
419
504
|
// polling. Only runs when the policy enables Pulse in automated mode.
|
|
420
505
|
// ---------------------------------------------------------------------------
|
|
421
506
|
|
|
422
|
-
export async function pulseTick({ boardPath, spawnWorker = null, context = null, now = null }) {
|
|
507
|
+
export async function pulseTick({ boardPath, spawnWorker = null, context = null, now = null, routerRuntimeFor = null }) {
|
|
423
508
|
const base = observeBoardForPulse({ boardPath });
|
|
424
509
|
if (!base.ok) return { ok: false, code: base.code, reason: base.reason, landedAssignments: [] };
|
|
425
510
|
const { effectivePolicy, pulsePolicy } = base;
|
|
426
511
|
if (!pulsePolicy || pulsePolicy.enabled !== true) return { ok: true, skipped: true, reason: "pulse-disabled", landedAssignments: [] };
|
|
427
512
|
if (pulsePolicy.mode !== "automated") return { ok: true, skipped: true, reason: "interactive-mode-tick-is-a-no-op", landedAssignments: [] };
|
|
428
|
-
|
|
513
|
+
// W3: an automated tick refreshes its capacity snapshot per tick. A factory
|
|
514
|
+
// supplied by the timer re-reads collector caches and health probes so
|
|
515
|
+
// read-time freshness and the prior-reservation term are never frozen at
|
|
516
|
+
// session setup. A factory failure fails the tick closed (no stale routing).
|
|
517
|
+
let tickContext = context;
|
|
518
|
+
if (typeof routerRuntimeFor === "function") {
|
|
519
|
+
try {
|
|
520
|
+
const refreshed = await routerRuntimeFor({ boardPath, now });
|
|
521
|
+
tickContext = { ...(context ?? {}), ...(refreshed ?? {}) };
|
|
522
|
+
} catch (error) {
|
|
523
|
+
return { ok: false, code: "router-unavailable", reason: String(error?.message || error).slice(0, 512), landedAssignments: [] };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const check = pulseCheck({ boardPath, modelRegistry: tickContext?.modelRegistry ?? null, scopedModels: tickContext?.scopedModels ?? null, observedAt: now,
|
|
527
|
+
activeSessions: tickContext?.activeSessions ?? [], providerLimit: tickContext?.providerLimit ?? null });
|
|
429
528
|
if (!check.ok) return { ok: false, code: check.code, reason: check.reason, landedAssignments: [] };
|
|
430
|
-
const
|
|
529
|
+
const routed = await routePulseCheck(check, { ...(tickContext?.routerSnapshot ?? {}), routerConfig: tickContext?.routerConfig, now });
|
|
530
|
+
if (!routed.ok) return { ok: false, code: routed.code, reason: routed.reason, landedAssignments: [] };
|
|
531
|
+
const batch = mintBatchContext({ scan: check.scan, cards: check.validated.cards, policy: effectivePolicy, instruction: "automated-tick", now, routeDecisions: routed.decisions });
|
|
431
532
|
// Automated ticks never infer host dispatch: allowHostDispatch stays false.
|
|
432
|
-
const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: false });
|
|
533
|
+
const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context: tickContext, now, allowHostDispatch: false, routerStore: tickContext?.routerStore ?? null });
|
|
433
534
|
return { ok: true, skipped: false, scan: check.scan, landedAssignments: landed, batchTerminalReason: terminalReason };
|
|
434
535
|
}
|
|
435
536
|
|
|
@@ -443,14 +544,46 @@ export async function pulseTick({ boardPath, spawnWorker = null, context = null,
|
|
|
443
544
|
// shell:false); any container/microvm placement is denied WITHOUT touching
|
|
444
545
|
// the lifecycle — Pulse never represents a contained assignment as contained
|
|
445
546
|
// while starting an ordinary host worker.
|
|
547
|
+
// §5.2 execution validation: the spawn step verifies the model/endpoint
|
|
548
|
+
// passed to Herdr exactly matches the authenticated envelope (model +
|
|
549
|
+
// provider + seat identity). A mismatch fails closed and is reported as a
|
|
550
|
+
// structured route-mismatch result (the caller logs the event).
|
|
551
|
+
export function validateSpawnMatchesEnvelope({ envelope, model, provider = null, seatId = null, endpointRef = null, routerConfig = null }) {
|
|
552
|
+
if (envelope === null || typeof envelope !== "object") {
|
|
553
|
+
return { ok: false, code: "route-mismatch", reason: "no authenticated envelope (fails closed)" };
|
|
554
|
+
}
|
|
555
|
+
if (typeof model !== "string" || model !== envelope.model) {
|
|
556
|
+
return { ok: false, code: "route-mismatch", reason: `spawn model "${model}" does not match the authenticated envelope model "${envelope.model}" (fails closed)` };
|
|
557
|
+
}
|
|
558
|
+
if (typeof provider !== "string" || provider !== envelope.provider) {
|
|
559
|
+
return { ok: false, code: "route-mismatch", reason: `spawn provider "${provider}" does not match the authenticated envelope provider "${envelope.provider}" (fails closed)` };
|
|
560
|
+
}
|
|
561
|
+
if (typeof seatId !== "string" || seatId !== envelope.seatId) {
|
|
562
|
+
return { ok: false, code: "route-mismatch", reason: `spawn seat "${seatId}" does not match the authenticated envelope seat "${envelope.seatId}" (fails closed)` };
|
|
563
|
+
}
|
|
564
|
+
if (routerConfig !== null) {
|
|
565
|
+
const seat = routerConfig?.seats?.find?.((s) => s.seatId === envelope.seatId);
|
|
566
|
+
if (!seat || seat.model !== envelope.model || seat.provider !== envelope.provider || seat.endpointRef !== endpointRef
|
|
567
|
+
|| routerConfig?.endpoints?.[endpointRef]?.kind !== envelope.provider) {
|
|
568
|
+
return { ok: false, code: "route-mismatch", reason: "spawn endpoint does not resolve to the authenticated envelope route (fails closed)" };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return { ok: true, code: null, reason: null };
|
|
572
|
+
}
|
|
573
|
+
|
|
446
574
|
export function pulseWorkerSpawnSeam({ executeHerdrSpawnWorker } = {}) {
|
|
447
575
|
if (typeof executeHerdrSpawnWorker !== "function") {
|
|
448
576
|
throw new Error("pulseWorkerSpawnSeam requires the guarded herdr-lifecycle executeHerdrSpawnWorker");
|
|
449
577
|
}
|
|
450
|
-
return async ({ role, repository, model, placement, context, signal }) => {
|
|
578
|
+
return async ({ role, repository, model, placement, context, signal, envelope = null, provider = null, seatId = null, endpointRef = null }) => {
|
|
451
579
|
if (placement !== "host") {
|
|
452
580
|
return { ok: false, code: "containment-seam-unavailable", reason: CONTAINMENT_SEAM_UNAVAILABLE };
|
|
453
581
|
}
|
|
582
|
+
// §5.2 execution validation: the spawn must exactly match the
|
|
583
|
+
// authenticated envelope's route identity. A mismatch fails closed
|
|
584
|
+
// BEFORE any Herdr call is attempted.
|
|
585
|
+
const routeCheck = validateSpawnMatchesEnvelope({ envelope, model, provider, seatId, endpointRef, routerConfig: context?.routerConfig ?? null });
|
|
586
|
+
if (!routeCheck.ok) return routeCheck;
|
|
454
587
|
// Board policy stores canonical repository paths, while the guarded
|
|
455
588
|
// lifecycle accepts only registry names. Convert only a canonical sibling
|
|
456
589
|
// of the coordinator repository; the lifecycle then revalidates the name,
|
|
@@ -810,7 +943,7 @@ export function createPulseTimer({ intervalSeconds, fillOnStart = false, tick, s
|
|
|
810
943
|
// Tool registration (§12.2)
|
|
811
944
|
// ---------------------------------------------------------------------------
|
|
812
945
|
|
|
813
|
-
export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn = null, spawnWorker = null } = {}) {
|
|
946
|
+
export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn = null, spawnWorker = null, routerRuntimeFor = null } = {}) {
|
|
814
947
|
const boardPathFor = (ctx) => {
|
|
815
948
|
const cwd = typeof ctx === "string" ? ctx : ctx?.cwd;
|
|
816
949
|
if (typeof resolveBoardPathFn === "function") return resolveBoardPathFn(ctx);
|
|
@@ -856,18 +989,47 @@ export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn =
|
|
|
856
989
|
};
|
|
857
990
|
const action = input?.action;
|
|
858
991
|
const activeBoardPath = boardPathFor(ctx);
|
|
992
|
+
let runtimeContext = ctx;
|
|
993
|
+
let runtimeError = null;
|
|
994
|
+
if (["check", "run"].includes(action) && typeof routerRuntimeFor === "function" && activeBoardPath) {
|
|
995
|
+
try {
|
|
996
|
+
// W4: `check` is pure observation, so the runtime is assembled
|
|
997
|
+
// read-only (no store writes, no store creation) while `run` uses
|
|
998
|
+
// the write-mode runtime its claims need.
|
|
999
|
+
runtimeContext = { ...ctx, ...(await routerRuntimeFor(ctx, activeBoardPath, { readOnly: action === "check" })) };
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) { runtimeError = String(error?.message || error).slice(0, 512); }
|
|
1002
|
+
}
|
|
859
1003
|
if (action === "check") {
|
|
860
1004
|
if (!activeBoardPath || !existsSync(activeBoardPath)) {
|
|
861
1005
|
return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
|
|
862
1006
|
}
|
|
863
1007
|
const result = pulseCheck({
|
|
864
1008
|
boardPath: activeBoardPath,
|
|
865
|
-
modelRegistry:
|
|
866
|
-
scopedModels:
|
|
1009
|
+
modelRegistry: runtimeContext?.modelRegistry ?? null,
|
|
1010
|
+
scopedModels: runtimeContext?.scopedModels ?? null,
|
|
867
1011
|
observedAt: new Date().toISOString(),
|
|
1012
|
+
activeSessions: runtimeContext?.activeSessions ?? [],
|
|
1013
|
+
providerLimit: runtimeContext?.providerLimit ?? null,
|
|
868
1014
|
});
|
|
869
1015
|
if (!result.ok) return fail(result.code, result.reason);
|
|
870
|
-
|
|
1016
|
+
// Routing is part of the observation: when the router is available
|
|
1017
|
+
// the check carries the real route decisions; when it is not, the
|
|
1018
|
+
// scan is still reported (pure observation, zero scheduling
|
|
1019
|
+
// effects) with a structured routing failure — dispatch paths
|
|
1020
|
+
// (run/tick) fail closed separately.
|
|
1021
|
+
let routing;
|
|
1022
|
+
if (runtimeError !== null) {
|
|
1023
|
+
routing = { ok: false, code: "router-unavailable", reason: runtimeError };
|
|
1024
|
+
} else {
|
|
1025
|
+
const routed = await routePulseCheck(result, { ...(runtimeContext?.routerSnapshot ?? {}),
|
|
1026
|
+
routerConfig: runtimeContext?.routerConfig, now: runtimeContext?.routerNow ?? new Date().toISOString() });
|
|
1027
|
+
routing = routed.ok
|
|
1028
|
+
? { ok: true, routeDecisions: routed.decisions.map((decision) => ({ cardId: decision.cardId,
|
|
1029
|
+
seatId: decision.selectedSeatId, routeDecisionDigest: decision.digest })) }
|
|
1030
|
+
: { ok: false, code: routed.code, reason: routed.reason };
|
|
1031
|
+
}
|
|
1032
|
+
const value = { ok: true, nonAuthorizing: true, persisted: false, scan: result.scan, routing };
|
|
871
1033
|
return {
|
|
872
1034
|
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
873
1035
|
details: { ...value, diagnosticHash: result.diagnosticHash },
|
|
@@ -877,10 +1039,11 @@ export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn =
|
|
|
877
1039
|
if (!activeBoardPath || !existsSync(activeBoardPath)) {
|
|
878
1040
|
return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
|
|
879
1041
|
}
|
|
1042
|
+
if (runtimeError !== null) return fail("router-unavailable", runtimeError);
|
|
880
1043
|
const result = await pulseRun({
|
|
881
1044
|
boardPath: activeBoardPath,
|
|
882
1045
|
instruction: input?.instruction,
|
|
883
|
-
context:
|
|
1046
|
+
context: runtimeContext,
|
|
884
1047
|
spawnWorker,
|
|
885
1048
|
});
|
|
886
1049
|
if (!result.ok) return fail(result.code, result.reason);
|