diff --git a/src/main/codexContext.ts b/src/main/codexContext.ts new file mode 100644 index 000000000..efeb4e2bf --- /dev/null +++ b/src/main/codexContext.ts @@ -0,0 +1,205 @@ +/** + * Codex context-gauge backfill. + * + * Context coverage has provider-specific inputs. Claude can report exact + * readings through status hooks, while the renderer independently backfills + * missing Claude readings from transcripts every 15 seconds. Fleet + * tokens/lastTool use separate OpenTelemetry inputs. Codex supplies none of + * those Claude-shaped context inputs, but its rollout logs contain a compatible + * `token_count` reading. Hook delivery was traced through the shared shim + * architecture, not empirically verified here; rollout polling is independent + * of that path. + * + * HiveManager exposes each worker's isolated CODEX_HOME at + * `/agents//.codex`. This module tails its latest rollout and feeds + * the reading through `HookServer.reportContext()`. + * + * Pure Node - no `electron` import - so it runs standalone in `node --test`. + */ +import { readdirSync, statSync, existsSync, openSync, fstatSync, readSync, closeSync, type Dirent } from 'node:fs'; +import { join } from 'node:path'; + +export interface CodexContextReading { + tokens: number; + limit: number; +} + +const MAX_WALK_DEPTH = 6; +const DISCOVERY_TTL_MS = 60_000; + +interface RolloutFile { + path: string; + mtimeMs: number; + size: number; +} + +interface ContextCacheEntry { + discoveredAt: number; + selected: RolloutFile | null; + reading: CodexContextReading | null; +} + +const contextCache = new Map(); + +function collectRolloutFiles(dir: string, depth = 0): RolloutFile[] { + if (depth > MAX_WALK_DEPTH) return []; + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const found: RolloutFile[] = []; + for (const entry of entries) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + found.push(...collectRolloutFiles(full, depth + 1)); + } else if (entry.isFile() && entry.name.startsWith('rollout-') && entry.name.endsWith('.jsonl')) { + try { + const stat = statSync(full); + found.push({ path: full, mtimeMs: stat.mtimeMs, size: stat.size }); + } catch { + // The file can disappear between directory enumeration and stat. + } + } + } + return found; +} + +function findLatestRollout(codexHome: string): RolloutFile | null { + const sessionsDir = join(codexHome, 'sessions'); + if (!existsSync(sessionsDir)) return null; + const files = collectRolloutFiles(sessionsDir); + if (!files.length) return null; + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + return files[0]; +} + +export function findLatestCodexRollout(codexHome: string): string | null { + return findLatestRollout(codexHome)?.path ?? null; +} + +const DEFAULT_TAIL_BYTES = 256 * 1024; + +interface RolloutTokenCountPayload { + type: 'token_count'; + info?: { + last_token_usage?: { input_tokens?: unknown }; + model_context_window?: unknown; + }; +} + +function isTokenCountPayload(value: unknown): value is RolloutTokenCountPayload { + return !!value && typeof value === 'object' && (value as { type?: unknown }).type === 'token_count'; +} + +export function readLatestTokenCount( + filePath: string, + tailBytes: number = DEFAULT_TAIL_BYTES +): CodexContextReading | null { + let fd: number; + try { + fd = openSync(filePath, 'r'); + } catch { + return null; + } + try { + const size = fstatSync(fd).size; + const start = Math.max(0, size - tailBytes); + const length = size - start; + if (length <= 0) return null; + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, start); + const lines = buffer.toString('utf8').split('\n'); + let startsAtLineBoundary = start === 0; + if (start > 0) { + const previousByte = Buffer.alloc(1); + readSync(fd, previousByte, 0, 1, start - 1); + startsAtLineBoundary = previousByte[0] === 0x0a; + } + const usableLines = startsAtLineBoundary ? lines : lines.slice(1); + for (let i = usableLines.length - 1; i >= 0; i--) { + const line = usableLines[i].trim(); + if (!line) continue; + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + const payload = (event as { payload?: unknown } | null)?.payload; + if (!isTokenCountPayload(payload)) continue; + const tokens = payload.info?.last_token_usage?.input_tokens; + const limit = payload.info?.model_context_window; + if (typeof tokens === 'number' && Number.isFinite(tokens) + && typeof limit === 'number' && Number.isFinite(limit) && limit > 0) { + return { tokens, limit }; + } + } + return null; + } finally { + closeSync(fd); + } +} + +export function clearCodexContextCache(): void { + contextCache.clear(); +} + +export function readCodexContext( + codexHome: string, + nowMs: number = Date.now() +): CodexContextReading | null { + try { + const cached = contextCache.get(codexHome); + if (cached && nowMs - cached.discoveredAt < DISCOVERY_TTL_MS) { + if (!cached.selected) return cached.reading; + try { + const stat = statSync(cached.selected.path); + if (stat.mtimeMs === cached.selected.mtimeMs && stat.size === cached.selected.size) { + return cached.reading; + } + const reading = readLatestTokenCount(cached.selected.path); + contextCache.set(codexHome, { + ...cached, + selected: { path: cached.selected.path, mtimeMs: stat.mtimeMs, size: stat.size }, + reading + }); + return reading; + } catch { + contextCache.delete(codexHome); + } + } + + const selected = findLatestRollout(codexHome); + const reading = selected ? readLatestTokenCount(selected.path) : null; + contextCache.set(codexHome, { discoveredAt: nowMs, selected, reading }); + return reading; + } catch { + return null; + } +} + +interface CodexRegistrySource { + registry(): { + agents: Record; + }; + codexHome(agentId: string): string | null; +} + +export interface CodexAgentContextReading { + agentId: string; + reading: CodexContextReading; +} + +export function readCodexRegistryContexts(source: CodexRegistrySource): CodexAgentContextReading[] { + const readings: CodexAgentContextReading[] = []; + for (const [agentId, agent] of Object.entries(source.registry().agents)) { + if (agent.archived || agent.provider !== 'codex') continue; + const codexHome = source.codexHome(agentId); + if (!codexHome) continue; + const reading = readCodexContext(codexHome); + if (reading) readings.push({ agentId, reading }); + } + return readings; +} diff --git a/src/main/hive.ts b/src/main/hive.ts index 4537c2bf6..4ed878f97 100644 --- a/src/main/hive.ts +++ b/src/main/hive.ts @@ -417,6 +417,11 @@ export class HiveManager { private agentDir(id: string): string { return join(this.root()!, 'agents', id); } + /** The isolated CODEX_HOME provisioned for a hive worker. */ + codexHome(id: string): string | null { + const root = this.root(); + return root ? join(root, 'agents', id, '.codex') : null; + } /** IPC endpoint the cth-hook shim talks to (Phase 1 autonomy). * On POSIX this is a Unix-domain socket file under the hive root. On Windows, * Node's `net` IPC uses named pipes (a flat `\\.\pipe\` namespace, not the diff --git a/src/main/hooks.ts b/src/main/hooks.ts index a4cf07439..45e7c48d6 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -148,6 +148,21 @@ export class HookServer { return this.contextById.get(agentId); } + /** Route a non-status context reading through the shared retention and IPC + * path. Codex rollout polling uses this entry point; the renderer's Claude + * transcript fallback remains independent. */ + reportContext(agentId: string, tokens: number, limit: number): void { + if (!agentId || !Number.isFinite(tokens) || !Number.isFinite(limit) || limit <= 0) return; + this.setContext(agentId, tokens, limit); + } + + private setContext(agentId: string, tokens: number, limit: number): void { + // Retain for main-side reads (voice get_agent_detail / list_agents) … + this.contextById.set(agentId, { tokens, limit, ts: Date.now() }); + // … and forward live to the renderer's agent-card context gauge. + this.getWebContents()?.send('hive:contextUpdate', { agentId, tokens, limit }); + } + private handle(p: HookPayload): unknown { const agentId = p.agent_id ?? undefined; const event = p.hook_event_name ?? 'Unknown'; @@ -170,18 +185,7 @@ export class HookServer { const cw = p.context_window; if (agentId && cw && typeof cw.total_input_tokens === 'number' && typeof cw.context_window_size === 'number' && cw.context_window_size > 0) { - // Retain for main-side reads (voice get_agent_detail / list_agents) … - this.contextById.set(agentId, { - tokens: cw.total_input_tokens, - limit: cw.context_window_size, - ts: Date.now() - }); - // … and forward live to the renderer's agent-card context gauge. - this.getWebContents()?.send('hive:contextUpdate', { - agentId, - tokens: cw.total_input_tokens, - limit: cw.context_window_size - }); + this.setContext(agentId, cw.total_input_tokens, cw.context_window_size); } return {}; } diff --git a/src/main/index.ts b/src/main/index.ts index 40634ac6f..bd7f0916a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -55,6 +55,7 @@ import { registerRealtimeActionIpc } from './realtimeActions'; import { initCompletionWatcher } from './realtimeCompletionWatcher'; import type { TaskCard, InboxMessage } from './realtimeCompletionWatcher'; import { TelemetryCollector } from './telemetry'; +import { readCodexRegistryContexts } from './codexContext'; import { CostLedgerTotals } from './costLifetime'; import { analytics, isRendererMessageSurface } from './analytics'; import type { SpawnFailReason } from './analytics'; @@ -272,6 +273,7 @@ const breaker = new CircuitBreaker(() => { // heartbeat mission is disabled (it ships off). let fleetTimer: ReturnType | null = null; let breakerBeatTimer: ReturnType | null = null; +let codexContextTimer: ReturnType | null = null; // Feed the breaker's api_error-storm trip from Oscar's OTel api_error spans — // Jim's one breaker input with no on-branch source (telemetry.onApiError seam). telemetry.onApiError((agentId) => breaker.recordError(agentId)); @@ -1310,6 +1312,21 @@ function writeFleetSnapshot(): void { } } +/** Backfill Codex context from its provider-specific rollout data. Claude can + * use status hooks or the renderer's 15-second transcript fallback, while + * fleet telemetry is a separate input. Codex hook delivery was traced through + * the shared shim architecture but was not empirically verified for this fix. */ +function pollCodexContext(): void { + if (!hive.enabled()) return; + try { + for (const { agentId, reading } of readCodexRegistryContexts(hive)) { + hookServer.reportContext(agentId, reading.tokens, reading.limit); + } + } catch (e) { + console.error('[fleet] codex context poll failed:', e); + } +} + /** Arm the heartbeat with an adaptive, self-rescheduling cadence (recursive * setTimeout instead of a fixed setInterval). Each beat runs the cost/breaker * pass, re-engages a quiet floor, stamps lastFiredAt, then re-arms: ~base on a @@ -5180,6 +5197,9 @@ function armAlwaysOnBeats(): void { if (fleetTimer) clearInterval(fleetTimer); writeFleetSnapshot(); fleetTimer = setInterval(writeFleetSnapshot, 8_000); + if (codexContextTimer) clearInterval(codexContextTimer); + pollCodexContext(); + codexContextTimer = setInterval(pollCodexContext, 8_000); if (breakerBeatTimer) clearInterval(breakerBeatTimer); breakerBeatTimer = setInterval(() => { try { runBreakerBeat(300_000); } catch (e) { console.error('[breaker beat]', e); } }, 30_000); if (workerWakeTimer) clearInterval(workerWakeTimer); diff --git a/test/codex-context.test.cjs b/test/codex-context.test.cjs new file mode 100644 index 000000000..8fcf8aef8 --- /dev/null +++ b/test/codex-context.test.cjs @@ -0,0 +1,256 @@ +'use strict'; +/** + * Codex rollout-log context-gauge backfill (see src/main/codexContext.ts). + * + * Claude context updates can arrive through status hooks or the renderer's + * transcript backfill. Codex rollout logs are a third provider-specific input + * that can supply the same gauge without changing telemetry accounting. + * + * Line shapes below are trimmed/sanitized from a real `rollout-*.jsonl` + * produced by a live Codex worker (`token_count` event, `info.last_token_usage` + * / `info.model_context_window`), not invented. + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const loadTs = require('./load-ts.cjs'); + +const { + clearCodexContextCache, + findLatestCodexRollout, + readLatestTokenCount, + readCodexContext, + readCodexRegistryContexts +} = + loadTs('src/main/codexContext.ts'); +const { HiveManager } = loadTs('src/main/hive.ts'); + +function tokenCountLine(inputTokens, contextWindow, ts) { + return JSON.stringify({ + timestamp: ts, + type: 'event_msg', + payload: { + type: 'token_count', + info: { + total_token_usage: { input_tokens: inputTokens, output_tokens: 10, total_tokens: inputTokens + 10 }, + last_token_usage: { input_tokens: inputTokens, output_tokens: 10, total_tokens: inputTokens + 10 }, + model_context_window: contextWindow + }, + rate_limits: { limit_id: 'codex' } + } + }); +} + +/** A realistic non-token_count line (a tool call), so tests exercise a mixed + * rollout rather than a synthetic file of nothing but token_count events. */ +function functionCallLine(name) { + return JSON.stringify({ + timestamp: '2026-09-15T09:16:30.919Z', + type: 'response_item', + payload: { type: 'function_call', name, arguments: '{}', call_id: 'call_x' } + }); +} + +const tempDirs = new Set(); + +function tempRepo() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-context-test-')); + tempDirs.add(dir); + return dir; +} + +test.afterEach(() => { + for (const dir of tempDirs) { + fs.rmSync(dir, { recursive: true, force: true }); + assert.equal(fs.existsSync(dir), false, `removed temporary directory ${dir}`); + } + tempDirs.clear(); + clearCodexContextCache?.(); +}); + +test('findLatestCodexRollout returns null when the agent has no Codex session data yet', () => { + const cwd = tempRepo(); + assert.equal(findLatestCodexRollout(cwd), null); +}); + +test('findLatestCodexRollout finds a rollout nested under sessions/YYYY/MM/DD', () => { + const codexHome = tempRepo(); + const dir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, 'rollout-2026-09-15T08-00-00-abc.jsonl'); + fs.writeFileSync(file, tokenCountLine(1000, 258400, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + assert.equal(findLatestCodexRollout(codexHome), file); +}); + +test('findLatestCodexRollout picks the most recently modified file across multiple sessions', () => { + const codexHome = tempRepo(); + const day1 = path.join(codexHome, 'sessions', '2026', '09', '14'); + const day2 = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(day1, { recursive: true }); + fs.mkdirSync(day2, { recursive: true }); + const older = path.join(day1, 'rollout-2026-09-14T08-00-00-old.jsonl'); + const newer = path.join(day2, 'rollout-2026-09-15T08-00-00-new.jsonl'); + fs.writeFileSync(older, tokenCountLine(500, 258400, '2026-09-14T08:00:00.000Z') + '\n', 'utf8'); + // Force a distinct, later mtime regardless of how fast the two writes ran. + const past = new Date(Date.now() - 60_000); + fs.utimesSync(older, past, past); + fs.writeFileSync(newer, tokenCountLine(9000, 258400, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + assert.equal(findLatestCodexRollout(codexHome), newer); +}); + +test('readLatestTokenCount reads the real event shape (payload.info.last_token_usage / model_context_window)', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + fs.writeFileSync(file, tokenCountLine(17724, 258400, '2026-09-15T08:09:58.124Z') + '\n', 'utf8'); + assert.deepEqual(readLatestTokenCount(file), { tokens: 17724, limit: 258400 }); +}); + +test('readLatestTokenCount returns the LAST token_count in a mixed, multi-turn file', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const lines = [ + tokenCountLine(17724, 258400, '2026-09-15T08:09:58.124Z'), + functionCallLine('exec'), + functionCallLine('apply_patch'), + tokenCountLine(45000, 258400, '2026-09-15T09:00:00.000Z'), + functionCallLine('wait'), + tokenCountLine(90603, 258400, '2026-09-15T14:09:33.383Z') + ]; + fs.writeFileSync(file, lines.join('\n') + '\n', 'utf8'); + assert.deepEqual(readLatestTokenCount(file), { tokens: 90603, limit: 258400 }); +}); + +test('readLatestTokenCount finds the last reading even when the tail window must be re-scanned', () => { + // Force a tail window that lands mid-file (smaller than the whole fixture, + // but comfortably larger than the final line) so the "drop the possibly + // partial first line of the window" behavior is actually exercised, not + // just theoretical. + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const padding = 'x'.repeat(2000); + const lines = [ + tokenCountLine(1000, 258400, '2026-09-15T08:00:00.000Z'), + functionCallLine(padding), + tokenCountLine(2000, 258400, '2026-09-15T08:05:00.000Z') + ]; + const content = lines.join('\n') + '\n'; + fs.writeFileSync(file, content, 'utf8'); + const lastLineBytes = Buffer.byteLength(lines[lines.length - 1], 'utf8'); + const tailBytes = lastLineBytes + 20; // fits the final line, cuts into the one before it + assert.ok(tailBytes < Buffer.byteLength(content, 'utf8'), 'the window must actually land mid-file'); + assert.deepEqual(readLatestTokenCount(file, tailBytes), { tokens: 2000, limit: 258400 }); +}); + +test('readLatestTokenCount keeps a complete first line at a tail-window boundary', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const latest = tokenCountLine(7777, 258400, '2026-09-15T08:05:00.000Z'); + const content = functionCallLine('x'.repeat(2000)) + '\n' + latest + '\n'; + fs.writeFileSync(file, content, 'utf8'); + const tailBytes = Buffer.byteLength(latest + '\n', 'utf8'); + assert.equal(content.length - tailBytes, Buffer.byteLength(functionCallLine('x'.repeat(2000)) + '\n')); + assert.deepEqual(readLatestTokenCount(file, tailBytes), { tokens: 7777, limit: 258400 }); +}); + +test('readLatestTokenCount ignores a trailing partial line from a session still being written', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + const complete = tokenCountLine(5000, 258400, '2026-09-15T08:00:00.000Z'); + // No trailing newline, and the JSON itself is cut off mid-write. + const partial = tokenCountLine(9999, 258400, '2026-09-15T08:10:00.000Z').slice(0, 40); + fs.writeFileSync(file, complete + '\n' + partial, 'utf8'); + assert.deepEqual(readLatestTokenCount(file), { tokens: 5000, limit: 258400 }); +}); + +test('readLatestTokenCount returns null for a rollout with no token_count event yet', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + fs.writeFileSync(file, functionCallLine('exec') + '\n', 'utf8'); + assert.equal(readLatestTokenCount(file), null); +}); + +test('readLatestTokenCount returns null for a missing file', () => { + assert.equal(readLatestTokenCount(path.join(tempRepo(), 'does-not-exist.jsonl')), null); +}); + +test('readLatestTokenCount rejects a non-positive or non-numeric context window', () => { + const cwd = tempRepo(); + const file = path.join(cwd, 'rollout.jsonl'); + fs.writeFileSync(file, tokenCountLine(1000, 0, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + assert.equal(readLatestTokenCount(file), null); +}); + +test('readCodexContext locates and reads in one call', () => { + const codexHome = tempRepo(); + const dir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(dir, 'rollout-2026-09-15T08-00-00-abc.jsonl'), + tokenCountLine(30100, 258400, '2026-09-15T08:00:00.000Z') + '\n', + 'utf8' + ); + assert.deepEqual(readCodexContext(codexHome), { tokens: 30100, limit: 258400 }); +}); + +test('readCodexContext caches discovery and refreshes the selected rollout when it changes', () => { + const codexHome = tempRepo(); + const dir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(dir, { recursive: true }); + const selected = path.join(dir, 'rollout-selected.jsonl'); + fs.writeFileSync(selected, tokenCountLine(1000, 258400, '2026-09-15T08:00:00.000Z') + '\n', 'utf8'); + + assert.deepEqual(readCodexContext(codexHome, 1000), { tokens: 1000, limit: 258400 }); + fs.appendFileSync(selected, tokenCountLine(2000, 258400, '2026-09-15T08:01:00.000Z') + '\n', 'utf8'); + assert.deepEqual(readCodexContext(codexHome, 2000), { tokens: 2000, limit: 258400 }); + + const newer = path.join(dir, 'rollout-newer.jsonl'); + fs.writeFileSync(newer, tokenCountLine(3000, 258400, '2026-09-15T08:02:00.000Z') + '\n', 'utf8'); + const future = new Date(Date.now() + 60_000); + fs.utimesSync(newer, future, future); + assert.deepEqual(readCodexContext(codexHome, 3000), { tokens: 2000, limit: 258400 }); + assert.deepEqual(readCodexContext(codexHome, 62_000), { tokens: 3000, limit: 258400 }); +}); + +test('registry polling reads the HiveManager CODEX_HOME instead of the project cwd', () => { + const harnessHome = tempRepo(); + const hiveRoot = path.join(harnessHome, 'hive'); + const projectCwd = path.join(harnessHome, 'project-worktree'); + fs.mkdirSync(projectCwd, { recursive: true }); + fs.mkdirSync(hiveRoot, { recursive: true }); + fs.writeFileSync(path.join(hiveRoot, 'registry.json'), JSON.stringify({ + godId: null, + agents: { + 'codex-worker': { + id: 'codex-worker', + name: 'Codex Worker', + provider: 'codex', + cwd: projectCwd, + status: 'working', + lastSeen: Date.now() + } + } + }), 'utf8'); + + const hive = new HiveManager(() => harnessHome); + const codexHome = hive.codexHome('codex-worker'); + assert.notEqual(codexHome, null); + assert.notEqual(codexHome, projectCwd); + const sessionDir = path.join(codexHome, 'sessions', '2026', '09', '15'); + fs.mkdirSync(sessionDir, { recursive: true }); + fs.writeFileSync( + path.join(sessionDir, 'rollout-real-home.jsonl'), + tokenCountLine(4242, 258400, '2026-09-15T08:00:00.000Z') + '\n', + 'utf8' + ); + + assert.deepEqual(readCodexRegistryContexts(hive), [ + { agentId: 'codex-worker', reading: { tokens: 4242, limit: 258400 } } + ]); +}); + +test('readCodexContext returns null for an agent that was never a Codex worker', () => { + assert.equal(readCodexContext(tempRepo()), null); +});