From 11d82815daa515cc4da471a8c4acc4d0b80e9d91 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 29 Jan 2026 16:00:19 -0500 Subject: [PATCH 1/4] feat: track log file size to detect actual changes Use file size comparison instead of mtime alone to detect log changes. This prevents false activity updates when backups/syncs modify mtime without actual content changes. - Add lastKnownLogSize column to track file sizes - Add isCodexExec column for codex exec session tracking - Extract timestamps from log entries for accurate activity times - Update tests for new database schema Co-Authored-By: Claude Opus 4.5 --- src/client/App.tsx | 6 ++ src/server/__tests__/agentSessions.test.ts | 2 + src/server/__tests__/db.test.ts | 4 + src/server/__tests__/index.test.ts | 38 +++++--- src/server/__tests__/indexPortCheck.test.ts | 87 +++++++++++-------- .../__tests__/isolated/indexHandlers.test.ts | 2 + src/server/__tests__/logMatchGate.test.ts | 15 +++- .../__tests__/logMatchWorkerClient.test.ts | 2 +- src/server/__tests__/logPollData.test.ts | 1 + src/server/__tests__/logPoller.test.ts | 6 ++ .../pin-sessions.integration.test.ts | 4 + src/server/db.ts | 43 +++++++-- src/server/logDiscovery.ts | 3 +- src/server/logMatchGate.ts | 5 +- src/server/logMatchWorker.ts | 3 + src/server/logMatchWorkerClient.ts | 22 +++-- src/server/logMatcher.ts | 26 ++++++ src/server/logPollData.ts | 12 ++- src/server/logPoller.ts | 57 ++++++++---- 19 files changed, 255 insertions(+), 83 deletions(-) diff --git a/src/client/App.tsx b/src/client/App.tsx index 58d1ed4..68fd35d 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -189,6 +189,12 @@ export default function App() { } setSelectedSessionId(message.session.id) addRecentPath(message.session.projectPath) + + // Auto-add to filter if filters are active and project isn't included + const { projectFilters, setProjectFilters } = useSettingsStore.getState() + if (projectFilters.length > 0 && !projectFilters.includes(message.session.projectPath)) { + setProjectFilters([...projectFilters, message.session.projectPath]) + } } if (message.type === 'session-removed') { // setSessions handles marking removed sessions as exiting for animation diff --git a/src/server/__tests__/agentSessions.test.ts b/src/server/__tests__/agentSessions.test.ts index 95b87a5..5dc4754 100644 --- a/src/server/__tests__/agentSessions.test.ts +++ b/src/server/__tests__/agentSessions.test.ts @@ -15,6 +15,8 @@ const baseRecord: AgentSessionRecord = { currentWindow: 'agentboard:1', isPinned: false, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, } describe('agentSessions', () => { diff --git a/src/server/__tests__/db.test.ts b/src/server/__tests__/db.test.ts index 673a8d1..bb08504 100644 --- a/src/server/__tests__/db.test.ts +++ b/src/server/__tests__/db.test.ts @@ -20,6 +20,8 @@ function makeSession(overrides: Partial<{ currentWindow: string | null isPinned: boolean lastResumeError: string | null + lastKnownLogSize: number | null + isCodexExec: boolean }> = {}) { return { sessionId: 'session-abc', @@ -33,6 +35,8 @@ function makeSession(overrides: Partial<{ currentWindow: 'agentboard:1', isPinned: false, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, ...overrides, } } diff --git a/src/server/__tests__/index.test.ts b/src/server/__tests__/index.test.ts index 4b18634..3a8d9e1 100644 --- a/src/server/__tests__/index.test.ts +++ b/src/server/__tests__/index.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test' import fs from 'node:fs/promises' import path from 'node:path' import os from 'node:os' @@ -16,7 +16,8 @@ const originalDbPath = process.env.AGENTBOARD_DB_PATH let tempDbPath: string | null = null const serveCalls: Array<{ port: number }> = [] -const importIndex = (suffix: string) => import(`../index?test=${suffix}`) +let importCounter = 0 +let spawnSyncImpl: typeof Bun.spawnSync describe('server entrypoint', () => { beforeAll(() => { @@ -27,22 +28,36 @@ describe('server entrypoint', () => { process.env.AGENTBOARD_DB_PATH = tempDbPath }) - test('starts server without side effects', async () => { + beforeEach(() => { serveCalls.length = 0 process.env.AGENTBOARD_LOG_MATCH_WORKER = 'false' - bunAny.spawnSync = () => + + // Default mock: port not in use + spawnSyncImpl = () => ({ exitCode: 0, stdout: Buffer.from(''), stderr: Buffer.from(''), }) as ReturnType + + bunAny.spawnSync = ((...args: Parameters) => + spawnSyncImpl(...args)) as typeof Bun.spawnSync bunAny.serve = ((options: { port?: number }) => { serveCalls.push({ port: options.port ?? 0 }) return {} as ReturnType }) as unknown as typeof Bun.serve globalThis.setInterval = (() => 0) as unknown as typeof globalThis.setInterval + }) - await importIndex('no-side-effects') + afterEach(() => { + bunAny.serve = originalServe + bunAny.spawnSync = originalSpawnSync + globalThis.setInterval = originalSetInterval + }) + + test('starts server without side effects', async () => { + importCounter += 1 + await import(`../index?test=no-side-effects-${importCounter}`) const expectedPort = Number(process.env.PORT) || 4040 expect(serveCalls).toHaveLength(1) @@ -50,9 +65,8 @@ describe('server entrypoint', () => { }) test('starts server when lsof is unavailable', async () => { - serveCalls.length = 0 - process.env.AGENTBOARD_LOG_MATCH_WORKER = 'false' - bunAny.spawnSync = ((...args: Parameters) => { + // Override spawnSyncImpl to throw for lsof + spawnSyncImpl = ((...args: Parameters) => { const command = Array.isArray(args[0]) ? args[0][0] : '' if (command === 'lsof') { throw new Error('missing lsof') @@ -63,13 +77,9 @@ describe('server entrypoint', () => { stderr: Buffer.from(''), } as ReturnType }) as typeof Bun.spawnSync - bunAny.serve = ((options: { port?: number }) => { - serveCalls.push({ port: options.port ?? 0 }) - return {} as ReturnType - }) as unknown as typeof Bun.serve - globalThis.setInterval = (() => 0) as unknown as typeof globalThis.setInterval - await importIndex('missing-lsof') + importCounter += 1 + await import(`../index?test=missing-lsof-${importCounter}`) const expectedPort = Number(process.env.PORT) || 4040 expect(serveCalls).toHaveLength(1) diff --git a/src/server/__tests__/indexPortCheck.test.ts b/src/server/__tests__/indexPortCheck.test.ts index da885da..cd750e6 100644 --- a/src/server/__tests__/indexPortCheck.test.ts +++ b/src/server/__tests__/indexPortCheck.test.ts @@ -1,8 +1,20 @@ -import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, mock } from 'bun:test' import fs from 'node:fs/promises' import path from 'node:path' import os from 'node:os' +// Mock logger to suppress expected error output during port conflict tests +mock.module('../logger', () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + }, + flushLogger: () => {}, + closeLogger: () => {}, +})) + const bunAny = Bun as typeof Bun & { serve: typeof Bun.serve spawnSync: typeof Bun.spawnSync @@ -18,6 +30,7 @@ const originalProcessExit = processAny.exit const originalSetInterval = globalThis.setInterval const originalDbPath = process.env.AGENTBOARD_DB_PATH let tempDbPath: string | null = null +let importCounter = 0 beforeAll(() => { const suffix = `port-check-${process.pid}-${Date.now()}-${Math.random() @@ -27,6 +40,42 @@ beforeAll(() => { process.env.AGENTBOARD_DB_PATH = tempDbPath }) +beforeEach(() => { + // Set up mocks that simulate port already in use + bunAny.spawnSync = ((...args: Parameters) => { + const command = Array.isArray(args[0]) ? args[0][0] : '' + if (command === 'lsof') { + return { + exitCode: 0, + stdout: Buffer.from('123\n'), + stderr: Buffer.from(''), + } as ReturnType + } + if (command === 'ps') { + return { + exitCode: 0, + stdout: Buffer.from('node\n'), + stderr: Buffer.from(''), + } as ReturnType + } + return { + exitCode: 0, + stdout: Buffer.from(''), + stderr: Buffer.from(''), + } as ReturnType + }) as typeof Bun.spawnSync + + bunAny.serve = ((_options: Parameters[0]) => { + return {} as ReturnType + }) as typeof Bun.serve + + globalThis.setInterval = (() => 0) as unknown as typeof globalThis.setInterval + + processAny.exit = ((code?: number) => { + throw new Error(`exit:${code ?? 0}`) + }) as typeof processAny.exit +}) + afterEach(() => { bunAny.serve = originalServe bunAny.spawnSync = originalSpawnSync @@ -47,40 +96,8 @@ afterAll(() => { describe('port availability', () => { test('exits when the configured port is already in use', async () => { - const suffix = `port-check-${Date.now()}` - - bunAny.spawnSync = ((...args: Parameters) => { - const command = Array.isArray(args[0]) ? args[0][0] : '' - if (command === 'lsof') { - return { - exitCode: 0, - stdout: Buffer.from('123\n'), - stderr: Buffer.from(''), - } as ReturnType - } - if (command === 'ps') { - return { - exitCode: 0, - stdout: Buffer.from('node\n'), - stderr: Buffer.from(''), - } as ReturnType - } - return { - exitCode: 0, - stdout: Buffer.from(''), - stderr: Buffer.from(''), - } as ReturnType - }) as typeof Bun.spawnSync - - bunAny.serve = ((_options: Parameters[0]) => { - return {} as ReturnType - }) as typeof Bun.serve - - globalThis.setInterval = (() => 0) as unknown as typeof globalThis.setInterval - - processAny.exit = ((code?: number) => { - throw new Error(`exit:${code ?? 0}`) - }) as typeof processAny.exit + importCounter += 1 + const suffix = `port-check-${importCounter}` let thrown: Error | null = null try { diff --git a/src/server/__tests__/isolated/indexHandlers.test.ts b/src/server/__tests__/isolated/indexHandlers.test.ts index f3d2641..95c8da6 100644 --- a/src/server/__tests__/isolated/indexHandlers.test.ts +++ b/src/server/__tests__/isolated/indexHandlers.test.ts @@ -85,6 +85,8 @@ function makeRecord(overrides: Partial = {}): AgentSessionRe currentWindow: null, isPinned: false, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, ...overrides, } } diff --git a/src/server/__tests__/logMatchGate.test.ts b/src/server/__tests__/logMatchGate.test.ts index 39d3306..bd67aca 100644 --- a/src/server/__tests__/logMatchGate.test.ts +++ b/src/server/__tests__/logMatchGate.test.ts @@ -10,6 +10,7 @@ function makeEntry( logPath: 'log-1', mtime: 1_700_000_000_000, birthtime: 1_699_000_000_000, + size: 1000, sessionId: 'session-1', projectPath: '/proj', agentType: 'claude', @@ -49,18 +50,21 @@ describe('logMatchGate', () => { logFilePath: 'log-stale', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different from entry.size (1000), so needs match }, { sessionId: 'session-active', logFilePath: 'log-active', currentWindow: '2', lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 1000, }, { sessionId: 'session-invalid', logFilePath: 'log-invalid', currentWindow: null, lastActivityAt: 'not-a-date', + lastKnownLogSize: 500, // Different from entry.size (1000), so needs match }, ] @@ -133,12 +137,14 @@ describe('logMatchGate', () => { logFilePath: 'log-orphan-exec', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size to trigger re-match check }, { sessionId: 'session-orphan-normal', logFilePath: 'log-orphan-normal', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size to trigger re-match }, ] @@ -146,7 +152,7 @@ describe('logMatchGate', () => { minTokens: 0, skipMatchingPatterns: [''], }) - // Only the normal orphan should need matching + // Only the normal orphan should need matching (codex-exec is always skipped) expect(needs.map((e) => e.sessionId)).toEqual(['session-orphan-normal']) }) @@ -171,12 +177,14 @@ describe('logMatchGate', () => { logFilePath: 'log-orphan-tmp', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size }, { sessionId: 'session-orphan-normal', logFilePath: 'log-orphan-normal', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size to trigger re-match }, ] @@ -204,10 +212,11 @@ describe('logMatchGate', () => { logFilePath: 'log-orphan-exec', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size }, ] - // Should work with different case variations + // Should work with different case variations - codex exec is always skipped for (const pattern of ['', '', '']) { const needs = getEntriesNeedingMatch(entries, sessions, { minTokens: 0, @@ -229,12 +238,14 @@ describe('logMatchGate', () => { logFilePath: 'log-tmp', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size }, { sessionId: 'session-normal', logFilePath: 'log-normal', currentWindow: null, lastActivityAt: '2025-01-01T00:00:00Z', + lastKnownLogSize: 500, // Different size to trigger re-match }, ] diff --git a/src/server/__tests__/logMatchWorkerClient.test.ts b/src/server/__tests__/logMatchWorkerClient.test.ts index de34377..f693741 100644 --- a/src/server/__tests__/logMatchWorkerClient.test.ts +++ b/src/server/__tests__/logMatchWorkerClient.test.ts @@ -131,7 +131,7 @@ describe('LogMatchWorkerClient', () => { await waitForMessage(worker) client.dispose() - await expect(promise).rejects.toThrow('Log match worker disposed') + await expect(promise).rejects.toThrow('Log match worker is disposed') expect(worker.terminated).toBe(true) }) diff --git a/src/server/__tests__/logPollData.test.ts b/src/server/__tests__/logPollData.test.ts index e69f8b5..9a5d233 100644 --- a/src/server/__tests__/logPollData.test.ts +++ b/src/server/__tests__/logPollData.test.ts @@ -138,6 +138,7 @@ describe('collectLogEntryBatch', () => { sessionId: 'session-known-id', projectPath: '/project/known', agentType: 'claude', + isCodexExec: false, }, ], }) diff --git a/src/server/__tests__/logPoller.test.ts b/src/server/__tests__/logPoller.test.ts index 4369227..4eb60cb 100644 --- a/src/server/__tests__/logPoller.test.ts +++ b/src/server/__tests__/logPoller.test.ts @@ -385,6 +385,8 @@ describe('LogPoller', () => { currentWindow: baseSession.tmuxWindow, isPinned: false, lastResumeError: null, + lastKnownLogSize: 0, + isCodexExec: false, }) const poller = new LogPoller(db, registry, { @@ -435,6 +437,8 @@ describe('LogPoller', () => { currentWindow: null, isPinned: false, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, }) const poller = new LogPoller(db, registry, { @@ -497,6 +501,8 @@ describe('LogPoller', () => { currentWindow: null, isPinned: false, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, }) const poller = new LogPoller(db, registry, { diff --git a/src/server/__tests__/pin-sessions.integration.test.ts b/src/server/__tests__/pin-sessions.integration.test.ts index c062c33..fa2b03c 100644 --- a/src/server/__tests__/pin-sessions.integration.test.ts +++ b/src/server/__tests__/pin-sessions.integration.test.ts @@ -113,6 +113,8 @@ if (!tmuxAvailable) { currentWindow: `${sessionName}:1`, // active isPinned: false, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, }) db.close() @@ -166,6 +168,8 @@ if (!tmuxAvailable) { currentWindow: null, isPinned: true, lastResumeError: null, + lastKnownLogSize: null, + isCodexExec: false, }) db.close() diff --git a/src/server/db.ts b/src/server/db.ts index cfe2671..509728b 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -17,6 +17,8 @@ export interface AgentSessionRecord { currentWindow: string | null isPinned: boolean lastResumeError: string | null + lastKnownLogSize: number | null + isCodexExec: boolean } export interface SessionDatabase { @@ -60,7 +62,9 @@ const AGENT_SESSIONS_COLUMNS_SQL = ` last_user_message TEXT, current_window TEXT, is_pinned INTEGER NOT NULL DEFAULT 0, - last_resume_error TEXT + last_resume_error TEXT, + last_known_log_size INTEGER, + is_codex_exec INTEGER NOT NULL DEFAULT 0 ` const CREATE_TABLE_SQL = ` @@ -101,11 +105,13 @@ export function initDatabase(options: { path?: string } = {}): SessionDatabase { migrateDeduplicateDisplayNames(db) migrateIsPinnedColumn(db) migrateLastResumeErrorColumn(db) + migrateLastKnownLogSizeColumn(db) + migrateIsCodexExecColumn(db) const insertStmt = db.prepare( `INSERT INTO agent_sessions - (session_id, log_file_path, project_path, agent_type, display_name, created_at, last_activity_at, last_user_message, current_window, is_pinned, last_resume_error) - VALUES ($sessionId, $logFilePath, $projectPath, $agentType, $displayName, $createdAt, $lastActivityAt, $lastUserMessage, $currentWindow, $isPinned, $lastResumeError)` + (session_id, log_file_path, project_path, agent_type, display_name, created_at, last_activity_at, last_user_message, current_window, is_pinned, last_resume_error, last_known_log_size, is_codex_exec) + VALUES ($sessionId, $logFilePath, $projectPath, $agentType, $displayName, $createdAt, $lastActivityAt, $lastUserMessage, $currentWindow, $isPinned, $lastResumeError, $lastKnownLogSize, $isCodexExec)` ) const selectBySessionId = db.prepare( @@ -163,6 +169,8 @@ export function initDatabase(options: { path?: string } = {}): SessionDatabase { $currentWindow: session.currentWindow, $isPinned: session.isPinned ? 1 : 0, $lastResumeError: session.lastResumeError, + $lastKnownLogSize: session.lastKnownLogSize, + $isCodexExec: session.isCodexExec ? 1 : 0, }) const row = selectBySessionId.get({ $sessionId: session.sessionId }) as | Record @@ -193,6 +201,8 @@ export function initDatabase(options: { path?: string } = {}): SessionDatabase { currentWindow: 'current_window', isPinned: 'is_pinned', lastResumeError: 'last_resume_error', + lastKnownLogSize: 'last_known_log_size', + isCodexExec: 'is_codex_exec', } const fields: string[] = [] @@ -203,8 +213,8 @@ export function initDatabase(options: { path?: string } = {}): SessionDatabase { const field = fieldMap[key] if (!field) continue fields.push(field) - // Normalize isPinned to 0/1 for SQLite - if (key === 'isPinned') { + // Normalize boolean fields to 0/1 for SQLite + if (key === 'isPinned' || key === 'isCodexExec') { params[`$${field}`] = value ? 1 : 0 } else { params[`$${field}`] = value as string | number | null @@ -349,6 +359,13 @@ function mapRow(row: Record): AgentSessionRecord { row.last_resume_error === null || row.last_resume_error === undefined ? null : String(row.last_resume_error), + // Note: null lastKnownLogSize is treated as "unknown", triggering a match check + // on first poll after upgrade. This is intentional (one-time cost). + lastKnownLogSize: + row.last_known_log_size === null || row.last_known_log_size === undefined + ? null + : Number(row.last_known_log_size), + isCodexExec: Number(row.is_codex_exec) === 1, } } @@ -429,6 +446,22 @@ function migrateLastResumeErrorColumn(db: SQLiteDatabase) { db.exec('ALTER TABLE agent_sessions ADD COLUMN last_resume_error TEXT') } +function migrateLastKnownLogSizeColumn(db: SQLiteDatabase) { + const columns = getColumnNames(db, 'agent_sessions') + if (columns.length === 0 || columns.includes('last_known_log_size')) { + return + } + db.exec('ALTER TABLE agent_sessions ADD COLUMN last_known_log_size INTEGER') +} + +function migrateIsCodexExecColumn(db: SQLiteDatabase) { + const columns = getColumnNames(db, 'agent_sessions') + if (columns.length === 0 || columns.includes('is_codex_exec')) { + return + } + db.exec('ALTER TABLE agent_sessions ADD COLUMN is_codex_exec INTEGER NOT NULL DEFAULT 0') +} + function migrateDeduplicateDisplayNames(db: SQLiteDatabase) { // Find all display names that have duplicates const duplicates = db diff --git a/src/server/logDiscovery.ts b/src/server/logDiscovery.ts index f1cb16e..60bde10 100644 --- a/src/server/logDiscovery.ts +++ b/src/server/logDiscovery.ts @@ -138,12 +138,13 @@ export function getLogBirthtime(logPath: string): Date | null { export function getLogTimes( logPath: string -): { mtime: Date; birthtime: Date } | null { +): { mtime: Date; birthtime: Date; size: number } | null { try { const stats = fs.statSync(logPath) return { mtime: stats.mtime, birthtime: stats.birthtime ?? stats.mtime, + size: stats.size, } } catch { return null diff --git a/src/server/logMatchGate.ts b/src/server/logMatchGate.ts index 5832f63..bfd34a2 100644 --- a/src/server/logMatchGate.ts +++ b/src/server/logMatchGate.ts @@ -6,6 +6,7 @@ export interface SessionSnapshot { currentWindow: string | null lastActivityAt: string lastUserMessage?: string | null + lastKnownLogSize?: number | null } /** @@ -90,8 +91,8 @@ export function getEntriesNeedingMatch( if (shouldSkipMatching(entry, skipMatchingPatterns)) { continue } - const lastActivity = Date.parse(session.lastActivityAt) - if (!Number.isFinite(lastActivity) || entry.mtime > lastActivity) { + // Gate on size change - if size differs, content changed and we should re-match + if (entry.size !== session.lastKnownLogSize) { needs.push(entry) } } diff --git a/src/server/logMatchWorker.ts b/src/server/logMatchWorker.ts index 2503014..465a263 100644 --- a/src/server/logMatchWorker.ts +++ b/src/server/logMatchWorker.ts @@ -198,6 +198,7 @@ function buildOrphanEntries( logPath, mtime: times.mtime.getTime(), birthtime: times.birthtime.getTime(), + size: times.size, sessionId: record.sessionId, projectPath: record.projectPath ?? null, agentType: agentType ?? null, @@ -219,6 +220,7 @@ function buildOrphanEntries( logPath, mtime: times.mtime.getTime(), birthtime: times.birthtime.getTime(), + size: times.size, sessionId: record.sessionId, projectPath: record.projectPath ?? null, agentType: agentType ?? null, @@ -261,6 +263,7 @@ function buildLastMessageEntries( logPath, mtime: times.mtime.getTime(), birthtime: times.birthtime.getTime(), + size: times.size, sessionId: record.sessionId, projectPath: record.projectPath ?? null, agentType: resolvedAgentType, diff --git a/src/server/logMatchWorkerClient.ts b/src/server/logMatchWorkerClient.ts index bc742cd..1ab1789 100644 --- a/src/server/logMatchWorkerClient.ts +++ b/src/server/logMatchWorkerClient.ts @@ -19,6 +19,7 @@ export class LogMatchWorkerClient { private readyPromise: Promise | null = null private readyResolve: (() => void) | null = null private readyReject: ((error: Error) => void) | null = null + private initFailed = false constructor() { this.spawnWorker() @@ -42,6 +43,17 @@ export class LogMatchWorkerClient { if (this.disposed) { throw new Error('Log match worker is disposed') } + // If init failed, restart worker and retry + if (this.initFailed) { + this.initFailed = false + this.restartWorker() + if (this.readyPromise) { + await this.readyPromise + } + if (this.initFailed) { + throw new Error('Log match worker failed to initialize after restart') + } + } const id = `${Date.now()}-${this.counter++}` const payload: MatchWorkerRequest = { ...request, id } @@ -61,12 +73,12 @@ export class LogMatchWorkerClient { dispose(): void { this.disposed = true if (this.readyReject) { - this.readyReject(new Error('Log match worker disposed')) + this.readyReject(new Error('Log match worker is disposed')) this.readyReject = null } this.readyResolve = null this.readyPromise = null - this.failAll(new Error('Log match worker disposed')) + this.failAll(new Error('Log match worker is disposed')) if (this.worker) { this.worker.terminate() this.worker = null @@ -77,6 +89,7 @@ export class LogMatchWorkerClient { if (this.disposed) return // Set up ready promise before creating the worker + this.initFailed = false this.readyPromise = new Promise((resolve, reject) => { this.readyResolve = resolve this.readyReject = reject @@ -85,11 +98,10 @@ export class LogMatchWorkerClient { if (this.readyResolve) { this.readyResolve = null this.readyReject = null - reject(new Error('Log match worker failed to initialize')) + this.initFailed = true + resolve() // Resolve instead of reject so poll() can handle gracefully } }, READY_TIMEOUT_MS) - }).catch(() => { - // Swallow the error - poll() will handle the timeout }) const worker = new Worker(new URL('./logMatchWorker.ts', import.meta.url).href, { diff --git a/src/server/logMatcher.ts b/src/server/logMatcher.ts index 73c50b5..70a5e24 100644 --- a/src/server/logMatcher.ts +++ b/src/server/logMatcher.ts @@ -1099,6 +1099,32 @@ export function extractLastUserMessageFromLog( return user && user.trim() ? user.trim() : null } +/** + * Extract the timestamp from the last entry in a log file. + * Returns an ISO timestamp string, or null if not found. + */ +export function extractLastEntryTimestamp( + logPath: string, + tailBytes = 32 * 1024 +): string | null { + const raw = readLogTail(logPath, tailBytes) + if (!raw) return null + + const lines = raw.split('\n').filter(Boolean) + // Iterate from the end to find the last entry with a timestamp + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]) + if (entry && typeof entry.timestamp === 'string') { + return entry.timestamp + } + } catch { + // Skip malformed lines + } + } + return null +} + export interface ExactMatchContext { agentType?: AgentType projectPath?: string diff --git a/src/server/logPollData.ts b/src/server/logPollData.ts index 627e53b..9393258 100644 --- a/src/server/logPollData.ts +++ b/src/server/logPollData.ts @@ -15,6 +15,7 @@ export interface LogEntrySnapshot { logPath: string mtime: number birthtime: number + size: number sessionId: string | null projectPath: string | null agentType: AgentType | null @@ -22,6 +23,8 @@ export interface LogEntrySnapshot { isCodexExec: boolean logTokenCount: number lastUserMessage?: string + /** Timestamp from the last log entry (ISO string), if parsed */ + lastEntryTimestamp?: string } export interface LogEntryBatch { @@ -36,6 +39,7 @@ export interface KnownSession { sessionId: string projectPath: string | null agentType: AgentType | null + isCodexExec: boolean } export interface CollectLogEntryBatchOptions { @@ -64,12 +68,14 @@ export function collectLogEntryBatch( logPath, mtime: times.mtime.getTime(), birthtime: times.birthtime.getTime(), + size: times.size, } }) .filter(Boolean) as Array<{ logPath: string mtime: number birthtime: number + size: number }> const sortStart = performance.now() @@ -83,17 +89,16 @@ export function collectLogEntryBatch( if (known) { // Use cached metadata from DB, skip file content reads // logTokenCount = -1 indicates enrichment was skipped (already validated) - // Still need to check isCodexExec for matching decisions - const codexExec = known.agentType === 'codex' ? isCodexExec(entry.logPath) : false return { logPath: entry.logPath, mtime: entry.mtime, birthtime: entry.birthtime, + size: entry.size, sessionId: known.sessionId, projectPath: known.projectPath, agentType: known.agentType, isCodexSubagent: false, - isCodexExec: codexExec, + isCodexExec: known.isCodexExec, logTokenCount: -1, } satisfies LogEntrySnapshot } @@ -111,6 +116,7 @@ export function collectLogEntryBatch( logPath: entry.logPath, mtime: entry.mtime, birthtime: entry.birthtime, + size: entry.size, sessionId, projectPath, agentType: agentType ?? null, diff --git a/src/server/logPoller.ts b/src/server/logPoller.ts index cb7114f..80efca9 100644 --- a/src/server/logPoller.ts +++ b/src/server/logPoller.ts @@ -2,7 +2,7 @@ import { logger } from './logger' import { config } from './config' import type { SessionDatabase } from './db' import { getLogSearchDirs } from './logDiscovery' -import { DEFAULT_SCROLLBACK_LINES, isToolNotificationText } from './logMatcher' +import { DEFAULT_SCROLLBACK_LINES, extractLastEntryTimestamp, isToolNotificationText } from './logMatcher' import { deriveDisplayName } from './agentSessions' import { generateUniqueSessionName } from './nameGenerator' import type { SessionRegistry } from './SessionRegistry' @@ -40,25 +40,43 @@ interface SessionRecord { currentWindow: string | null isPinned: boolean lastResumeError: string | null + lastKnownLogSize: number | null } // Fields that applyLogEntryToExistingRecord may update -type SessionUpdate = Pick +type SessionUpdate = Pick /** * Computes the update object for an existing session record based on a log entry. * Shared logic between the "existing by logPath" and "existing by sessionId" branches. + * Uses file size comparison to detect actual log growth (not just mtime changes from backups/syncs). */ function applyLogEntryToExistingRecord( record: SessionRecord, entry: LogEntrySnapshot, - opts: { isLastUserMessageLocked: boolean } + opts: { isLastUserMessageLocked: boolean; logPath: string } ): Partial | null { - const hasActivity = entry.mtime > Date.parse(record.lastActivityAt) const update: Partial = {} - if (hasActivity) { - update.lastActivityAt = new Date(entry.mtime).toISOString() + // Use file size to detect actual log changes (mtime can change from backups/syncs) + const lastKnownSize = record.lastKnownLogSize ?? 0 + const sizeChanged = entry.size !== lastKnownSize + const hasGrown = entry.size > lastKnownSize + + if (sizeChanged) { + // Log size changed - could be growth or truncation/rotation + if (hasGrown) { + // Log grew - extract timestamp from the last entry + const logTimestamp = extractLastEntryTimestamp(opts.logPath) + if (logTimestamp) { + update.lastActivityAt = logTimestamp + } else { + // Fallback to mtime if we can't parse a timestamp + update.lastActivityAt = new Date(entry.mtime).toISOString() + } + } + // Always update lastKnownLogSize on any size change (including truncation) + update.lastKnownLogSize = entry.size } if (entry.lastUserMessage && !isToolNotificationText(entry.lastUserMessage)) { @@ -67,7 +85,7 @@ function applyLogEntryToExistingRecord( const shouldReplace = !record.lastUserMessage || isToolNotificationText(record.lastUserMessage) || - (hasActivity && entry.lastUserMessage !== record.lastUserMessage) + (sizeChanged && entry.lastUserMessage !== record.lastUserMessage) if (shouldReplace) { update.lastUserMessage = entry.lastUserMessage } @@ -236,8 +254,9 @@ export class LogPoller { sessionId: session.sessionId, logFilePath: session.logFilePath, currentWindow: session.currentWindow, - lastActivityAt: '', // Force re-check for orphan matching + lastActivityAt: session.lastActivityAt, lastUserMessage: session.lastUserMessage, + lastKnownLogSize: null, // Force re-check for orphan matching })) // Use longer timeout for orphan rematch since it processes many files @@ -418,6 +437,7 @@ export class LogPoller { currentWindow: session.currentWindow, lastActivityAt: session.lastActivityAt, lastUserMessage: session.lastUserMessage, + lastKnownLogSize: session.lastKnownLogSize, })) // Build known sessions list to skip expensive file reads for already-tracked logs const knownSessions: KnownSession[] = sessionRecords @@ -427,6 +447,7 @@ export class LogPoller { sessionId: session.sessionId, projectPath: session.projectPath ?? null, agentType: session.agentType ?? null, + isCodexExec: session.isCodexExec, })) let exactWindowMatches = new Map() let entriesToMatch: LogEntrySnapshot[] = [] @@ -554,15 +575,16 @@ export class LogPoller { try { const existing = this.db.getSessionByLogPath(entry.logPath) if (existing) { - const hasActivity = entry.mtime > Date.parse(existing.lastActivityAt) + // Use file size to detect actual log growth (mtime is unreliable due to backups/syncs) + const hasGrown = entry.size > (existing.lastKnownLogSize ?? 0) const isLocked = Boolean(existing.currentWindow && this.isLastUserMessageLocked?.(existing.currentWindow)) - const update = applyLogEntryToExistingRecord(existing, entry, { isLastUserMessageLocked: isLocked }) + const update = applyLogEntryToExistingRecord(existing, entry, { isLastUserMessageLocked: isLocked, logPath: entry.logPath }) if (update) { this.db.updateSession(existing.sessionId, update) } const shouldAttemptRematch = !existing.currentWindow && - (hasActivity || matchEligibleLogPaths.has(entry.logPath)) + (hasGrown || matchEligibleLogPaths.has(entry.logPath)) if (shouldAttemptRematch) { const lastAttempt = this.rematchAttemptCache.get(existing.sessionId) ?? 0 @@ -616,13 +638,16 @@ export class LogPoller { } const projectPath = entry.projectPath ?? '' const createdAt = new Date(entry.birthtime || entry.mtime).toISOString() - const lastActivityAt = new Date(entry.mtime).toISOString() + // Extract timestamp from log entry for accurate activity time (mtime is unreliable due to backups/syncs) + const logTimestamp = extractLastEntryTimestamp(entry.logPath) + const lastActivityAt = logTimestamp || new Date(entry.mtime).toISOString() const existingById = this.db.getSessionById(sessionId) if (existingById) { - const hasActivity = entry.mtime > Date.parse(existingById.lastActivityAt) + // Use file size to detect actual log growth + const hasGrown = entry.size > (existingById.lastKnownLogSize ?? 0) const isLocked = Boolean(existingById.currentWindow && this.isLastUserMessageLocked?.(existingById.currentWindow)) - const updateById = applyLogEntryToExistingRecord(existingById, entry, { isLastUserMessageLocked: isLocked }) + const updateById = applyLogEntryToExistingRecord(existingById, entry, { isLastUserMessageLocked: isLocked, logPath: entry.logPath }) if (updateById) { this.db.updateSession(sessionId, updateById) } @@ -630,7 +655,7 @@ export class LogPoller { // Re-attempt matching for orphaned sessions (no currentWindow) const shouldAttemptRematch = !existingById.currentWindow && - (hasActivity || matchEligibleLogPaths.has(entry.logPath)) + (hasGrown || matchEligibleLogPaths.has(entry.logPath)) if (shouldAttemptRematch) { const lastAttempt = this.rematchAttemptCache.get(sessionId) ?? 0 if (Date.now() - lastAttempt > REMATCH_COOLDOWN_MS) { @@ -724,6 +749,8 @@ export class LogPoller { currentWindow, isPinned: false, lastResumeError: null, + lastKnownLogSize: entry.size, + isCodexExec: entry.isCodexExec, }) newSessions += 1 if (currentWindow) { From d00b5cda43969e675b89a43df0017ccf6e861f9e Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 29 Jan 2026 16:05:05 -0500 Subject: [PATCH 2/4] fix: address code review issues in log size tracking - Fix lastKnownLogSize null handling: always initialize on first observation even when entry.size is 0, preventing DB field from staying null forever - Defer orphan rematch until after first poll completes to avoid worker contention on startup - Clear readyPromise in timeout handler for consistent state management Co-Authored-By: Claude Opus 4.5 --- src/server/logMatchWorkerClient.ts | 1 + src/server/logPoller.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/server/logMatchWorkerClient.ts b/src/server/logMatchWorkerClient.ts index 1ab1789..64075be 100644 --- a/src/server/logMatchWorkerClient.ts +++ b/src/server/logMatchWorkerClient.ts @@ -98,6 +98,7 @@ export class LogMatchWorkerClient { if (this.readyResolve) { this.readyResolve = null this.readyReject = null + this.readyPromise = null this.initFailed = true resolve() // Resolve instead of reject so poll() can handle gracefully } diff --git a/src/server/logPoller.ts b/src/server/logPoller.ts index 80efca9..d3b4aeb 100644 --- a/src/server/logPoller.ts +++ b/src/server/logPoller.ts @@ -60,10 +60,12 @@ function applyLogEntryToExistingRecord( // Use file size to detect actual log changes (mtime can change from backups/syncs) const lastKnownSize = record.lastKnownLogSize ?? 0 + const isFirstObservation = record.lastKnownLogSize === null const sizeChanged = entry.size !== lastKnownSize const hasGrown = entry.size > lastKnownSize - if (sizeChanged) { + // Enter block on size change OR first observation (to initialize null -> actual size) + if (sizeChanged || isFirstObservation) { // Log size changed - could be growth or truncation/rotation if (hasGrown) { // Log grew - extract timestamp from the last entry @@ -180,11 +182,12 @@ export class LogPoller { this.interval = setInterval(() => { void this.pollOnce() }, safeInterval) - void this.pollOnce() - // Start orphan rematch in background - doesn't block regular polling - if (this.orphanRematchPending && !this.orphanRematchInProgress) { - this.orphanRematchPromise = this.runOrphanRematchInBackground() - } + // Start orphan rematch after first poll completes to avoid worker contention + void this.pollOnce().then(() => { + if (this.orphanRematchPending && !this.orphanRematchInProgress) { + this.orphanRematchPromise = this.runOrphanRematchInBackground() + } + }) } /** Wait for the background orphan rematch to complete (for testing) */ From 5f7262c09e9d9c42faa374a540a3c2640263962e Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 29 Jan 2026 16:43:28 -0500 Subject: [PATCH 3/4] fix: add tests and improve worker init error handling - Add comprehensive tests for extractLastEntryTimestamp function - Document NULL semantics for last_known_log_size in schema - Refactor LogMatchWorkerClient to use proper rejection pattern instead of confusing initFailed flag - Add disposed check during worker restart to prevent race condition Co-Authored-By: Claude Opus 4.5 --- src/server/__tests__/logMatcher.test.ts | 86 +++++++++++++++++++++++++ src/server/db.ts | 2 + src/server/logMatchWorkerClient.ts | 43 ++++++++----- 3 files changed, 114 insertions(+), 17 deletions(-) diff --git a/src/server/__tests__/logMatcher.test.ts b/src/server/__tests__/logMatcher.test.ts index ebad06e..4f635e1 100644 --- a/src/server/__tests__/logMatcher.test.ts +++ b/src/server/__tests__/logMatcher.test.ts @@ -14,6 +14,7 @@ import { extractActionFromUserAction, hasMessageInValidUserContext, isToolNotificationText, + extractLastEntryTimestamp, } from '../logMatcher' const bunAny = Bun as typeof Bun & { spawnSync: typeof Bun.spawnSync } @@ -773,3 +774,88 @@ describe('isToolNotificationText', () => { }) }) }) + +describe('extractLastEntryTimestamp', () => { + let tmpDir: string + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'extract-timestamp-')) + }) + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }) + }) + + test('extracts timestamp from last entry', async () => { + const logPath = path.join(tmpDir, 'test.jsonl') + await fs.writeFile( + logPath, + [ + JSON.stringify({ timestamp: '2025-01-01T00:00:00Z', type: 'message' }), + JSON.stringify({ timestamp: '2025-01-02T00:00:00Z', type: 'message' }), + JSON.stringify({ timestamp: '2025-01-03T00:00:00Z', type: 'message' }), + ].join('\n') + ) + expect(extractLastEntryTimestamp(logPath)).toBe('2025-01-03T00:00:00Z') + }) + + test('returns null for empty file', async () => { + const logPath = path.join(tmpDir, 'empty.jsonl') + await fs.writeFile(logPath, '') + expect(extractLastEntryTimestamp(logPath)).toBe(null) + }) + + test('returns null for non-existent file', () => { + const logPath = path.join(tmpDir, 'nonexistent.jsonl') + expect(extractLastEntryTimestamp(logPath)).toBe(null) + }) + + test('skips malformed JSON lines', async () => { + const logPath = path.join(tmpDir, 'malformed.jsonl') + await fs.writeFile( + logPath, + [ + JSON.stringify({ timestamp: '2025-01-01T00:00:00Z', type: 'message' }), + 'this is not valid json', + '{ broken json', + ].join('\n') + ) + // Should find the first (only valid) entry + expect(extractLastEntryTimestamp(logPath)).toBe('2025-01-01T00:00:00Z') + }) + + test('returns null when no entries have timestamp field', async () => { + const logPath = path.join(tmpDir, 'no-timestamp.jsonl') + await fs.writeFile( + logPath, + [ + JSON.stringify({ type: 'message', content: 'hello' }), + JSON.stringify({ type: 'message', content: 'world' }), + ].join('\n') + ) + expect(extractLastEntryTimestamp(logPath)).toBe(null) + }) + + test('finds timestamp even if last line has none', async () => { + const logPath = path.join(tmpDir, 'mixed.jsonl') + await fs.writeFile( + logPath, + [ + JSON.stringify({ timestamp: '2025-01-01T00:00:00Z', type: 'message' }), + JSON.stringify({ timestamp: '2025-01-02T00:00:00Z', type: 'message' }), + JSON.stringify({ type: 'status', content: 'no timestamp here' }), + ].join('\n') + ) + // Should iterate backwards and find the second entry's timestamp + expect(extractLastEntryTimestamp(logPath)).toBe('2025-01-02T00:00:00Z') + }) + + test('handles trailing newline', async () => { + const logPath = path.join(tmpDir, 'trailing.jsonl') + await fs.writeFile( + logPath, + JSON.stringify({ timestamp: '2025-01-01T00:00:00Z', type: 'message' }) + '\n' + ) + expect(extractLastEntryTimestamp(logPath)).toBe('2025-01-01T00:00:00Z') + }) +}) diff --git a/src/server/db.ts b/src/server/db.ts index 509728b..6a434bf 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -63,6 +63,8 @@ const AGENT_SESSIONS_COLUMNS_SQL = ` current_window TEXT, is_pinned INTEGER NOT NULL DEFAULT 0, last_resume_error TEXT, + -- NULL means "unknown" (e.g., after upgrade). First poll will initialize to actual size. + -- This triggers a one-time match check for upgraded sessions. last_known_log_size INTEGER, is_codex_exec INTEGER NOT NULL DEFAULT 0 ` diff --git a/src/server/logMatchWorkerClient.ts b/src/server/logMatchWorkerClient.ts index 64075be..3eab25e 100644 --- a/src/server/logMatchWorkerClient.ts +++ b/src/server/logMatchWorkerClient.ts @@ -11,6 +11,13 @@ interface PendingRequest { const DEFAULT_TIMEOUT_MS = 15000 const READY_TIMEOUT_MS = 10000 +class WorkerInitError extends Error { + constructor(message: string) { + super(message) + this.name = 'WorkerInitError' + } +} + export class LogMatchWorkerClient { private worker: Worker | null = null private disposed = false @@ -19,7 +26,6 @@ export class LogMatchWorkerClient { private readyPromise: Promise | null = null private readyResolve: (() => void) | null = null private readyReject: ((error: Error) => void) | null = null - private initFailed = false constructor() { this.spawnWorker() @@ -38,22 +44,26 @@ export class LogMatchWorkerClient { // Wait for the worker to be ready before sending the first message if (this.readyPromise) { - await this.readyPromise + try { + await this.readyPromise + } catch (error) { + // Worker failed to initialize - restart and retry once + if (error instanceof WorkerInitError) { + if (this.disposed) { + throw new Error('Log match worker is disposed') + } + this.restartWorker() + if (this.readyPromise) { + await this.readyPromise // This will throw if restart also fails + } + } else { + throw error + } + } } if (this.disposed) { throw new Error('Log match worker is disposed') } - // If init failed, restart worker and retry - if (this.initFailed) { - this.initFailed = false - this.restartWorker() - if (this.readyPromise) { - await this.readyPromise - } - if (this.initFailed) { - throw new Error('Log match worker failed to initialize after restart') - } - } const id = `${Date.now()}-${this.counter++}` const payload: MatchWorkerRequest = { ...request, id } @@ -89,18 +99,17 @@ export class LogMatchWorkerClient { if (this.disposed) return // Set up ready promise before creating the worker - this.initFailed = false this.readyPromise = new Promise((resolve, reject) => { this.readyResolve = resolve this.readyReject = reject // Timeout if worker doesn't become ready setTimeout(() => { - if (this.readyResolve) { + if (this.readyReject) { + const rejectFn = this.readyReject this.readyResolve = null this.readyReject = null this.readyPromise = null - this.initFailed = true - resolve() // Resolve instead of reject so poll() can handle gracefully + rejectFn(new WorkerInitError('Log match worker failed to initialize')) } }, READY_TIMEOUT_MS) }) From 1fb02688b430a9cd355e57e5d604eafe7420a91a Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 29 Jan 2026 17:04:23 -0500 Subject: [PATCH 4/4] fix: replace remaining mtime usage with size for change detection - Worker enrichment gating now uses size instead of mtime - Empty log cache uses size instead of mtime - Backfill isCodexExec for existing codex sessions - Remove unused lastEntryTimestamp field from LogEntrySnapshot - Trim lines in extractLastEntryTimestamp to reduce parse failures Co-Authored-By: Claude Opus 4.5 --- src/server/logMatchWorker.ts | 6 ++++-- src/server/logMatcher.ts | 2 +- src/server/logPollData.ts | 8 +++++--- src/server/logPoller.ts | 20 +++++++++++++------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/src/server/logMatchWorker.ts b/src/server/logMatchWorker.ts index 465a263..9bff004 100644 --- a/src/server/logMatchWorker.ts +++ b/src/server/logMatchWorker.ts @@ -285,6 +285,7 @@ function attachLastUserMessage( logFilePath: string currentWindow: string | null lastUserMessage?: string | null + lastKnownLogSize?: number | null } > ) { @@ -300,8 +301,9 @@ function attachLastUserMessage( } return } - const lastActivity = Date.parse(snapshot.lastActivityAt) - if (!Number.isNaN(lastActivity) && entry.mtime <= lastActivity) { + // Use file size to detect actual log growth (mtime is unreliable due to backups/syncs) + const knownSize = snapshot.lastKnownLogSize ?? 0 + if (entry.size <= knownSize) { return } } diff --git a/src/server/logMatcher.ts b/src/server/logMatcher.ts index 70a5e24..595f55a 100644 --- a/src/server/logMatcher.ts +++ b/src/server/logMatcher.ts @@ -1110,7 +1110,7 @@ export function extractLastEntryTimestamp( const raw = readLogTail(logPath, tailBytes) if (!raw) return null - const lines = raw.split('\n').filter(Boolean) + const lines = raw.split('\n').map(l => l.trim()).filter(Boolean) // Iterate from the end to find the last entry with a timestamp for (let i = lines.length - 1; i >= 0; i--) { try { diff --git a/src/server/logPollData.ts b/src/server/logPollData.ts index 9393258..0cb561d 100644 --- a/src/server/logPollData.ts +++ b/src/server/logPollData.ts @@ -23,8 +23,6 @@ export interface LogEntrySnapshot { isCodexExec: boolean logTokenCount: number lastUserMessage?: string - /** Timestamp from the last log entry (ISO string), if parsed */ - lastEntryTimestamp?: string } export interface LogEntryBatch { @@ -89,6 +87,10 @@ export function collectLogEntryBatch( if (known) { // Use cached metadata from DB, skip file content reads // logTokenCount = -1 indicates enrichment was skipped (already validated) + // For codex sessions, backfill isCodexExec if not yet set (cheap header check) + const codexExec = known.agentType === 'codex' && !known.isCodexExec + ? isCodexExec(entry.logPath) + : known.isCodexExec return { logPath: entry.logPath, mtime: entry.mtime, @@ -98,7 +100,7 @@ export function collectLogEntryBatch( projectPath: known.projectPath, agentType: known.agentType, isCodexSubagent: false, - isCodexExec: known.isCodexExec, + isCodexExec: codexExec, logTokenCount: -1, } satisfies LogEntrySnapshot } diff --git a/src/server/logPoller.ts b/src/server/logPoller.ts index d3b4aeb..c8fbfe7 100644 --- a/src/server/logPoller.ts +++ b/src/server/logPoller.ts @@ -41,10 +41,11 @@ interface SessionRecord { isPinned: boolean lastResumeError: string | null lastKnownLogSize: number | null + isCodexExec: boolean } // Fields that applyLogEntryToExistingRecord may update -type SessionUpdate = Pick +type SessionUpdate = Pick /** * Computes the update object for an existing session record based on a log entry. @@ -58,6 +59,11 @@ function applyLogEntryToExistingRecord( ): Partial | null { const update: Partial = {} + // Backfill isCodexExec if the entry detected it but record doesn't have it + if (entry.isCodexExec && !record.isCodexExec) { + update.isCodexExec = true + } + // Use file size to detect actual log changes (mtime can change from backups/syncs) const lastKnownSize = record.lastKnownLogSize ?? 0 const isFirstObservation = record.lastKnownLogSize === null @@ -131,7 +137,7 @@ export class LogPoller { private orphanRematchPromise: Promise | null = null private warnedWorkerDisabled = false private startupLastMessageBackfillPending = true - // Cache of empty logs: logPath -> mtime when checked (re-check if mtime changes) + // Cache of empty logs: logPath -> size when checked (re-check if size changes) private emptyLogCache: Map = new Map() // Cache of re-match attempts: sessionId -> timestamp of last attempt private rematchAttemptCache: Map = new Map() @@ -617,9 +623,9 @@ export class LogPoller { continue } - // Skip logs we've already checked and found empty (unless mtime changed) - const cachedMtime = this.emptyLogCache.get(entry.logPath) - if (cachedMtime !== undefined && cachedMtime >= entry.mtime) { + // Skip logs we've already checked and found empty (unless size changed) + const cachedSize = this.emptyLogCache.get(entry.logPath) + if (cachedSize !== undefined && cachedSize >= entry.size) { continue } @@ -636,7 +642,7 @@ export class LogPoller { const sessionId = entry.sessionId if (!sessionId) { // No session ID yet - cache and retry on next poll when log has more content - this.emptyLogCache.set(entry.logPath, entry.mtime) + this.emptyLogCache.set(entry.logPath, entry.size) continue } const projectPath = entry.projectPath ?? '' @@ -697,7 +703,7 @@ export class LogPoller { const logTokenCount = entry.logTokenCount if (logTokenCount < MIN_LOG_TOKENS_FOR_INSERT) { // Cache this empty log so we don't re-check it every poll - this.emptyLogCache.set(entry.logPath, entry.mtime) + this.emptyLogCache.set(entry.logPath, entry.size) logger.info('log_match_skipped', { logPath: entry.logPath, reason: 'too_few_tokens',