diff --git a/packages/daemon/src/domain/health-detectors.ts b/packages/daemon/src/domain/health-detectors.ts index bce435543..343707f9e 100644 --- a/packages/daemon/src/domain/health-detectors.ts +++ b/packages/daemon/src/domain/health-detectors.ts @@ -19,6 +19,7 @@ import type { ContextUsageStore } from "./context-usage-store.js"; import type { RigRepository } from "./rig-repository.js"; import type { SessionRegistry } from "./session-registry.js"; import { queryUsageSeries } from "./usage-series.js"; +import { parseSqliteUtcMs } from "./sqlite-time.js"; const CONTEXT_PRESSURE_PERCENT = 95; const CONTEXT_CRITICAL_PERCENT = 99; @@ -213,7 +214,7 @@ export class LiveContextHealthSource implements HealthObservationSource { }) .filter((sample) => sample.nodeId === node.id && sample.sampledAt !== null - && Date.parse(sample.sampledAt) >= sqliteTimestampMs(tenureStartedAt) + && Date.parse(sample.sampledAt) >= parseSqliteUtcMs(tenureStartedAt) && Date.parse(sample.sampledAt) <= Date.parse(usage.sampledAt!)) .map((sample, sourceOrder): HealthEvidenceReference => ({ type: "context-usage", @@ -292,10 +293,6 @@ function selectContextEpisodeEvidence( .map((item, sourceOrder) => ({ ...item, sourceOrder })); } -function sqliteTimestampMs(value: string): number { - return Date.parse(value.includes("T") ? value : `${value.replace(" ", "T")}Z`); -} - export function healthScopeId(scope: HealthScope): string { switch (scope.type) { case "instance": return scope.instanceId; diff --git a/packages/daemon/src/domain/restore-plan-preview.ts b/packages/daemon/src/domain/restore-plan-preview.ts index 4684347d3..47c2c8c54 100644 --- a/packages/daemon/src/domain/restore-plan-preview.ts +++ b/packages/daemon/src/domain/restore-plan-preview.ts @@ -13,6 +13,7 @@ import type Database from "better-sqlite3"; import { resolveActiveOccupantRow, resolveActiveSnapshotSession, deriveRehydrateSessionIdByNode, activeOccupantAmbiguityError, type ActiveOccupantResolution } from "./active-occupant.js"; import type { RigWithRelations, Snapshot } from "./types.js"; +import { parseSqliteUtcMs } from "./sqlite-time.js"; /** OPR.0.4.3.20 FR-6 — a present token whose last verification is older than this * is surfaced as `stale` (age-based staleness — the "stale while running" signal @@ -103,6 +104,7 @@ function tokenStateFor( return { tokenState: "unverified", provenance, lastVerified }; } if (lastVerified) { + // NaN (unparseable) skips the age check: an unreadable stamp is never stale-by-age. const verifiedMs = parseSqliteUtcMs(lastVerified); if (!Number.isNaN(verifiedMs) && nowMs - verifiedMs > RESUME_FRESHNESS_THRESHOLD_MS) { return { tokenState: "stale", provenance, lastVerified }; @@ -111,13 +113,6 @@ function tokenStateFor( return { tokenState: "present", provenance, lastVerified }; } -/** Parse a SQLite `datetime('now')` value ("YYYY-MM-DD HH:MM:SS", UTC, no zone - * marker) to epoch ms. Returns NaN on an unparseable value (age check is then - * skipped — an unparseable stamp is never treated as stale-by-age). */ -function parseSqliteUtcMs(value: string): number { - return new Date(value.replace(" ", "T") + "Z").getTime(); -} - /** OPR.0.4.3.20 FR-6 — forecast the runtime prompt a resume WOULD hit, so it is * an expected operator step, not a terminal surprise. Only meaningful when the * seat would attempt a resume (has a token). */ diff --git a/packages/daemon/src/domain/snapshot-repository.ts b/packages/daemon/src/domain/snapshot-repository.ts index 5f025751a..8423d4936 100644 --- a/packages/daemon/src/domain/snapshot-repository.ts +++ b/packages/daemon/src/domain/snapshot-repository.ts @@ -1,6 +1,7 @@ import type Database from "better-sqlite3"; import { ulid } from "ulid"; import type { RestoreSnapshotSelection, RestoreSnapshotSummary, Snapshot, SnapshotData } from "./types.js"; +import { parseSqliteUtcMs } from "./sqlite-time.js"; export type RestoreSnapshotSelectionOutcome = | { ok: true; snapshot: Snapshot; selection: RestoreSnapshotSelection } @@ -115,7 +116,7 @@ export class SnapshotRepository { // A concurrent pruner can remove the selected row after it was read. Its // insertion position is then unknown, so only strictly newer timestamps count. const newerRows = selectedIndex < 0 - ? candidates.filter((row) => Date.parse(sqliteUtc(row.created_at)) > Date.parse(sqliteUtc(snapshot!.createdAt))) + ? candidates.filter((row) => parseSqliteUtcMs(row.created_at) > parseSqliteUtcMs(snapshot!.createdAt)) : candidates.slice(0, selectedIndex); const newer = newerRows.flatMap((row) => { const candidate = this.restoreUsableRow(row); @@ -243,16 +244,12 @@ export class SnapshotRepository { } } -function sqliteUtc(value: string): string { - return /Z$|[+-]\d\d:\d\d$/.test(value) ? value : value.replace(" ", "T") + "Z"; -} - export function summarizeSnapshot(snapshot: Snapshot, nowMs: number = Date.now()): RestoreSnapshotSummary { return { snapshotId: snapshot.id, kind: snapshot.kind, createdAt: snapshot.createdAt, - ageMs: Math.max(0, nowMs - Date.parse(sqliteUtc(snapshot.createdAt))), + ageMs: Math.max(0, nowMs - parseSqliteUtcMs(snapshot.createdAt)), }; } diff --git a/packages/daemon/test/restore-plan-preview.test.ts b/packages/daemon/test/restore-plan-preview.test.ts index d5a6f395b..6f0feebab 100644 --- a/packages/daemon/test/restore-plan-preview.test.ts +++ b/packages/daemon/test/restore-plan-preview.test.ts @@ -93,6 +93,12 @@ describe("FR-6 restore-plan token state", () => { expect(p.nodes[0]!.tokenState).toBe("stale"); }); + it("stale (age): an already-zoned ISO last-verified stamp is aged too, not skipped as unparseable", () => { + const rig = rigWith([{ id: "n1", logicalId: "a", runtime: "claude-code" }]); + const p = buildRestorePlanPreview(rig, null, [row("n1", { resumeLastVerified: "2026-07-02T10:00:00.000Z", resumeLastProbeStatus: "resumable" })], undefined, NOW); + expect(p.nodes[0]!.tokenState).toBe("stale"); + }); + it("codex runtime prompt surfaced for a resumable Codex seat", () => { const rig = rigWith([{ id: "n1", logicalId: "a", runtime: "codex" }]); const p = buildRestorePlanPreview(rig, null, [row("n1", { resumeType: "codex_id", resumeLastProbeStatus: "resumable", resumeLastVerified: FRESH })], undefined, NOW); diff --git a/packages/daemon/test/sqlite-time.test.ts b/packages/daemon/test/sqlite-time.test.ts index 0a6302023..935dd71bf 100644 --- a/packages/daemon/test/sqlite-time.test.ts +++ b/packages/daemon/test/sqlite-time.test.ts @@ -12,6 +12,8 @@ describe("parseSqliteUtcMs", () => { // A non-UTC host is where a bare Date.parse skews the stamp by the UTC offset. process.env.TZ = "America/Los_Angeles"; expect(parseSqliteUtcMs("2026-09-28 02:45:08")).toBe(Date.UTC(2026, 8, 28, 2, 45, 8)); + // Same for the T-separated form: no zone marker means UTC, whatever the separator. + expect(parseSqliteUtcMs("2026-09-28T02:45:08")).toBe(Date.UTC(2026, 8, 28, 2, 45, 8)); }); it("leaves zoned ISO values unchanged and returns NaN for garbage", () => {