Compare commits

..
1 Commits
Author SHA1 Message Date
Garry TanandClaude Fable 5 4deee227be v0.45.18.0 fix(serve-http,pglite): UTC-instant spend day boundary + snapshot timezone parity (#4131)
* fix(serve-http,pglite): UTC-instant spend day boundary + snapshot timezone parity

The admin spend query compared created_at against a NAIVE date_trunc result,
reinterpreted in each session's timezone — any non-UTC session shifted the day
boundary by its offset and underreported today's spend every evening. The
boundary is now a timestamptz instant (double AT TIME ZONE), pinned by a
session-timezone-adversarial regression test (Etc/GMT+12 / Etc/GMT-12 / UTC)
that is red on the old query at any wall-clock hour.

Root cause of the local-red/CI-green suite: dumpDataDir bakes the BUILD
process's TimeZone into the snapshot tar, so snapshot-restored engines ran
sessions in the build machine's zone while cold-init engines follow the
runtime (bun test pins TZ=UTC). Restored engines now re-pin the session to
the runtime zone (heals existing tarballs with no rebuild), the builder pins
TZ=UTC before any PGLite work, and a serial parity test asserts cold and
snapshot engines agree on their session UTC offset.

* chore: bump version and changelog (v0.45.18.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 21:59:24 -07:00
11 changed files with 141 additions and 6 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
<!-- gbrain-runbook-stamp: 0.45.17.0 -->
<!-- gbrain-runbook-stamp: 0.45.18.0 -->
<!-- This stamp must equal the VERSION file at every release; CI enforces it
(scripts/check-bootstrap-tag.sh). `gbrain bootstrap status` compares it to
the installed binary and warns on skew. -->
+10
View File
@@ -2,6 +2,16 @@
All notable changes to GBrain will be documented in this file.
## [0.45.18.0] - 2026-08-15
**Today's agent spend now reads correctly at every hour, in every timezone.** The admin spend endpoint computed "today" against a naive timestamp that each database session reinterpreted in its own timezone — on any non-UTC session (a PGLite brain following the host clock, a timezone-configured Postgres role), the day boundary shifted by the offset and every evening's spend silently underreported as 0. The boundary is now a UTC instant, independent of session timezone, pinned by a regression test that exercises sessions 12 hours either side of UTC at any wall-clock hour.
The same class also made the new test-suite snapshot fixture time-of-day flaky: the snapshot bakes the build machine's timezone into the restored cluster, so snapshot-restored engines ran sessions in the builder's zone while cold-init engines followed the running process. Restored engines now re-pin their session to the runtime zone (existing tarballs heal without a rebuild), the snapshot builder pins UTC so tarballs are deterministic across hosts, and a parity test asserts cold and snapshot engines agree on their UTC offset.
### Fixed
- `/admin/api/agents/spend`: `spent_cents_today` no longer underreports on non-UTC sessions (UTC-instant day boundary).
- Snapshot-restored PGLite engines behave identically to cold-init engines regardless of the machine that built the tarball.
## [0.45.17.0] - 2026-08-15
**A test run can no longer silently touch a real brain.** `gbrain init` writes your
+1 -1
View File
@@ -1 +1 @@
0.45.17.0
0.45.18.0
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "gbrain-context-engine",
"name": "gbrain",
"version": "0.45.17.0",
"version": "0.45.18.0",
"description": "Personal knowledge brain with Postgres + pgvector hybrid search",
"family": "bundle-plugin",
"configSchema": {
+1 -1
View File
@@ -156,7 +156,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.45.17.0",
"version": "0.45.18.0",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.5",
+7
View File
@@ -1,6 +1,13 @@
#!/usr/bin/env bun
// scripts/build-pglite-snapshot.ts
//
// TZ pinned to UTC BEFORE any PGLite work: dumpDataDir bakes this process's
// TimeZone into the tar's cluster defaults. Building under the host zone made
// restored engines run sessions in the build machine's zone (the engine also
// re-pins at restore — this is the belt to that suspender, and it keeps any
// OTHER zone-derived state baked into the tar deterministic across hosts).
process.env.TZ = 'UTC';
//
// Tier 3 fast-restore: boot a fresh PGLite, run the full initSchema (forward
// bootstrap + PGLITE_SCHEMA_SQL + every migration), dump the post-init state
// to a tar fixture. Test files that read GBRAIN_PGLITE_SNAPSHOT can skip the
+7 -1
View File
@@ -551,7 +551,13 @@ export async function queryAgentClientSpend(engine: BrainEngine): Promise<AgentC
SELECT SUM(spend_cents)::text
FROM mcp_spend_log
WHERE client_id = c.client_id
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC')
-- Double AT TIME ZONE: the inner one yields NAIVE UTC-midnight;
-- the outer one converts it back to a timestamptz INSTANT. Without
-- it, the naive value is reinterpreted in the SESSION timezone, so
-- any non-UTC session (host-tz PGLite, a tz-configured Postgres
-- role) shifts the day boundary by the offset and today's spend
-- underreports every evening.
AND created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'
), '0') AS spent_cents_today,
COALESCE((
SELECT SUM(estimated_cents)::text
+12
View File
@@ -616,6 +616,18 @@ export class PGLiteEngine implements BrainEngine {
...embedded,
}),
);
// Snapshot-timezone parity: dumpDataDir bakes the BUILD process's
// TimeZone into the restored cluster's defaults, so a snapshot-loaded
// engine would run sessions in the build machine's zone while a
// cold-init engine follows this process (bun test pins TZ=UTC; bun run
// follows the host). That divergence shifted every naive-timestamp
// day-boundary comparison by the offset — date-dependent tests failed
// only in the evening, only under the snapshot. Pin the session to the
// RUNTIME zone so restored engines behave exactly like cold ones.
if (this._snapshotLoaded && this._db) {
const runtimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
await this._db.query(`SELECT set_config('TimeZone', $1, false)`, [runtimeZone]);
}
// Healthy open: close any repair episode left open by a prior failed
// attempt (red-team: episodes otherwise stayed open forever — doctor
// kept reporting corruption-likely and a weeks-stale episode backup
+1 -1
View File
@@ -1,6 +1,6 @@
# gbrain agent workspace — template
<!-- gbrain-template-stamp: 0.45.17.0 -->
<!-- gbrain-template-stamp: 0.45.18.0 -->
This repository is the **"Use this template"** distribution artifact for a
[gbrain](https://github.com/garrytan/gbrain) personal-agent workspace — the same
+35
View File
@@ -150,6 +150,41 @@ describe('queryAgentClientSpend (v0.38 Slice 4 — /admin/api/agents/spend SQL)'
expect(rows[0].spent_cents_today).toBe(50);
});
it('day boundary is a UTC INSTANT, independent of the session timezone', async () => {
// Regression pin for the snapshot-timezone incident: the old predicate
// compared created_at against a NAIVE date_trunc result, which the
// session timezone reinterpreted — a non-UTC session (host-tz PGLite,
// tz-configured Postgres role, snapshot-restored engine pre-parity-fix)
// shifted the day boundary by its offset and underreported evening spend.
//
// Deterministic at ANY wall-clock hour: rows exactly AT UTC midnight and
// 1s BEFORE it must classify identically under sessions ±12h from UTC.
// Under the old predicate, Etc/GMT+12 excluded the midnight row and
// Etc/GMT-12 included the pre-midnight row — one of the two always broke.
await seedClient({ id: 'tz-edge', scope: 'read agent' });
await engine.executeRaw(
`INSERT INTO mcp_spend_log (client_id, operation, spend_cents, created_at)
VALUES
('tz-edge', 'subagent_loop', 7,
date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'),
('tz-edge', 'subagent_loop', 999,
(date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC') - interval '1 second')`,
);
const original = (await engine.executeRaw<{ TimeZone: string }>(`SHOW timezone`))[0].TimeZone;
for (const zone of ['Etc/GMT+12', 'Etc/GMT-12', 'UTC']) {
await engine.executeRaw(`SELECT set_config('TimeZone', '${zone}', false)`);
try {
const rows = await queryAgentClientSpend(engine);
const edge = rows.find(r => r.client_id === 'tz-edge')!;
// Only the exactly-at-midnight row (7¢) counts as today — never the
// 1s-before row (999¢) — regardless of session zone.
expect(`${zone}:${edge.spent_cents_today}`).toBe(`${zone}:7`);
} finally {
await engine.executeRaw(`SELECT set_config('TimeZone', $1, false)`, [original]);
}
}
});
it('isolates spend by client_id (no cross-client leakage)', async () => {
await seedClient({ id: 'alice', scope: 'read agent' });
await seedClient({ id: 'bob', scope: 'read agent' });
@@ -0,0 +1,65 @@
/**
* Snapshot-timezone parity pin.
*
* dumpDataDir bakes the BUILD process's TimeZone into the snapshot tar's
* cluster defaults. Un-pinned, a snapshot-restored engine ran sessions in the
* build machine's zone while cold-init engines follow the runtime process
* (bun test pins TZ=UTC) so every naive-timestamp day-boundary comparison
* shifted by the offset, and date-dependent tests failed only in the evening,
* only under GBRAIN_PGLITE_SNAPSHOT. Two fixes hold the line: the build
* script pins TZ=UTC before dumping, and the engine re-pins the session to
* the runtime zone on snapshot restore.
*
* This test is the deterministic pin: at ANY wall-clock hour, a cold engine
* and a snapshot engine created by the same process must report the same
* session TimeZone. Serial file: it mutates GBRAIN_PGLITE_SNAPSHOT (R1).
*/
import { describe, test, expect, afterAll } from 'bun:test';
import { existsSync } from 'fs';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
const SNAPSHOT = 'test/fixtures/pglite-snapshot.tar';
async function sessionUtcOffsetSeconds(env: Record<string, string | undefined>): Promise<number> {
const prev = process.env.GBRAIN_PGLITE_SNAPSHOT;
if (env.GBRAIN_PGLITE_SNAPSHOT === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
else process.env.GBRAIN_PGLITE_SNAPSHOT = env.GBRAIN_PGLITE_SNAPSHOT;
const engine = new PGLiteEngine();
try {
await engine.connect({} as never);
await engine.initSchema();
// Compare the effective UTC OFFSET, not the zone label: cold init spells
// the runtime zone one way (Etc/GMT0), the restore re-pin another (UTC) —
// the invariant is identical instant arithmetic, not identical strings.
const rows = await engine.executeRaw<{ off: string }>(
`SELECT extract(timezone FROM now())::text AS off`,
);
return Number(rows[0].off);
} finally {
await engine.disconnect?.();
if (prev === undefined) delete process.env.GBRAIN_PGLITE_SNAPSHOT;
else process.env.GBRAIN_PGLITE_SNAPSHOT = prev;
}
}
describe('PGLite snapshot timezone parity', () => {
// Build the fixture in-test (idempotent hash short-circuit makes re-runs
// cheap) so this pin cannot silently skip in lanes without a prebuilt tar.
test('snapshot fixture builds', async () => {
const proc = Bun.spawnSync(['bun', 'run', 'build:pglite-snapshot'], {
cwd: `${import.meta.dir}/..`,
stdout: 'pipe',
stderr: 'pipe',
timeout: 300_000,
killSignal: 'SIGKILL',
});
expect(proc.exitCode).toBe(0);
expect(existsSync(`${import.meta.dir}/../${SNAPSHOT}`)).toBe(true);
}, 320_000);
test('snapshot-restored session UTC offset equals cold-init session UTC offset', async () => {
const cold = await sessionUtcOffsetSeconds({ GBRAIN_PGLITE_SNAPSHOT: undefined });
const snap = await sessionUtcOffsetSeconds({ GBRAIN_PGLITE_SNAPSHOT: SNAPSHOT });
expect(snap).toBe(cold);
}, 120_000);
});