refactor(daemon): use the shared SQLite time parser in three more places (#330)

health-detectors.ts, snapshot-repository.ts and restore-plan-preview.ts each
kept a private copy of the SQLite-stamp-to-UTC conversion that sqlite-time.ts
now provides. The copies had drifted:

- restore-plan-preview always appended "Z", so an already-zoned ISO
  last-verified stamp became "...ZZ", parsed to NaN, and skipped the
  freshness check: a 2h-old token showed as present instead of stale.
- health-detectors treated any value containing "T" as zoned, so a
  zone-less ISO value would have been read as host-local time.

All writers currently store datetime('now') stamps, so neither drift was
reachable in production; this removes the traps rather than fixing a live
bug. Adds a restore-plan-preview case for the ISO stamp (fails before this
change) and a helper case for the T-separated zone-less form.

Co-authored-by: Coder8124 <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-01 09:47:25 -07:00
1 parent 7d43151ec5
commit cdc22865fd
5 files changed
+15 -18

No files matched your search

@@ -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;
@@ -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). */
@@ -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)),
};
}
@@ -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);
+2
View File
@@ -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", () => {