mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-08 08:11:17 -10:00
feat(telemetry): W2a-1 — SOURCE-BOUND generation identity at the activity store (store half)
getLatestForNode compares the recorded vs live occupant-tenure generation_uuid. The emitting occupant's generation is CARRIED SOURCE-BOUND on the hook (the producer/relay supplies it at fire time; the route ingests body.generation) — NEVER inferred from record-time state, which mis-attributes a DELAYED prior-occupant hook to the successor occupant (a timing/boot_at inference is unsound at SQLite 1-second precision + cross-host clock skew). Read verdicts: - carried != live -> unknown/generation_mismatch/stale, provenance resolved (REFUSED; dead-tenure evidence; same-second-immune because nothing is timed). - carried == live (incl. a same-native relaunch continuation) -> fresh, provenance resolved. - null on EITHER side -> unknown + a distinct reason (generation_unresolvable = live null / generation_unverifiable = carried none) + stale, generationProvenance='unresolved' ALONGSIDE (never fresh; delivered-labelled). - resolver THROWS -> unknown/generation_resolver_error (degrade; never crashes, never fresh). - no resolver -> legacy, no label. Scope: STORE HALF + route ingestion + a SessionTransport advisory blast-radius fix. SessionTransport.diagnoseProducerLink now distinguishes the four generation reasons from clock-stale BEFORE the clock-stale fallback (a fresh generation verdict is stale:true at age ~0; collapsing it to "beyond the store window / seat quiet" mislabelled producer-not-landed / dead-tenure as a DARK seat). INERT + OBSERVABLE: the relay/env producer-carry is a FILED PREREQUISITE (qitem-20260808195950-73d8311f); until it lands every hook carries null -> generation_unverifiable -> detection INERT (sound, never false-fresh). generation_unverifiable is the MECHANISM (carried null), NOT a single cause: producer-not-wired OR the emitting occupant had no tenure at fire time (mint-race / pre-060 upgrade). Reader rule: ANY resolved/generation_mismatch => producer wired (live); ALL generation_unverifiable => likely-not-wired but not proof (a fleet in mint-race/upgrade looks the same) — disambiguate by tenure. Consumer verify-demotion is the separate tap follow-on (qitem-20260808183747-f7f04662). A structural guard pins exactly one production construction site AND that it injects the resolver. VM-only (mm2 has no occupant_tenures table until 0.5.1). Upgrade-transition: migration 060 at startup + mint-at-register => already-running seats read unknown until re-register. Restacked onto exact main045dea26(was 4 commits behind atc9e2120c0). (B) receipt: the 8 store files' per-file patch-ids are IDENTICAL pre/post restack — store logic unchanged; only startup.ts merged (my source-bound construction site + main's watchdog auto-registration, non-overlapping, auto-merged; the construction-site guard re-earned at the tip). Evidence (measured at the restacked tip): full daemon matrix 2-fail/7105-pass, tsc:0. The only 2 fails are the seat-handover-model-fidelity + seat-handover-cutover e2e, PROVEN NOT MINE by a fresh A/B AT THIS BASE: they fail with IDENTICAL signatures (successor_create_failed / graceful-handover-false) at exact main045dea26WITHOUT this change — a structured deterministic handover-create failure, not a timeout; the definitive uncontended real-vs-flake confirmation is QA/operator domain (the box must drain). Store 17/17 (incl. the same-second carried-A-vs-live-B mismatch + resolver-exception degrade + real-SessionRegistry composition); provider-c4 30/30; sessions-routes 41/41; send-prompt-guard advisory pin (fresh generation_unverifiable -> generation- distinct advisory, never clock-stale). Mechanism note (routed to PM by dev50-planner): source-bound beats the locked rev-2 88b9a3bc line 112 record-time resolution (the delayed-hook mislabel) — a build-discovered refinement toward correctness. Review status: dev50-guard round-3 IN FLIGHT — mechanism shape approved; the diagnoseProducerLink blocker fixed at this SHA; clearance re-requested on the restacked SHA. dev50-planner + orch-advisor prior gates cleared; orch-advisor merge-desk restack/receipt requirements addressed. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
497a85c161
commit
1e41c0cea2
11 files changed
+608
-2
No files matched your search
@@ -11,6 +11,11 @@ export interface HookActivityInput {
|
||||
hookEvent: string;
|
||||
subtype?: string | null;
|
||||
occurredAt?: string | null;
|
||||
/** W2a-1 — the EMITTING occupant's generation, CARRIED source-bound on the hook (the producer/relay
|
||||
* supplies it at fire time; the route ingests it). NOT inferred from record-time state — that
|
||||
* inference mis-attributes a delayed prior-occupant hook to the live occupant. Absent (producer not
|
||||
* yet wired) ⇒ stamped null ⇒ unresolved at read (never false-fresh). */
|
||||
generation?: string | null;
|
||||
}
|
||||
|
||||
export type RecordHookActivityResult =
|
||||
@@ -22,6 +27,13 @@ interface AgentActivityStoreDeps {
|
||||
eventBus: EventBus;
|
||||
now?: () => Date;
|
||||
freshnessMs?: number;
|
||||
/** W2a-1 — resolve the LIVE occupant generation for a node (the shipped occupant-tenure
|
||||
* generation_uuid), or null when UNKNOWN. Injected (not self-constructed) so the store stays
|
||||
* decoupled from SessionRegistry and unit-testable with a fake. When ABSENT the store applies no
|
||||
* generation gate — legacy clock-only freshness — so the existing callers are unchanged until the
|
||||
* producer wiring injects the real resolver. Resolution is SYNCHRONOUS (a better-sqlite3 read),
|
||||
* which is why the gate lives inline in the sync read path with no signature churn to 9 callers. */
|
||||
resolveOccupantGeneration?: (nodeId: string) => string | null;
|
||||
}
|
||||
|
||||
interface SessionLookupRow {
|
||||
@@ -40,12 +52,14 @@ export class AgentActivityStore {
|
||||
private readonly eventBus: EventBus;
|
||||
private readonly now: () => Date;
|
||||
private readonly freshnessMs: number;
|
||||
private readonly resolveOccupantGeneration?: (nodeId: string) => string | null;
|
||||
|
||||
constructor(deps: AgentActivityStoreDeps) {
|
||||
this.db = deps.db;
|
||||
this.eventBus = deps.eventBus;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
this.freshnessMs = deps.freshnessMs ?? AGENT_ACTIVITY_FRESHNESS_MS;
|
||||
this.resolveOccupantGeneration = deps.resolveOccupantGeneration;
|
||||
}
|
||||
|
||||
recordHookEvent(input: HookActivityInput): RecordHookActivityResult {
|
||||
@@ -68,12 +82,22 @@ export class AgentActivityStore {
|
||||
|
||||
const sampledAt = this.now().toISOString();
|
||||
const eventAt = parseTimestamp(input.occurredAt) ?? sampledAt;
|
||||
// W2a-1 — SOURCE-BOUND stamp: the emitting occupant's generation is CARRIED on the hook (supplied
|
||||
// by the producer/relay at fire time, ingested by the route), NEVER inferred from record-time
|
||||
// state. A delayed prior-occupant hook recorded after a new tenure minted carries its OWN (prior)
|
||||
// generation, so the read detects a mismatch rather than crediting it to the live occupant — and
|
||||
// this is immune to boot_at precision / clock skew because nothing is timed. Absent (the relay/env
|
||||
// producer-carry is a filed prerequisite, inert until it lands) ⇒ null ⇒ unresolved at read (the
|
||||
// P21 claimed-era pattern: absence recorded as its own state, never false-fresh). No record-time
|
||||
// resolver call — the live-gen resolver is used only at READ, for the comparison.
|
||||
const generation = input.generation ?? null;
|
||||
const activity = normalizeHookActivity({
|
||||
runtime: input.runtime ?? session.runtime,
|
||||
hookEvent: input.hookEvent,
|
||||
subtype: input.subtype ?? null,
|
||||
sampledAt,
|
||||
eventAt,
|
||||
generation,
|
||||
});
|
||||
|
||||
const event = this.eventBus.emit({
|
||||
@@ -108,6 +132,49 @@ export class AgentActivityStore {
|
||||
if (input.sessionName && payload.sessionName !== input.sessionName) return null;
|
||||
|
||||
const referenceTime = input.now ?? this.now();
|
||||
|
||||
// W2a-1 — compare-at-read against the shipped occupant-tenure generation_uuid (pm ruling
|
||||
// 2026-08-08, the fully-specified Variant C):
|
||||
// (1) both generations known and DIFFERENT ⇒ MISMATCH: positive evidence the claim belongs to a
|
||||
// prior, dead tenure ⇒ unknown + `generation_mismatch`, stale:true, provenance RESOLVED —
|
||||
// REFUSED, so no dead tenure is honored as the live seat (detection fires).
|
||||
// (2) both known and EQUAL (incl. a same-native-session relaunch — the ledger treats it as a
|
||||
// continuation with no new generation) ⇒ provenance RESOLVED, deliver normally (fresh).
|
||||
// (3) null on EITHER side ⇒ UNRESOLVABLE: ABSENCE of evidence, not a dead-tenure finding. State
|
||||
// UNKNOWN + its distinct reason, stale:true — NEVER fresh (fresh is a positive liveness claim
|
||||
// the seat has not earned; marking null-tenure fresh is bare-attribution one field over). The
|
||||
// STORE delivers the row carrying generationProvenance='unresolved' at the ACTIVITY layer. The
|
||||
// tap's discard-condition change that turns this label into a still-flowing consumer row is a
|
||||
// FOLLOW-ON (qitem-20260808183747-f7f04662, verify-demotion) — NOT this fold; in the INTERIM
|
||||
// the unchanged tap still discards it (a bounded regression whose exposure is the mint-race
|
||||
// transient; mm2 is unaffected — its daemon carries no occupant_tenures table until 0.5.1).
|
||||
// Ignorance and evidence get DIFFERENT verdicts; the two paths must not collapse.
|
||||
// Gate runs only when a resolver is injected (else legacy clock-only, no label).
|
||||
let generationProvenance: "resolved" | "unresolved" | undefined;
|
||||
if (this.resolveOccupantGeneration) {
|
||||
let liveGeneration: string | null;
|
||||
try {
|
||||
liveGeneration = this.resolveOccupantGeneration(nodeId);
|
||||
} catch {
|
||||
// Resolver EXCEPTION (e.g. a transient ledger/db fault) ⇒ DEGRADE to a distinct unknown
|
||||
// branch: never crash the read, never render fresh. Distinct from a clean null (unresolvable).
|
||||
return this.generationDegraded(activity, referenceTime);
|
||||
}
|
||||
const recordedGeneration = activity.generation ?? null;
|
||||
if (liveGeneration !== null && recordedGeneration !== null) {
|
||||
if (recordedGeneration !== liveGeneration) {
|
||||
return this.generationMismatch(activity, referenceTime);
|
||||
}
|
||||
generationProvenance = "resolved";
|
||||
} else {
|
||||
return this.generationUnresolved(
|
||||
activity,
|
||||
referenceTime,
|
||||
liveGeneration === null ? "generation_unresolvable" : "generation_unverifiable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const eventTime = activity.eventAt ? Date.parse(activity.eventAt) : NaN;
|
||||
if (Number.isFinite(eventTime) && referenceTime.getTime() - eventTime > this.freshnessMs) {
|
||||
return {
|
||||
@@ -118,6 +185,7 @@ export class AgentActivityStore {
|
||||
sampledAt: referenceTime.toISOString(),
|
||||
fallback: false,
|
||||
stale: true,
|
||||
generationProvenance,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +194,67 @@ export class AgentActivityStore {
|
||||
sampledAt: referenceTime.toISOString(),
|
||||
fallback: false,
|
||||
stale: false,
|
||||
generationProvenance,
|
||||
};
|
||||
}
|
||||
|
||||
/** W2a-1 — the dead-tenure verdict: the claim's occupant generation is KNOWN and DIFFERENT from the
|
||||
* live one, so it must not be honored as the live seat. Returns unknown + `generation_mismatch`,
|
||||
* stale:true (detection fires), provenance `resolved` (both generations WERE resolved — they simply
|
||||
* differ). This is the ONLY generation case that abstains; a null on either side is UNRESOLVED
|
||||
* provenance and is DELIVERED labelled, never routed here. */
|
||||
private generationMismatch(activity: AgentActivity, referenceTime: Date): AgentActivity {
|
||||
return {
|
||||
...activity,
|
||||
state: "unknown",
|
||||
reason: "generation_mismatch",
|
||||
generationProvenance: "resolved",
|
||||
evidenceSource: "runtime_hook",
|
||||
sampledAt: referenceTime.toISOString(),
|
||||
fallback: false,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** W2a-1 — the UNRESOLVABLE-provenance verdict (ABSENCE of evidence, not a dead-tenure finding).
|
||||
* State UNKNOWN + a distinct reason + stale:true so no consumer reads it as verified-live (fresh is
|
||||
* a positive liveness claim). The STORE delivers the row carrying generationProvenance:"unresolved"
|
||||
* at the ACTIVITY layer; turning that label into a still-flowing CONSUMER row is the tap FOLLOW-ON
|
||||
* (verify-demotion, qitem-20260808183747-f7f04662) — the unchanged tap still discards it in the
|
||||
* INTERIM (a bounded regression, mint-race-transient exposure; mm2 has no occupant_tenures table
|
||||
* until 0.5.1). Kept DISTINCT from generationMismatch: ignorance and evidence get different verdicts,
|
||||
* and the paths must not collapse — at the store now, and at the tap when the follow-on lands. */
|
||||
private generationUnresolved(
|
||||
activity: AgentActivity,
|
||||
referenceTime: Date,
|
||||
reason: "generation_unresolvable" | "generation_unverifiable",
|
||||
): AgentActivity {
|
||||
return {
|
||||
...activity,
|
||||
state: "unknown",
|
||||
reason,
|
||||
generationProvenance: "unresolved",
|
||||
evidenceSource: "runtime_hook",
|
||||
sampledAt: referenceTime.toISOString(),
|
||||
fallback: false,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** W2a-1 — the resolver-EXCEPTION verdict: the live-generation resolver threw (a transient ledger/db
|
||||
* fault). DEGRADE to unknown + a DISTINCT reason (`generation_resolver_error`) + stale:true, so the
|
||||
* read never crashes and the claim is never rendered fresh; provenance `unresolved`. Kept separate
|
||||
* from a clean null (unresolvable) so a resolver FAULT is distinguishable from an honest ABSENCE. */
|
||||
private generationDegraded(activity: AgentActivity, referenceTime: Date): AgentActivity {
|
||||
return {
|
||||
...activity,
|
||||
state: "unknown",
|
||||
reason: "generation_resolver_error",
|
||||
generationProvenance: "unresolved",
|
||||
evidenceSource: "runtime_hook",
|
||||
sampledAt: referenceTime.toISOString(),
|
||||
fallback: false,
|
||||
stale: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,6 +300,7 @@ function normalizeHookActivity(input: {
|
||||
subtype: string | null;
|
||||
sampledAt: string;
|
||||
eventAt: string;
|
||||
generation?: string | null;
|
||||
}): AgentActivity {
|
||||
const rawEvent = input.hookEvent;
|
||||
const rawSubtype = input.subtype;
|
||||
@@ -222,6 +352,7 @@ function normalizeHookActivity(input: {
|
||||
runtime,
|
||||
fallback: false,
|
||||
stale: false,
|
||||
generation: input.generation ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1171,6 +1171,28 @@ export class SessionTransport {
|
||||
if (!latest || latest.evidenceSource !== "runtime_hook") {
|
||||
return `daemon-ingest link DOWN — no activity hook has ever been received for this seat; the ingest is rejecting posts (token mismatch → 401, or ingest unconfigured → 503) or Codex hook-trust is uncleared. Verify the seat was OpenRig-launched with hook-trust cleared`;
|
||||
}
|
||||
// W2a-1 — a GENERATION verdict is stale:true but the hook is RECENT (age ~0); collapsing it to
|
||||
// "beyond the store window / seat quiet" mislabels producer-not-landed / dead-tenure as a DARK
|
||||
// seat and defeats the inert-visible differentiation. Distinguish the generation cause explicitly
|
||||
// BEFORE the clock-stale fallback. generation_unverifiable is the INERT signal (producer-carry not
|
||||
// yet wired — sound, not dark); the others name a real generation condition, not a quiet seat.
|
||||
if (latest.stale === true && typeof latest.reason === "string" && latest.reason.startsWith("generation_")) {
|
||||
const ageS = latest.eventAt ? Math.round((this.now().getTime() - Date.parse(latest.eventAt)) / 1000) : null;
|
||||
const age = ageS !== null ? `${ageS}s ago` : "recently";
|
||||
switch (latest.reason) {
|
||||
case "generation_unverifiable":
|
||||
// Carried generation was null — the MECHANISM, not a single cause: EITHER the source-bound
|
||||
// producer-carry (relay/env) is not yet wired (inert), OR the emitting occupant had no tenure
|
||||
// at fire time (mint-race / pre-060 upgrade-transition). Sound, not dark; not a quiet seat.
|
||||
return `producer link OK — a recent hook exists (${age}) but carried NO occupant generation; either the source-bound producer-carry (relay/env) is not yet wired, OR the emitting occupant had no tenure at fire time (mint-race / pre-060 upgrade). Generation UNVERIFIABLE, not a quiet seat`;
|
||||
case "generation_unresolvable":
|
||||
return `producer link OK — a recent hook exists (${age}) but the LIVE occupant generation could not be resolved (no tenure row); generation UNRESOLVABLE, not a quiet seat`;
|
||||
case "generation_mismatch":
|
||||
return `producer link OK — a recent hook exists (${age}) but it belongs to a PRIOR occupant generation (a dead tenure), not this live occupant; the seat is NOT quiet`;
|
||||
case "generation_resolver_error":
|
||||
return `producer link OK — a recent hook exists (${age}) but the occupant-generation resolver errored; generation verdict DEGRADED, not a quiet seat`;
|
||||
}
|
||||
}
|
||||
if (latest.stale === true) {
|
||||
const ageS = latest.eventAt ? Math.round((this.now().getTime() - Date.parse(latest.eventAt)) / 1000) : null;
|
||||
return `producer link OK but STALE — the last activity hook arrived ${ageS !== null ? `${ageS}s ago` : "long ago"} (beyond the store window); the seat has gone quiet or its hooks stopped firing`;
|
||||
|
||||
@@ -443,6 +443,19 @@ export interface AgentActivity {
|
||||
runtime?: string | null;
|
||||
fallback?: boolean;
|
||||
stale?: boolean;
|
||||
/** W2a-1 — the occupant-tenure generation_uuid in force when this claim was recorded — the per-tenure
|
||||
* occupant identity that CHANGES for a new occupant (a handover/swap to a different occupant mints a
|
||||
* new generation) and persists only WITHIN a single tenure (a same-native-session relaunch is a
|
||||
* continuation, same generation); null = UNKNOWN at record time. node_id, NOT this, is what is stable
|
||||
* across handover — that is exactly why comparing generation detects a dead tenure. */
|
||||
generation?: string | null;
|
||||
/** W2a-1 — the read-side provenance verdict for the occupant generation (present only when the
|
||||
* read resolved through an injected generation resolver): `resolved` = both the recorded and the
|
||||
* live generation were known (equal here; a KNOWN mismatch returns state:"unknown" instead);
|
||||
* `unresolved` = null on either side, so the claim is DELIVERED carrying this honest label rather
|
||||
* than dropped or bare-attributed (pm ruling 2026-08-08, the P21 claimed-era pattern). Absent when
|
||||
* no resolver is wired (legacy clock-only path). */
|
||||
generationProvenance?: "resolved" | "unresolved";
|
||||
}
|
||||
|
||||
export interface NodeRecoveryGuidance {
|
||||
|
||||
@@ -126,6 +126,10 @@ activityRoutes.post("/hooks", async (c) => {
|
||||
hookEvent: typeof body.hookEvent === "string" ? body.hookEvent : "",
|
||||
subtype: stringOrNull(body.subtype),
|
||||
occurredAt: stringOrNull(body.occurredAt),
|
||||
// W2a-1 — source-bound emitting generation, carried by the producer/relay. Until the relay/env
|
||||
// injection lands (a filed prerequisite), this is absent ⇒ stamped null ⇒ unresolved at read
|
||||
// (detection inert-but-sound; never false-fresh).
|
||||
generation: stringOrNull(body.generation),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -895,7 +895,16 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// (ContextUsageStore is constructed above, ahead of ClaimService, for FR-3.)
|
||||
const whoamiService = new WhoamiService({ db, rigRepo, sessionRegistry, transcriptStore, contextUsageStore });
|
||||
const nodeCmuxService = new NodeCmuxService(rigRepo, sessionRegistry, cmuxAdapter, tmuxAdapter);
|
||||
const agentActivityStore = new AgentActivityStore({ db, eventBus });
|
||||
// W2a-1 — producer wiring: the live occupant generation resolves synchronously from the shipped
|
||||
// occupant-tenure ledger. generation_uuid CHANGES for a new occupant and persists only within one
|
||||
// tenure (a same-session relaunch is a continuation with no new generation); node_id is the stable-
|
||||
// across-handover key, not this. null = UNKNOWN, honored by the store as abstain (never a stale claim
|
||||
// rendered live). No tmux exec — a better-sqlite3 read on the hot path.
|
||||
const agentActivityStore = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
|
||||
});
|
||||
const { SeatAttentionReconciler } = await import("./domain/seat-attention-reconciler.js");
|
||||
const seatAttentionReconciler = new SeatAttentionReconciler({
|
||||
sessionRegistry, eventBus, agentActivityStore, db,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// W2a-1 STRUCTURAL GUARD (A4 sixth-site shape).
|
||||
//
|
||||
// The generation gate is an OPTIONAL dependency: a construction site that omits
|
||||
// `resolveOccupantGeneration` silently falls to the legacy clock-only path and DISABLES the
|
||||
// generation detector — with nothing failing. "One production site, resolver injected" is true today
|
||||
// (startup.ts) but unenforced tomorrow. This pins it: production `src/` must contain EXACTLY ONE
|
||||
// `new AgentActivityStore(` and that site must inject the resolver. A second site, or a naked one,
|
||||
// fails LOUD here rather than turning the detector off in the dark.
|
||||
//
|
||||
// (Direct constructions in `test/` intentionally omit the resolver to exercise the legacy path; they
|
||||
// are excluded because they are prod-unreachable by construction — the whole reason this guard exists.)
|
||||
describe("AgentActivityStore — single production construction site (W2a-1)", () => {
|
||||
const srcDir = fileURLToPath(new URL("../src", import.meta.url));
|
||||
|
||||
function walkTs(dir: string): string[] {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) return walkTs(full);
|
||||
return entry.isFile() && full.endsWith(".ts") ? [full] : [];
|
||||
});
|
||||
}
|
||||
|
||||
it("has exactly one production construction site, and it injects resolveOccupantGeneration", () => {
|
||||
const sites: Array<{ file: string; injectsResolver: boolean }> = [];
|
||||
for (const file of walkTs(srcDir)) {
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
const re = /new AgentActivityStore\(/g;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(text)) !== null) {
|
||||
// Inspect a bounded window of the constructor-arg object literal for the resolver key.
|
||||
const window = text.slice(match.index, match.index + 400);
|
||||
sites.push({
|
||||
file: path.relative(srcDir, file),
|
||||
injectsResolver: /resolveOccupantGeneration/.test(window),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
sites,
|
||||
`expected exactly ONE production construction site of AgentActivityStore; got ${JSON.stringify(sites)}`,
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
sites[0]?.injectsResolver,
|
||||
`the sole construction site (${sites[0]?.file}) must inject resolveOccupantGeneration — a naked site ` +
|
||||
`silently disables the generation gate (legacy clock-only path)`,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -225,3 +225,295 @@ describe("AgentActivityStore", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// W2a-1 — GENERATION IDENTITY (SOURCE-BOUND). A claim from a PRIOR occupant generation must not be
|
||||
// honored as the live seat. The emitting occupant's generation is CARRIED source-bound on the hook (the
|
||||
// producer/relay supplies it at fire time; the route ingests it) — NEVER inferred from record-time state
|
||||
// (that inference mis-attributes a DELAYED prior-occupant hook, and timing/boot_at is unsound at 1s
|
||||
// precision + clock skew). The READ resolves the LIVE generation (session-registry currentOccupantTenure)
|
||||
// and compares. Cases: (1) carried != live ⇒ MISMATCH — unknown/generation_mismatch/stale, provenance
|
||||
// RESOLVED, REFUSED (positive dead-tenure evidence; immune to timing — same-second is still a mismatch);
|
||||
// (2) carried == live (incl. a same-native relaunch continuation) ⇒ fresh, provenance resolved; (3) null
|
||||
// on EITHER side ⇒ UNRESOLVABLE (absence of evidence): unknown + a DISTINCT reason (unresolvable = live
|
||||
// null / unverifiable = carried none) + stale — NEVER fresh — with generationProvenance='unresolved'
|
||||
// riding alongside so the row is DELIVERED (tap verify-demotion is a follow-on). The carried-none case
|
||||
// is the INERT-until-producer state: sound, never false-fresh, detection off until the relay/env carry
|
||||
// lands (a filed prerequisite); (4) no resolver ⇒ legacy, no label; (5) resolver EXCEPTION ⇒ degrade
|
||||
// (generation_resolver_error). Ignorance and evidence get different verdicts and must not collapse.
|
||||
describe("AgentActivityStore — generation identity (W2a-1)", () => {
|
||||
let db: Database.Database;
|
||||
let rigRepo: RigRepository;
|
||||
let sessionRegistry: SessionRegistry;
|
||||
let eventBus: EventBus;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createFullTestDb();
|
||||
rigRepo = new RigRepository(db);
|
||||
sessionRegistry = new SessionRegistry(db);
|
||||
eventBus = new EventBus(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
function seedGenSession() {
|
||||
const rig = rigRepo.createRig("gen-rig");
|
||||
const node = rigRepo.addNode(rig.id, "dev.qa", { runtime: "codex" });
|
||||
const sessionName = "dev-qa@gen-rig";
|
||||
const session = sessionRegistry.registerSession(node.id, sessionName);
|
||||
sessionRegistry.updateStatus(session.id, "running");
|
||||
sessionRegistry.updateBinding(node.id, { tmuxSession: sessionName, attachmentType: "tmux" });
|
||||
return { node, sessionName };
|
||||
}
|
||||
|
||||
// (1) DELAYED PRIOR-OCCUPANT / SAME-SECOND — the atom's whole case, closed by SOURCE-BINDING, not
|
||||
// timing. A hook CARRYING a prior generation (gen-A), read while the live occupant is gen-B, is a
|
||||
// MISMATCH — even in the same wall-clock second (nothing is timed; boot_at 1s precision + clock skew
|
||||
// are irrelevant because the generation is carried, not inferred). Never rendered fresh for the live
|
||||
// occupant. This is the delayed prior-occupant hook that the record-time-inference version mis-credited.
|
||||
it("carried prior generation vs live (same-second) ⇒ unknown/generation_mismatch, never fresh", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: () => "gen-B", // the LIVE occupant at read time
|
||||
});
|
||||
|
||||
// The hook CARRIES gen-A (its emitting occupant), recorded while gen-B is already live.
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "Stop",
|
||||
occurredAt: "2026-04-24T11:59:00.000Z",
|
||||
generation: "gen-A",
|
||||
});
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest).toMatchObject({
|
||||
state: "unknown",
|
||||
reason: "generation_mismatch",
|
||||
evidenceSource: "runtime_hook",
|
||||
stale: true,
|
||||
generationProvenance: "resolved", // both generations WERE resolved; they simply differ
|
||||
});
|
||||
});
|
||||
|
||||
// (2) CARRIED == LIVE (incl. relaunch-continuation) ⇒ fresh. The hook carries the same generation the
|
||||
// ledger now reports live (a same-native relaunch is a continuation, no new generation).
|
||||
it("carried generation equals live (incl. relaunch-continuation) ⇒ fresh, provenance resolved", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: () => "gen-A",
|
||||
});
|
||||
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "UserPromptSubmit",
|
||||
occurredAt: "2026-04-24T11:59:30.000Z",
|
||||
generation: "gen-A",
|
||||
});
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest).toMatchObject({
|
||||
state: "running",
|
||||
reason: "user_prompt_submit",
|
||||
stale: false,
|
||||
generationProvenance: "resolved",
|
||||
});
|
||||
});
|
||||
|
||||
// (3a) UNRESOLVABLE — live side. The live generation is UNKNOWN (resolver ⇒ null) even though the hook
|
||||
// carried gen-A. ABSENCE of evidence, not a dead-tenure finding: state UNKNOWN + distinct reason +
|
||||
// stale:true — NEVER fresh — with generationProvenance='unresolved' riding alongside so the row is
|
||||
// DELIVERED (tap verify-demotion, follow-on). Distinct reason from (3b) and from mismatch.
|
||||
it("unresolvable live generation (resolver ⇒ null) ⇒ unknown/generation_unresolvable + unresolved, stale", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: () => null, // the ledger cannot resolve the LIVE occupant generation
|
||||
});
|
||||
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "Stop",
|
||||
occurredAt: "2026-04-24T11:59:00.000Z",
|
||||
generation: "gen-A", // carried, but the live side is unknown ⇒ cannot verify
|
||||
});
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest).toMatchObject({
|
||||
state: "unknown", // NOT fresh — no false liveness claim
|
||||
reason: "generation_unresolvable",
|
||||
stale: true,
|
||||
generationProvenance: "unresolved",
|
||||
});
|
||||
});
|
||||
|
||||
// (3b) UNVERIFIABLE — carried side. The hook carried NO generation (the relay/env producer-carry is a
|
||||
// filed prerequisite, not yet wired ⇒ absent ⇒ null), so it cannot be verified against the live
|
||||
// generation. Same family as (3a) — unknown + label, never fresh — but a DISTINCT reason (keep them
|
||||
// separate; ignorance has more than one shape and collapsing them loses forensics). This is the
|
||||
// INERT-until-producer state: sound, never false-fresh, detection off until the carry lands.
|
||||
it("unverifiable carried generation (hook carried none) ⇒ unknown/generation_unverifiable + unresolved, stale", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: () => "gen-B",
|
||||
});
|
||||
|
||||
// Producer did NOT carry a generation ⇒ recorded null (the inert-but-sound path until the carry lands).
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "Stop",
|
||||
occurredAt: "2026-04-24T11:59:00.000Z",
|
||||
});
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest).toMatchObject({
|
||||
state: "unknown",
|
||||
reason: "generation_unverifiable",
|
||||
stale: true,
|
||||
generationProvenance: "unresolved",
|
||||
});
|
||||
});
|
||||
|
||||
// (4) ZERO-RIPPLE — with NO resolver injected, reads are unchanged and carry NO provenance label
|
||||
// (the legacy clock-only path). This is the prod-unreachable branch (production always injects the
|
||||
// resolver — see the single-construction-site guard), kept green so the injection stays opt-in-safe.
|
||||
it("no generation resolver ⇒ legacy behavior, no label", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({ db, eventBus, now: () => NOW });
|
||||
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "UserPromptSubmit",
|
||||
occurredAt: "2026-04-24T11:59:30.000Z",
|
||||
});
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest).toMatchObject({ state: "running", reason: "user_prompt_submit", stale: false });
|
||||
expect(latest?.generationProvenance).toBeUndefined();
|
||||
});
|
||||
|
||||
// (2b) PURE-FRESHNESS pin — GREEN AT BASE by design (documented so it never reads as a dud). Asserts
|
||||
// ONLY the same-generation-stays-fresh BEHAVIOUR (which exists at base) and deliberately NOT the new
|
||||
// provenance label. A red here means freshness regressed, unambiguously — never "the label went
|
||||
// missing" (test (2) above guards the label). One pin per claim: a pin that can fail for two reasons
|
||||
// names neither (P32 — assert the discriminating field).
|
||||
it("same generation ⇒ stays fresh — PURE freshness, no label assertion (green at base + after)", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: () => "gen-A",
|
||||
});
|
||||
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "UserPromptSubmit",
|
||||
occurredAt: "2026-04-24T11:59:30.000Z",
|
||||
generation: "gen-A",
|
||||
});
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest?.state).toBe("running");
|
||||
expect(latest?.stale).toBe(false);
|
||||
// deliberately NO generationProvenance assertion here — freshness-only discriminator.
|
||||
});
|
||||
|
||||
// (5) MECHANISM proof against the SHIPPED occupant-tenure ledger — NOT a constant fake. The READ-side
|
||||
// resolver is the real currentOccupantTenure(nodeId).generationUuid; the hook CARRIES the emitting
|
||||
// generation minted through the real mintOccupantTenure. The effect (does the store honor the real
|
||||
// ledger), not the indicator. Both boundary directions at once: a same-native relaunch is a
|
||||
// continuation (the live generation stays == the carried one ⇒ fresh); a different-native occupant
|
||||
// mints a NEW live generation (⇒ the carried prior generation is a dead tenure ⇒ mismatch/refused).
|
||||
it("REAL ledger: a hook carrying gen-A stays fresh across a relaunch (same native), mismatches a new occupant (different native)", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
|
||||
});
|
||||
|
||||
// The emitting occupant's generation, minted through the real ledger, is CARRIED on the hook.
|
||||
const genA = sessionRegistry.mintOccupantTenure(node.id, "initial", "boot-A").generationUuid;
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "Stop",
|
||||
occurredAt: "2026-04-24T11:59:00.000Z",
|
||||
generation: genA,
|
||||
});
|
||||
|
||||
// RELAUNCH — same native id ⇒ mintOccupantTenure returns the EXISTING tenure (no new generation),
|
||||
// so the LIVE generation stays gen-A ⇒ carried == live ⇒ fresh.
|
||||
const genRelaunch = sessionRegistry.mintOccupantTenure(node.id, "initial", "boot-A").generationUuid;
|
||||
expect(genRelaunch).toBe(genA); // continuation, proven at the ledger (not stipulated by a fake)
|
||||
expect(store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW })).toMatchObject({
|
||||
state: "idle",
|
||||
reason: "stop",
|
||||
stale: false,
|
||||
generationProvenance: "resolved",
|
||||
});
|
||||
|
||||
// NEW OCCUPANT — a different native id mints a NEW live generation ⇒ the carried gen-A is a dead tenure.
|
||||
const genB = sessionRegistry.mintOccupantTenure(node.id, "initial", "boot-B").generationUuid;
|
||||
expect(genB).not.toBe(genA);
|
||||
expect(store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW })).toMatchObject({
|
||||
state: "unknown",
|
||||
reason: "generation_mismatch",
|
||||
stale: true,
|
||||
generationProvenance: "resolved",
|
||||
});
|
||||
});
|
||||
|
||||
// (6) RESOLVER EXCEPTION ⇒ DEGRADE. The live-generation resolver throws (a transient ledger/db
|
||||
// fault). The read must NOT crash and must NOT render fresh: a distinct unknown/generation_resolver_
|
||||
// error verdict, stale, provenance unresolved. Record does not resolve the generation (source-bound),
|
||||
// so recording never throws either.
|
||||
it("resolver exception at read ⇒ unknown/generation_resolver_error (degrade), never crashes", () => {
|
||||
const { node, sessionName } = seedGenSession();
|
||||
const store = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => NOW,
|
||||
resolveOccupantGeneration: () => {
|
||||
throw new Error("occupant-tenure ledger unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
store.recordHookEvent({
|
||||
runtime: "codex",
|
||||
sessionName,
|
||||
hookEvent: "Stop",
|
||||
occurredAt: "2026-04-24T11:59:00.000Z",
|
||||
generation: "gen-A",
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
const latest = store.getLatestForNode({ nodeId: node.id, sessionName, now: NOW });
|
||||
expect(latest).toMatchObject({
|
||||
state: "unknown",
|
||||
reason: "generation_resolver_error",
|
||||
stale: true,
|
||||
generationProvenance: "unresolved",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -329,6 +329,10 @@ export function createTestApp(
|
||||
db,
|
||||
eventBus,
|
||||
freshnessMs: opts?.activityFreshnessMs,
|
||||
// W2a-1 — mirror the production producer wiring so integration tests exercise generation-honesty.
|
||||
// Fixtures mint occupant tenures at registerSession, so same-tenure reads stay fresh; a test that
|
||||
// reads across a minted new generation (or with no tenure) sees the honest UNKNOWN.
|
||||
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
|
||||
});
|
||||
|
||||
const podBundleSourceResolver = new PodBundleSourceResolver();
|
||||
|
||||
@@ -272,6 +272,11 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
|
||||
registryRuntime?: string;
|
||||
seatRuntime?: string;
|
||||
now?: string;
|
||||
// W2a-1 — mint a realistic occupant tenure for the seat (default true). A real registered seat has
|
||||
// one (minted at the register verbs), so the activity resolves RESOLVED provenance and stays fresh,
|
||||
// letting the eligibility discriminators (runtime / profile / event-class) actually run. mintTenure:
|
||||
// false leaves the seat tenure-less ⇒ UNRESOLVED provenance, for the deliberate interim-drop guard.
|
||||
mintTenure?: boolean;
|
||||
}
|
||||
|
||||
function publicFixture(options: PublicFixtureOptions = {}) {
|
||||
@@ -296,6 +301,13 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
|
||||
).run("node-1", "rig-1", "dev.impl", options.seatRuntime ?? "codex", "/project", "pod-1", "local:agents/impl", "default", "impl", "1.0.0", "abc123");
|
||||
db.prepare("INSERT INTO sessions (id, node_id, session_name, status, startup_status) VALUES (?, ?, ?, ?, ?)").run("session-1", "node-1", SESSION, "running", "ready");
|
||||
db.prepare("INSERT INTO bindings (id, node_id, tmux_session) VALUES (?, ?, ?)").run("binding-1", "node-1", SESSION);
|
||||
if (options.mintTenure !== false) {
|
||||
// A real registered seat mints an occupant tenure at the register verbs — reproduce it so the
|
||||
// read resolves RESOLVED provenance (fresh), not the tenure-less UNRESOLVED path.
|
||||
db.prepare(
|
||||
"INSERT INTO occupant_tenures (id, node_id, generation_ordinal, generation_uuid, kind, native_session_id_at_boot) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run("tenure-node-1", "node-1", 1, "gen-uuid-node-1", "initial", null);
|
||||
}
|
||||
|
||||
const testApp = createTestApp(db, { activityHookToken: TOKEN });
|
||||
const service = new ProviderServiceImpl({
|
||||
@@ -310,7 +322,7 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
|
||||
|
||||
async function postHook(
|
||||
app: ReturnType<typeof createTestApp>["app"],
|
||||
input: { runtime?: string; hookEvent?: string; subtype?: string; occurredAt?: string },
|
||||
input: { runtime?: string; hookEvent?: string; subtype?: string; occurredAt?: string; generation?: string | null },
|
||||
): Promise<Response> {
|
||||
return app.request("/api/activity/hooks", {
|
||||
method: "POST",
|
||||
@@ -321,6 +333,10 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
|
||||
hookEvent: input.hookEvent ?? "Notification",
|
||||
subtype: input.subtype,
|
||||
occurredAt: input.occurredAt ?? EVENT_AT,
|
||||
// W2a-1 — the emitting occupant's generation, carried source-bound (simulate the WIRED producer).
|
||||
// Defaults to the fixture's minted tenure generation so the read RESOLVES (carried == live) and
|
||||
// the eligibility discriminators actually run; pass null to exercise the INERT unwired path.
|
||||
generation: input.generation === undefined ? "gen-uuid-node-1" : input.generation,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -343,6 +359,19 @@ describe("Slice-04 C4 correction — public-altitude eligibility gate + producer
|
||||
}]);
|
||||
});
|
||||
|
||||
// W2a-1 CONSUMER-LEVEL guard — the INERT-until-producer state. The seat HAS a tenure (the read-side
|
||||
// resolver returns a live generation), but the relay/env producer-carry is NOT yet wired, so the hook
|
||||
// carries NO generation ⇒ recorded null ⇒ generation_unverifiable ⇒ unknown/stale on the ACTIVITY
|
||||
// (asserted in agent-activity-store.test.ts). This fold ships the STORE half + route ingestion only;
|
||||
// the source-bound producer-carry (relay/env) is a filed PREREQUISITE and the tap's verify-demotion
|
||||
// is a filed FOLLOW-ON. So the unchanged tap discards the unverifiable read → no reactive row yet.
|
||||
// Sound (never false-fresh), inert until the carry lands; pins it honestly, NOT a restored false-fresh.
|
||||
it("W2a-1 (interim/inert): producer not carrying the generation (hook carries none) ⇒ unverifiable ⇒ store-delivered unknown, tap does not yet emit", async () => {
|
||||
const fx = publicFixture(); // seat HAS a tenure; the relay/env generation-carry is unwired
|
||||
expect((await postHook(fx.app, { runtime: "codex", hookEvent: "rate_limit", generation: null })).status).toBe(200);
|
||||
expect(reactiveRows(await fx.service.getReadModel())).toEqual([]);
|
||||
});
|
||||
|
||||
it("HIGH-1a: a claude-code activity attached to a Codex seat must NOT emit a Codex row", async () => {
|
||||
const fx = publicFixture();
|
||||
expect((await postHook(fx.app, { runtime: "claude-code", hookEvent: "rate_limit" })).status).toBe(200);
|
||||
|
||||
@@ -407,6 +407,48 @@ describe("OPR.0.4.1.10 rig send prompt/permission guard (keystone)", () => {
|
||||
expect(sendText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// W2a-1 round-3 — the producer-link advisory must NOT collapse a FRESH generation verdict into a
|
||||
// false clock-stale/seat-quiet warning. A resolver-backed store + a hook that carried NO generation
|
||||
// ⇒ generation_unverifiable (state unknown, stale:true, but age ~0). The advisory must name the
|
||||
// generation cause honestly (UNVERIFIABLE — either not-wired OR no-tenure-at-fire), never "beyond the
|
||||
// store window / seat quiet". This is the inert-visible differentiation at the SessionTransport seam.
|
||||
it("W2a-1: a FRESH generation_unverifiable read yields a generation-distinct advisory, NOT clock-stale/seat-quiet", async () => {
|
||||
const now = new Date("2026-06-27T12:00:00.000Z");
|
||||
// The seat HAS a tenure (registerSession minted one), so the live generation resolves; the hook
|
||||
// carries NO generation ⇒ generation_unverifiable (fresh, age ~0).
|
||||
const genStore = new AgentActivityStore({
|
||||
db,
|
||||
eventBus,
|
||||
now: () => now,
|
||||
resolveOccupantGeneration: (nodeId) => sessionRegistry.currentOccupantTenure(nodeId)?.generationUuid ?? null,
|
||||
});
|
||||
genStore.recordHookEvent({
|
||||
runtime: "claude-code",
|
||||
sessionName: "dev-impl@my-rig",
|
||||
hookEvent: "UserPromptSubmit",
|
||||
occurredAt: now.toISOString(),
|
||||
// deliberately NO generation carried ⇒ recorded null ⇒ unverifiable
|
||||
});
|
||||
const { sendText } = spies();
|
||||
const t = new SessionTransport({
|
||||
db,
|
||||
rigRepo,
|
||||
sessionRegistry,
|
||||
eventBus,
|
||||
tmuxAdapter: mockTmux({ capturePaneContent: async () => "xyzzy no prompt here", sendText }),
|
||||
agentActivityStore: genStore,
|
||||
now: () => now,
|
||||
});
|
||||
const r = await t.send("dev-impl@my-rig", "hi");
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.warning).toContain("producer-link:");
|
||||
expect(r.warning).toContain("carried NO occupant generation"); // generation-distinct, not clock-stale
|
||||
expect(r.warning).toContain("UNVERIFIABLE, not a quiet seat");
|
||||
expect(r.warning).not.toContain("beyond the store window");
|
||||
expect(r.warning).not.toContain("gone quiet");
|
||||
expect(sendText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("AMEND: a hook fresh within the send window (idle) is authoritative and the send proceeds", async () => {
|
||||
const fixedNow = new Date("2026-06-27T12:00:00.000Z");
|
||||
agentActivityStore.recordHookEvent({
|
||||
|
||||
@@ -643,6 +643,9 @@ describe("Session routes", () => {
|
||||
hookEvent: "Notification",
|
||||
subtype: "permission_prompt",
|
||||
occurredAt: new Date().toISOString(),
|
||||
// W2a-1 — carry the emitting occupant generation (source-bound) so the read RESOLVES and the fresh
|
||||
// hook state is honored, rather than degrading to generation_unverifiable.
|
||||
generation: sessionRegistry.currentOccupantTenure(node.id)?.generationUuid ?? null,
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/rigs/${rig.id}/nodes`);
|
||||
@@ -680,6 +683,9 @@ describe("Session routes", () => {
|
||||
sessionName: "dev-impl@test-rig",
|
||||
hookEvent: "UserPromptSubmit",
|
||||
occurredAt: "2000-01-01T00:00:00.000Z",
|
||||
// W2a-1 — carry the emitting generation so the read RESOLVES, letting the CLOCK-staleness verdict
|
||||
// (stale_runtime_hook) fire — rather than the generation gate short-circuiting to unverifiable.
|
||||
generation: sessionRegistry.currentOccupantTenure(node.id)?.generationUuid ?? null,
|
||||
});
|
||||
|
||||
const res = await app.request(`/api/rigs/${rig.id}/nodes`);
|
||||
|
||||
Reference in new issue
Block a user