fix(telemetry): ACTIVITY D1+D2 — age motion at read time; a dead cache must not read as running

dev50-guard HOLD on 3d0fda724 (2026-08-11T05:12:46Z), confirmed at source.

`SeatActivityService.pollSeat` returns null on a tmux error BEFORE it replaces
or deletes the cached record (seat-activity-service.ts:74), and
`pollAllRunningTmuxSeats` only evicts seats that are no longer running in the
DB. A seat still marked running whose tmux read keeps failing therefore keeps
its last observation forever — including `isActiveWithinWindow: true`.

The fold trusted that boolean and never aged the raw `lastActivityAt` against
the request clock, so an UNAVAILABLE observation became an affirmative liveness
claim: ACTIVITY would report `running` indefinitely after the instrument went
dark. Guard's discriminator fed an hour-old cached `true` and got
`state: running` out of the classifier.

That is this ladder's own no-fabrication rule broken in the direction nobody
was watching. Three guards already stopped motion inventing `idle` from
silence; none stopped a dead cache inventing `running`. A silent seat and a
seat we can no longer SEE are different states, and only the raw timestamp
tells them apart.

Freshness is now decided at read time from the raw `lastActivityAt` against the
request clock, using the seat's OWN `silenceWindowSeconds` — the same threshold
the service used at poll time, not a second one invented here. It fails CLOSED
when the fact is absent or unparseable, because an un-ageable affirmative is
indistinguishable from a long-dead one. A negative age still reads live,
preserving pollSeat's deliberate clock-skew tolerance.

Six RED-first controls added. The previous nine could not catch this: every one
of them varied the cached boolean and NONE varied observation age independently
of it, so the defect sat on an axis the gate never swept. A9/A10 (hour-old
`true` must not upgrade `unknown`, must not overturn positive `idle`), A11
(un-ageable fails closed), A13 (just inside / just outside the window) were
observed RED before this change; A12 and A14 pin the preserve side so a
correction that simply disabled motion cannot pass.

Gate 15/15. Full daemon suite 7283 passed / 0 failed. tsc 0. seat-activity,
structural-signal, healthz-wedge, attach-terminal-activity, ps-projection and
startup-wiring regressions all green.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
v-openrig-buildandClaude Opus 5 committed 2026-08-11 05:22:49 +00:00
1 parent 3d0fda724d
commit 6124955107
2 files changed
+139 -5

No files matched your search

+31 -1
View File
@@ -1098,7 +1098,37 @@ export async function attachAgentActivity(
const motion = entry.canonicalSessionName
? deps.seatActivity?.getSeatActivity(entry.canonicalSessionName) ?? null
: null;
const motionRunning = motion?.isActiveWithinWindow === true;
/**
* Freshness is decided HERE, at read time, from the RAW `lastActivityAt` against the request
* clock — NEVER from the cached `isActiveWithinWindow` boolean alone.
*
* WHY (dev50-guard HOLD on 29ad1b2b9): `SeatActivityService.pollSeat` returns null on a tmux
* error BEFORE it replaces or deletes the cached record, and `pollAllRunningTmuxSeats` only
* evicts seats that are no longer running in the DB. A seat still marked running whose tmux read
* keeps failing therefore KEEPS its last observation forever — including a `true`. Trusting that
* boolean turned an UNAVAILABLE observation into an affirmative liveness claim: ACTIVITY would
* report `running` indefinitely after the instrument went dark.
*
* That is this ladder's own no-fabrication rule broken in the unwatched direction. Three guards
* below stop motion inventing `idle` from silence; none of them stopped a dead cache inventing
* `running`. A silent seat and a seat we can no longer SEE are different states, and only the
* raw timestamp distinguishes them.
*
* Fail CLOSED when the fact cannot be aged (absent or unparseable): an un-ageable affirmative is
* indistinguishable from a long-dead one, so it must not become a liveness claim.
*
* The seat's OWN `silenceWindowSeconds` is the threshold — the same one the service used at poll
* time. No second threshold is invented here. A negative age (raw fact slightly ahead of the
* request clock) still reads live, preserving pollSeat's deliberate clock-skew tolerance.
*/
const motionRunning = (() => {
if (motion?.isActiveWithinWindow !== true) return false;
const rawMs = motion.lastActivityAt ? Date.parse(motion.lastActivityAt) : Number.NaN;
if (!Number.isFinite(rawMs)) return false;
const ageSeconds = (sampledAt.getTime() - rawMs) / 1000;
return ageSeconds < motion.silenceWindowSeconds;
})();
/**
* ACTIVITY D1+D2 — ONE precedence rule, applied at EVERY exit of the ladder:
@@ -49,8 +49,15 @@ function entries(sessionName: string, runtime = "claude-code") {
return [{ canonicalSessionName: sessionName, runtime, attachmentType: "tmux", logicalId: "dev.impl" }] as never;
}
/** The cached MOTION observation, as SeatActivityService exposes it. A cache read, never a capture. */
function mkMotion(isActiveWithinWindow: boolean | null) {
/** A request clock, so observation age is a variable the tests control independently of the cache. */
const NOW = new Date("2026-08-11T04:52:00.000Z");
/** The cached MOTION observation, as SeatActivityService exposes it. A cache read, never a capture.
* `lastActivityAt` defaults FRESH relative to NOW; the stale-cache tests override it. */
function mkMotion(
isActiveWithinWindow: boolean | null,
lastActivityAt: string | null = "2026-08-11T04:51:59.000Z",
) {
return {
getSeatActivity: () =>
isActiveWithinWindow === null
@@ -60,7 +67,7 @@ function mkMotion(isActiveWithinWindow: boolean | null) {
isActiveWithinWindow,
silenceWindowSeconds: 3,
lastObservedAt: "2026-08-11T04:52:00.000Z",
lastActivityAt: "2026-08-11T04:51:59.000Z",
lastActivityAt,
},
} as never;
}
@@ -88,7 +95,7 @@ function hook(state: AgentActivity["state"], reason: string, extra: Partial<Agen
const store = (activity: AgentActivity | null) => ({ getLatestForNode: () => activity }) as never;
async function activityOf(deps: Record<string, unknown>, runtime = "claude-code"): Promise<AgentActivity> {
const out = (await attachAgentActivity(entries("s@rig", runtime), deps as never)) as Array<{
const out = (await attachAgentActivity(entries("s@rig", runtime), { now: NOW, ...deps } as never)) as Array<{
agentActivity: AgentActivity;
}>;
return out[0]!.agentActivity;
@@ -211,6 +218,103 @@ describe("ACTIVITY D1+D2 — motion folded into the ladder", () => {
expect(activity.reason).toBe("generation_unverifiable");
});
// ---------------------------------------------------------------------------------------------
// STALE-CACHE CONTROLS (dev50-guard HOLD on 29ad1b2b9, 2026-08-11T05:12:46Z).
//
// `SeatActivityService.pollSeat` returns null on a tmux error BEFORE it replaces or deletes the
// cached record (seat-activity-service.ts:74), and `pollAllRunningTmuxSeats` only evicts seats that
// are no longer running in the DB. So a seat still marked running whose tmux read keeps failing
// KEEPS its last observation indefinitely — including `isActiveWithinWindow: true`.
//
// The original fold read that boolean and never aged the raw fact, so a DEAD OBSERVATION became an
// affirmative liveness claim. That is this atom's own no-fabrication rule broken in the direction
// nobody was watching: three guards existed against motion inventing IDLE from silence, and none
// against an unavailable instrument inventing RUNNING.
//
// The nine tests above could not catch it because every one of them varies the cached boolean and
// none varies observation AGE independently of it. These do.
// ---------------------------------------------------------------------------------------------
it("A9 [stale cache] isActiveWithinWindow=true with an HOUR-OLD raw fact must NOT upgrade unknown", async () => {
const activity = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(hook("unknown", "generation_unverifiable", { stale: true })),
structuralActivity: mkStructural("unknown"),
seatActivity: mkMotion(true, "2026-08-11T03:59:59.000Z"), // 52 minutes before NOW, window is 3s
});
expect(activity.state).toBe("unknown");
expect(activity.reason).not.toBe("window_activity_motion");
});
it("A10 [stale cache] the same stale true must NOT overturn a positive idle hook", async () => {
const activity = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(hook("idle", "stop_hook")),
structuralActivity: mkStructural(null),
seatActivity: mkMotion(true, "2026-08-11T03:59:59.000Z"),
});
expect(activity.state).toBe("idle");
});
it("A11 [fail closed] a cached true whose raw fact cannot be AGED must not upgrade anything", async () => {
// If the observation carries no usable timestamp there is no way to tell live from long-dead, and
// an un-ageable affirmative is exactly the input that must not become a liveness claim.
for (const raw of [null, "not-a-timestamp"]) {
const activity = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(null),
structuralActivity: mkStructural("unknown"),
seatActivity: mkMotion(true, raw),
});
expect(activity.state).toBe("unknown");
}
});
it("A12 [not over-corrected] a fresh raw fact still upgrades — the fix must not disable motion", async () => {
// The mirror of A9. A correction that made every motion read stale would pass A9/A10/A11 and
// silently restore the original defect, so freshness is pinned from BOTH sides.
const activity = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(hook("idle", "stop_hook")),
structuralActivity: mkStructural(null),
seatActivity: mkMotion(true, "2026-08-11T04:51:59.000Z"), // 1s before NOW
});
expect(activity.state).toBe("running");
expect(activity.reason).toBe("window_activity_motion");
});
it("A13 [boundary] freshness is judged against the seat's OWN silence window, both sides of it", async () => {
// Just INSIDE the 3s window.
const inside = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(null),
structuralActivity: mkStructural("unknown"),
seatActivity: mkMotion(true, "2026-08-11T04:51:57.500Z"), // 2.5s old
});
expect(inside.state).toBe("running");
// Just OUTSIDE it.
const outside = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(null),
structuralActivity: mkStructural("unknown"),
seatActivity: mkMotion(true, "2026-08-11T04:51:56.500Z"), // 3.5s old
});
expect(outside.state).toBe("unknown");
});
it("A14 [clock skew] a raw fact slightly AHEAD of the request clock still reads as live", async () => {
// pollSeat deliberately treats negative age as active (the daemon's clock can lag tmux briefly).
// Read-time aging must keep that behavior rather than reading the future as stale.
const activity = await activityOf({
tmuxAdapter: mkTmux({ captures: 0 }),
activityStore: store(null),
structuralActivity: mkStructural("unknown"),
seatActivity: mkMotion(true, "2026-08-11T04:52:01.000Z"), // 1s in the future
});
expect(activity.state).toBe("running");
});
it("A8 [the live fleet shape] an unverifiable-generation hook + LIVE motion reports running", async () => {
// Measured live: all 14 seats carry state=unknown reason=generation_unverifiable, so on this fleet
// the demoted hook — not a positive idle one — is what stands between a generating seat and a