From 7296d92526565fc194a48d8814d48ed1dc60a333 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:47:11 -0500 Subject: [PATCH 01/40] feat(layout): scale pinned placement with spatial hash --- public/src/lib/layout.ts | 446 +++++++++++++++++++++++++++++---------- 1 file changed, 336 insertions(+), 110 deletions(-) diff --git a/public/src/lib/layout.ts b/public/src/lib/layout.ts index 3bb730b..57b6825 100644 --- a/public/src/lib/layout.ts +++ b/public/src/lib/layout.ts @@ -5,8 +5,13 @@ import { isoToScreen } from './iso'; const GRID_SCALE = 2; const TILE_W = 96; const TILE_H = 48; +const CELL_W = TILE_W; +const CELL_H = TILE_H; const MAX_PULSE = 7; const MAX_LAYOUT_HEIGHT = 120 + MAX_PULSE; +const CELL_OFFSET = 32768; + +type CellBucket = string | string[]; interface BoundsRect { left: number; @@ -15,20 +20,50 @@ interface BoundsRect { bottom: number; } +interface SpatialIndex { + cells: Map; + bounds: Map; +} + +interface SpiralState { + cx: number; + cy: number; + dir: 0 | 1 | 2 | 3; + legLen: number; + legProgress: number; + legsAtLen: 0 | 1; + started: boolean; +} + +interface GroupState { + anchor: Coordinate; + spiral: SpiralState; + freeStack: number[]; +} + export interface LayoutState { layout: Map; - occupied: Map; - bounds: Map; groupAnchors: Map; + agentGroupKey: Map; + groups: Map; + spatial: SpatialIndex; + seenGen: Map; + generation: number; locked: boolean; } export function createLayoutState(): LayoutState { return { layout: new Map(), - occupied: new Map(), - bounds: new Map(), groupAnchors: new Map(), + agentGroupKey: new Map(), + groups: new Map(), + spatial: { + cells: new Map(), + bounds: new Map(), + }, + seenGen: new Map(), + generation: 0, locked: false, }; } @@ -45,10 +80,66 @@ function hashString(input: string): number { function layoutIdForAgent(agent: AgentSnapshot): string { const identity = agentIdentity(agent); if (identity) return identity; + if (typeof agent.pid === 'number') return `${agent.pid}`; + if (agent.id) return agent.id; const groupKey = groupKeyForAgent(agent); if (groupKey) return groupKey; - if (typeof agent.pid === 'number') return `${agent.pid}`; - return agent.id || 'unknown'; + return 'unknown'; +} + +function worldToCellX(x: number): number { + return Math.floor(x / CELL_W); +} + +function worldToCellY(y: number): number { + return Math.floor(y / CELL_H); +} + +function packCell(cx: number, cy: number): number { + const ux = (cx + CELL_OFFSET) & 0xffff; + const uy = (cy + CELL_OFFSET) & 0xffff; + return ((ux << 16) | uy) >>> 0; +} + +function unpackCell(key: number): { cx: number; cy: number } { + const ux = key >>> 16; + const uy = key & 0xffff; + return { cx: ux - CELL_OFFSET, cy: uy - CELL_OFFSET }; +} + +function cellToWorld(cx: number, cy: number): Coordinate { + return { x: cx * GRID_SCALE, y: cy * GRID_SCALE }; +} + +function gridKeyFromWorld(coord: Coordinate): number { + const cx = Math.round(coord.x / GRID_SCALE); + const cy = Math.round(coord.y / GRID_SCALE); + return packCell(cx, cy); +} + +function bucketAdd(bucket: CellBucket | undefined, id: string): CellBucket { + if (bucket === undefined) return id; + if (typeof bucket === 'string') { + if (bucket === id) return bucket; + return [bucket, id]; + } + for (let i = 0; i < bucket.length; i += 1) { + if (bucket[i] === id) return bucket; + } + bucket.push(id); + return bucket; +} + +function bucketRemove(bucket: CellBucket | undefined, id: string): CellBucket | undefined { + if (bucket === undefined) return undefined; + if (typeof bucket === 'string') return bucket === id ? undefined : bucket; + const index = bucket.indexOf(id); + if (index === -1) return bucket; + const last = bucket.pop(); + if (last === undefined) return undefined; + if (index < bucket.length) bucket[index] = last; + if (bucket.length === 1) return bucket[0]; + return bucket.length ? bucket : undefined; } function boundsForCoord(coord: Coordinate): BoundsRect { @@ -67,46 +158,123 @@ function boundsIntersect(a: BoundsRect, b: BoundsRect): boolean { return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; } -function hasBoundsOverlap(testBounds: BoundsRect, placedBounds: Map): boolean { - for (const bounds of placedBounds.values()) { - if (boundsIntersect(testBounds, bounds)) return true; +function cellRangeForBounds(bounds: BoundsRect): { + minCx: number; + maxCx: number; + minCy: number; + maxCy: number; +} { + const minCx = worldToCellX(bounds.left); + const maxCx = worldToCellX(bounds.right - 1); + const minCy = worldToCellY(bounds.top); + const maxCy = worldToCellY(bounds.bottom - 1); + return { minCx, maxCx, minCy, maxCy }; +} + +function indexAgent(id: string, coord: Coordinate, spatial: SpatialIndex): void { + const bounds = boundsForCoord(coord); + spatial.bounds.set(id, bounds); + + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + spatial.cells.set(key, bucketAdd(bucket, id)); + } } - return false; } -function tryPlaceCoordinate( - coord: Coordinate, - nextOccupied: Map, - nextBounds: Map -): { coord: Coordinate; bounds: BoundsRect; cellKey: string } | null { - const cellKey = `${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`; - if (nextOccupied.has(cellKey)) return null; - const testBounds = boundsForCoord(coord); - if (hasBoundsOverlap(testBounds, nextBounds)) return null; - return { coord, bounds: testBounds, cellKey }; -} - -function findPlacementNearAnchor( - anchor: Coordinate, - maxRadius: number, - nextOccupied: Map, - nextBounds: Map -): { coord: Coordinate; bounds: BoundsRect; cellKey: string } | null { - const baseX = Math.round(anchor.x / GRID_SCALE); - const baseY = Math.round(anchor.y / GRID_SCALE); - - for (let radius = 0; radius <= maxRadius; radius += 1) { - for (let dx = -radius; dx <= radius; dx += 1) { - for (let dy = -radius; dy <= radius; dy += 1) { - if (Math.abs(dx) !== radius && Math.abs(dy) !== radius) continue; - const coord = { x: (baseX + dx) * GRID_SCALE, y: (baseY + dy) * GRID_SCALE }; - const placed = tryPlaceCoordinate(coord, nextOccupied, nextBounds); - if (placed) return placed; +function unindexAgent(id: string, spatial: SpatialIndex): void { + const bounds = spatial.bounds.get(id); + if (!bounds) return; + + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + const next = bucketRemove(bucket, id); + if (next === undefined) { + spatial.cells.delete(key); + } else { + spatial.cells.set(key, next); } } } - return null; + spatial.bounds.delete(id); +} + +function hasCollision(bounds: BoundsRect, spatial: SpatialIndex): boolean { + const range = cellRangeForBounds(bounds); + for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { + for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { + const key = packCell(cx, cy); + const bucket = spatial.cells.get(key); + if (!bucket) continue; + if (typeof bucket === 'string') { + const other = spatial.bounds.get(bucket); + if (other && boundsIntersect(bounds, other)) return true; + } else { + for (let i = 0; i < bucket.length; i += 1) { + const other = spatial.bounds.get(bucket[i]); + if (other && boundsIntersect(bounds, other)) return true; + } + } + } + } + return false; +} + +function spiralInit(anchor: Coordinate): SpiralState { + const cx = Math.round(anchor.x / GRID_SCALE); + const cy = Math.round(anchor.y / GRID_SCALE); + return { + cx, + cy, + dir: 0, + legLen: 1, + legProgress: 0, + legsAtLen: 0, + started: false, + }; +} + +function spiralNext(state: SpiralState): { cx: number; cy: number } { + if (!state.started) { + state.started = true; + return { cx: state.cx, cy: state.cy }; + } + + switch (state.dir) { + case 0: + state.cx += 1; + break; + case 1: + state.cy -= 1; + break; + case 2: + state.cx -= 1; + break; + case 3: + state.cy += 1; + break; + } + + state.legProgress += 1; + if (state.legProgress === state.legLen) { + state.legProgress = 0; + state.dir = ((state.dir + 1) & 3) as 0 | 1 | 2 | 3; + if (state.legsAtLen === 1) { + state.legsAtLen = 0; + state.legLen += 1; + } else { + state.legsAtLen = 1; + } + } + + return { cx: state.cx, cy: state.cy }; } function hashedAnchorForGroup(groupKey: string): Coordinate { @@ -116,22 +284,97 @@ function hashedAnchorForGroup(groupKey: string): Coordinate { return { x: baseX * GRID_SCALE, y: baseY * GRID_SCALE }; } +function ensureGroupState( + state: LayoutState, + groupKey: string, + fallbackAnchor?: Coordinate +): GroupState { + let group = state.groups.get(groupKey); + if (!group) { + const anchor = state.groupAnchors.get(groupKey) ?? fallbackAnchor ?? hashedAnchorForGroup(groupKey); + group = { + anchor, + spiral: spiralInit(anchor), + freeStack: [], + }; + state.groups.set(groupKey, group); + if (!state.groupAnchors.has(groupKey)) { + state.groupAnchors.set(groupKey, anchor); + } + } else if (!state.groupAnchors.has(groupKey)) { + state.groupAnchors.set(groupKey, group.anchor); + } + return group; +} + +function findPlacement( + group: GroupState, + spatial: SpatialIndex, + maxAttempts = 256 +): Coordinate | null { + while (group.freeStack.length) { + const key = group.freeStack.pop(); + if (key === undefined) break; + const { cx, cy } = unpackCell(key); + const coord = cellToWorld(cx, cy); + const bounds = boundsForCoord(coord); + if (!hasCollision(bounds, spatial)) return coord; + } + + for (let i = 0; i < maxAttempts; i += 1) { + const { cx, cy } = spiralNext(group.spiral); + const coord = cellToWorld(cx, cy); + const bounds = boundsForCoord(coord); + if (!hasCollision(bounds, spatial)) return coord; + } + + return null; +} + +function addAgent( + state: LayoutState, + id: string, + groupKey: string +): void { + const hadGroup = state.groups.has(groupKey); + const group = ensureGroupState(state, groupKey); + const placement = findPlacement(group, state.spatial); + if (!placement) return; + state.layout.set(id, placement); + state.agentGroupKey.set(id, groupKey); + indexAgent(id, placement, state.spatial); + + if (!hadGroup) { + group.anchor = placement; + group.spiral = spiralInit(placement); + state.groupAnchors.set(groupKey, placement); + } +} + +function removeAgent(state: LayoutState, id: string): void { + const coord = state.layout.get(id); + if (coord) { + unindexAgent(id, state.spatial); + } + state.layout.delete(id); + + const groupKey = state.agentGroupKey.get(id); + if (groupKey && coord) { + const group = state.groups.get(groupKey); + if (group) { + group.freeStack.push(gridKeyFromWorld(coord)); + } + } + state.agentGroupKey.delete(id); +} + export function assignCoordinate( state: LayoutState, key: string, baseKey: string ): void { if (state.layout.has(key)) return; - const anchor = state.groupAnchors.get(baseKey) ?? hashedAnchorForGroup(baseKey || key); - const maxRadius = Math.max(24, Math.ceil(Math.sqrt(state.layout.size + 1)) * 32); - const placement = findPlacementNearAnchor(anchor, maxRadius, state.occupied, state.bounds); - if (!placement) return; - state.layout.set(key, placement.coord); - state.occupied.set(placement.cellKey, key); - state.bounds.set(key, placement.bounds); - if (!state.groupAnchors.has(baseKey)) { - state.groupAnchors.set(baseKey, placement.coord); - } + addAgent(state, key, baseKey || key); } export function updateLayout( @@ -139,74 +382,49 @@ export function updateLayout( agents: AgentSnapshot[] ): void { if (state.locked) return; + if (agents.length === 0) { state.layout.clear(); - state.occupied.clear(); - state.bounds.clear(); state.groupAnchors.clear(); + state.agentGroupKey.clear(); + state.groups.clear(); + state.spatial.cells.clear(); + state.spatial.bounds.clear(); + state.seenGen.clear(); return; } - const agentMap = new Map(); + state.generation += 1; + const gen = state.generation; + const added: Array<{ id: string; agent: AgentSnapshot; groupKey: string }> = []; + for (const agent of agents) { const id = layoutIdForAgent(agent); const groupKey = groupKeyForAgent(agent) || id; - agentMap.set(id, { agent, groupKey }); - } + state.seenGen.set(id, gen); + state.agentGroupKey.set(id, groupKey); - for (const id of state.layout.keys()) { - if (!agentMap.has(id)) { - const coord = state.layout.get(id); - if (coord) { - state.occupied.delete(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`); - } - state.layout.delete(id); - state.bounds.delete(id); + const existingCoord = state.layout.get(id); + if (existingCoord) { + ensureGroupState(state, groupKey, existingCoord); + continue; } + added.push({ id, agent, groupKey }); } - state.occupied.clear(); - state.bounds.clear(); - for (const [id, coord] of state.layout.entries()) { - state.occupied.set(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`, id); - state.bounds.set(id, boundsForCoord(coord)); - } - - const activeGroups = new Set(); - for (const { agent, groupKey } of agentMap.values()) { - activeGroups.add(groupKey); - if (!state.groupAnchors.has(groupKey)) { - const coord = state.layout.get(layoutIdForAgent(agent)); - if (coord) state.groupAnchors.set(groupKey, coord); + for (const id of Array.from(state.layout.keys())) { + if (state.seenGen.get(id) !== gen) { + removeAgent(state, id); } } - for (const key of state.groupAnchors.keys()) { - if (!activeGroups.has(key)) state.groupAnchors.delete(key); - } - const addedAgents = Array.from(agentMap.values()) - .filter(({ agent }) => !state.layout.has(layoutIdForAgent(agent))) - .sort((a, b) => { - if (a.groupKey === b.groupKey) { - return layoutIdForAgent(a.agent).localeCompare(layoutIdForAgent(b.agent)); - } - return a.groupKey.localeCompare(b.groupKey); - }); - - const maxRadius = Math.max(32, Math.ceil(Math.sqrt(state.layout.size + addedAgents.length)) * 32); - - for (const entry of addedAgents) { - const layoutId = layoutIdForAgent(entry.agent); - const groupKey = entry.groupKey || layoutId; - const anchor = state.groupAnchors.get(groupKey) ?? hashedAnchorForGroup(groupKey); - const placement = findPlacementNearAnchor(anchor, maxRadius, state.occupied, state.bounds); - if (!placement) continue; - state.layout.set(layoutId, placement.coord); - state.occupied.set(placement.cellKey, layoutId); - state.bounds.set(layoutId, placement.bounds); - if (!state.groupAnchors.has(groupKey)) { - state.groupAnchors.set(groupKey, placement.coord); - } + added.sort((a, b) => { + if (a.groupKey === b.groupKey) return a.id.localeCompare(b.id); + return a.groupKey.localeCompare(b.groupKey); + }); + + for (const entry of added) { + addAgent(state, entry.id, entry.groupKey); } } @@ -231,11 +449,14 @@ export function setLayoutPositions( positions: Array<{ id?: string; pid?: number; x: number; y: number }> ): void { state.layout.clear(); - state.occupied.clear(); - state.bounds.clear(); state.groupAnchors.clear(); + state.agentGroupKey.clear(); + state.groups.clear(); + state.spatial.cells.clear(); + state.spatial.bounds.clear(); + state.seenGen.clear(); state.locked = true; - + const byIdentity = new Map( agents.map((agent) => [agentIdentity(agent), agent]) ); @@ -244,21 +465,26 @@ export function setLayoutPositions( .filter((agent) => typeof agent.pid === 'number') .map((agent) => [`${agent.pid}`, agent]) ); - + for (const entry of positions) { const keyId = entry?.id ?? entry?.pid; if (keyId === undefined || keyId === null) continue; const agent = byIdentity.get(String(keyId)) || byPid.get(String(keyId)) || null; if (!agent) continue; - const key = layoutIdForAgent(agent); + const id = layoutIdForAgent(agent); + const groupKey = groupKeyForAgent(agent) || id; const coord = { x: Number(entry.x) || 0, y: Number(entry.y) || 0 }; - state.layout.set(key, coord); - state.occupied.set(`${coord.x / GRID_SCALE},${coord.y / GRID_SCALE}`, key); - state.bounds.set(key, boundsForCoord(coord)); - const groupKey = groupKeyForAgent(agent) || key; + state.layout.set(id, coord); + state.agentGroupKey.set(id, groupKey); + indexAgent(id, coord, state.spatial); + const group = ensureGroupState(state, groupKey, coord); if (!state.groupAnchors.has(groupKey)) { state.groupAnchors.set(groupKey, coord); } + if (!group.spiral.started) { + group.anchor = coord; + group.spiral = spiralInit(coord); + } } } From f68594c99ccb95d73675e29bed6400e6d94e1a38 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:47:27 -0500 Subject: [PATCH 02/40] perf(ui): stabilize canvas rendering loop --- public/src/components/CanvasScene.tsx | 182 ++------------------------ public/src/hooks/useCanvasRenderer.ts | 142 ++++++++++++++------ 2 files changed, 117 insertions(+), 207 deletions(-) diff --git a/public/src/components/CanvasScene.tsx b/public/src/components/CanvasScene.tsx index d87de34..c9259a6 100644 --- a/public/src/components/CanvasScene.tsx +++ b/public/src/components/CanvasScene.tsx @@ -1,32 +1,10 @@ import { useRef, useEffect, useLayoutEffect, useCallback, useState } from 'react'; import type { AgentSnapshot, ViewState } from '../types'; import { useCanvasRenderer } from '../hooks/useCanvasRenderer'; -import { createLayoutState, updateLayout, getCoordinate } from '../lib/layout'; -import { agentIdentity, keyForAgent } from '../lib/format'; -import { pointInDiamond, pointInQuad, isoToScreen } from '../lib/iso'; +import { createLayoutState, updateLayout } from '../lib/layout'; +import { agentIdentity } from '../lib/format'; import { Tooltip } from './Tooltip'; -const TILE_W = 96; -const TILE_H = 48; -const ROOF_SCALE = 0.28; -const ROOF_HIT_SCALE = 0.44; -const MARKER_SCALE = 0.36; -const MARKER_OFFSET = TILE_H * 0.6; - -interface HitItem { - x: number; - y: number; - roofY: number; - roofW: number; - roofH: number; - roofHitW: number; - roofHitH: number; - height: number; - agent: AgentSnapshot; - key: string; - markerY?: number; -} - interface CanvasSceneProps { agents: AgentSnapshot[]; view: ViewState; @@ -42,21 +20,19 @@ export function CanvasScene({ agents, view, selected, - searchMatches, + searchMatches: _searchMatches, onSelect, onMouseDown, onKeyDown, onWheel, }: CanvasSceneProps) { - const containerRef = useRef(null); const layoutRef = useRef(createLayoutState()); const spawnTimesRef = useRef>(new Map()); const knownIdsRef = useRef>(new Set()); const [hovered, setHovered] = useState(null); const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); - const hitListRef = useRef([]); - const { canvasRef, startRender, stopRender } = useCanvasRenderer(); + const { canvasRef, startRender, stopRender, getAgentAtPoint, getHitList } = useCanvasRenderer(); // Update layout and spawn times when agents change useLayoutEffect(() => { @@ -81,99 +57,16 @@ export function CanvasScene({ updateLayout(layoutRef.current, agents); }, [agents]); - // Rebuild hit list whenever agents or view changes - useEffect(() => { - const roofW = TILE_W * ROOF_SCALE; - const roofH = roofW * 0.5; - const roofHitW = TILE_W * ROOF_HIT_SCALE; - const roofHitH = roofHitW * 0.5; - - // Sort by depth (same as render order) - const sortedAgents = [...agents].sort((a, b) => { - const coordA = getCoordinate(layoutRef.current, a) ?? { x: 0, y: 0 }; - const coordB = getCoordinate(layoutRef.current, b) ?? { x: 0, y: 0 }; - return coordA.x + coordA.y - (coordB.x + coordB.y); - }); - - const hitList: HitItem[] = []; - - for (const agent of sortedAgents) { - const coord = getCoordinate(layoutRef.current, agent); - if (!coord) continue; - - const screen = isoToScreen(coord.x, coord.y, TILE_W, TILE_H); - const memMB = agent.mem / (1024 * 1024); - const heightBase = Math.min(120, Math.max(18, memMB * 0.4)); - const idleScale = agent.state === 'idle' ? 0.6 : 1; - const height = heightBase * idleScale; - const roofY = screen.y - height - TILE_H * 0.15; - - hitList.push({ - x: screen.x, - y: screen.y, - roofY, - roofW, - roofH, - roofHitW, - roofHitH, - height, - agent, - key: keyForAgent(agent), - }); - } - - // Calculate obstruction and marker positions - const obstructedIds = new Set(); - for (const a of hitList) { - const roofPoint = { x: a.x, y: a.roofY }; - for (const b of hitList) { - if (a === b) continue; - const topY = b.y - b.height; - const halfW = TILE_W / 2; - const halfH = TILE_H / 2; - - const leftA = { x: b.x - halfW, y: topY }; - const leftB = { x: b.x, y: topY + halfH }; - const leftC = { x: b.x, y: b.y + halfH }; - const leftD = { x: b.x - halfW, y: b.y }; - - const rightA = { x: b.x + halfW, y: topY }; - const rightB = { x: b.x, y: topY + halfH }; - const rightC = { x: b.x, y: b.y + halfH }; - const rightD = { x: b.x + halfW, y: b.y }; - - if ( - pointInQuad(roofPoint, leftA, leftB, leftC, leftD) || - pointInQuad(roofPoint, rightA, rightB, rightC, rightD) - ) { - obstructedIds.add(agentIdentity(a.agent)); - break; - } - } - } - - for (const item of hitList) { - if (obstructedIds.has(agentIdentity(item.agent))) { - item.markerY = item.roofY - MARKER_OFFSET; - } - } - - hitListRef.current = hitList; - }, [agents, view]); - useEffect(() => { const win = window as any; if (!win.__consensusMock) { win.__consensusMock = {}; } - win.__consensusMock.getHitList = () => hitListRef.current; + win.__consensusMock.getHitList = () => getHitList(); win.__consensusMock.getView = () => ({ x: view.x, y: view.y, scale: view.scale }); - }, [view]); + }, [view, getHitList]); - // Start/stop render loop useEffect(() => { - if (!canvasRef.current) return; - startRender(view, agents, { layout: layoutRef.current, hovered, @@ -181,62 +74,19 @@ export function CanvasScene({ spawnTimes: spawnTimesRef.current, deviceScale: window.devicePixelRatio || 1, }); + }, [agents, view, hovered, selected, startRender]); + useEffect(() => { return () => { stopRender(); }; - }, [agents, view, hovered, selected, searchMatches, startRender, stopRender, canvasRef]); - - const findAgentAt = useCallback((canvasX: number, canvasY: number): AgentSnapshot | null => { - // Transform canvas coordinates to world coordinates - const worldX = (canvasX - view.x) / view.scale; - const worldY = (canvasY - view.y) / view.scale; - - const markerW = TILE_W * MARKER_SCALE; - const markerH = markerW * 0.5; - const hitList = hitListRef.current; - - if (!hitList.length) return null; - - // Check markers first (for obstructed agents) - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (!item.markerY) continue; - if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) { - return item.agent; - } - } - - // Check roofs - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (pointInDiamond(worldX, worldY, item.x, item.roofY, item.roofHitW, item.roofHitH)) { - return item.agent; - } - } - - // Check base tiles - for (let i = hitList.length - 1; i >= 0; i--) { - const item = hitList[i]; - if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) { - return item.agent; - } - } - - return null; - }, [view]); + }, [stopRender]); const handleMouseMove = useCallback((e: React.MouseEvent) => { - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - - const found = findAgentAt(x, y); + const found = getAgentAtPoint(e.clientX, e.clientY); setHovered(found); setTooltipPos({ x: e.clientX + 12, y: e.clientY + 12 }); - }, [findAgentAt]); + }, [getAgentAtPoint]); const handleMouseLeave = useCallback(() => { setHovered(null); @@ -249,18 +99,14 @@ export function CanvasScene({ return; } - // Calculate from click position (for direct clicks without hover) - const rect = (e.target as HTMLCanvasElement).getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - const found = findAgentAt(x, y); + const found = getAgentAtPoint(e.clientX, e.clientY); if (found) { onSelect(found); } - }, [hovered, onSelect, findAgentAt]); + }, [hovered, onSelect, getAgentAtPoint]); return ( -
+
b.x + halfW || + roofPoint.y < topY || + roofPoint.y > b.y + halfH + ) { + continue; + } + const leftA = { x: b.x - halfW, y: topY }; const leftB = { x: b.x, y: topY + halfH }; const leftC = { x: b.x, y: b.y + halfH }; const leftD = { x: b.x - halfW, y: b.y }; - + const rightA = { x: b.x + halfW, y: topY }; const rightB = { x: b.x, y: topY + halfH }; const rightC = { x: b.x, y: b.y + halfH }; const rightD = { x: b.x + halfW, y: b.y }; - + if ( pointInQuad(roofPoint, leftA, leftB, leftC, leftD) || pointInQuad(roofPoint, rightA, rightB, rightC, rightD) @@ -362,6 +372,54 @@ export function useCanvasRenderer() { const hitListRef = useRef([]); const rafRef = useRef(null); const deviceScaleRef = useRef(1); + const ctxRef = useRef(null); + const viewRef = useRef({ x: 0, y: 0, scale: 1 }); + const agentsRef = useRef([]); + const optionsRef = useRef(null); + const reducedMotionRef = useRef(false); + + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return; + const mql = window.matchMedia('(prefers-reduced-motion: reduce)'); + const update = () => { + reducedMotionRef.current = mql.matches; + }; + update(); + if (mql.addEventListener) { + mql.addEventListener('change', update); + } else { + mql.addListener(update); + } + return () => { + if (mql.removeEventListener) { + mql.removeEventListener('change', update); + } else { + mql.removeListener(update); + } + }; + }, []); + + const syncCanvasSize = useCallback((canvas: HTMLCanvasElement) => { + const dpr = window.devicePixelRatio || 1; + const cssW = window.innerWidth; + const cssH = window.innerHeight; + const nextW = Math.max(1, Math.floor(cssW * dpr)); + const nextH = Math.max(1, Math.floor(cssH * dpr)); + + if ( + canvas.width !== nextW || + canvas.height !== nextH || + deviceScaleRef.current !== dpr + ) { + deviceScaleRef.current = dpr; + canvas.width = nextW; + canvas.height = nextH; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + } + + return { width: canvas.width, height: canvas.height, dpr }; + }, []); const startRender = useCallback(( view: ViewState, @@ -371,37 +429,45 @@ export function useCanvasRenderer() { const canvas = canvasRef.current; if (!canvas) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; + viewRef.current = view; + agentsRef.current = agents; + optionsRef.current = options; + + if (!ctxRef.current) { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + ctxRef.current = ctx; + } + + if (rafRef.current) return; - // Update canvas size - deviceScaleRef.current = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * deviceScaleRef.current; - canvas.height = window.innerHeight * deviceScaleRef.current; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; + syncCanvasSize(canvas); const render = () => { - const width = canvas.width; - const height = canvas.height; - + const canvasEl = canvasRef.current; + const ctx = ctxRef.current; + const opts = optionsRef.current; + if (!canvasEl || !ctx || !opts) { + rafRef.current = null; + return; + } + + const { width, height, dpr } = syncCanvasSize(canvasEl); hitListRef.current = renderFrame({ ctx, - view, - agents, - options: { ...options, deviceScale: deviceScaleRef.current }, + view: viewRef.current, + agents: agentsRef.current, + options: { ...opts, deviceScale: dpr }, width, height, + reducedMotion: reducedMotionRef.current, }); - + rafRef.current = requestAnimationFrame(render); }; - if (rafRef.current) { - cancelAnimationFrame(rafRef.current); - } rafRef.current = requestAnimationFrame(render); - }, []); + }, [syncCanvasSize]); const stopRender = useCallback(() => { if (rafRef.current) { @@ -410,19 +476,16 @@ export function useCanvasRenderer() { } }, []); + const getHitList = useCallback(() => hitListRef.current, []); + const getAgentAtPoint = useCallback((screenX: number, screenY: number): AgentSnapshot | null => { const canvas = canvasRef.current; if (!canvas) return null; const rect = canvas.getBoundingClientRect(); - const x = screenX - rect.left; - const y = screenY - rect.top; - - const viewX = (x - canvas.width / 2 / deviceScaleRef.current) / 1; // simplified - const viewY = (y - canvas.height / 2 / deviceScaleRef.current) / 1; - - // Transform to world space (this needs proper view transform) - // For now, return simplified hit detection + const view = viewRef.current; + const worldX = (screenX - rect.left - view.x) / view.scale; + const worldY = (screenY - rect.top - view.y) / view.scale; const hitList = hitListRef.current; if (!hitList.length) return null; @@ -432,7 +495,7 @@ export function useCanvasRenderer() { if (!item.markerY) continue; const markerW = TILE_W * MARKER_SCALE; const markerH = markerW * 0.5; - if (pointInDiamond(viewX, viewY, item.x, item.markerY, markerW, markerH)) { + if (pointInDiamond(worldX, worldY, item.x, item.markerY, markerW, markerH)) { return item.agent; } } @@ -442,7 +505,7 @@ export function useCanvasRenderer() { const item = hitList[i]; const roofHitW = item.roofHitW; const roofHitH = item.roofHitH; - if (pointInDiamond(viewX, viewY, item.x, item.roofY, roofHitW, roofHitH)) { + if (pointInDiamond(worldX, worldY, item.x, item.roofY, roofHitW, roofHitH)) { return item.agent; } } @@ -450,7 +513,7 @@ export function useCanvasRenderer() { // Check base for (let i = hitList.length - 1; i >= 0; i -= 1) { const item = hitList[i]; - if (pointInDiamond(viewX, viewY, item.x, item.y, TILE_W, TILE_H)) { + if (pointInDiamond(worldX, worldY, item.x, item.y, TILE_W, TILE_H)) { return item.agent; } } @@ -469,5 +532,6 @@ export function useCanvasRenderer() { startRender, stopRender, getAgentAtPoint, + getHitList, }; } From 374f06aeb00b41b6741d11ffb7266b5773feb573 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:47:43 -0500 Subject: [PATCH 03/40] chore: update continuity ledger --- CONTINUITY.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CONTINUITY.md b/CONTINUITY.md index d36e8c7..54c7509 100644 --- a/CONTINUITY.md +++ b/CONTINUITY.md @@ -34,6 +34,7 @@ Key decisions: - React UI is primary; server should serve React build when present. - Agent lane follows vanilla JS behavior: show all open sessions (active + idle) and sort by state rank then CPU. - Layout uses pinned positions per agent identity; only new agents are placed; existing tiles never move; placement uses max-height bounds to prevent overlap. +- Scale placement with spatial hash buckets (cell -> occupants) and persistent per-group spiral frontier; collisions checked by bounds against bucket occupants. - Codex prompt-like titles containing temp paths/turn markers are ignored for lane labels; fallback to repo or codex#pid. - Agent lane now shows only active/error sessions and uses stable sort (state rank then identity) to prevent reordering. - Active lane items now glow via `is-active` class (lane item box-shadow) to make active codex sessions visibly highlighted. @@ -133,6 +134,11 @@ State: - Tests re-run: `npm run test:unit` (149 pass), `npm run test:integration` (54 pass), `npm run test:ui` (19 pass). - Build run after pinned layout changes: `npm run build` (pass). - Copied edited files to clipboard via pbcopy; temp bundle at `/tmp/consensus-edited-files.txt`. + - Implemented spatial hash buckets + persistent group spiral frontier; fixed cell packing and bounds-based collision checks (public/src/lib/layout.ts). + - Renderer now keeps a single RAF loop, exposes hit list, and uses correct view transform for hit tests (public/src/hooks/useCanvasRenderer.ts). + - CanvasScene no longer rebuilds hit list; uses renderer hit list and stable render loop (public/src/components/CanvasScene.tsx). + - Obstruction detection checks only front occluders with AABB guard (public/src/hooks/useCanvasRenderer.ts). + - Tests run after layout/renderer updates: `npm run test:unit` (152 pass), `npm run test:integration` (58 pass), `npm run test:ui` (19 pass). - Now: - Answer user question about hot reload configuration (Vite HMR + server watch + live reload SSE). - Answer whether any additional setup is missing when using Vite. @@ -161,6 +167,7 @@ State: - Claude CLI hooks not firing for real sessions; user reports Claude does not show active when working. - Need to validate live Claude session while recording via agent-browser and inspect hook delivery. - Copy edited layout/test files to clipboard via pbcopy. (DONE) + - Implement spatial hash buckets + persistent group frontier with correct cell packing and bounds checks. (DONE) - User requested a best-practice plan (no code edits) to address review comment: `npm run dev` no longer starts Vite, so TSX client fails unless dev:client or build is run. - Q&A answered for dev workflow: always start Vite; use CONSENSUS_UI_PORT for port; on port conflict try next port. - Review plan requested for new P2/P3 items: Codex in-flight timeout default in codexLogs; TOML [tui] notifications insertion in cli/setup. From dc7fe61e58ea8c9dc52e14108a17eefecb28375e Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:32:42 -0500 Subject: [PATCH 04/40] fix(activity): stabilize codex and opencode states Ensure codex in-flight ends within 2.5s of completion, pick active JSONL per process, and make opencode message completion authoritative. Remove wheel passive listener warnings and align docs/tests. --- README.md | 11 +- docs/configuration.md | 15 +- public/src/components/AgentPanel.tsx | 32 ++- public/src/components/CanvasScene.tsx | 13 +- public/src/hooks/useCanvasRenderer.ts | 14 +- public/src/hooks/useViewState.ts | 4 +- src/codexLogs.ts | 168 ++++++++++++--- src/codexState.ts | 2 +- src/opencodeApi.ts | 38 +++- src/opencodeEvents.ts | 109 +++++++--- src/opencodeState.ts | 4 +- src/scan.ts | 199 ++++++++++++++---- tests/integration/codexLogs.test.ts | 86 +++++++- tests/integration/opencodeActivity.test.ts | 39 ++++ .../opencodeSessionActivity.test.ts | 11 + tests/unit/opencodeSessionActivity.test.ts | 23 +- 16 files changed, 608 insertions(+), 160 deletions(-) diff --git a/README.md b/README.md index 852f3e4..475b327 100644 --- a/README.md +++ b/README.md @@ -83,20 +83,21 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream. - `CONSENSUS_OPENCODE_HOME`: override OpenCode storage (default `~/.local/share/opencode`). - `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS`: OpenCode active window after last event in ms (default `0`). -- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `3000`). +- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `0`). - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS`: OpenCode in-flight idle timeout in ms (defaults to `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`). -- `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `15000`). +- `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `2500`). +- `CONSENSUS_OPENCODE_INFLIGHT_STALE_MS`: OpenCode stale in-flight cutoff in ms (default `0`). - `CONSENSUS_PROCESS_MATCH`: regex to match codex processes. - `CONSENSUS_REDACT_PII`: set to `0` to disable redaction (default enabled). - `CONSENSUS_UI_PORT`: dev UI port for Vite when running `npm run dev` (default `5173`). - `CONSENSUS_DEBUG_OPENCODE`: set to `1` to log OpenCode server discovery. - `CONSENSUS_CODEX_EVENT_ACTIVE_MS`: Codex active window after last event in ms (default `30000`). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `3000`). +- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `0`). - `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`: Codex in-flight idle timeout in ms (default `30000`, set to `0` to disable). - `CONSENSUS_CODEX_CPU_SUSTAIN_MS`: sustained CPU window before Codex becomes active without logs (default `500`). - `CONSENSUS_CODEX_CPU_SPIKE`: Codex CPU spike threshold for immediate activation (default derived). -- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`: Codex in-flight timeout in ms (default `3000`). -- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS`: Codex max event age for in-flight signals (default `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`). +- `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`: Codex in-flight timeout in ms (default `2500`). +- `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS`: Codex max event age for in-flight signals (default `max(CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS, 2500)`). - `CONSENSUS_PROCESS_CACHE_MS`: process cache TTL in ms for full scans (default `1000`). - `CONSENSUS_PROCESS_CACHE_FAST_MS`: process cache TTL in ms for fast scans (default `500`). - `CONSENSUS_SESSION_CACHE_MS`: Codex session list cache TTL in ms for full scans (default `1000`). diff --git a/docs/configuration.md b/docs/configuration.md index 1b4e7f6..1ade105 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,14 +87,17 @@ All configuration is via environment variables. - Default: `0` - OpenCode event window before dropping to idle. - `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS` - - Default: `3000` + - Default: `0` - OpenCode hold window after activity. - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS` - Default: `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - OpenCode in-flight idle timeout in ms before dropping to idle if no activity is observed. - `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` - - Default: `15000` + - Default: `2500` - Hard timeout (ms) used to clear OpenCode in-flight when no fresh events are observed. +- `CONSENSUS_OPENCODE_INFLIGHT_STALE_MS` + - Default: `0` + - Treat OpenCode in-flight signals older than this as stale to avoid ghost sessions. - `CONSENSUS_PROCESS_MATCH` - Default: unset - Regex to match process name or command line. @@ -114,7 +117,7 @@ All configuration is via environment variables. - Default: `750` - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). - `CONSENSUS_CODEX_ACTIVE_HOLD_MS` - - Default: `3000` + - Default: `0` - Codex hold window after activity. - `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - Default: `750` @@ -126,10 +129,10 @@ All configuration is via environment variables. - Default: `30000` - Idle timeout to clear Codex in-flight when activity is stale. Set to `0` to disable. - `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` - - Default: `3000` + - Default: `2500` - Hard timeout to clear Codex in-flight if no recent signals and file is not fresh. - `CONSENSUS_CODEX_FILE_FRESH_MS` - - Default: `10000` + - Default: `2500` - Treat recent Codex JSONL file mtime within this window as fresh and keep in-flight on. - `CONSENSUS_CODEX_CPU_SUSTAIN_MS` - Default: `500` @@ -141,7 +144,7 @@ All configuration is via environment variables. - Default: `120000` - Treat Codex JSONL files older than this as stale to prevent stale sessions from staying active. - `CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS` - - Default: `CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS` + - Default: `max(CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS, 2500)` - Ignore Codex events older than this when setting in-flight signals (prevents stale sessions after restart). - `CONSENSUS_PROCESS_CACHE_MS` - Default: `1000` diff --git a/public/src/components/AgentPanel.tsx b/public/src/components/AgentPanel.tsx index 38a25d6..a5ee475 100644 --- a/public/src/components/AgentPanel.tsx +++ b/public/src/components/AgentPanel.tsx @@ -1,10 +1,8 @@ import type { AgentSnapshot } from '../types'; import { - agentIdentity, labelFor, formatBytes, formatPercent, - escapeHtml, formatDate, formatDateFull, } from '../lib/format'; @@ -56,7 +54,7 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
name - {escapeHtml(labelFor(agent))} + {labelFor(agent)}
pid @@ -64,16 +62,16 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
kind - {escapeHtml(agent.kind)} + {agent.kind}
state - {escapeHtml(agent.state)} + {agent.state}
{startedAt && (
started - {escapeHtml(startedAt)} + {startedAt}
)}
@@ -85,8 +83,8 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { {summaryRows.length > 0 ? ( summaryRows.map(([label, value]) => (
- {escapeHtml(label)} - {escapeHtml(value)} + {label} + {value}
)) ) : ( @@ -95,19 +93,19 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { {agent.activityReason && (
activity reason - {escapeHtml(agent.activityReason)} + {agent.activityReason}
)} {lastActivityAt && (
last activity - {escapeHtml(lastActivityAt)} + {lastActivityAt}
)} {lastEventAt && (
last event - {escapeHtml(lastEventAt)} + {lastEventAt}
)}
@@ -127,23 +125,23 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
repo - {escapeHtml(agent.repo || '-')} + {agent.repo || '-'}
cwd - {escapeHtml(agent.cwd || '-')} + {agent.cwd || '-'}
session - {escapeHtml(agent.sessionPath || '-')} + {agent.sessionPath || '-'}
cmd - {escapeHtml(agent.cmd || '-')} + {agent.cmd || '-'}
model - {escapeHtml(agent.model || '-')} + {agent.model || '-'}
@@ -164,7 +162,7 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { const time = new Date(ev.ts).toLocaleTimeString(); return (
- [{escapeHtml(time)}] {escapeHtml(truncate(ev.summary, 120))} + [{time}] {truncate(ev.summary, 120)}
); }) diff --git a/public/src/components/CanvasScene.tsx b/public/src/components/CanvasScene.tsx index c9259a6..0a2bd63 100644 --- a/public/src/components/CanvasScene.tsx +++ b/public/src/components/CanvasScene.tsx @@ -13,7 +13,7 @@ interface CanvasSceneProps { onSelect: (agent: AgentSnapshot | null) => void; onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: React.WheelEvent) => void; + onWheel: (e: WheelEvent) => void; } export function CanvasScene({ @@ -92,6 +92,16 @@ export function CanvasScene({ setHovered(null); }, []); + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const handleWheel = (e: WheelEvent) => onWheel(e); + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return () => { + canvas.removeEventListener('wheel', handleWheel); + }; + }, [canvasRef, onWheel]); + const handleClick = useCallback((e: React.MouseEvent) => { // First try hovered state, otherwise calculate from click position if (hovered) { @@ -118,7 +128,6 @@ export function CanvasScene({ onClick={handleClick} onMouseDown={onMouseDown} onKeyDown={onKeyDown} - onWheel={onWheel} />
diff --git a/public/src/hooks/useCanvasRenderer.ts b/public/src/hooks/useCanvasRenderer.ts index d288b0e..46ed383 100644 --- a/public/src/hooks/useCanvasRenderer.ts +++ b/public/src/hooks/useCanvasRenderer.ts @@ -44,6 +44,18 @@ interface HitItem { markerY?: number; } +interface CanvasRendererHook { + canvasRef: React.RefObject; + startRender: ( + view: ViewState, + agents: AgentSnapshot[], + options: RendererOptions + ) => void; + stopRender: () => void; + getAgentAtPoint: (screenX: number, screenY: number) => AgentSnapshot | null; + getHitList: () => HitItem[]; +} + function drawRoundedRect( ctx: CanvasRenderingContext2D, x: number, @@ -367,7 +379,7 @@ function renderFrame(context: RenderContext): HitItem[] { return hitList; } -export function useCanvasRenderer() { +export function useCanvasRenderer(): CanvasRendererHook { const canvasRef = useRef(null); const hitListRef = useRef([]); const rafRef = useRef(null); diff --git a/public/src/hooks/useViewState.ts b/public/src/hooks/useViewState.ts index 103d05a..869c568 100644 --- a/public/src/hooks/useViewState.ts +++ b/public/src/hooks/useViewState.ts @@ -26,7 +26,7 @@ export function useViewState( ): [ViewState, ViewActions, DragState, { onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: React.WheelEvent) => void; + onWheel: (e: WheelEvent) => void; }] { const [view, setView] = useState({ x: initialView.x ?? window.innerWidth / 2, @@ -149,7 +149,7 @@ export function useViewState( } }, [pan, zoom]); - const onWheel = useCallback((e: React.WheelEvent) => { + const onWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = Math.sign(e.deltaY) * -WHEEL_SENSITIVITY; zoom(delta); diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 6871a78..99e353d 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -42,6 +42,9 @@ interface TailState { lastInFlightSignalAt?: number; turnOpen?: boolean; reviewMode?: boolean; + pendingEndAt?: number; + lastEndAt?: number; + lastToolSignalAt?: number; openCallIds?: Set; lastCommand?: EventSummary; lastEdit?: EventSummary; @@ -176,13 +179,17 @@ export function hydrateTailNotify(sessionPath: string, codexHome?: string): void export async function findSessionByCwd( sessions: SessionFile[], cwd?: string, - startMs?: number + startMs?: number, + excludePaths?: Set ): Promise { if (!cwd) return undefined; const target = path.resolve(cwd); let best: SessionFile | undefined; let bestDelta = Number.POSITIVE_INFINITY; for (const session of sessions.slice(0, SESSION_CWD_CHECK_MAX)) { + if (excludePaths && excludePaths.has(path.resolve(session.path))) { + continue; + } const meta = await getSessionMeta(session.path); if (!meta?.cwd) continue; if (path.resolve(meta.cwd) !== target) continue; @@ -656,10 +663,14 @@ function summarizeEvent(ev: any): { return { kind: "other", isError, model, type }; } -export async function updateTail(sessionPath: string): Promise { +export async function updateTail( + sessionPath: string, + options?: { keepStale?: boolean } +): Promise { const nowMs = Date.now(); + const keepStale = options?.keepStale === true; const inflightEnv = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - const defaultInflightTimeoutMs = 3000; + const defaultInflightTimeoutMs = 2500; const inflightTimeoutMs = (() => { if (inflightEnv === undefined || inflightEnv.trim() === "") { return defaultInflightTimeoutMs; @@ -669,16 +680,17 @@ export async function updateTail(sessionPath: string): Promise if (parsed <= 0) return 0; return parsed; })(); + const defaultSignalFreshMs = Math.max(inflightTimeoutMs, 2500); const signalFreshMs = (() => { const raw = process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; - if (raw === undefined || raw.trim() === "") return inflightTimeoutMs; + if (raw === undefined || raw.trim() === "") return defaultSignalFreshMs; const parsed = Number(raw); - if (!Number.isFinite(parsed)) return inflightTimeoutMs; + if (!Number.isFinite(parsed)) return defaultSignalFreshMs; if (parsed <= 0) return 0; return parsed; })(); const fileFreshMs = Number( - process.env.CONSENSUS_CODEX_FILE_FRESH_MS || 1500 + process.env.CONSENSUS_CODEX_FILE_FRESH_MS || 2500 ); const staleFileMs = Number(process.env.CONSENSUS_CODEX_STALE_FILE_MS || 120000); let stat: fs.Stats; @@ -692,7 +704,10 @@ export async function updateTail(sessionPath: string): Promise fileFreshMs > 0 && nowMs - stat.mtimeMs <= fileFreshMs; const isStaleFile = - Number.isFinite(staleFileMs) && staleFileMs > 0 && nowMs - stat.mtimeMs > staleFileMs; + !keepStale && + Number.isFinite(staleFileMs) && + staleFileMs > 0 && + nowMs - stat.mtimeMs > staleFileMs; const prev = tailStates.get(sessionPath); const state: TailState = @@ -712,13 +727,48 @@ export async function updateTail(sessionPath: string): Promise state.lastInFlightSignalAt = undefined; state.lastIngestAt = undefined; }; + const clearEndMarkers = () => { + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + }; + const recordToolSignal = (ts: number) => { + state.lastToolSignalAt = Math.max(state.lastToolSignalAt || 0, ts); + }; + const finalizeEnd = (ts: number, { clearReview = false }: { clearReview?: boolean } = {}) => { + if (clearReview) state.reviewMode = false; + state.turnOpen = false; + state.inFlight = false; + state.inFlightStart = false; + state.pendingEndAt = undefined; + state.lastEndAt = ts; + clearActivitySignals(); + if (state.openCallIds) state.openCallIds.clear(); + }; + const deferEnd = (ts: number) => { + state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); + }; const expireInFlight = () => { if (!state.inFlight) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; + if (state.pendingEndAt) { + const elapsed = nowMs - state.pendingEndAt; + const forceEndMs = inflightTimeoutMs > 0 ? inflightTimeoutMs : defaultInflightTimeoutMs; + if (elapsed >= forceEndMs) { + finalizeEnd(state.pendingEndAt); + return; + } + } if (state.reviewMode) return; if (state.openCallIds && state.openCallIds.size > 0) return; + if (state.lastEndAt) { + state.inFlight = false; + state.inFlightStart = false; + state.pendingEndAt = undefined; + clearActivitySignals(); + return; + } if (fileFresh) { return; } @@ -729,11 +779,15 @@ export async function updateTail(sessionPath: string): Promise state.lastEventAt; if ( typeof lastSignal === "number" && - nowMs - lastSignal > inflightTimeoutMs + nowMs - lastSignal >= inflightTimeoutMs ) { state.inFlight = false; state.inFlightStart = false; + state.turnOpen = false; + state.pendingEndAt = undefined; + state.lastEndAt = nowMs; clearActivitySignals(); + if (state.openCallIds) state.openCallIds.clear(); } }; @@ -742,6 +796,10 @@ export async function updateTail(sessionPath: string): Promise state.inFlight = false; state.inFlightStart = false; state.turnOpen = false; + state.reviewMode = false; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastToolSignalAt = undefined; state.lastInFlightSignalAt = undefined; if (state.openCallIds) state.openCallIds.clear(); } @@ -755,6 +813,10 @@ export async function updateTail(sessionPath: string): Promise state.partial = ""; state.events = []; state.lastEventAt = undefined; + state.reviewMode = false; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastToolSignalAt = undefined; state.lastCommand = undefined; state.lastEdit = undefined; state.lastMessage = undefined; @@ -766,6 +828,7 @@ export async function updateTail(sessionPath: string): Promise state.turnOpen = undefined; state.openCallIds = undefined; state.lastInFlightSignalAt = undefined; + state.lastToolSignalAt = undefined; } if (stat.size === state.offset) { @@ -920,6 +983,7 @@ export async function updateTail(sessionPath: string): Promise ]); if (typeof type === "string") { if (isTurnStart) { + clearEndMarkers(); state.turnOpen = true; if (canSignal) { state.inFlight = true; @@ -929,6 +993,7 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isResponseStart) { + clearEndMarkers(); state.turnOpen = true; if (canSignal) { state.inFlight = true; @@ -938,6 +1003,7 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isItemStarted && itemStartWorkTypes.has(itemTypeLower)) { + clearEndMarkers(); if (canSignal) { state.turnOpen = true; state.inFlight = true; @@ -947,6 +1013,7 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isResponseDelta) { + clearEndMarkers(); state.turnOpen = true; if (canSignal) { state.inFlight = true; @@ -956,26 +1023,31 @@ export async function updateTail(sessionPath: string): Promise state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isReviewEnter) { + clearEndMarkers(); state.reviewMode = true; state.turnOpen = true; + state.inFlight = true; + state.inFlightStart = true; if (canSignal) { - state.inFlight = true; - state.inFlightStart = true; markInFlightSignal(); } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isReviewExit) { state.reviewMode = false; - state.turnOpen = false; - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts, { clearReview: false }); + } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } } if (payloadType.includes("agent_reasoning") || payloadType === "reasoning") { if (canSignal) { + clearEndMarkers(); state.turnOpen = true; state.inFlight = true; state.inFlightStart = true; @@ -984,6 +1056,7 @@ export async function updateTail(sessionPath: string): Promise } if (payloadType.includes("user_message") || payloadRole === "user") { if (canSignal) { + clearEndMarkers(); state.turnOpen = true; state.inFlight = true; state.inFlightStart = true; @@ -992,12 +1065,7 @@ export async function updateTail(sessionPath: string): Promise } } if (payloadType === "token_count") { - if (canSignal) { - state.turnOpen = true; - if (!state.inFlight) { - state.inFlight = true; - state.inFlightStart = true; - } + if (canSignal && (state.inFlight || state.turnOpen)) { markInFlightSignal(); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } @@ -1021,7 +1089,9 @@ export async function updateTail(sessionPath: string): Promise if (isToolCall) { if (!state.openCallIds) state.openCallIds = new Set(); if (callId) state.openCallIds.add(callId); + recordToolSignal(ts); if (canSignal) { + clearEndMarkers(); state.turnOpen = true; state.inFlight = true; state.inFlightStart = true; @@ -1041,7 +1111,11 @@ export async function updateTail(sessionPath: string): Promise if (state.openCallIds && callId) { state.openCallIds.delete(callId); } + recordToolSignal(ts); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { + finalizeEnd(state.pendingEndAt); + } } if (payloadType === "reasoning") { state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); @@ -1065,11 +1139,13 @@ export async function updateTail(sessionPath: string): Promise } } if (isResponseEnd) { - if (state.openCallIds) state.openCallIds.clear(); - state.turnOpen = false; - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls || state.reviewMode) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts); + } } const itemTypeIsAgentReasoning = itemTypeLower.includes("agent_reasoning"); const itemTypeIsAgentMessage = itemTypeLower.includes("agent_message"); @@ -1109,17 +1185,22 @@ export async function updateTail(sessionPath: string): Promise } } if (itemTypeIsTurnAbort) { - if (state.openCallIds) state.openCallIds.clear(); - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls || state.reviewMode) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts, { clearReview: false }); + } } if (isTurnEnd) { - if (state.openCallIds) state.openCallIds.clear(); - state.turnOpen = false; - state.inFlight = false; - state.inFlightStart = false; - clearActivitySignals(); + const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + if (hasOpenCalls || state.reviewMode) { + deferEnd(ts); + state.turnOpen = false; + } else { + finalizeEnd(ts); + } } if (summary) { @@ -1198,6 +1279,12 @@ export async function updateTail(sessionPath: string): Promise } } + if (state.pendingEndAt) { + if ((state.openCallIds?.size ?? 0) === 0 && !state.turnOpen) { + finalizeEnd(state.pendingEndAt); + } + } + state.offset = stat.size; expireInFlight(); tailStates.set(sessionPath, state); @@ -1227,6 +1314,10 @@ export function summarizeTail(state: TailState): { lastPromptAt?: number; lastInFlightSignalAt?: number; lastIngestAt?: number; + lastEndAt?: number; + reviewMode?: boolean; + openCallCount?: number; + lastToolSignalAt?: number; inFlight?: boolean; notifyLastAt?: number; notifyLastIngestAt?: number; @@ -1248,6 +1339,9 @@ export function summarizeTail(state: TailState): { lastTool: state.lastTool?.summary, lastPrompt: state.lastPrompt?.summary, }; + const openCallCount = state.openCallIds?.size ?? 0; + const hasOpenCalls = openCallCount > 0; + const inFlight = state.inFlight || state.reviewMode || hasOpenCalls; return { doing, title, @@ -1260,7 +1354,11 @@ export function summarizeTail(state: TailState): { lastPromptAt: state.lastPrompt?.ts, lastInFlightSignalAt: state.lastInFlightSignalAt, lastIngestAt: state.lastIngestAt, - inFlight: state.inFlight, + lastEndAt: state.lastEndAt, + lastToolSignalAt: state.lastToolSignalAt, + reviewMode: state.reviewMode, + openCallCount, + inFlight: inFlight ? true : undefined, notifyLastAt: state.notifyLastAt, notifyLastIngestAt: state.notifyLastIngestAt, }; diff --git a/src/codexState.ts b/src/codexState.ts index 4648390..5df1c4c 100644 --- a/src/codexState.ts +++ b/src/codexState.ts @@ -3,7 +3,7 @@ import { deriveStateWithHold } from "./activity.js"; const DEFAULT_CODEX_CPU_SUSTAIN_MS = 500; const DEFAULT_CODEX_INFLIGHT_IDLE_MS = 30_000; -const DEFAULT_CODEX_HOLD_MS = 3000; +const DEFAULT_CODEX_HOLD_MS = 0; export interface CodexStateInput { cpu: number; diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index 75736ab..fe5537e 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -189,6 +189,11 @@ export async function getOpenCodeSessionActivity( port: number = 4096, options?: OpenCodeApiOptions ): Promise { + const staleMsRaw = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + const staleMs = + staleMsRaw !== undefined && staleMsRaw !== "" + ? Number(staleMsRaw) + : 0; const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 3000; const timeoutId = setTimeout(() => controller.abort(), timeoutMs); @@ -276,6 +281,7 @@ export async function getOpenCodeSessionActivity( // Also check for pending/running tool calls in message parts let hasPendingTool = false; let hasIncompletePart = false; + let latestPartStart: number | undefined; if (Array.isArray(latestAssistant.parts)) { for (const part of latestAssistant.parts) { @@ -287,17 +293,29 @@ export async function getOpenCodeSessionActivity( } } // Check for parts with start but no end time (still in progress) - if (typeof part?.time?.start === "number" && typeof part?.time?.end !== "number") { - hasIncompletePart = true; + if (typeof part?.time?.start === "number") { + if (typeof part?.time?.end !== "number") { + hasIncompletePart = true; + } + latestPartStart = latestPartStart + ? Math.max(latestPartStart, part.time.start) + : part.time.start; } } } // Session is in flight if: // 1. Assistant message has no completed timestamp, OR - // 2. There's a pending/running tool call, OR - // 3. There's a part still in progress - let inFlight = !hasCompleted || hasPendingTool || hasIncompletePart; + // 2. There's a pending/running tool call + // Incomplete parts only matter when the message is not completed. + let inFlight = hasPendingTool || !hasCompleted; + const assistantCreatedAt = latestAssistant?.info?.time?.created; + const inFlightSignalAt = + !hasCompleted && typeof assistantCreatedAt === "number" + ? assistantCreatedAt + : hasPendingTool && typeof latestPartStart === "number" + ? latestPartStart + : undefined; if (!inFlight && latestMessageRole === "user" && typeof latestMessageAt === "number") { const windowMsRaw = process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS; @@ -310,6 +328,16 @@ export async function getOpenCodeSessionActivity( } } + if (inFlight && Number.isFinite(staleMs) && staleMs > 0) { + if (typeof inFlightSignalAt === "number") { + if (Date.now() - inFlightSignalAt > staleMs) { + inFlight = false; + } + } else if (typeof latestActivityAt !== "number") { + inFlight = false; + } + } + return { ok: true, inFlight, diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index 0f3a697..a9da1e5 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -31,6 +31,8 @@ interface ActivityState { lastEventAt?: number; lastActivityAt?: number; lastInFlightSignalAt?: number; + lastStatus?: string; + lastStatusAt?: number; lastCommand?: EventSummary; lastEdit?: EventSummary; lastMessage?: EventSummary; @@ -87,6 +89,7 @@ function logDebug(message: string): void { function expireInFlight(state: ActivityState, now: number): void { if (!state.inFlight) return; + if (typeof state.lastStatusAt === "number") return; const lastSignal = state.lastInFlightSignalAt ?? state.lastActivityAt ?? state.lastEventAt; if (typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS) { state.inFlight = false; @@ -191,6 +194,7 @@ function summarizeEvent(raw: any): { isError?: boolean; type?: string; inFlight?: boolean; + status?: string; } { const typeRaw = raw?.type || @@ -204,8 +208,8 @@ function summarizeEvent(raw: any): { const normalizedType = lowerType.startsWith("tui.") ? lowerType.slice(4) : lowerType; - const status = raw?.status || raw?.state || raw?.properties?.status; - const statusStr = typeof status === "string" ? status.toLowerCase() : ""; + const statusRaw = raw?.status || raw?.state || raw?.properties?.status; + const statusStr = typeof statusRaw === "string" ? statusRaw.toLowerCase() : ""; const isError = !!raw?.error || lowerType.includes("error") || statusStr.includes("error"); let inFlight: boolean | undefined; @@ -226,6 +230,12 @@ function summarizeEvent(raw: any): { // Handle session.status event with status property const sessionStatus = raw?.properties?.status?.type || raw?.properties?.status; const sessionStatusStr = typeof sessionStatus === "string" ? sessionStatus : ""; + const status = + normalizedType === "session.status" + ? sessionStatusStr + : normalizedType === "session.idle" + ? "idle" + : undefined; if (normalizedType === "session.status") { // session.status event: check if status is "busy" for in-flight @@ -234,6 +244,8 @@ function summarizeEvent(raw: any): { } else { inFlight = false; } + } else if (normalizedType === "session.idle") { + inFlight = false; } else if (START_EVENT_RE.test(normalizedType) || DELTA_EVENT_RE.test(normalizedType)) { if (!isMessagePartUpdated || shouldTreatMessagePartActive) { inFlight = true; @@ -245,7 +257,7 @@ function summarizeEvent(raw: any): { if (normalizedType.includes("compaction")) { const phase = statusStr || raw?.phase || raw?.properties?.phase; const summary = phase ? `compaction: ${phase}` : "compaction"; - return { summary, kind: "other", isError, type, inFlight }; + return { summary, kind: "other", isError, type, inFlight, status }; } const cmd = @@ -258,7 +270,7 @@ function summarizeEvent(raw: any): { (Array.isArray(raw?.args) ? raw.args.join(" ") : undefined); if (typeof cmd === "string" && cmd.trim()) { const summary = redactText(`cmd: ${cmd.trim()}`) || `cmd: ${cmd.trim()}`; - return { summary, kind: "command", isError, type, inFlight }; + return { summary, kind: "command", isError, type, inFlight, status }; } const pathHint = @@ -270,7 +282,7 @@ function summarizeEvent(raw: any): { raw?.properties?.file; if (typeof pathHint === "string" && pathHint.trim() && normalizedType.includes("file")) { const summary = redactText(`edit: ${pathHint.trim()}`) || `edit: ${pathHint.trim()}`; - return { summary, kind: "edit", isError, type, inFlight }; + return { summary, kind: "edit", isError, type, inFlight, status }; } const tool = @@ -281,7 +293,7 @@ function summarizeEvent(raw: any): { raw?.properties?.tool_name; if (typeof tool === "string" && tool.trim() && lowerType.includes("tool")) { const summary = redactText(`tool: ${tool.trim()}`) || `tool: ${tool.trim()}`; - return { summary, kind: "tool", isError, type, inFlight }; + return { summary, kind: "tool", isError, type, inFlight, status }; } const promptText = @@ -293,7 +305,7 @@ function summarizeEvent(raw: any): { const trimmed = promptText.replace(/\s+/g, " ").trim(); const snippet = trimmed.slice(0, 120); const summary = redactText(`prompt: ${snippet}`) || `prompt: ${snippet}`; - return { summary, kind: "prompt", isError, type, inFlight }; + return { summary, kind: "prompt", isError, type, inFlight, status }; } const roleRaw = @@ -320,20 +332,27 @@ function summarizeEvent(raw: any): { const snippet = trimmed.slice(0, 80); if (role === "assistant" || role === "agent") { const summary = redactText(snippet) || snippet; - return { summary, kind: "message", isError, type, inFlight: isMessagePartUpdated ? true : undefined }; + return { + summary, + kind: "message", + isError, + type, + inFlight: isMessagePartUpdated ? true : undefined, + status, + }; } if (role === "user") { const summary = redactText(`prompt: ${snippet}`) || `prompt: ${snippet}`; - return { summary, kind: "prompt", isError, type, inFlight }; + return { summary, kind: "prompt", isError, type, inFlight, status }; } } if (type && type !== "event") { const summary = redactText(`event: ${type}`) || `event: ${type}`; - return { summary, kind: "other", isError, type, inFlight }; + return { summary, kind: "other", isError, type, inFlight, status }; } - return { kind: "other", isError, type, inFlight }; + return { kind: "other", isError, type, inFlight, status }; } function isActivityEvent(input: { @@ -422,7 +441,7 @@ function handleRawEvent(raw: any): void { const ts = parsedTs ?? now; const sessionId = getSessionId(raw); const pid = getPid(raw); - const { summary, kind, isError, type, inFlight } = summarizeEvent(raw); + const { summary, kind, isError, type, inFlight, status } = summarizeEvent(raw); const activity = isActivityEvent({ kind, type: typeof type === "string" ? type : undefined, @@ -468,6 +487,17 @@ function handleRawEvent(raw: any): void { } else if (activity && state.inFlight) { state.lastInFlightSignalAt = now; } + if (typeof status === "string" && status.trim()) { + state.lastStatus = status; + state.lastStatusAt = now; + if (status.toLowerCase() === "idle") { + state.inFlight = false; + state.lastInFlightSignalAt = undefined; + } else { + state.inFlight = true; + state.lastInFlightSignalAt = now; + } + } if (prevInFlight !== state.inFlight) { logDebug( `inFlight ${prevInFlight ? "on" : "off"} -> ${state.inFlight ? "on" : "off"} ` + @@ -505,6 +535,17 @@ function handleRawEvent(raw: any): void { } else if (activity && state.inFlight) { state.lastInFlightSignalAt = now; } + if (typeof status === "string" && status.trim()) { + state.lastStatus = status; + state.lastStatusAt = now; + if (status.toLowerCase() === "idle") { + state.inFlight = false; + state.lastInFlightSignalAt = undefined; + } else { + state.inFlight = true; + state.lastInFlightSignalAt = now; + } + } } if (touched) notifyListeners(); if (rawListeners.size) notifyRawListeners(raw); @@ -619,6 +660,8 @@ export function getOpenCodeActivityBySession( lastActivityAt?: number; hasError?: boolean; inFlight?: boolean; + lastStatus?: string; + lastStatusAt?: number; } | null { if (!sessionId) return null; const state = sessionActivity.get(sessionId); @@ -626,15 +669,17 @@ export function getOpenCodeActivityBySession( expireInFlight(state, nowMs()); const events = state.events.slice(-20); const hasError = !!state.lastError || events.some((ev) => ev.isError); - return { - events, - summary: state.summary, - lastEventAt: state.lastEventAt, - lastActivityAt: state.lastActivityAt, - hasError, - inFlight: state.inFlight, - }; -} + return { + events, + summary: state.summary, + lastEventAt: state.lastEventAt, + lastActivityAt: state.lastActivityAt, + hasError, + inFlight: state.inFlight, + lastStatus: state.lastStatus, + lastStatusAt: state.lastStatusAt, + }; + } export function getOpenCodeActivityByPid( pid?: number @@ -645,6 +690,8 @@ export function getOpenCodeActivityByPid( lastActivityAt?: number; hasError?: boolean; inFlight?: boolean; + lastStatus?: string; + lastStatusAt?: number; } | null { if (typeof pid !== "number") return null; const state = pidActivity.get(pid); @@ -652,12 +699,14 @@ export function getOpenCodeActivityByPid( expireInFlight(state, nowMs()); const events = state.events.slice(-20); const hasError = !!state.lastError || events.some((ev) => ev.isError); - return { - events, - summary: state.summary, - lastEventAt: state.lastEventAt, - lastActivityAt: state.lastActivityAt, - hasError, - inFlight: state.inFlight, - }; -} + return { + events, + summary: state.summary, + lastEventAt: state.lastEventAt, + lastActivityAt: state.lastActivityAt, + hasError, + inFlight: state.inFlight, + lastStatus: state.lastStatus, + lastStatusAt: state.lastStatusAt, + }; + } diff --git a/src/opencodeState.ts b/src/opencodeState.ts index 16f8a62..a28fad6 100644 --- a/src/opencodeState.ts +++ b/src/opencodeState.ts @@ -2,7 +2,7 @@ import type { AgentState } from "./types.js"; import type { ActivityHoldResult } from "./activity.js"; import { deriveStateWithHold } from "./activity.js"; -const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 15000; +const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 2500; export interface OpenCodeStateInput { hasError: boolean; @@ -33,7 +33,7 @@ export function deriveOpenCodeState(input: OpenCodeStateInput): ActivityHoldResu return { state, lastActiveAt: input.inFlight ? now : undefined, reason }; } const holdMs = - input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 3000); + input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 0); const envInFlightIdle = process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS; const envInFlightTimeout = process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS; let inFlightIdleMs: number | undefined = diff --git a/src/scan.ts b/src/scan.ts index 47096a5..42271f3 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -816,6 +816,24 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + const jsonlCandidates = Array.from( + new Set([ + ...codexWrapperProcs.map((proc) => proc.pid), + ...codexVendorChildren.map((proc) => proc.pid), + ]) + ); + if (jsonlCandidates.length > 0) { + const jsonlTimer = startProfile("jsonl", "fast"); + try { + jsonlByPid = await getJsonlForPids(jsonlCandidates); + processCache.jsonlByPid = jsonlByPid; + } catch { + // ignore refresh failures + } + endProfile(jsonlTimer, { count: jsonlByPid.size }); + } + } } if (!shouldUseProcessCache) { const usageTimer = startProfile("pidusage"); @@ -999,18 +1017,33 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise[] = []; for (const [dir, sessions] of opencodeAllSessionsByDir.entries()) { const tuiCount = opencodeTuiByDir.get(dir)?.length ?? 0; - if (tuiCount <= 1) continue; - const candidates = sessions.slice(0, Math.max(tuiCount, 2)); + if (tuiCount === 0) continue; + const minCandidates = Math.max(tuiCount, 2); tasks.push( (async () => { const activeIds: string[] = []; - for (const session of candidates) { + let checked = 0; + for (const session of sessions) { + if (checked >= minCandidates && activeIds.length >= tuiCount) break; const id = getOpenCodeSessionId(session); - if (!id) continue; + if (!id) { + checked += 1; + continue; + } + const statusActivity = getOpenCodeActivityBySession(id); + const statusValue = statusActivity?.lastStatus?.toLowerCase(); + if (statusValue) { + if (statusValue !== "idle") { + activeIds.push(id); + } + checked += 1; + continue; + } const activity = await getCachedOpenCodeSessionActivity(id); if (activity.ok && activity.inFlight) { activeIds.push(id); } + checked += 1; } if (activeIds.length > 0) { opencodeActiveSessionIdsByDir.set(dir, activeIds); @@ -1057,7 +1090,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const codexHoldMs = resolveMs( process.env.CONSENSUS_CODEX_ACTIVE_HOLD_MS, - 3000 + 0 ); const codexEventIdleMs = resolveMs( process.env.CONSENSUS_CODEX_EVENT_IDLE_MS, @@ -1074,6 +1107,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); @@ -1136,28 +1170,48 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise item.path.includes(sessionId))) || (sessionId ? await findSessionById(codexHome, sessionId) : undefined) || - cachedSession || - session; + session || + cwdSession || + cachedSession; + if (cwdSession && mappedSession) { + const mappedMtime = mappedSession.mtimeMs ?? 0; + const cwdMtime = cwdSession.mtimeMs ?? 0; + if (cwdMtime > mappedMtime + 1000) { + session = cwdSession; + } + } const hasExplicitSession = !!sessionId || !!mappedJsonl; const allowReuse = hasExplicitSession || /\bresume\b/i.test(cmdRaw); const initialSessionPath = normalizeSessionPath(session?.path); + let reuseBlocked = false; if (initialSessionPath && usedSessionPaths.has(initialSessionPath) && !allowReuse) { - const alternate = await findSessionByCwd(sessions, cwdRaw, startMs); + const alternate = cwdSession; const alternatePath = normalizeSessionPath(alternate?.path); if (alternate && alternatePath && alternatePath !== initialSessionPath) { session = alternate; + } else { + reuseBlocked = true; } } - if (!session && cwdRaw) { - session = await findSessionByCwd(sessions, cwdRaw, startMs); + if (!session) { + session = cwdSession; + reuseBlocked = false; } const sessionPath = normalizeSessionPath(session?.path); if (sessionPath) { @@ -1172,21 +1226,37 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const cachedTails = new Map>>(); + const tailOptionsByPath = new Map(); if (includeActivity) { if (dirtySessions) { for (const dirtyPath of dirtySessions) { - tailTargets.add(path.resolve(dirtyPath)); + const resolved = path.resolve(dirtyPath); + tailTargets.add(resolved); + if (!tailOptionsByPath.has(resolved)) { + tailOptionsByPath.set(resolved, { keepStale: false }); + } } } for (const ctx of codexContexts) { const sessionPath = normalizeSessionPath(ctx.session?.path); if (!sessionPath) continue; tailTargets.add(sessionPath); + tailOptionsByPath.set(sessionPath, { keepStale: true }); + if (ctx.jsonlPaths?.length) { + for (const jsonlPath of ctx.jsonlPaths) { + tailTargets.add(jsonlPath); + if (!tailOptionsByPath.has(jsonlPath)) { + tailOptionsByPath.set(jsonlPath, { keepStale: true }); + } + } + } } } const tailsTimer = startProfile("tails"); @@ -1194,7 +1264,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { - const tail = await updateTail(sessionPath); + const tail = await updateTail(sessionPath, tailOptionsByPath.get(sessionPath)); return [sessionPath, tail] as const; }) ) @@ -1205,7 +1275,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { + if (!paths || paths.length === 0) return undefined; + let bestPath: string | undefined; + let bestScore = -1; + let bestActivity = -1; + for (const candidate of paths) { + const tail = includeActivity ? tailsByPath.get(candidate) : getTailState(candidate); + const summary = tail ? summarizeTail(tail) : undefined; + const inFlightScore = summary?.inFlight ? 1 : 0; + const activity = summary?.lastActivityAt ?? summary?.lastEventAt ?? 0; + const score = inFlightScore * 10 + (activity > 0 ? 1 : 0); + if (score > bestScore || (score === bestScore && activity > bestActivity)) { + bestScore = score; + bestActivity = activity; + bestPath = candidate; + } + } + if (bestPath) return bestPath; + const fallback = pickNewestJsonl(paths, jsonlMtimes); + return normalizeSessionPath(fallback); + }; + const sessionPath = + pickBestJsonl(ctx.jsonlPaths) || normalizeSessionPath(session?.path); // Get thread state from event store (webhook-based events) let threadId: string | undefined; @@ -1238,6 +1330,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise= tailActivityAtCandidate); + const notifyShouldEnd = tailAllowsNotifyEnd && notifyEndIsFresh && !tailEndAt; + if (notifyShouldEnd) { + inFlight = false; + } + const explicitEndAt = tailEndAt ?? (notifyShouldEnd ? notifyEndAt : undefined); + const effectiveHoldMs = explicitEndAt ? 0 : codexHoldMs; const effectiveIdleMs = inFlight ? 0 : codexEventIdleMs; const eventState = deriveCodexEventState({ inFlight, lastActivityAt: activityAt, hasError, now, - holdMs: codexHoldMs, + holdMs: effectiveHoldMs, idleMs: effectiveIdleMs, }); state = eventState.state; @@ -1382,7 +1495,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { const second = await updateTail(file); assert.ok(second); const secondSummary = summarizeTail(second); - assert.equal(secondSummary.inFlight, false); + assert.equal(secondSummary.inFlight, undefined); const turnCompleted = { type: "turn.completed", @@ -775,7 +775,7 @@ test("response.completed clears in-flight even without turn end", async () => { const third = await updateTail(file); assert.ok(third); const thirdSummary = summarizeTail(third); - assert.equal(thirdSummary.inFlight, false); + assert.equal(thirdSummary.inFlight, undefined); Date.now = originalNow; await fs.rm(dir, { recursive: true, force: true }); @@ -854,13 +854,77 @@ test("review mode keeps in-flight until exited", async () => { const stateExit = await updateTail(file); assert.ok(stateExit); const summaryExit = summarizeTail(stateExit); - assert.equal(summaryExit.inFlight, false); + assert.equal(summaryExit.inFlight, undefined); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; await fs.rm(dir, { recursive: true, force: true }); }); +test("forces end after timeout when tool outputs never arrive", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { type: "response.started", ts: 1 }, + { type: "response_item", ts: 2, payload: { type: "function_call", name: "tool", call_id: "call_1" } }, + { type: "response.completed", ts: 3 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 6_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("turnOpen expires after timeout without explicit end", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { type: "turn.started", ts: 1 }, + { type: "event_msg", ts: 2, payload: { type: "agent_message", message: "working" } }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 6_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + test("open tool call keeps in-flight without new events", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); @@ -898,7 +962,7 @@ test("open tool call keeps in-flight without new events", async () => { await fs.rm(dir, { recursive: true, force: true }); }); -test("expires in-flight codex state by default", async () => { +test("keeps in-flight codex state within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; @@ -919,18 +983,18 @@ test("expires in-flight codex state by default", async () => { const summaryStart = summarizeTail(stateStart); assert.equal(summaryStart.inFlight, true); - Date.now = () => 5_000; + Date.now = () => 2_000; const stateLater = await updateTail(file); assert.ok(stateLater); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, false); + assert.equal(summaryLater.inFlight, true); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; await fs.rm(dir, { recursive: true, force: true }); }); -test("invalid in-flight timeout falls back to default", async () => { +test("invalid in-flight timeout keeps in-flight within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; @@ -951,11 +1015,11 @@ test("invalid in-flight timeout falls back to default", async () => { const summaryStart = summarizeTail(stateStart); assert.equal(summaryStart.inFlight, true); - Date.now = () => 5_000; + Date.now = () => 2_000; const stateLater = await updateTail(file); assert.ok(stateLater); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, false); + assert.equal(summaryLater.inFlight, true); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; @@ -998,7 +1062,7 @@ test("turn.completed clears in-flight after response completion", async () => { const stateEnd = await updateTail(file); assert.ok(stateEnd); const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, false); + assert.equal(summaryEnd.inFlight, undefined); Date.now = originalNow; await fs.rm(dir, { recursive: true, force: true }); diff --git a/tests/integration/opencodeActivity.test.ts b/tests/integration/opencodeActivity.test.ts index 2441b7b..5fb9a46 100644 --- a/tests/integration/opencodeActivity.test.ts +++ b/tests/integration/opencodeActivity.test.ts @@ -4,6 +4,9 @@ import http from "node:http"; import { getOpenCodeSessionActivity } from "../../src/opencodeApi.js"; import { deriveOpenCodeState } from "../../src/opencodeState.ts"; +const originalStale = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; +process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = "0"; + // Helper to create a mock HTTP server that returns specified messages function createMockServer( responseData: unknown, @@ -186,6 +189,34 @@ test("getOpenCodeSessionActivity returns inFlight=true for incomplete part (no e } }); +test("getOpenCodeSessionActivity returns inFlight=false when message completed but parts incomplete", async () => { + const now = Date.now(); + const messages = [ + { + info: { + id: "msg_1", + sessionID: "ses_test", + role: "assistant", + time: { created: now - 2000, completed: now - 1000 }, + }, + parts: [ + { id: "prt_1", type: "reasoning", time: { start: now - 1500 } }, + ], + }, + ]; + const mock = await createMockServer(messages); + try { + const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { + silent: true, + timeoutMs: 5000, + }); + assert.equal(result.ok, true); + assert.equal(result.inFlight, false, "Completed message should end in-flight"); + } finally { + await mock.close(); + } +}); + test("getOpenCodeSessionActivity handles empty messages array", async () => { const mock = await createMockServer([]); try { @@ -389,3 +420,11 @@ test("getOpenCodeSessionActivity handles completed message with completed tools" await mock.close(); } }); + +process.on("exit", () => { + if (originalStale === undefined) { + delete process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + } else { + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = originalStale; + } +}); diff --git a/tests/integration/opencodeSessionActivity.test.ts b/tests/integration/opencodeSessionActivity.test.ts index 02e3112..e093738 100644 --- a/tests/integration/opencodeSessionActivity.test.ts +++ b/tests/integration/opencodeSessionActivity.test.ts @@ -3,6 +3,9 @@ import assert from "node:assert/strict"; import http from "node:http"; import { getOpenCodeSessionActivity } from "../../src/opencodeApi.ts"; +const originalStale = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; +process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = "0"; + type ServerHandle = { server: http.Server; port: number }; async function startServer( @@ -50,6 +53,14 @@ test("getOpenCodeSessionActivity detects in-flight assistant message", async () } }); +process.on("exit", () => { + if (originalStale === undefined) { + delete process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + } else { + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = originalStale; + } +}); + test("getOpenCodeSessionActivity treats pending tool as in-flight", async () => { const { server, port } = await startServer((req, res) => { if (req.url === "/session/s2/message") { diff --git a/tests/unit/opencodeSessionActivity.test.ts b/tests/unit/opencodeSessionActivity.test.ts index edb7e9a..9f014cf 100644 --- a/tests/unit/opencodeSessionActivity.test.ts +++ b/tests/unit/opencodeSessionActivity.test.ts @@ -91,9 +91,9 @@ function parseMessageActivity(messages: OpenCodeMessage[]): ActivityResult { // Session is in flight if: // 1. Assistant message has no completed timestamp, OR - // 2. There's a pending/running tool call, OR - // 3. There's a part still in progress - const inFlight = !hasCompleted || hasPendingTool || hasIncompletePart; + // 2. There's a pending/running tool call + // Incomplete parts only matter when the message is not completed. + const inFlight = hasPendingTool || !hasCompleted; return { inFlight, lastActivityAt: latestActivityAt }; } @@ -259,6 +259,19 @@ test("returns inFlight=true when part has start but no end time", () => { assert.equal(result.inFlight, true, "Should be in flight due to incomplete part"); }); +test("returns inFlight=false when message completed but parts lack end", () => { + const messages: OpenCodeMessage[] = [ + { + info: { id: "msg_1", role: "assistant", time: { created: 2000, completed: 3000 } }, + parts: [ + { id: "prt_1", type: "reasoning", time: { start: 2001 } }, + ], + }, + ]; + const result = parseMessageActivity(messages); + assert.equal(result.inFlight, false, "Completed message should end in-flight"); +}); + test("returns inFlight=false when all parts have end times", () => { const messages: OpenCodeMessage[] = [ { @@ -273,7 +286,7 @@ test("returns inFlight=false when all parts have end times", () => { assert.equal(result.inFlight, false, "Should be idle when all parts completed"); }); -test("returns inFlight=true when message completed but part still in progress", () => { +test("returns inFlight=false when message completed but part still in progress", () => { const messages: OpenCodeMessage[] = [ { info: { id: "msg_1", role: "assistant", time: { created: 2000, completed: 3000 } }, @@ -284,7 +297,7 @@ test("returns inFlight=true when message completed but part still in progress", }, ]; const result = parseMessageActivity(messages); - assert.equal(result.inFlight, true, "Incomplete part should override completed timestamp"); + assert.equal(result.inFlight, false, "Completed message should end in-flight"); }); // ============================================================================= From 922e9b96a3eaf40ed4d6bf244575b483dd6a7b7f Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 30 Jan 2026 18:58:47 -0500 Subject: [PATCH 05/40] fix(dev): increment server port and refresh opencode status Start the server on the next available port during dev and ignore stale OpenCode status events when determining in-flight state. --- dev/dev.mjs | 74 +++++++++++++++++++++++++++++++++++++++++-- src/opencodeEvents.ts | 8 ++++- src/scan.ts | 17 ++++++++-- 3 files changed, 94 insertions(+), 5 deletions(-) diff --git a/dev/dev.mjs b/dev/dev.mjs index a5b6f40..578ce1a 100644 --- a/dev/dev.mjs +++ b/dev/dev.mjs @@ -52,7 +52,6 @@ const tsc = spawnChild("tsc", [ "--pretty", "false", ]); -const server = spawnChild("server", [tsxPath, "watch", "src/server.ts"]); const parsePort = (value) => { const num = Number(value); @@ -66,6 +65,77 @@ const basePort = parsePort(process.env.VITE_PORT) ?? 5173; +const baseServerPort = parsePort(process.env.CONSENSUS_PORT) ?? 8787; + +const startServer = async () => { + const maxAttempts = 6; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const port = baseServerPort + attempt; + process.stderr.write(`[dev] starting server on port ${port}\n`); + + const child = spawn( + process.execPath, + [tsxPath, "watch", "src/server.ts"], + { + env: { ...process.env, CONSENSUS_PORT: String(port) }, + stdio: ["ignore", "pipe", "pipe"], + } + ); + + let output = ""; + let running = false; + const onData = (chunk) => { + const text = chunk.toString(); + output += text; + process.stdout.write(text); + }; + child.stdout?.on("data", onData); + child.stderr?.on("data", onData); + + child.on("exit", (code) => { + if (running) { + process.stderr.write(`[dev] server exited with ${code ?? 0}\n`); + shutdown(code ?? 1); + } + }); + + const result = await new Promise((resolve) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + running = true; + children.push(child); + resolve({ status: "running" }); + }, 1500); + + child.on("exit", (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + const conflict = /EADDRINUSE|address already in use|port .* in use/i.test( + output + ); + resolve({ status: conflict ? "conflict" : "exit", code }); + }); + }); + + if (result.status === "running") return; + if (result.status === "conflict") { + process.stderr.write(`[dev] port ${port} in use, trying next\n`); + continue; + } + + process.stderr.write("[dev] server exited unexpectedly\n"); + shutdown(1); + return; + } + + process.stderr.write("[dev] failed to find an open port for server\n"); + shutdown(1); +}; + const startVite = async () => { const maxAttempts = 6; @@ -127,6 +197,7 @@ const startVite = async () => { shutdown(1); }; +void startServer(); void startVite(); const shutdown = (code = 0) => { @@ -138,7 +209,6 @@ const shutdown = (code = 0) => { }; tsc.on("exit", (code) => shutdown(code ?? 0)); -server.on("exit", (code) => shutdown(code ?? 0)); process.on("SIGINT", () => shutdown(0)); process.on("SIGTERM", () => shutdown(0)); diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index a9da1e5..1060d0f 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -89,7 +89,13 @@ function logDebug(message: string): void { function expireInFlight(state: ActivityState, now: number): void { if (!state.inFlight) return; - if (typeof state.lastStatusAt === "number") return; + if (typeof state.lastStatusAt === "number") { + if (now - state.lastStatusAt <= INFLIGHT_TIMEOUT_MS) { + return; + } + state.lastStatusAt = undefined; + state.lastStatus = undefined; + } const lastSignal = state.lastInFlightSignalAt ?? state.lastActivityAt ?? state.lastEventAt; if (typeof lastSignal === "number" && now - lastSignal > INFLIGHT_TIMEOUT_MS) { state.inFlight = false; diff --git a/src/scan.ts b/src/scan.ts index 42271f3..b193090 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1000,6 +1000,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise> >(); + const opencodeStatusFreshMs = Math.max( + 2500, + resolveMs(process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS, 2500) + ); const getCachedOpenCodeSessionActivity = async (sessionId: string) => { const cached = opencodeMessageActivityCache.get(sessionId); if (cached) return cached; @@ -1032,7 +1036,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise Date: Fri, 30 Jan 2026 20:31:21 -0500 Subject: [PATCH 06/40] fix(activity): smooth opencode transitions and avoid wheel warnings --- README.md | 2 +- docs/configuration.md | 2 +- public/src/App.tsx | 2 +- public/src/components/CanvasScene.tsx | 8 ++++---- public/src/hooks/useViewState.ts | 6 +++--- src/opencodeApi.ts | 2 +- src/opencodeEvents.ts | 2 +- src/opencodeState.ts | 2 +- src/scan.ts | 18 ++++++++++++------ 9 files changed, 25 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 475b327..9f6256a 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_OPENCODE_EVENTS`: set to `0` to disable OpenCode event stream. - `CONSENSUS_OPENCODE_HOME`: override OpenCode storage (default `~/.local/share/opencode`). - `CONSENSUS_OPENCODE_EVENT_ACTIVE_MS`: OpenCode active window after last event in ms (default `0`). -- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `0`). +- `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS`: OpenCode hold window in ms (default `3000`). - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS`: OpenCode in-flight idle timeout in ms (defaults to `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`). - `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS`: OpenCode hard in-flight timeout in ms (default `2500`). - `CONSENSUS_OPENCODE_INFLIGHT_STALE_MS`: OpenCode stale in-flight cutoff in ms (default `0`). diff --git a/docs/configuration.md b/docs/configuration.md index 1ade105..1f12222 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -87,7 +87,7 @@ All configuration is via environment variables. - Default: `0` - OpenCode event window before dropping to idle. - `CONSENSUS_OPENCODE_ACTIVE_HOLD_MS` - - Default: `0` + - Default: `3000` - OpenCode hold window after activity. - `CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS` - Default: `CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS` diff --git a/public/src/App.tsx b/public/src/App.tsx index a1117f9..ca15ab3 100644 --- a/public/src/App.tsx +++ b/public/src/App.tsx @@ -73,7 +73,7 @@ function App() { onSelect={select} onMouseDown={viewHandlers.onMouseDown} onKeyDown={viewHandlers.onKeyDown} - onWheel={viewHandlers.onWheel} + onCanvasWheel={viewHandlers.onCanvasWheel} /> void; onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: WheelEvent) => void; + onCanvasWheel: (e: WheelEvent) => void; } export function CanvasScene({ @@ -24,7 +24,7 @@ export function CanvasScene({ onSelect, onMouseDown, onKeyDown, - onWheel, + onCanvasWheel, }: CanvasSceneProps) { const layoutRef = useRef(createLayoutState()); const spawnTimesRef = useRef>(new Map()); @@ -95,12 +95,12 @@ export function CanvasScene({ useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; - const handleWheel = (e: WheelEvent) => onWheel(e); + const handleWheel = (e: WheelEvent) => onCanvasWheel(e); canvas.addEventListener('wheel', handleWheel, { passive: false }); return () => { canvas.removeEventListener('wheel', handleWheel); }; - }, [canvasRef, onWheel]); + }, [canvasRef, onCanvasWheel]); const handleClick = useCallback((e: React.MouseEvent) => { // First try hovered state, otherwise calculate from click position diff --git a/public/src/hooks/useViewState.ts b/public/src/hooks/useViewState.ts index 869c568..22deb66 100644 --- a/public/src/hooks/useViewState.ts +++ b/public/src/hooks/useViewState.ts @@ -26,7 +26,7 @@ export function useViewState( ): [ViewState, ViewActions, DragState, { onMouseDown: (e: React.MouseEvent) => void; onKeyDown: (e: React.KeyboardEvent) => void; - onWheel: (e: WheelEvent) => void; + onCanvasWheel: (e: WheelEvent) => void; }] { const [view, setView] = useState({ x: initialView.x ?? window.innerWidth / 2, @@ -149,7 +149,7 @@ export function useViewState( } }, [pan, zoom]); - const onWheel = useCallback((e: WheelEvent) => { + const onCanvasWheel = useCallback((e: WheelEvent) => { e.preventDefault(); const delta = Math.sign(e.deltaY) * -WHEEL_SENSITIVITY; zoom(delta); @@ -161,7 +161,7 @@ export function useViewState( startX: dragStartRef.current.x, startY: dragStartRef.current.y, }; - const handlers = { onMouseDown, onKeyDown, onWheel }; + const handlers = { onMouseDown, onKeyDown, onCanvasWheel }; return [view, actions, dragState, handlers]; } diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index fe5537e..1404d63 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -36,7 +36,7 @@ function shouldWarn(options?: OpenCodeApiOptions): boolean { return options?.silent ? false : true; } -const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 15000; +const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 2500; export async function getOpenCodeSessions( host: string = "localhost", diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index 1060d0f..5db910b 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -7,7 +7,7 @@ const STALE_TTL_MS = 30 * 60 * 1000; const RECONNECT_MIN_MS = 10_000; const isDebugActivity = () => process.env.CONSENSUS_DEBUG_ACTIVITY === "1"; const INFLIGHT_TIMEOUT_MS = Number( - process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS || 15000 + process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS || 2500 ); const ACTIVITY_KINDS = new Set(["command", "edit", "message", "prompt", "tool"]); // Meta events that don't indicate real activity diff --git a/src/opencodeState.ts b/src/opencodeState.ts index a28fad6..dbd7626 100644 --- a/src/opencodeState.ts +++ b/src/opencodeState.ts @@ -33,7 +33,7 @@ export function deriveOpenCodeState(input: OpenCodeStateInput): ActivityHoldResu return { state, lastActiveAt: input.inFlight ? now : undefined, reason }; } const holdMs = - input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 0); + input.holdMs ?? Number(process.env.CONSENSUS_OPENCODE_ACTIVE_HOLD_MS || 3000); const envInFlightIdle = process.env.CONSENSUS_OPENCODE_INFLIGHT_IDLE_MS; const envInFlightTimeout = process.env.CONSENSUS_OPENCODE_INFLIGHT_TIMEOUT_MS; let inFlightIdleMs: number | undefined = diff --git a/src/scan.ts b/src/scan.ts index b193090..5c1a472 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1517,7 +1517,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise Date: Fri, 30 Jan 2026 21:36:14 -0500 Subject: [PATCH 07/40] fix(opencode): treat paused statuses as idle --- src/opencodeEvents.ts | 5 +++-- src/scan.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/opencodeEvents.ts b/src/opencodeEvents.ts index 5db910b..d83c377 100644 --- a/src/opencodeEvents.ts +++ b/src/opencodeEvents.ts @@ -24,6 +24,7 @@ const DELTA_EVENT_RE = /(response\.((output_text|function_call_arguments|content_part|text)\.delta)|message\.part\.updated)/i; // OpenCode session status values that indicate busy const BUSY_STATUS_RE = /^(busy|running|generating|processing)$/i; +const IDLE_STATUS_RE = /^(idle|paused|stopped)$/i; interface ActivityState { events: EventSummary[]; @@ -496,7 +497,7 @@ function handleRawEvent(raw: any): void { if (typeof status === "string" && status.trim()) { state.lastStatus = status; state.lastStatusAt = now; - if (status.toLowerCase() === "idle") { + if (IDLE_STATUS_RE.test(status)) { state.inFlight = false; state.lastInFlightSignalAt = undefined; } else { @@ -544,7 +545,7 @@ function handleRawEvent(raw: any): void { if (typeof status === "string" && status.trim()) { state.lastStatus = status; state.lastStatusAt = now; - if (status.toLowerCase() === "idle") { + if (IDLE_STATUS_RE.test(status)) { state.inFlight = false; state.lastInFlightSignalAt = undefined; } else { diff --git a/src/scan.ts b/src/scan.ts index 5c1a472..2792de8 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1642,9 +1642,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise Date: Sat, 31 Jan 2026 19:37:29 -0500 Subject: [PATCH 08/40] chore(dev): improve tmux worktree setup --- dev/tmux-start.sh | 11 +- dev/tmux.conf | 9 +- dev/worktrees.sh | 201 ++++++++++++++++++++++++++++++---- docs/worktrees-tmux-agents.md | 3 +- 4 files changed, 195 insertions(+), 29 deletions(-) diff --git a/dev/tmux-start.sh b/dev/tmux-start.sh index e2911d8..b3c15cb 100755 --- a/dev/tmux-start.sh +++ b/dev/tmux-start.sh @@ -2,4 +2,13 @@ set -euo pipefail SCOUT_DIR="$(pwd -P)" -exec env SCOUT_DIR="$SCOUT_DIR" tmux -f ./dev/tmux.conf new -A -s scout + +if tmux has-session -t scout 2>/dev/null; then + tmux set-environment -g SCOUT_DIR "$SCOUT_DIR" + tmux set-environment -g PATH "$PATH" + tmux set-environment -t scout SCOUT_DIR "$SCOUT_DIR" + tmux set-environment -t scout PATH "$PATH" + exec tmux -f ./dev/tmux.conf attach -t scout +fi + +exec env SCOUT_DIR="$SCOUT_DIR" PATH="$PATH" tmux -f ./dev/tmux.conf new -s scout diff --git a/dev/tmux.conf b/dev/tmux.conf index 066d134..e738d94 100644 --- a/dev/tmux.conf +++ b/dev/tmux.conf @@ -1,8 +1,11 @@ +# Keep PATH in sync between shell and tmux. +set -ga update-environment "PATH" + # Show useful window names automatically: ":" # This makes Ctrl+b w show both the agent (command) and the worktree (dir). setw -g automatic-rename on setw -g automatic-rename-format '#{pane_current_command}:#{b:pane_current_path}' -# New windows should always start in the default worktree ("scout directory" in the demo). -# We set SCOUT_DIR in the session start command (see next section). -bind c new-window -c "#{env:SCOUT_DIR}" +# New windows should start in the default worktree when SCOUT_DIR is set. +# Fallback to the current pane path if SCOUT_DIR is missing. +bind c new-window -c "#{?env:SCOUT_DIR,#{env:SCOUT_DIR},#{pane_current_path}}" diff --git a/dev/worktrees.sh b/dev/worktrees.sh index 80718d6..a174860 100644 --- a/dev/worktrees.sh +++ b/dev/worktrees.sh @@ -1,14 +1,162 @@ # gwt: create a new branch off the current branch + create a new worktree + cd into it + +_gwt_git_bin() { + local bin + for bin in /opt/homebrew/bin/git /usr/bin/git /usr/local/bin/git; do + if [ -x "$bin" ]; then + printf '%s\n' "$bin" + return 0 + fi + done + bin="$(type -P git 2>/dev/null)" + if [ -n "$bin" ] && [ -x "$bin" ]; then + printf '%s\n' "$bin" + return 0 + fi + bin="$(command -v git 2>/dev/null)" + if [ -n "$bin" ] && [ -x "$bin" ]; then + printf '%s\n' "$bin" + return 0 + fi + return 1 +} + +_gwt_env_bin() { + if [ -x /usr/bin/env ]; then + printf '%s\n' "/usr/bin/env" + return 0 + fi + if [ -x /bin/env ]; then + printf '%s\n' "/bin/env" + return 0 + fi + if command -v env >/dev/null 2>&1; then + printf '%s\n' "env" + return 0 + fi + return 1 +} + +_gwt_mkdir_bin() { + if [ -x /bin/mkdir ]; then + printf '%s\n' "/bin/mkdir" + return 0 + fi + if command -v mkdir >/dev/null 2>&1; then + printf '%s\n' "mkdir" + return 0 + fi + return 1 +} + +_gwt_sed_bin() { + if [ -x /usr/bin/sed ]; then + printf '%s\n' "/usr/bin/sed" + return 0 + fi + if [ -x /bin/sed ]; then + printf '%s\n' "/bin/sed" + return 0 + fi + if command -v sed >/dev/null 2>&1; then + printf '%s\n' "sed" + return 0 + fi + return 1 +} + +_gwt_cat_bin() { + if [ -x /bin/cat ]; then + printf '%s\n' "/bin/cat" + return 0 + fi + if [ -x /usr/bin/cat ]; then + printf '%s\n' "/usr/bin/cat" + return 0 + fi + if command -v cat >/dev/null 2>&1; then + printf '%s\n' "cat" + return 0 + fi + return 1 +} + +_gwt_pwd_bin() { + if [ -x /bin/pwd ]; then + printf '%s\n' "/bin/pwd" + return 0 + fi + if [ -x /usr/bin/pwd ]; then + printf '%s\n' "/usr/bin/pwd" + return 0 + fi + printf '%s\n' "pwd" + return 0 +} + +_gwt_git_common_dir() { + local git_bin env_bin sed_bin cat_bin pwd_bin common gitdir commondir cwd + git_bin="$(_gwt_git_bin)" || return 1 + env_bin="$(_gwt_env_bin)" || return 1 + sed_bin="$(_gwt_sed_bin)" || return 1 + cat_bin="$(_gwt_cat_bin)" || return 1 + pwd_bin="$(_gwt_pwd_bin)" + cwd="$("$pwd_bin" -P)" + + common="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ + "$git_bin" -C "$cwd" rev-parse --git-common-dir 2>/dev/null)" + if [ -n "$common" ]; then + case "$common" in + /*) ;; + *) common="$cwd/$common" ;; + esac + printf '%s\n' "$common" + return 0 + fi + + if [ -e "$cwd/.git" ]; then + if [ -d "$cwd/.git" ]; then + printf '%s\n' "$cwd/.git" + return 0 + fi + gitdir="$("$sed_bin" -n 's/^gitdir: //p' "$cwd/.git" 2>/dev/null)" + if [ -n "$gitdir" ]; then + case "$gitdir" in + /*) ;; + *) gitdir="$cwd/$gitdir" ;; + esac + if [ -f "$gitdir/commondir" ]; then + commondir="$("$cat_bin" "$gitdir/commondir" 2>/dev/null)" + if [ -n "$commondir" ]; then + printf '%s\n' "$(cd "$gitdir/$commondir" && "$pwd_bin" -P)" + return 0 + fi + fi + printf '%s\n' "$gitdir" + return 0 + fi + fi + + return 1 +} + gwt() { - # Must be inside a git repo - local common main wt_root branch dir path - common="$(git rev-parse --git-common-dir 2>/dev/null)" || { + local common main wt_root branch dir path git_bin mkdir_bin pwd_bin + common="$(_gwt_git_common_dir)" || { echo "gwt: not inside a git repository" >&2 return 1 } + git_bin="$(_gwt_git_bin)" || { + echo "gwt: git not found in PATH" >&2 + return 1 + } + mkdir_bin="$(_gwt_mkdir_bin)" || { + echo "gwt: mkdir not found in PATH" >&2 + return 1 + } + pwd_bin="$(_gwt_pwd_bin)" - # The common dir is
/.git for all worktrees; main worktree is its parent - main="$(cd "$common/.." && pwd -P)" + main="$(cd "$common/.." && "$pwd_bin" -P)" wt_root="${main}.worktrees" branch="$1" @@ -21,47 +169,52 @@ gwt() { return 1 fi - # Directory name should be filesystem-friendly AND show up clearly in tmux - # Replace slashes so "task/123-foo" becomes "task-123-foo" dir="${branch//\//-}" path="${wt_root}/${dir}" - mkdir -p "$wt_root" || return 1 + "$mkdir_bin" -p "$wt_root" || return 1 - # Create branch from current HEAD and add worktree - git worktree add -b "$branch" "$path" HEAD || return 1 + "$git_bin" worktree add -b "$branch" "$path" HEAD || return 1 cd "$path" || return 1 } -# gwe: delete the current worktree (and cd back to the default worktree first) gwe() { - local common main cur branch - common="$(git rev-parse --git-common-dir 2>/dev/null)" || { + local common main cur branch git_bin env_bin pwd_bin + common="$(_gwt_git_common_dir)" || { echo "gwe: not inside a git repository" >&2 return 1 } - main="$(cd "$common/.." && pwd -P)" - cur="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1 + git_bin="$(_gwt_git_bin)" || { + echo "gwe: git not found in PATH" >&2 + return 1 + } + env_bin="$(_gwt_env_bin)" || { + echo "gwe: env not found in PATH" >&2 + return 1 + } + pwd_bin="$(_gwt_pwd_bin)" + + main="$(cd "$common/.." && "$pwd_bin" -P)" + cur="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ + "$git_bin" -C "$main" rev-parse --show-toplevel 2>/dev/null)" + if [ -z "$cur" ]; then + return 1 + fi - # Prevent nuking the default worktree if [ "$cur" = "$main" ]; then echo "gwe: refusing to remove the default worktree: $main" >&2 return 1 fi - branch="$(git -C "$cur" rev-parse --abbrev-ref HEAD 2>/dev/null)" + branch="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ + "$git_bin" -C "$cur" rev-parse --abbrev-ref HEAD 2>/dev/null)" - # Move out of the worktree before removing it cd "$main" || return 1 - # Remove the worktree directory - git worktree remove --force "$cur" || return 1 + "$git_bin" worktree remove --force "$cur" || return 1 - # In the demo the intent is "task is done -> delete the worktree". - # Most teams also want to delete the local branch created for that worktree. - # This will fail safely if the branch is checked out elsewhere. if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then - git branch -D "$branch" >/dev/null 2>&1 || true + "$git_bin" branch -D "$branch" >/dev/null 2>&1 || true fi } diff --git a/docs/worktrees-tmux-agents.md b/docs/worktrees-tmux-agents.md index e93ea9a..a511e26 100644 --- a/docs/worktrees-tmux-agents.md +++ b/docs/worktrees-tmux-agents.md @@ -36,9 +36,10 @@ These must be shell functions (not standalone executables) because they `cd` int `dev/tmux.conf` enables automatic window renaming so Ctrl+B W shows `command:path`: ``` +set -ga update-environment "PATH" setw -g automatic-rename on setw -g automatic-rename-format '#{pane_current_command}:#{b:pane_current_path}' -bind c new-window -c "#{env:SCOUT_DIR}" +bind c new-window -c "#{?env:SCOUT_DIR,#{env:SCOUT_DIR},#{pane_current_path}}" ``` ## Start tmux the same way every time From 87ede28be9a9eade3eda01147d62828f339f642d Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sat, 31 Jan 2026 19:37:43 -0500 Subject: [PATCH 09/40] fix(opencode): improve subagent visibility --- package.json | 4 +- public/src/components/AgentPanel.tsx | 8 +- public/src/types.ts | 6 +- src/opencodeApi.ts | 131 +++++-- src/opencodeFilter.ts | 7 +- src/opencodeSessionAssign.ts | 41 ++- src/scan.ts | 526 ++++++++++++++++++++++++--- src/server.ts | 12 +- src/types.ts | 5 +- 9 files changed, 656 insertions(+), 84 deletions(-) diff --git a/package.json b/package.json index 03cb885..cd2e35c 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,6 @@ "dependencies": { "@effect/opentelemetry": "^0.61.0", "@effect/platform": "^0.94.2", - "react": "^18.3.0", - "react-dom": "^18.3.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-metrics-otlp-http": "^0.211.0", "@opentelemetry/exporter-trace-otlp-http": "^0.211.0", @@ -62,6 +60,8 @@ "express": "^4.19.2", "pidusage": "^3.0.2", "ps-list": "^8.1.1", + "react": "^18.3.0", + "react-dom": "^18.3.0", "ws": "^8.17.0" }, "devDependencies": { diff --git a/public/src/components/AgentPanel.tsx b/public/src/components/AgentPanel.tsx index a5ee475..11c16a7 100644 --- a/public/src/components/AgentPanel.tsx +++ b/public/src/components/AgentPanel.tsx @@ -58,8 +58,14 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) {
pid - {agent.pid} + {typeof agent.pid === 'number' ? agent.pid : '—'}
+ {!agent.pid && agent.sessionPath && ( +
+ session + {agent.sessionPath.replace(/^opencode:/, '')} +
+ )}
kind {agent.kind} diff --git a/public/src/types.ts b/public/src/types.ts index 8e6ddf7..68c2374 100644 --- a/public/src/types.ts +++ b/public/src/types.ts @@ -16,9 +16,11 @@ export interface AgentEvent { } export interface AgentSnapshot { - identity: string; + identity?: string; id: string; - pid: number; + pid?: number; + sessionId?: string; + parentSessionId?: string; cmd?: string; cmdShort?: string; kind: string; diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index 1404d63..47324d4 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -30,12 +30,89 @@ export interface OpenCodeSessionResult { export interface OpenCodeApiOptions { timeoutMs?: number; silent?: boolean; + signal?: AbortSignal; } function shouldWarn(options?: OpenCodeApiOptions): boolean { return options?.silent ? false : true; } +interface AbortContext { + signal: AbortSignal; + cleanup: () => void; + isTimedOut: () => boolean; + isParentAborted: () => boolean; +} + +function createAbortContext( + parentSignal: AbortSignal | undefined, + timeoutMs: number +): AbortContext { + const controller = new AbortController(); + let timedOut = false; + let parentAborted = false; + let timeoutId: ReturnType | undefined; + let onParentAbort: (() => void) | undefined; + + const abort = (reason?: unknown) => { + if (controller.signal.aborted) return; + try { + controller.abort(reason); + } catch { + controller.abort(); + } + }; + + if (parentSignal) { + if (parentSignal.aborted) { + parentAborted = true; + abort(parentSignal.reason); + } else { + onParentAbort = () => { + parentAborted = true; + abort(parentSignal.reason); + }; + parentSignal.addEventListener("abort", onParentAbort, { once: true }); + } + } + + if (controller.signal.aborted) { + return { + signal: controller.signal, + cleanup: () => { + if (parentSignal && onParentAbort) { + parentSignal.removeEventListener("abort", onParentAbort); + } + }, + isTimedOut: () => timedOut, + isParentAborted: () => parentAborted, + }; + } + + if (Number.isFinite(timeoutMs) && timeoutMs > 0) { + timeoutId = setTimeout(() => { + timedOut = true; + abort(); + }, timeoutMs); + } + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + if (parentSignal && onParentAbort) { + parentSignal.removeEventListener("abort", onParentAbort); + } + }; + + controller.signal.addEventListener("abort", cleanup, { once: true }); + + return { + signal: controller.signal, + cleanup, + isTimedOut: () => timedOut, + isParentAborted: () => parentAborted, + }; +} + const DEFAULT_OPENCODE_INFLIGHT_TIMEOUT_MS = 2500; export async function getOpenCodeSessions( @@ -43,9 +120,8 @@ export async function getOpenCodeSessions( port: number = 4096, options?: OpenCodeApiOptions ): Promise { - const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 5000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const abortContext = createAbortContext(options?.signal, timeoutMs); const warn = shouldWarn(options); try { @@ -54,11 +130,9 @@ export async function getOpenCodeSessions( Accept: "application/json", "User-Agent": "consensus-scanner", }, - signal: controller.signal, + signal: abortContext.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { if (warn) { console.warn(`OpenCode API error: ${response.status} ${response.statusText}`); @@ -84,10 +158,17 @@ export async function getOpenCodeSessions( } return { ok: true, sessions: [], reachable: true }; } catch (error) { - clearTimeout(timeoutId); - if (warn) { + const timedOut = abortContext.isTimedOut(); + const parentAborted = abortContext.isParentAborted(); + if (warn && !timedOut && !parentAborted) { console.warn("Failed to fetch OpenCode sessions:", error); } + if (timedOut) { + return { ok: false, sessions: [], error: "timeout", reachable: false }; + } + if (parentAborted) { + return { ok: false, sessions: [], error: "aborted", reachable: false }; + } const errorCode = typeof (error as any)?.cause?.code === "string" ? (error as any).cause.code @@ -95,6 +176,8 @@ export async function getOpenCodeSessions( ? (error as any).code : undefined; return { ok: false, sessions: [], error: errorCode, reachable: false }; + } finally { + abortContext.cleanup(); } } @@ -104,9 +187,8 @@ export async function getOpenCodeSession( port: number = 4096, options?: OpenCodeApiOptions ): Promise { - const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 5000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const abortContext = createAbortContext(options?.signal, timeoutMs); const warn = shouldWarn(options); try { @@ -115,11 +197,9 @@ export async function getOpenCodeSession( Accept: "application/json", "User-Agent": "consensus-scanner", }, - signal: controller.signal, + signal: abortContext.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { if (warn) { console.warn( @@ -131,11 +211,14 @@ export async function getOpenCodeSession( return await response.json(); } catch (error) { - clearTimeout(timeoutId); - if (warn) { + const timedOut = abortContext.isTimedOut(); + const parentAborted = abortContext.isParentAborted(); + if (warn && !timedOut && !parentAborted) { console.warn(`Failed to fetch OpenCode session ${sessionId}:`, error); } return null; + } finally { + abortContext.cleanup(); } } @@ -194,9 +277,8 @@ export async function getOpenCodeSessionActivity( staleMsRaw !== undefined && staleMsRaw !== "" ? Number(staleMsRaw) : 0; - const controller = new AbortController(); const timeoutMs = options?.timeoutMs ?? 3000; - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); + const abortContext = createAbortContext(options?.signal, timeoutMs); const warn = shouldWarn(options); try { @@ -205,11 +287,9 @@ export async function getOpenCodeSessionActivity( Accept: "application/json", "User-Agent": "consensus-scanner", }, - signal: controller.signal, + signal: abortContext.signal, }); - clearTimeout(timeoutId); - if (!response.ok) { return { ok: false, inFlight: false, error: `status_${response.status}` }; } @@ -344,10 +424,19 @@ export async function getOpenCodeSessionActivity( lastActivityAt: latestActivityAt, }; } catch (error) { - clearTimeout(timeoutId); - if (warn) { + const timedOut = abortContext.isTimedOut(); + const parentAborted = abortContext.isParentAborted(); + if (warn && !timedOut && !parentAborted) { console.warn(`Failed to fetch OpenCode session activity ${sessionId}:`, error); } + if (timedOut) { + return { ok: false, inFlight: false, error: "timeout" }; + } + if (parentAborted) { + return { ok: false, inFlight: false, error: "aborted" }; + } return { ok: false, inFlight: false, error: "fetch_error" }; + } finally { + abortContext.cleanup(); } } diff --git a/src/opencodeFilter.ts b/src/opencodeFilter.ts index be33ea2..7566a37 100644 --- a/src/opencodeFilter.ts +++ b/src/opencodeFilter.ts @@ -7,7 +7,12 @@ export interface OpenCodeIncludeInput { export function shouldIncludeOpenCodeProcess(input: OpenCodeIncludeInput): boolean { if (input.kind === "opencode-server") return true; - if (input.kind === "opencode-tui" || input.kind === "opencode-cli") return true; + if (input.kind === "opencode-session") { + return input.hasEventActivity; + } + if (input.kind === "opencode-tui" || input.kind === "opencode-cli") { + return true; + } if (input.opencodeApiAvailable) { return input.hasSession || input.hasEventActivity; } diff --git a/src/opencodeSessionAssign.ts b/src/opencodeSessionAssign.ts index 3ad969a..7a9916f 100644 --- a/src/opencodeSessionAssign.ts +++ b/src/opencodeSessionAssign.ts @@ -61,6 +61,19 @@ export const getOpenCodeSessionPid = (session: unknown): number | undefined => { ); }; +export const getOpenCodeSessionParentId = (session: unknown): string | undefined => { + if (!session || typeof session !== "object") return undefined; + const target = session as { parentID?: unknown; parentId?: unknown; parent_id?: unknown }; + const raw = target.parentID ?? target.parentId ?? target.parent_id; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + return trimmed ? trimmed : undefined; +}; + +export const isOpenCodeChildSession = (session: unknown): boolean => { + return typeof getOpenCodeSessionParentId(session) === "string"; +}; + export const markOpenCodeSessionUsed = ( used: UsedSessionMap, sessionId: string, @@ -93,14 +106,16 @@ export const pickOpenCodeSessionByDir = ({ const activeIds = activeSessionIdsByDir.get(dir); if (activeIds) { for (const id of activeIds) { - if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; const activeSession = sessionsById.get(id); + if (activeSession && isOpenCodeChildSession(activeSession)) continue; + if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; if (activeSession) return activeSession; // If API no longer lists this session, keep id reserved to avoid collisions. return undefined; } } for (const session of sessions) { + if (isOpenCodeChildSession(session)) continue; const id = getOpenCodeSessionId(session); if (!id) continue; if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; @@ -129,16 +144,26 @@ export const selectOpenCodeSessionForTui = ({ usedSessionIds: UsedSessionMap; }): { session?: T; sessionId?: string; source: "pid" | "cache" | "dir" | "none" } => { const sessionByPidId = getOpenCodeSessionId(sessionByPid); - if (sessionByPidId && markOpenCodeSessionUsed(usedSessionIds, sessionByPidId, pid)) { + if ( + sessionByPidId && + !isOpenCodeChildSession(sessionByPid) && + markOpenCodeSessionUsed(usedSessionIds, sessionByPidId, pid) + ) { return { session: sessionByPid, sessionId: sessionByPidId, source: "pid" }; } - if (cachedSessionId && markOpenCodeSessionUsed(usedSessionIds, cachedSessionId, pid)) { - return { - session: sessionsById.get(cachedSessionId), - sessionId: cachedSessionId, - source: "cache", - }; + if (cachedSessionId) { + const cachedSession = sessionsById.get(cachedSessionId); + if ( + !isOpenCodeChildSession(cachedSession) && + markOpenCodeSessionUsed(usedSessionIds, cachedSessionId, pid) + ) { + return { + session: cachedSession, + sessionId: cachedSessionId, + source: "cache", + }; + } } const byDir = pickOpenCodeSessionByDir({ diff --git a/src/scan.ts b/src/scan.ts index 2792de8..b490e9e 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -80,6 +80,55 @@ const pidSessionCache = new Map< number, { path: string; lastSeenAt: number; startMs?: number } >(); + +const opencodePrefetchFailures = new Map< + string, + { count: number; firstAt: number; cooldownUntil?: number } +>(); +const OPENCODE_PREFETCH_FAILURE_WINDOW_MS = 10_000; +const OPENCODE_PREFETCH_FAILURE_THRESHOLD = 3; +const OPENCODE_PREFETCH_COOLDOWN_MS = 30_000; + +// Track subagent observation times for hold mechanism +const subagentSeen = new Map< + string, + { lastAt?: number; observedAt?: number } +>(); + +function getOpencodePrefetchKey(host: string, port: number): string { + return `${host}:${port}`; +} + +function shouldSkipOpenCodePrefetch(key: string, now: number): boolean { + const entry = opencodePrefetchFailures.get(key); + if (!entry) return false; + if (typeof entry.cooldownUntil === "number" && now < entry.cooldownUntil) { + return true; + } + if (typeof entry.cooldownUntil === "number" && now >= entry.cooldownUntil) { + opencodePrefetchFailures.delete(key); + } + return false; +} + +function recordOpenCodePrefetchFailure(key: string, now: number): void { + const entry = opencodePrefetchFailures.get(key); + if (!entry || now - entry.firstAt > OPENCODE_PREFETCH_FAILURE_WINDOW_MS) { + opencodePrefetchFailures.set(key, { count: 1, firstAt: now }); + return; + } + entry.count += 1; + if (entry.count >= OPENCODE_PREFETCH_FAILURE_THRESHOLD) { + entry.cooldownUntil = now + OPENCODE_PREFETCH_COOLDOWN_MS; + } + opencodePrefetchFailures.set(key, entry); +} + +function recordOpenCodePrefetchSuccess(key: string): void { + if (opencodePrefetchFailures.has(key)) { + opencodePrefetchFailures.delete(key); + } +} const opencodeServerLogged = new Set(); const opencodeSessionByPidCache = new Map< number, @@ -146,10 +195,18 @@ function consumeDirtySessions(): Set { return dirty; } +function throwIfAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + const error = new Error("Scan cancelled"); + error.name = "AbortError"; + throw error; +} + type ScanMode = "fast" | "full"; export interface ScanOptions { mode?: ScanMode; includeActivity?: boolean; + signal?: AbortSignal; } type PsProcess = Awaited>[number]; @@ -411,6 +468,22 @@ function parseTimestamp(value?: string | number): number | undefined { return undefined; } +function toMs(value: unknown): number | undefined { + if (typeof value === "number" || typeof value === "string") { + return parseTimestamp(value); + } + return undefined; +} + +function maxMs(...values: Array): number | undefined { + let max: number | undefined; + for (const value of values) { + if (typeof value !== "number" || !Number.isFinite(value)) continue; + if (max === undefined || value > max) max = value; + } + return max; +} + function deriveTitle( doing: string | undefined, repo: string | undefined, @@ -683,6 +756,8 @@ function findRepoRoot(cwd: string): string | null { export async function scanCodexProcesses(options: ScanOptions = {}): Promise { const now = Date.now(); const mode: ScanMode = options.mode ?? "full"; + const signal = options.signal; + throwIfAborted(signal); const includeActivity = options.includeActivity !== false; const scanTimer = startProfile("total", mode); const dirtySessions = includeActivity && mode === "fast" ? consumeDirtySessions() : null; @@ -914,6 +989,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { opencodeSessionCache = result; opencodeSessionCacheAt = now; @@ -922,12 +998,14 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0); if (opencodeProcs.length > 0 || opencodeResult.ok) { ensureOpenCodeEventStream(opencodeHost, opencodePort); @@ -1004,63 +1082,141 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { + const opencodePrefetchKey = getOpencodePrefetchKey(opencodeHost, opencodePort); + const getCachedOpenCodeSessionActivity = async ( + sessionId: string, + options: { signal?: AbortSignal; timeoutMs?: number; cache?: boolean } = {} + ) => { const cached = opencodeMessageActivityCache.get(sessionId); if (cached) return cached; const result = await getOpenCodeSessionActivity( sessionId, opencodeHost, opencodePort, - { silent: true, timeoutMs: 2000 } + { silent: true, timeoutMs: options.timeoutMs ?? 2000, signal: options.signal } ); - opencodeMessageActivityCache.set(sessionId, result); + if (options.cache !== false && result.error !== "aborted" && result.error !== "timeout") { + opencodeMessageActivityCache.set(sessionId, result); + } return result; }; const opencodeActiveSessionIdsByDir = new Map(); - const prefetchActiveSessionsByDir = async () => { - const tasks: Promise[] = []; - for (const [dir, sessions] of opencodeAllSessionsByDir.entries()) { - const tuiCount = opencodeTuiByDir.get(dir)?.length ?? 0; - if (tuiCount === 0) continue; - const minCandidates = Math.max(tuiCount, 2); - tasks.push( - (async () => { - const activeIds: string[] = []; - let checked = 0; - for (const session of sessions) { - if (checked >= minCandidates && activeIds.length >= tuiCount) break; - const id = getOpenCodeSessionId(session); - if (!id) { - checked += 1; - continue; - } - const statusActivity = getOpenCodeActivityBySession(id); - const statusValue = statusActivity?.lastStatus?.toLowerCase(); - const statusAt = statusActivity?.lastStatusAt; - const statusFresh = - typeof statusAt === "number" && now - statusAt <= opencodeStatusFreshMs; - if (statusValue && statusFresh) { - if (statusValue !== "idle") { - activeIds.push(id); + const prefetchActiveSessionsByDir = async (signal?: AbortSignal) => { + if (mode === "fast") return; + if (!opencodeApiAvailable) return; + if (signal?.aborted) return; + if (shouldSkipOpenCodePrefetch(opencodePrefetchKey, now)) return; + + const prefetchBudgetMs = 2500; + if (!Number.isFinite(prefetchBudgetMs) || prefetchBudgetMs <= 0) return; + const prefetchCallTimeoutMs = Math.min(750, prefetchBudgetMs); + const prefetchDeadline = Date.now() + prefetchBudgetMs; + + const entries = Array.from(opencodeAllSessionsByDir.entries()); + if (entries.length === 0) return; + + const prefetchController = new AbortController(); + let prefetchTimeoutId: ReturnType | undefined; + let onScanAbort: (() => void) | undefined; + + const abortPrefetch = (reason?: unknown) => { + if (prefetchController.signal.aborted) return; + try { + prefetchController.abort(reason); + } catch { + prefetchController.abort(); + } + }; + + if (signal) { + if (signal.aborted) { + abortPrefetch(signal.reason); + } else { + onScanAbort = () => abortPrefetch(signal.reason); + signal.addEventListener("abort", onScanAbort, { once: true }); + } + } + prefetchTimeoutId = setTimeout(() => abortPrefetch(), prefetchBudgetMs); + + const prefetchSignal = prefetchController.signal; + const hasBudget = () => !prefetchSignal.aborted && Date.now() < prefetchDeadline; + + const prefetchEffect = Effect.forEach( + entries, + ([dir, sessions]) => + Effect.tryPromise({ + try: async () => { + if (!hasBudget()) return; + const tuiCount = opencodeTuiByDir.get(dir)?.length ?? 0; + if (tuiCount === 0) return; + + const minCandidates = Math.max(tuiCount, 2); + const hardCap = 10; + const absoluteCap = 20; + const cappedLimit = minCandidates > hardCap ? absoluteCap : hardCap; + const maxCandidates = Math.min(sessions.length, cappedLimit); + if (maxCandidates <= 0) return; + + const activeIds: string[] = []; + let checked = 0; + const candidates = sessions.slice(0, maxCandidates); + + for (const session of candidates) { + if (!hasBudget()) return; + if (checked >= minCandidates && activeIds.length >= tuiCount) break; + const id = getOpenCodeSessionId(session); + if (!id) { + checked += 1; + continue; + } + const statusActivity = getOpenCodeActivityBySession(id); + const statusValue = statusActivity?.lastStatus?.toLowerCase(); + const statusAt = statusActivity?.lastStatusAt; + const statusFresh = + typeof statusAt === "number" && now - statusAt <= opencodeStatusFreshMs; + if (statusValue && statusFresh) { + if (!/idle|stopped|paused/.test(statusValue)) { + activeIds.push(id); + } + checked += 1; + continue; + } + const activity = await getCachedOpenCodeSessionActivity(id, { + signal: prefetchSignal, + timeoutMs: prefetchCallTimeoutMs, + cache: false, + }); + if (activity.ok) { + recordOpenCodePrefetchSuccess(opencodePrefetchKey); + if (activity.inFlight) { + activeIds.push(id); + } + } else if (activity.error !== "aborted" && activity.error !== "timeout") { + recordOpenCodePrefetchFailure(opencodePrefetchKey, Date.now()); } checked += 1; - continue; } - const activity = await getCachedOpenCodeSessionActivity(id); - if (activity.ok && activity.inFlight) { - activeIds.push(id); + + if (activeIds.length > 0) { + opencodeActiveSessionIdsByDir.set(dir, activeIds); } - checked += 1; - } - if (activeIds.length > 0) { - opencodeActiveSessionIdsByDir.set(dir, activeIds); - } - })() - ); + }, + catch: () => undefined, + }).pipe(Effect.catchAll(() => Effect.void)), + { concurrency: 5 } + ).pipe(Effect.asVoid); + + try { + await runPromise(prefetchEffect); + } finally { + if (prefetchTimeoutId) clearTimeout(prefetchTimeoutId); + if (signal && onScanAbort) { + signal.removeEventListener("abort", onScanAbort); + } } - await Promise.all(tasks); }; - await prefetchActiveSessionsByDir(); + await prefetchActiveSessionsByDir(signal); + throwIfAborted(signal); const cacheMatchesHome = sessionCache.home === codexHome && sessionCache.sessions.length > 0; let sessions: SessionFile[] = []; @@ -1529,7 +1685,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); + const assignedOpenCodeSessionIds = new Set(); for (const proc of opencodeProcs) { + if (signal?.aborted) break; const stats = usage[proc.pid] || ({} as pidusage.Status); const cpu = typeof stats.cpu === "number" ? stats.cpu : 0; const mem = typeof stats.memory === "number" ? stats.memory : 0; @@ -1601,6 +1759,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + const subagentCandidates: Array<{ + session: OpenCodeSession; + sessionId: string; + parentId: string; + eventActivity: ReturnType | null; + messageActivity: Awaited> | null; + activityAt: number; + inFlight: boolean; + }> = []; + for (const session of opencodeSessions) { + const sessionId = getOpenCodeSessionId(session); + if (!sessionId) continue; + const parentId = typeof (session as { parentID?: string }).parentID === "string" + ? (session as { parentID?: string }).parentID + : undefined; + if (!parentId) continue; + if (!assignedOpenCodeSessionIds.has(parentId)) continue; + if (assignedOpenCodeSessionIds.has(sessionId)) continue; + + const eventActivity = getOpenCodeActivityBySession(sessionId); + const messageActivity = + opencodeMessageActivityCache.get(sessionId) || + (await getCachedOpenCodeSessionActivity(sessionId, { + signal, + timeoutMs: 750, + cache: true, + })); + const sessionUpdatedAt = opencodeSessionTimestamp(session); + const activityAt = maxMs( + toMs(eventActivity?.lastActivityAt), + toMs(messageActivity?.lastActivityAt), + sessionUpdatedAt + ); + const inFlight = + eventActivity?.inFlight === true || messageActivity?.inFlight === true; + const hasEventActivity = + typeof activityAt === "number" && + Number.isFinite(opencodeSubagentWindowMs) && + opencodeSubagentWindowMs > 0 && + now - activityAt <= opencodeSubagentWindowMs; + + // Observation hold: track when we see activity timestamps increase + const entry = subagentSeen.get(sessionId); + if (!entry) { + // First sight: store lastAt, and set observedAt only if within remote window + const newEntry: { lastAt?: number; observedAt?: number } = { + lastAt: activityAt, + }; + if ( + typeof activityAt === "number" && + now - activityAt <= opencodeSubagentWindowMs + ) { + newEntry.observedAt = now; + } + subagentSeen.set(sessionId, newEntry); + } else if ( + typeof activityAt === "number" && + (typeof entry.lastAt !== "number" || activityAt > entry.lastAt) + ) { + // Timestamp increased: update and refresh observedAt + entry.lastAt = activityAt; + entry.observedAt = now; + } else if (inFlight) { + // Currently in flight: refresh observedAt + entry.observedAt = now; + } + + const observed = subagentSeen.get(sessionId); + const hasRecentObserved = + typeof observed?.observedAt === "number" && + now - observed.observedAt <= opencodeSubagentHoldMs; + + if (!inFlight && !hasEventActivity && !hasRecentObserved) { + if (debugSubagents) { + const ageMs = + typeof activityAt === "number" ? Math.max(0, now - activityAt) : undefined; + const obsAgeMs = + typeof observed?.observedAt === "number" + ? now - observed.observedAt + : undefined; + const remoteSkewMs = ageMs; + console.log( + `[consensus][subagent-drop] ${sessionId} ` + + `inFlight=${inFlight} ` + + `hasEventActivity=${hasEventActivity} ` + + `hasRecentObserved=${hasRecentObserved} ` + + `sseAt=${String(eventActivity?.lastActivityAt)} ` + + `msgAt=${String(messageActivity?.lastActivityAt)} ` + + `updatedAt=${String(sessionUpdatedAt)} ` + + `effectiveAt=${String(activityAt)} ` + + `ageMs=${String(ageMs)} ` + + `remoteSkewMs=${String(remoteSkewMs)} ` + + `observedAt=${String(observed?.observedAt)} ` + + `obsAgeMs=${String(obsAgeMs)} ` + + `windowMs=${opencodeSubagentWindowMs} ` + + `holdMs=${opencodeSubagentHoldMs}` + ); + } + continue; + } + if (debugSubagents) { + console.log( + `[consensus][subagent-keep] ${sessionId} ` + + `inFlight=${inFlight} ` + + `hasEventActivity=${hasEventActivity} ` + + `hasRecentObserved=${hasRecentObserved} ` + + `effectiveAt=${String(activityAt)} ` + + `windowMs=${opencodeSubagentWindowMs} ` + + `holdMs=${opencodeSubagentHoldMs}` + ); + } + + subagentCandidates.push({ + session, + sessionId, + parentId, + eventActivity, + messageActivity, + activityAt: activityAt ?? (inFlight ? now : 0), + inFlight, + }); + } + + if (subagentCandidates.length) { + subagentCandidates.sort((a, b) => b.activityAt - a.activityAt); + const capped = subagentCandidates.slice(0, 20); + for (const candidate of capped) { + const { session, sessionId, parentId, eventActivity, messageActivity, inFlight } = candidate; + + const sessionIdentity = `opencode:${sessionId}`; + const id = `session:${sessionId}`; + const cached = activityCache.get(id); + const statusRaw = typeof (session as { status?: string }).status === "string" + ? (session as { status?: string }).status + : undefined; + const status = eventActivity?.lastStatus ?? statusRaw?.toLowerCase(); + const statusIsError = !!status && /error|failed|failure/.test(status); + const statusIsIdle = !!status && /idle|stopped|paused/.test(status); + const statusIsActive = !!status && /running|active|processing|busy|retry/.test(status); + const sessionUpdatedAt = opencodeSessionTimestamp(session); + const lastActivityAt = maxMs( + toMs(eventActivity?.lastActivityAt), + toMs(messageActivity?.lastActivityAt), + sessionUpdatedAt + ); + const lastEventAt = eventActivity?.lastEventAt; + const hasError = !!eventActivity?.hasError || statusIsError; + + const activity = deriveOpenCodeState({ + hasError, + lastEventAt, + lastActivityAt, + inFlight, + status, + now, + previousActiveAt: cached?.lastActiveAt, + eventWindowMs: opencodeEventWindowMs, + holdMs: opencodeHoldMs, + inFlightIdleMs: opencodeInFlightIdleMs, + strictInFlight: opencodeStrictInFlight, + }); + let state = activity.state; + let reason = activity.reason || "session"; + const activityLastActiveAt = activity.lastActiveAt ?? cached?.lastActiveAt; + if ( + activity.state === "active" && + activity.reason === "hold" && + typeof activity.lastActiveAt === "number" && + opencodeHoldMs > 0 + ) { + bumpNextTickAt(activity.lastActiveAt + opencodeHoldMs); + } + + const hasSignal = + statusIsIdle || + statusIsActive || + statusIsError || + typeof lastActivityAt === "number" || + typeof inFlight === "boolean"; + if (!hasSignal && !activityLastActiveAt) { + state = "idle"; + reason = "no_signal"; + } + + if (cached?.lastState && cached.lastState !== state) { + metricEffects.push(recordActivityTransition("opencode", cached.lastState, state, reason)); + trackTransition("opencode", cached.lastState, state, reason); + } + + activityCache.set(id, { + lastActiveAt: activityLastActiveAt, + lastSeenAt: now, + lastState: state, + lastReason: reason, + startMs: sessionUpdatedAt || undefined, + }); + seenIds.add(id); + + const sessionTitle = normalizeTitle( + (session as { title?: string; name?: string }).title || + (session as { title?: string; name?: string }).name + ); + const sessionCwd = + (session as { directory?: string; cwd?: string }).directory || + (session as { directory?: string; cwd?: string }).cwd; + const cwd = redactText(sessionCwd) || sessionCwd; + const repoRoot = sessionCwd ? findRepoRoot(sessionCwd) : null; + const repoName = repoRoot ? path.basename(repoRoot) : undefined; + const summary = eventActivity?.summary; + const events = eventActivity?.events; + const cmd = `opencode session ${sessionId}`; + const cmdShort = shortenCmd(cmd); + const doing = summary?.current || sessionTitle; + + agents.push({ + identity: sessionIdentity, + id, + pid: undefined, + sessionId, + parentSessionId: parentId, + startedAt: sessionUpdatedAt ? Math.floor(sessionUpdatedAt / 1000) : undefined, + lastEventAt, + lastActivityAt, + activityReason: reason, + title: sessionTitle, + cmd, + cmdShort, + kind: "opencode-session", + cpu: 0, + mem: 0, + state, + doing: redactText(doing) || doing, + sessionPath: `opencode:${sessionId}`, + repo: repoName, + cwd, + model: + typeof (session as { model?: string }).model === "string" + ? (session as { model?: string }).model + : undefined, + summary: summary ? sanitizeSummary(summary) : undefined, + events, + }); + } + } + + // Prune old subagent seen entries (1 hour retention) + for (const [id, entry] of subagentSeen) { + if (entry.observedAt && now - entry.observedAt > 3_600_000) { + subagentSeen.delete(id); + continue; + } + if (!entry.observedAt) { + if (typeof entry.lastAt === "number" && now - entry.lastAt > 3_600_000) { + subagentSeen.delete(id); + continue; + } + if (entry.lastAt === undefined) { + subagentSeen.delete(id); + } + } + } + } + + throwIfAborted(signal); for (const proc of claudeProcs) { const stats = usage[proc.pid] || ({} as pidusage.Status); const cpu = typeof stats.cpu === "number" ? stats.cpu : 0; diff --git a/src/server.ts b/src/server.ts index 3beafdc..42011a1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,7 +7,7 @@ import chokidar from "chokidar"; import type { FSWatcher } from "chokidar"; import { WebSocketServer, WebSocket } from "ws"; import { fileURLToPath } from "url"; -import { Effect, Fiber } from "effect"; +import { Effect, Exit, Fiber } from "effect"; import { scanCodexProcesses, markSessionDirty } from "./scan.js"; import { resolveCodexHome } from "./codexLogs.js"; import { onOpenCodeEvent, stopOpenCodeEventStream } from "./opencodeEvents.js"; @@ -617,7 +617,7 @@ async function tick(): Promise { .pipe( Effect.andThen( Effect.tryPromise({ - try: () => scanCodexProcesses({ mode, includeActivity }), + try: (signal) => scanCodexProcesses({ mode, includeActivity, signal }), catch: (err) => err as Error, }).pipe(Effect.timeout(`${scanTimeoutMs} millis`)) ) @@ -655,8 +655,12 @@ async function tick(): Promise { ) ); - const snapshot = await runPromise(scanEffect); - if (snapshot) emitSnapshot(snapshot); + const fiber = runFork(scanEffect); + const exit = await runPromise(fiber.await); + if (Exit.isSuccess(exit)) { + const snapshot = exit.value; + if (snapshot) emitSnapshot(snapshot); + } } catch (err) { // Keep server alive on scan errors. logRuntimeError("scan crashed", err); diff --git a/src/types.ts b/src/types.ts index faa8ffc..68a0999 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export type AgentKind = | "app-server" | "opencode-tui" | "opencode-cli" + | "opencode-session" | "opencode-server" | "claude-tui" | "claude-cli" @@ -29,7 +30,9 @@ export interface WorkSummary { export interface AgentSnapshot { identity?: string; id: string; - pid: number; + pid?: number; + sessionId?: string; + parentSessionId?: string; startedAt?: number; lastEventAt?: number; lastActivityAt?: number; From 14b447c562de90816656cd95cfbd3afe5fe8e35a Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sat, 31 Jan 2026 21:32:33 -0500 Subject: [PATCH 10/40] fix(opencode): prevent stale subagents and child session binding --- src/opencodeSessionAssign.ts | 11 ++- src/scan.ts | 133 +++++++++++++++++++---------------- 2 files changed, 83 insertions(+), 61 deletions(-) diff --git a/src/opencodeSessionAssign.ts b/src/opencodeSessionAssign.ts index 7a9916f..c7bd39e 100644 --- a/src/opencodeSessionAssign.ts +++ b/src/opencodeSessionAssign.ts @@ -92,6 +92,7 @@ export const pickOpenCodeSessionByDir = ({ sessionsById, usedSessionIds, pid, + childSessionIds, }: { dir?: string; sessionsByDir: Map; @@ -99,13 +100,16 @@ export const pickOpenCodeSessionByDir = ({ sessionsById: Map; usedSessionIds: UsedSessionMap; pid: number; + childSessionIds?: Set; }): T | undefined => { if (!dir) return undefined; const sessions = sessionsByDir.get(dir); if (!sessions || sessions.length === 0) return undefined; const activeIds = activeSessionIdsByDir.get(dir); + const isChildId = (id: string): boolean => !!childSessionIds?.has(id); if (activeIds) { for (const id of activeIds) { + if (isChildId(id)) continue; const activeSession = sessionsById.get(id); if (activeSession && isOpenCodeChildSession(activeSession)) continue; if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; @@ -118,6 +122,7 @@ export const pickOpenCodeSessionByDir = ({ if (isOpenCodeChildSession(session)) continue; const id = getOpenCodeSessionId(session); if (!id) continue; + if (isChildId(id)) continue; if (!markOpenCodeSessionUsed(usedSessionIds, id, pid)) continue; return session; } @@ -133,6 +138,7 @@ export const selectOpenCodeSessionForTui = ({ sessionsByDir, activeSessionIdsByDir, usedSessionIds, + childSessionIds, }: { pid: number; dir?: string; @@ -142,17 +148,19 @@ export const selectOpenCodeSessionForTui = ({ sessionsByDir: Map; activeSessionIdsByDir: Map; usedSessionIds: UsedSessionMap; + childSessionIds?: Set; }): { session?: T; sessionId?: string; source: "pid" | "cache" | "dir" | "none" } => { const sessionByPidId = getOpenCodeSessionId(sessionByPid); if ( sessionByPidId && !isOpenCodeChildSession(sessionByPid) && + !childSessionIds?.has(sessionByPidId) && markOpenCodeSessionUsed(usedSessionIds, sessionByPidId, pid) ) { return { session: sessionByPid, sessionId: sessionByPidId, source: "pid" }; } - if (cachedSessionId) { + if (cachedSessionId && !childSessionIds?.has(cachedSessionId)) { const cachedSession = sessionsById.get(cachedSessionId); if ( !isOpenCodeChildSession(cachedSession) && @@ -173,6 +181,7 @@ export const selectOpenCodeSessionForTui = ({ sessionsById, usedSessionIds, pid, + childSessionIds, }); if (byDir) { return { session: byDir, sessionId: getOpenCodeSessionId(byDir), source: "dir" }; diff --git a/src/scan.ts b/src/scan.ts index b490e9e..97a74ef 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -38,6 +38,7 @@ import { } from "./opencodeEvents.js"; import { getOpenCodeSessionId, + getOpenCodeSessionParentId, getOpenCodeSessionPid, markOpenCodeSessionUsed, selectOpenCodeSessionForTui, @@ -92,7 +93,7 @@ const OPENCODE_PREFETCH_COOLDOWN_MS = 30_000; // Track subagent observation times for hold mechanism const subagentSeen = new Map< string, - { lastAt?: number; observedAt?: number } + { lastRemoteAt?: number; observedAt?: number; lastSeenAt: number } >(); function getOpencodePrefetchKey(host: string, port: number): string { @@ -1015,6 +1016,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const opencodeSessionsById = new Map(); + const opencodeChildSessionIds = new Set(); // Track ALL recent sessions per directory for activity polling (not just the latest) const opencodeAllSessionsByDir = new Map(); const opencodeSessionTimestamp = (session: OpenCodeSession): number => { @@ -1049,6 +1051,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + for (const sessionId of subagentSeen.keys()) { + const session = opencodeSessionsById.get(sessionId); + if (!session) { + subagentSeen.delete(sessionId); + continue; + } + const parentId = getOpenCodeSessionParentId(session); + if (!parentId || !opencodeSessionsById.has(parentId)) { + subagentSeen.delete(sessionId); + } + } + } for (const sessions of opencodeAllSessionsByDir.values()) { sessions.sort((a, b) => opencodeSessionTimestamp(b) - opencodeSessionTimestamp(a)); } @@ -1707,6 +1725,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0 && - now - activityAt <= opencodeSubagentWindowMs; + now - remoteAt <= opencodeSubagentWindowMs; + const hasFallbackFresh = + typeof fallbackAt === "number" && + Number.isFinite(opencodeSubagentWindowMs) && + opencodeSubagentWindowMs > 0 && + now - fallbackAt <= opencodeSubagentWindowMs; + const hasEventActivity = hasRemoteFresh || hasFallbackFresh; + const effectiveAt = typeof remoteAt === "number" ? remoteAt : fallbackAt; + const activityAt = + typeof effectiveAt === "number" ? effectiveAt : inFlight ? now : 0; // Observation hold: track when we see activity timestamps increase - const entry = subagentSeen.get(sessionId); - if (!entry) { - // First sight: store lastAt, and set observedAt only if within remote window - const newEntry: { lastAt?: number; observedAt?: number } = { - lastAt: activityAt, - }; - if ( - typeof activityAt === "number" && - now - activityAt <= opencodeSubagentWindowMs - ) { - newEntry.observedAt = now; - } - subagentSeen.set(sessionId, newEntry); - } else if ( - typeof activityAt === "number" && - (typeof entry.lastAt !== "number" || activityAt > entry.lastAt) - ) { - // Timestamp increased: update and refresh observedAt - entry.lastAt = activityAt; - entry.observedAt = now; - } else if (inFlight) { - // Currently in flight: refresh observedAt - entry.observedAt = now; + const prev = subagentSeen.get(sessionId); + let observedAt = prev?.observedAt; + const remoteIncreased = + typeof remoteAt === "number" && remoteAt > (prev?.lastRemoteAt ?? -Infinity); + if (!prev) { + if (inFlight || hasEventActivity) observedAt = now; + } else { + if (inFlight || hasEventActivity || remoteIncreased) observedAt = now; } + subagentSeen.set(sessionId, { + lastRemoteAt: typeof remoteAt === "number" ? remoteAt : prev?.lastRemoteAt, + observedAt, + lastSeenAt: now, + }); - const observed = subagentSeen.get(sessionId); const hasRecentObserved = - typeof observed?.observedAt === "number" && - now - observed.observedAt <= opencodeSubagentHoldMs; + typeof observedAt === "number" && now - observedAt <= opencodeSubagentHoldMs; if (!inFlight && !hasEventActivity && !hasRecentObserved) { if (debugSubagents) { const ageMs = - typeof activityAt === "number" ? Math.max(0, now - activityAt) : undefined; + typeof effectiveAt === "number" ? Math.max(0, now - effectiveAt) : undefined; const obsAgeMs = - typeof observed?.observedAt === "number" - ? now - observed.observedAt - : undefined; + typeof observedAt === "number" ? now - observedAt : undefined; const remoteSkewMs = ageMs; console.log( `[consensus][subagent-drop] ${sessionId} ` + @@ -2110,10 +2131,11 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 3_600_000) { - subagentSeen.delete(id); - continue; - } - if (!entry.observedAt) { - if (typeof entry.lastAt === "number" && now - entry.lastAt > 3_600_000) { - subagentSeen.delete(id); - continue; - } - if (entry.lastAt === undefined) { - subagentSeen.delete(id); - } - } + } + + // Prune old subagent seen entries (1 hour retention) + for (const [id, entry] of subagentSeen) { + if (typeof entry.lastSeenAt !== "number" || now - entry.lastSeenAt > 3_600_000) { + subagentSeen.delete(id); } } From 9b383a95aa565a05fcbb12ccb7bb13cf00465aac Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:25:17 -0500 Subject: [PATCH 11/40] fix(codex): add default hold window to reduce flicker --- README.md | 2 +- docs/configuration.md | 4 ++-- src/scan.ts | 2 +- tests/unit/codexEventState.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9f6256a..800b179 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ consensus dev server running on http://127.0.0.1:8787 - `CONSENSUS_UI_PORT`: dev UI port for Vite when running `npm run dev` (default `5173`). - `CONSENSUS_DEBUG_OPENCODE`: set to `1` to log OpenCode server discovery. - `CONSENSUS_CODEX_EVENT_ACTIVE_MS`: Codex active window after last event in ms (default `30000`). -- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `0`). +- `CONSENSUS_CODEX_ACTIVE_HOLD_MS`: Codex hold window in ms (default `2000`, set to `0` to disable). - `CONSENSUS_CODEX_INFLIGHT_IDLE_MS`: Codex in-flight idle timeout in ms (default `30000`, set to `0` to disable). - `CONSENSUS_CODEX_CPU_SUSTAIN_MS`: sustained CPU window before Codex becomes active without logs (default `500`). - `CONSENSUS_CODEX_CPU_SPIKE`: Codex CPU spike threshold for immediate activation (default derived). diff --git a/docs/configuration.md b/docs/configuration.md index 1f12222..b46223d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -117,8 +117,8 @@ All configuration is via environment variables. - Default: `750` - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). - `CONSENSUS_CODEX_ACTIVE_HOLD_MS` - - Default: `0` - - Codex hold window after activity. + - Default: `2000` + - Codex hold window after activity. Set to `0` to disable (immediate transitions may flicker). - `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - Default: `750` - Codex in-flight grace window after the last in-flight signal (prevents brief idle flicker). diff --git a/src/scan.ts b/src/scan.ts index 97a74ef..22dce5e 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1271,7 +1271,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const codexHoldMs = resolveMs( process.env.CONSENSUS_CODEX_ACTIVE_HOLD_MS, - 0 + 2000 ); const codexEventIdleMs = resolveMs( process.env.CONSENSUS_CODEX_EVENT_IDLE_MS, diff --git a/tests/unit/codexEventState.test.ts b/tests/unit/codexEventState.test.ts index a9a3d42..e26533d 100644 --- a/tests/unit/codexEventState.test.ts +++ b/tests/unit/codexEventState.test.ts @@ -30,6 +30,32 @@ test("codex event state: hold after recent activity", () => { assert.equal(result.reason, "event_hold"); }); +test("codex event state: hold expires after in-flight ends", () => { + const now = Date.now(); + const holdMs = 2000; + const lastActivityAt = now; + const active = deriveCodexEventState({ + inFlight: false, + lastActivityAt, + hasError: false, + now: now + holdMs - 1, + holdMs, + idleMs: 20000, + }); + const idle = deriveCodexEventState({ + inFlight: false, + lastActivityAt, + hasError: false, + now: now + holdMs + 1, + holdMs, + idleMs: 20000, + }); + assert.equal(active.state, "active"); + assert.equal(active.reason, "event_hold"); + assert.equal(idle.state, "idle"); + assert.equal(idle.reason, "event_idle"); +}); + test("codex event state: idle after hold window", () => { const now = Date.now(); const result = deriveCodexEventState({ From 05107e6aa1937b1562a832c8dbbfd5895a8a43b8 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Sun, 1 Feb 2026 19:05:42 -0500 Subject: [PATCH 12/40] fix(codex): detect codex-cli processes --- src/codexCmd.ts | 30 ++++++++++++++++++++++++++++++ src/scan.ts | 28 +++------------------------- tests/unit/codexCmd.test.ts | 26 ++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 25 deletions(-) create mode 100644 src/codexCmd.ts create mode 100644 tests/unit/codexCmd.test.ts diff --git a/src/codexCmd.ts b/src/codexCmd.ts new file mode 100644 index 0000000..d90f21a --- /dev/null +++ b/src/codexCmd.ts @@ -0,0 +1,30 @@ +import path from "node:path"; + +const CODEX_BINARIES = new Set([ + "codex", + "codex.exe", + "codex-cli", + "codex-cli.exe", +]); + +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +export function isCodexBinary(value: string | undefined): boolean { + if (!value) return false; + const cleaned = stripQuotes(value); + const base = path.basename(cleaned).toLowerCase(); + return CODEX_BINARIES.has(base); +} + +export function hasCodexVendorPath(cmdLine: string): boolean { + return /[\\/]+codex[\\/]+vendor[\\/]+/i.test(cmdLine); +} + +export function hasCodexToken(cmdLine: string): boolean { + return ( + /(?:^|\s|[\\/])codex(?:-cli)?(\.exe)?(?:\s|$)/i.test(cmdLine) || + /[\\/]+codex(?:-cli)?(\.exe)?/i.test(cmdLine) + ); +} diff --git a/src/scan.ts b/src/scan.ts index 22dce5e..15a0c45 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -48,6 +48,7 @@ import { deriveOpenCodeState } from "./opencodeState.js"; import { shouldUseOpenCodeApiActivityAt } from "./opencodeApiActivity.js"; import { shouldIncludeOpenCodeProcess } from "./opencodeFilter.js"; import { summarizeClaudeCommand } from "./claudeCli.js"; +import { hasCodexToken, hasCodexVendorPath, isCodexBinary } from "./codexCmd.js"; import { deriveStateWithHold } from "./activity.js"; import { getClaudeActivityByCwd, getClaudeActivityBySession } from "./services/claudeEvents.js"; import { parseOpenCodeCommand, summarizeOpenCodeCommand } from "./opencodeCmd.js"; @@ -241,24 +242,6 @@ function stripQuotes(value: string): string { return value.replace(/^["']|["']$/g, ""); } -function isCodexBinary(value: string | undefined): boolean { - if (!value) return false; - const cleaned = stripQuotes(value); - const base = path.basename(cleaned).toLowerCase(); - return base === "codex" || base === "codex.exe"; -} - -function hasCodexVendorPath(cmdLine: string): boolean { - return /[\\/]+codex[\\/]+vendor[\\/]+/i.test(cmdLine); -} - -function hasCodexToken(cmdLine: string): boolean { - return ( - /(?:^|\s|[\\/])codex(\.exe)?(?:\s|$)/i.test(cmdLine) || - /[\\/]+codex(\.exe)?/i.test(cmdLine) - ); -} - function isCodexProcess(cmd: string | undefined, name: string | undefined, matchRe?: RegExp): boolean { if (!cmd && !name) return false; if (isOpenCodeProcess(cmd, name)) return false; @@ -310,12 +293,7 @@ function inferKind(cmd: string): AgentKind { const claudeInfo = summarizeClaudeCommand(cmd); if (claudeInfo) return claudeInfo.kind; if (cmd.includes(" exec")) return "exec"; - if ( - cmd.includes(" codex") || - cmd.startsWith("codex") || - cmd.startsWith("codex.exe") || - /[\\/]+codex(\.exe)?/i.test(cmd) - ) { + if (hasCodexToken(cmd)) { return "tui"; } return "unknown"; @@ -373,7 +351,7 @@ function parseDoingFromCmd(cmd: string): string | undefined { return "monitor"; } if (cmd.includes("app-server")) return "app-server"; - if (cmd.startsWith("codex") || cmd.startsWith("codex.exe")) return "codex"; + if (hasCodexToken(cmd)) return "codex"; return undefined; } diff --git a/tests/unit/codexCmd.test.ts b/tests/unit/codexCmd.test.ts new file mode 100644 index 0000000..ec0d1c8 --- /dev/null +++ b/tests/unit/codexCmd.test.ts @@ -0,0 +1,26 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { hasCodexToken, hasCodexVendorPath, isCodexBinary } from "../../src/codexCmd.ts"; + +test("codex command matching recognizes codex-cli", () => { + assert.equal(isCodexBinary("codex"), true); + assert.equal(isCodexBinary("codex.exe"), true); + assert.equal(isCodexBinary("codex-cli"), true); + assert.equal(isCodexBinary("codex-cli.exe"), true); + assert.equal(isCodexBinary("/usr/local/bin/codex-cli"), true); + assert.equal(isCodexBinary('"/usr/local/bin/codex-cli"'), true); + assert.equal(isCodexBinary("codexer"), false); +}); + +test("codex token matching recognizes cli invocation", () => { + assert.equal(hasCodexToken("codex-cli exec --foo"), true); + assert.equal(hasCodexToken("node /opt/bin/codex-cli exec"), true); + assert.equal(hasCodexToken("/opt/bin/codex-cli.exe"), true); + assert.equal(hasCodexToken("/opt/bin/codex.exe"), true); + assert.equal(hasCodexToken("mycodex"), false); +}); + +test("codex vendor path matching", () => { + assert.equal(hasCodexVendorPath("/Users/me/codex/vendor/bin"), true); + assert.equal(hasCodexVendorPath("/Users/me/.codex/bin"), false); +}); From 115124a798517a8bf8704f7073efc5539795b7a5 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:02:45 -0500 Subject: [PATCH 13/40] fix(activity): prevent false idle and dev proxy misroutes --- dev/dev.mjs | 20 ++++++++++---- public/vite.config.ts | 13 +++++++-- src/scan.ts | 29 +++++++++++++++----- tests/unit/codexScan.test.ts | 53 ++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 14 deletions(-) create mode 100644 tests/unit/codexScan.test.ts diff --git a/dev/dev.mjs b/dev/dev.mjs index 578ce1a..32aa873 100644 --- a/dev/dev.mjs +++ b/dev/dev.mjs @@ -121,7 +121,7 @@ const startServer = async () => { }); }); - if (result.status === "running") return; + if (result.status === "running") return port; if (result.status === "conflict") { process.stderr.write(`[dev] port ${port} in use, trying next\n`); continue; @@ -134,9 +134,10 @@ const startServer = async () => { process.stderr.write("[dev] failed to find an open port for server\n"); shutdown(1); + return null; }; -const startVite = async () => { +const startVite = async (serverPort) => { const maxAttempts = 6; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { @@ -148,7 +149,11 @@ const startVite = async () => { [vitePath, "--port", String(port), "--host", "127.0.0.1", "--strictPort"], { cwd: path.join(root, "public"), - env: { ...process.env, CONSENSUS_UI_PORT: String(port) }, + env: { + ...process.env, + CONSENSUS_UI_PORT: String(port), + CONSENSUS_PORT: String(serverPort), + }, stdio: ["ignore", "pipe", "pipe"], } ); @@ -197,8 +202,13 @@ const startVite = async () => { shutdown(1); }; -void startServer(); -void startVite(); +const main = async () => { + const serverPort = await startServer(); + if (!serverPort) return; + await startVite(serverPort); +}; + +void main(); const shutdown = (code = 0) => { for (const child of children) { diff --git a/public/vite.config.ts b/public/vite.config.ts index df5de40..f27bf58 100644 --- a/public/vite.config.ts +++ b/public/vite.config.ts @@ -2,6 +2,15 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import path from 'path'; +const parsePort = (value?: string) => { + const num = Number(value); + if (!Number.isFinite(num)) return null; + if (num <= 0 || num > 65535) return null; + return Math.floor(num); +}; + +const consensusPort = parsePort(process.env.CONSENSUS_PORT) ?? 8787; + export default defineConfig({ plugins: [react()], root: path.resolve(__dirname), @@ -19,10 +28,10 @@ export default defineConfig({ port: 5173, proxy: { '/ws': { - target: 'ws://localhost:8787', + target: `ws://localhost:${consensusPort}`, ws: true, }, - '/api': 'http://localhost:8787', + '/api': `http://localhost:${consensusPort}`, }, }, }); diff --git a/src/scan.ts b/src/scan.ts index 15a0c45..4156ca4 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -191,6 +191,22 @@ export function markSessionDirty(sessionPath: string): void { dirtySessionPaths.add(sessionPath); } +export interface CodexNotifyEndGateInput { + tailAllowsNotifyEnd: boolean; + notifyEndIsFresh: boolean; + tailEndAt?: number; + tailInFlight?: boolean; +} + +export function shouldApplyCodexNotifyEnd(input: CodexNotifyEndGateInput): boolean { + return ( + input.tailAllowsNotifyEnd && + input.notifyEndIsFresh && + !input.tailEndAt && + !input.tailInFlight + ); +} + function consumeDirtySessions(): Set { const dirty = new Set(dirtySessionPaths); dirtySessionPaths.clear(); @@ -1521,12 +1537,6 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise= tailActivityAtCandidate); - const notifyShouldEnd = tailAllowsNotifyEnd && notifyEndIsFresh && !tailEndAt; + const notifyShouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd, + notifyEndIsFresh, + tailEndAt, + tailInFlight, + }); if (notifyShouldEnd) { inFlight = false; } diff --git a/tests/unit/codexScan.test.ts b/tests/unit/codexScan.test.ts new file mode 100644 index 0000000..2b4178c --- /dev/null +++ b/tests/unit/codexScan.test.ts @@ -0,0 +1,53 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { shouldApplyCodexNotifyEnd } from "../../src/scan.ts"; + +test("notify end does not override tail in-flight", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: true, + tailEndAt: undefined, + tailInFlight: true, + }); + assert.equal(shouldEnd, false); +}); + +test("notify end applies when tail is idle", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: true, + tailEndAt: undefined, + tailInFlight: false, + }); + assert.equal(shouldEnd, true); +}); + +test("notify end does not apply when tail has explicit end", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: true, + tailEndAt: Date.now(), + tailInFlight: false, + }); + assert.equal(shouldEnd, false); +}); + +test("notify end does not apply when tail disallows notify end", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: false, + notifyEndIsFresh: true, + tailEndAt: undefined, + tailInFlight: false, + }); + assert.equal(shouldEnd, false); +}); + +test("notify end does not apply when notify end is not fresh", () => { + const shouldEnd = shouldApplyCodexNotifyEnd({ + tailAllowsNotifyEnd: true, + notifyEndIsFresh: false, + tailEndAt: undefined, + tailInFlight: false, + }); + assert.equal(shouldEnd, false); +}); From d3775daabfed47cb6e91f8bdaa3cb78fd0471ea3 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 02:50:01 -0500 Subject: [PATCH 14/40] fix(codex): defer end during tool calls --- src/codexLogs.ts | 32 ++ src/scan.ts | 8 + tests/integration/codexLiveDebugger.test.ts | 235 ++++++++++ .../integration/codexLiveToolTimeout.test.ts | 284 ++++++++++++ tests/integration/codexLogs.test.ts | 121 +++++ tests/integration/liveCdpClient.ts | 131 ++++++ tests/integration/liveCodexHarness.ts | 423 ++++++++++++++++++ 7 files changed, 1234 insertions(+) create mode 100644 tests/integration/codexLiveDebugger.test.ts create mode 100644 tests/integration/codexLiveToolTimeout.test.ts create mode 100644 tests/integration/liveCdpClient.ts create mode 100644 tests/integration/liveCodexHarness.ts diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 99e353d..17c1ac7 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -735,6 +735,10 @@ export async function updateTail( state.lastToolSignalAt = Math.max(state.lastToolSignalAt || 0, ts); }; const finalizeEnd = (ts: number, { clearReview = false }: { clearReview?: boolean } = {}) => { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_FINALIZE_END"; + debugger; + } if (clearReview) state.reviewMode = false; state.turnOpen = false; state.inFlight = false; @@ -746,9 +750,17 @@ export async function updateTail( }; const deferEnd = (ts: number) => { state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_PENDING_END"; + debugger; + } }; const expireInFlight = () => { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_EXPIRE_CHECK"; + debugger; + } if (!state.inFlight) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; @@ -781,6 +793,10 @@ export async function updateTail( typeof lastSignal === "number" && nowMs - lastSignal >= inflightTimeoutMs ) { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_EXPIRE_TIMEOUT"; + debugger; + } state.inFlight = false; state.inFlightStart = false; state.turnOpen = false; @@ -1010,6 +1026,10 @@ export async function updateTail( state.inFlightStart = true; markInFlightSignal(); } + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_WORK_START"; + debugger; + } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (isResponseDelta) { @@ -1089,6 +1109,10 @@ export async function updateTail( if (isToolCall) { if (!state.openCallIds) state.openCallIds = new Set(); if (callId) state.openCallIds.add(callId); + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_TOOL_START"; + debugger; + } recordToolSignal(ts); if (canSignal) { clearEndMarkers(); @@ -1111,6 +1135,10 @@ export async function updateTail( if (state.openCallIds && callId) { state.openCallIds.delete(callId); } + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_TOOL_END"; + debugger; + } recordToolSignal(ts); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { @@ -1218,6 +1246,10 @@ export async function updateTail( if (kind === "tool") state.lastTool = entry; if (kind === "prompt") state.lastPrompt = entry; if (kind && workKinds.has(kind) && !isItemStarted) { + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_WORK_END"; + debugger; + } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } if (kind === "message") { diff --git a/src/scan.ts b/src/scan.ts index 4156ca4..90fc30a 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1538,6 +1538,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise>; +type ScriptIndex = Map; + +function markerForLocation( + markers: MarkerIndex, + scriptId: string, + lineNumber: number +): string | undefined { + const scriptMarkers = markers.get(scriptId); + return scriptMarkers?.get(lineNumber); +} + +async function markerForFrame( + client: CdpClient, + markers: MarkerIndex, + scriptId: string, + lineNumber: number +): Promise { + let scriptMarkers = markers.get(scriptId); + if (!scriptMarkers) { + const source = await client.getScriptSource(scriptId); + scriptMarkers = buildMarkerIndex(source); + if (scriptMarkers.size > 0) { + markers.set(scriptId, scriptMarkers); + } + } + return scriptMarkers?.get(lineNumber); +} + +async function waitForScripts( + scripts: ScriptIndex, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (scripts.size > 0) return; + await delay(200); + } + throw new Error("Timed out waiting for scripts to parse"); +} + +async function setMarkerBreakpoints( + client: CdpClient, + scripts: ScriptIndex, + markerIndex: MarkerIndex, + markerNames: Set +): Promise { + for (const [scriptId, url] of scripts.entries()) { + if (!url.includes("codexLogs.ts") && !url.includes("scan.ts")) continue; + const source = await client.getScriptSource(scriptId); + const markers = buildMarkerIndex(source); + if (markers.size === 0) continue; + markerIndex.set(scriptId, markers); + for (const [lineNumber, marker] of markers.entries()) { + if (!markerNames.has(marker)) continue; + await client.setBreakpoint(scriptId, lineNumber, 0); + } + } +} + +liveTest( + "debugger captures tool start/end hooks", + { timeout: 240_000 }, + async (t) => { + if (!(await isTmuxAvailable())) { + t.skip("tmux not available"); + return; + } + + const codexBin = process.env.CODEX_BIN; + if (!(await isCodexAvailable(codexBin))) { + t.skip("codex binary not available"); + return; + } + + const testTag = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const project = await createTempProject(); + const codexHome = await createCodexHome(); + const port = await getFreePort(); + const server = await startConsensusServer({ + port, + codexHome: codexHome.home, + inspector: true, + debugActivity: true, + testHooks: true, + }); + assert.ok(server.inspectorPort, "Inspector port missing"); + const tmux = await startTmuxSession(); + const debugEnv = process.env.CODEX_DEBUG_ENV ?? "RUST_LOG=debug"; + + const client = await CdpClient.connect(server.inspectorPort!); + const markerIndex: MarkerIndex = new Map(); + const scripts: ScriptIndex = new Map(); + + client.on("Debugger.scriptParsed", (event: { scriptId: string; url?: string }) => { + if (!event.url) return; + scripts.set(event.scriptId, event.url); + if (!event.url.includes("codexLogs.ts") && !event.url.includes("scan.ts")) return; + void (async () => { + const source = await client.getScriptSource(event.scriptId); + const markers = buildMarkerIndex(source); + if (markers.size > 0) { + markerIndex.set(event.scriptId, markers); + } + })(); + }); + + await client.enable(); + + try { + const startAt = Date.now(); + await startCodexInteractiveInTmux({ + session: tmux, + projectDir: project.root, + codexHome: codexHome.home, + codexRoot: codexHome.root, + codexBin, + debugEnv, + }); + + await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + await tmux.sendText(`Run bash: sleep 5. Tag: ${testTag}`); + + const paneOutput = await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + if (/unauthorized|login|api key|missing bearer/i.test(paneOutput)) { + t.skip("codex auth required in this environment"); + return; + } + + try { + await waitForSessionFile(codexHome.home, 30_000, startAt); + await waitForCodexPid({ startAfterMs: startAt, timeoutMs: 10_000 }); + } catch (err) { + const excerpt = paneOutput.slice(-500); + t.skip(`codex did not start session or pid: ${String(err)}\n${excerpt}`); + return; + } + await waitForScripts(scripts, 10_000); + await setMarkerBreakpoints( + client, + scripts, + markerIndex, + new Set([ + "TEST_HOOK_TOOL_START", + "TEST_HOOK_TOOL_END", + "TEST_HOOK_WORK_START", + "TEST_HOOK_WORK_END", + ]) + ); + + const expectedStart = new Set(["TEST_HOOK_TOOL_START", "TEST_HOOK_WORK_START"]); + const expectedEnd = new Set(["TEST_HOOK_TOOL_END", "TEST_HOOK_WORK_END"]); + const observations: Array<{ marker: string; openCallCount: number; inFlight: boolean }> = []; + const start = Date.now(); + + let sawStart = false; + let sawEnd = false; + while ((!sawStart || !sawEnd) && Date.now() - start < 120_000) { + const paused = await client.waitForPaused(60_000); + if (!paused) break; + const frame = paused.callFrames[0]; + const marker = await markerForFrame( + client, + markerIndex, + frame.location.scriptId, + frame.location.lineNumber + ); + if (marker && (marker.includes("_START") || marker.includes("_END"))) { + const evalResult = await client.evaluateOnCallFrame( + frame.callFrameId, + "({ inFlight: state.inFlight, openCallCount: state.openCallIds ? state.openCallIds.size : 0 })" + ); + const value = evalResult?.result?.value; + observations.push({ + marker, + openCallCount: value?.openCallCount ?? 0, + inFlight: value?.inFlight ?? false, + }); + if (expectedStart.has(marker)) sawStart = true; + if (expectedEnd.has(marker)) sawEnd = true; + } + await client.resume(); + } + + assert.ok(sawStart, "Did not observe work start marker"); + assert.ok(sawEnd, "Did not observe work end marker"); + + const startObs = observations.find((obs) => obs.marker.includes("_START")); + const endObs = observations.find((obs) => obs.marker.includes("_END")); + assert.ok(startObs, "Missing tool start observation"); + assert.ok(endObs, "Missing tool end observation"); + assert.ok(startObs.inFlight, "inFlight should be true at tool start"); + assert.ok(startObs.openCallCount >= 1, "openCallCount should be >=1 at tool start"); + assert.ok( + endObs.openCallCount <= startObs.openCallCount, + "openCallCount should not increase at tool end" + ); + } finally { + client.close(); + await tmux.kill(); + await server.stop(); + await project.cleanup(); + await codexHome.cleanup(); + } + } +); diff --git a/tests/integration/codexLiveToolTimeout.test.ts b/tests/integration/codexLiveToolTimeout.test.ts new file mode 100644 index 0000000..6773a44 --- /dev/null +++ b/tests/integration/codexLiveToolTimeout.test.ts @@ -0,0 +1,284 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + createCodexHome, + createTempProject, + delay, + fetchSnapshot, + getFreePort, + isCodexAvailable, + isTmuxAvailable, + startCodexInteractiveInTmux, + startConsensusServer, + startTmuxSession, + waitForCodexPid, + waitForPaneOutput, + waitForSessionFile, +} from "./liveCodexHarness.ts"; +import type { SnapshotPayload, AgentSnapshot } from "../../src/types.ts"; + +const liveEnabled = process.env.RUN_LIVE_CODEX === "1"; +const liveTest = liveEnabled ? test : test.skip; + +async function waitForAgent( + port: number, + sessionPath: string, + cwd: string, + pid: number | undefined, + tag: string, + timeoutMs: number +): Promise { + const sessionBase = sessionPath ? sessionPath.split("/").pop() : undefined; + const cwdBase = cwd ? cwd.split("/").pop() : undefined; + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const snapshot = (await fetchSnapshot(port)) as SnapshotPayload; + const agent = snapshot.agents.find((item) => { + if (pid && item.pid === pid) return true; + if (sessionBase && item.sessionPath?.includes(sessionBase)) return true; + if (item.sessionPath && sessionPath && item.sessionPath === sessionPath) return true; + if (item.cwd && cwd && item.cwd === cwd) return true; + if (cwdBase && item.cwd?.includes(cwdBase)) return true; + if (item.cmd?.includes(tag) || item.cmdShort?.includes(tag)) return true; + return false; + }); + if (agent) return agent; + await delay(200); + } + const snapshot = (await fetchSnapshot(port)) as SnapshotPayload; + const summary = snapshot.agents.map((item) => ({ + id: item.id, + cmd: item.cmdShort, + cwd: item.cwd, + sessionPath: item.sessionPath, + state: item.state, + })); + throw new Error(`Timed out waiting for codex agent in snapshot. Agents: ${JSON.stringify(summary)}`); +} + +async function collectStateHistory( + port: number, + agentId: string, + durationMs: number, + intervalMs: number +): Promise< + Array<{ ts: number; state: string; reason?: string; lastTool?: string; lastCommand?: string }> +> { + const history: Array<{ + ts: number; + state: string; + reason?: string; + lastTool?: string; + lastCommand?: string; + }> = []; + const start = Date.now(); + while (Date.now() - start < durationMs) { + const snapshot = (await fetchSnapshot(port)) as SnapshotPayload; + const agent = snapshot.agents.find((item) => item.id === agentId); + if (agent) { + history.push({ + ts: Date.now(), + state: agent.state, + reason: agent.activityReason, + lastTool: agent.summary?.lastTool, + lastCommand: agent.summary?.lastCommand, + }); + } + await delay(intervalMs); + } + return history; +} + +liveTest( + "live codex keeps active during long tool execution", + { timeout: 240_000 }, + async (t) => { + if (!(await isTmuxAvailable())) { + t.skip("tmux not available"); + return; + } + + const codexBin = process.env.CODEX_BIN; + if (!(await isCodexAvailable(codexBin))) { + t.skip("codex binary not available"); + return; + } + + const testTag = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const project = await createTempProject(); + const codexHome = await createCodexHome(); + const port = await getFreePort(); + const server = await startConsensusServer({ + port, + codexHome: codexHome.home, + debugActivity: true, + }); + const tmux = await startTmuxSession(); + + const debugEnv = process.env.CODEX_DEBUG_ENV ?? "RUST_LOG=debug"; + + try { + const startAt = Date.now(); + await startCodexInteractiveInTmux({ + session: tmux, + projectDir: project.root, + codexHome: codexHome.home, + codexRoot: codexHome.root, + codexBin, + debugEnv, + }); + + await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + await tmux.sendText(`Run bash: sleep 35. Tag: ${testTag}`); + + const paneOutput = await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + if (/unauthorized|login|api key|missing bearer/i.test(paneOutput)) { + t.skip("codex auth required in this environment"); + return; + } + + let sessionPath = ""; + let pid: number | undefined; + try { + sessionPath = await waitForSessionFile(codexHome.home, 30_000, startAt); + pid = await waitForCodexPid({ startAfterMs: startAt, timeoutMs: 10_000 }); + } catch (err) { + const excerpt = paneOutput.slice(-500); + t.skip(`codex did not start session or pid: ${String(err)}\n${excerpt}`); + return; + } + const agent = await waitForAgent(port, sessionPath, project.root, pid, testTag, 30_000); + + const history = await collectStateHistory(port, agent.id, 40_000, 1000); + + const firstActive = history.find((entry) => entry.state === "active"); + assert.ok(firstActive, "agent never became active"); + + const windowStart = firstActive.ts; + const windowEnd = windowStart + 30_000; + const idleDuring = history.filter( + (entry) => + entry.ts >= windowStart && entry.ts <= windowEnd && entry.state === "idle" + ); + assert.equal( + idleDuring.length, + 0, + `idle transitions detected during long tool execution: ${idleDuring.length}` + ); + + const sawTool = history.some((entry) => entry.lastTool?.includes("tool:")); + const sawCommand = history.some((entry) => entry.lastCommand?.includes("cmd:")); + assert.ok( + sawTool || sawCommand, + "no tool or command activity detected in snapshot summaries" + ); + } finally { + await tmux.kill(); + await server.stop(); + await project.cleanup(); + await codexHome.cleanup(); + } + } +); + +liveTest( + "live codex avoids idle gaps during sequential tools", + { timeout: 200_000 }, + async (t) => { + if (!(await isTmuxAvailable())) { + t.skip("tmux not available"); + return; + } + + const codexBin = process.env.CODEX_BIN; + if (!(await isCodexAvailable(codexBin))) { + t.skip("codex binary not available"); + return; + } + + const testTag = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const project = await createTempProject(); + const codexHome = await createCodexHome(); + const port = await getFreePort(); + const server = await startConsensusServer({ + port, + codexHome: codexHome.home, + debugActivity: true, + }); + const tmux = await startTmuxSession(); + const debugEnv = process.env.CODEX_DEBUG_ENV ?? "RUST_LOG=debug"; + + try { + const startAt = Date.now(); + await startCodexInteractiveInTmux({ + session: tmux, + projectDir: project.root, + codexHome: codexHome.home, + codexRoot: codexHome.root, + codexBin, + debugEnv, + }); + + await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + await tmux.sendText( + `Run bash: sleep 2; then run bash: sleep 2; then run bash: sleep 2. Tag: ${testTag}` + ); + + const paneOutput = await waitForPaneOutput( + tmux, + (output) => output.trim().length > 0, + 10_000 + ); + if (/unauthorized|login|api key|missing bearer/i.test(paneOutput)) { + t.skip("codex auth required in this environment"); + return; + } + + let sessionPath = ""; + let pid: number | undefined; + try { + sessionPath = await waitForSessionFile(codexHome.home, 30_000, startAt); + pid = await waitForCodexPid({ startAfterMs: startAt, timeoutMs: 10_000 }); + } catch (err) { + const excerpt = paneOutput.slice(-500); + t.skip(`codex did not start session or pid: ${String(err)}\n${excerpt}`); + return; + } + const agent = await waitForAgent(port, sessionPath, project.root, pid, testTag, 30_000); + + const history = await collectStateHistory(port, agent.id, 15_000, 500); + + const firstActive = history.find((entry) => entry.state === "active"); + assert.ok(firstActive, "agent never became active"); + + const windowStart = firstActive.ts; + const windowEnd = windowStart + 6_000; + const idleDuring = history.filter( + (entry) => + entry.ts >= windowStart && entry.ts <= windowEnd && entry.state === "idle" + ); + assert.equal( + idleDuring.length, + 0, + `idle transitions detected during sequential tools: ${idleDuring.length}` + ); + } finally { + await tmux.kill(); + await server.stop(); + await project.cleanup(); + await codexHome.cleanup(); + } + } +); diff --git a/tests/integration/codexLogs.test.ts b/tests/integration/codexLogs.test.ts index b4d1948..524e66d 100644 --- a/tests/integration/codexLogs.test.ts +++ b/tests/integration/codexLogs.test.ts @@ -962,6 +962,127 @@ test("open tool call keeps in-flight without new events", async () => { await fs.rm(dir, { recursive: true, force: true }); }); +test("tool call without call_id times out without open calls", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const start = { + type: "response_item", + ts: 1, + payload: { + type: "function_call", + arguments: "{}", + }, + }; + await fs.writeFile(file, `${JSON.stringify(start)}\n`); + + Date.now = () => 1_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.equal(stateStart.openCallIds?.size ?? 0, 0); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 5_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("pending end waits for tool output to finish", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { + type: "response_item", + ts: 1, + payload: { type: "function_call", name: "tool", call_id: "call_1" }, + }, + { type: "response.completed", ts: 2 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.ok(stateStart.pendingEndAt); + assert.equal(stateStart.inFlight, true); + + const output = { + type: "response_item", + ts: 3, + payload: { type: "function_call_output", call_id: "call_1" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + Date.now = () => 3_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + assert.equal(stateLater.pendingEndAt, undefined); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + +test("tool output without call_id does not retain open call", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { + type: "response_item", + ts: 1, + payload: { type: "function_call", name: "tool", call_id: "call_1" }, + }, + { type: "response.completed", ts: 2 }, + { + type: "response_item", + ts: 3, + payload: { type: "function_call_output" }, + }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 3_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.equal(stateStart.openCallIds?.size ?? 0, 1); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + Date.now = () => 10_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + assert.equal(stateLater.openCallIds?.size ?? 0, 0); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + test("keeps in-flight codex state within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); diff --git a/tests/integration/liveCdpClient.ts b/tests/integration/liveCdpClient.ts new file mode 100644 index 0000000..c3865a6 --- /dev/null +++ b/tests/integration/liveCdpClient.ts @@ -0,0 +1,131 @@ +import { EventEmitter } from "node:events"; +import WebSocket from "ws"; + +type Pending = { + resolve: (value: any) => void; + reject: (err: Error) => void; +}; + +export type PausedEvent = { + callFrames: Array<{ + callFrameId: string; + location: { scriptId: string; lineNumber: number; columnNumber: number }; + }>; +}; + +export class CdpClient extends EventEmitter { + private socket: WebSocket; + private idCounter = 1; + private pending = new Map(); + + private constructor(socket: WebSocket) { + super(); + this.socket = socket; + socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + if (message.id) { + const entry = this.pending.get(message.id); + if (!entry) return; + this.pending.delete(message.id); + if (message.error) { + entry.reject(new Error(message.error.message || "CDP error")); + } else { + entry.resolve(message.result); + } + return; + } + if (message.method) { + this.emit(message.method, message.params); + } + }); + } + + static async connect(inspectorPort: number): Promise { + const res = await fetch(`http://127.0.0.1:${inspectorPort}/json/list`); + if (!res.ok) { + throw new Error(`Inspector list failed: ${res.status}`); + } + const targets = (await res.json()) as Array<{ webSocketDebuggerUrl?: string }>; + const wsUrl = targets.find((target) => target.webSocketDebuggerUrl)?.webSocketDebuggerUrl; + if (!wsUrl) { + throw new Error("No webSocketDebuggerUrl found for inspector"); + } + const socket = new WebSocket(wsUrl); + await new Promise((resolve, reject) => { + socket.once("open", () => resolve()); + socket.once("error", (err) => reject(err)); + }); + return new CdpClient(socket); + } + + async send(method: string, params?: Record): Promise { + const id = this.idCounter++; + const payload = { id, method, params }; + const result = new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + }); + this.socket.send(JSON.stringify(payload)); + return result; + } + + async enable(): Promise { + await this.send("Runtime.enable"); + await this.send("Debugger.enable"); + } + + async waitForPaused(timeoutMs: number): Promise { + return await new Promise((resolve) => { + const timer = setTimeout(() => { + this.off("Debugger.paused", onPaused); + resolve(null); + }, timeoutMs); + const onPaused = (event: PausedEvent) => { + clearTimeout(timer); + this.off("Debugger.paused", onPaused); + resolve(event); + }; + this.on("Debugger.paused", onPaused); + }); + } + + async resume(): Promise { + await this.send("Debugger.resume"); + } + + async evaluateOnCallFrame(callFrameId: string, expression: string): Promise { + return await this.send("Debugger.evaluateOnCallFrame", { + callFrameId, + expression, + returnByValue: true, + }); + } + + async getScriptSource(scriptId: string): Promise { + const result = await this.send("Debugger.getScriptSource", { scriptId }); + return result?.scriptSource ?? ""; + } + + async setBreakpoint(scriptId: string, lineNumber: number, columnNumber = 0): Promise { + await this.send("Debugger.setBreakpoint", { + location: { scriptId, lineNumber, columnNumber }, + }); + } + + close(): void { + this.socket.close(); + } +} + +export function buildMarkerIndex( + source: string +): Map { + const markers = new Map(); + const lines = source.split("\n"); + for (let i = 0; i < lines.length; i += 1) { + const match = lines[i].match(/TEST_HOOK_[A-Z0-9_]+/); + if (match) { + markers.set(i, match[0]); + } + } + return markers; +} diff --git a/tests/integration/liveCodexHarness.ts b/tests/integration/liveCodexHarness.ts new file mode 100644 index 0000000..0596eb3 --- /dev/null +++ b/tests/integration/liveCodexHarness.ts @@ -0,0 +1,423 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import net from "node:net"; +import { spawn, execFile } from "node:child_process"; +import { once } from "node:events"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; + +const execFileAsync = promisify(execFile); +const currentFile = fileURLToPath(import.meta.url); +const currentDir = path.dirname(currentFile); +const repoRoot = path.resolve(currentDir, "..", ".."); + +export type ConsensusServer = { + port: number; + inspectorPort?: number; + output: string; + stop: () => Promise; +}; + +export type TmuxSession = { + socketPath: string; + sessionId: string; + target: string; + sendText: (text: string) => Promise; + capture: () => Promise; + panePid: () => Promise; + kill: () => Promise; +}; + +export async function isTmuxAvailable(): Promise { + try { + await execFileAsync("tmux", ["-V"]); + return true; + } catch { + return false; + } +} + +export async function isCodexAvailable(codexBin = "codex"): Promise { + try { + await execFileAsync(codexBin, ["--version"]); + return true; + } catch { + return false; + } +} + +export async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to acquire free port")); + return; + } + const { port } = address; + server.close(() => resolve(port)); + }); + }); +} + +export async function createTempProject(): Promise<{ root: string; cleanup: () => Promise }> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-live-project-")); + const srcDir = path.join(root, "src"); + await fs.mkdir(srcDir, { recursive: true }); + await fs.writeFile(path.join(root, "a.config.ts"), "export const A = 1;\n"); + await fs.writeFile(path.join(root, "b.config.ts"), "export const B = 2;\n"); + await fs.writeFile(path.join(root, "c.config.ts"), "export const C = 3;\n"); + await fs.writeFile(path.join(srcDir, "alpha.ts"), "export const alpha = 'alpha';\n"); + await fs.writeFile(path.join(srcDir, "beta.ts"), "export const beta = 'beta';\n"); + await fs.writeFile(path.join(srcDir, "gamma.ts"), "export const gamma = 'gamma';\n"); + await fs.writeFile(path.join(root, "README.md"), "Deterministic test project.\n"); + return { + root, + cleanup: async () => fs.rm(root, { recursive: true, force: true }), + }; +} + +export async function createCodexHome(options?: { + useRealHome?: boolean; +}): Promise<{ + home: string; + root: string; + cleanup: () => Promise; +}> { + const useRealHome = + options?.useRealHome ?? + (process.env.RUN_LIVE_CODEX === "1" && process.env.CODEX_TEST_USE_REAL_HOME !== "0"); + if (useRealHome) { + const root = os.homedir(); + const home = process.env.CODEX_HOME || path.join(root, ".codex"); + await fs.mkdir(path.join(home, "sessions"), { recursive: true }); + await fs.mkdir(path.join(home, "consensus"), { recursive: true }); + return { + home, + root, + cleanup: async () => {}, + }; + } + const root = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-codex-home-")); + const home = path.join(root, ".codex"); + await fs.mkdir(path.join(home, "sessions"), { recursive: true }); + await fs.mkdir(path.join(home, "consensus"), { recursive: true }); + return { + home, + root, + cleanup: async () => fs.rm(root, { recursive: true, force: true }), + }; +} + +function shellQuote(value: string): string { + return JSON.stringify(value); +} + +export async function startConsensusServer(options: { + port: number; + codexHome: string; + inspector?: boolean; + debugActivity?: boolean; + testHooks?: boolean; + processMatch?: string; + timeoutMs?: number; +}): Promise { + const args = [] as string[]; + if (options.inspector) { + args.push("--inspect=0"); + } + args.push("--import", "tsx", path.join(repoRoot, "src", "server.ts")); + + const child = spawn(process.execPath, args, { + cwd: repoRoot, + env: { + ...process.env, + CONSENSUS_PORT: String(options.port), + CONSENSUS_HOST: "127.0.0.1", + CONSENSUS_CODEX_HOME: options.codexHome, + CONSENSUS_DEBUG_ACTIVITY: options.debugActivity ? "1" : "0", + CODEX_TEST_HOOKS: options.testHooks ? "1" : "0", + CONSENSUS_PROCESS_MATCH: options.processMatch, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let output = ""; + const onOutput = (chunk: Buffer) => { + output += chunk.toString(); + }; + child.stdout?.on("data", onOutput); + child.stderr?.on("data", onOutput); + + let inspectorPort: number | undefined; + if (options.inspector && child.stderr) { + inspectorPort = await waitForInspectorPort(child.stderr, 5000); + } + + try { + await waitForHttpOk( + `http://127.0.0.1:${options.port}/health`, + options.timeoutMs ?? 15000, + child + ); + } catch (err) { + const trimmed = output.trim(); + const suffix = trimmed ? `\nServer output:\n${trimmed}` : ""; + throw new Error(`${(err as Error).message}${suffix}`); + } + + const stop = async () => { + if (child.killed) return; + child.kill("SIGTERM"); + const exited = await waitForExit(child, 5000); + if (!exited) { + child.kill("SIGKILL"); + await waitForExit(child, 2000); + } + }; + + return { port: options.port, inspectorPort, output, stop }; +} + +async function waitForExit(child: ReturnType, timeoutMs: number): Promise { + try { + await Promise.race([once(child, "exit"), delay(timeoutMs)]); + return true; + } catch { + return false; + } +} + +async function waitForInspectorPort( + stream: NodeJS.ReadableStream, + timeoutMs: number +): Promise { + const start = Date.now(); + let buffer = ""; + return await new Promise((resolve, reject) => { + const onData = (chunk: Buffer) => { + buffer += chunk.toString(); + const match = buffer.match(/Debugger listening on ws:\/\/[^\s:]+:(\d+)\//); + if (match) { + cleanup(); + resolve(Number(match[1])); + } + }; + const onTimeout = () => { + cleanup(); + reject(new Error("Timed out waiting for inspector port")); + }; + const cleanup = () => { + stream.off("data", onData); + clearTimeout(timer); + }; + stream.on("data", onData); + const remaining = Math.max(0, timeoutMs - (Date.now() - start)); + const timer = setTimeout(onTimeout, remaining); + }); +} + +async function waitForHttpOk( + url: string, + timeoutMs: number, + child: ReturnType +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (child.exitCode !== null) { + throw new Error(`Server process exited with code ${child.exitCode}`); + } + try { + const res = await fetch(url); + if (res.ok) return; + } catch { + // ignore + } + await delay(200); + } + throw new Error(`Timed out waiting for ${url}`); +} + +export async function startTmuxSession(): Promise { + const sessionId = `consensus-test-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; + const socketPath = path.join(os.tmpdir(), `${sessionId}.sock`); + await execFileAsync("tmux", [ + "-f", + "/dev/null", + "-S", + socketPath, + "new-session", + "-d", + "-s", + sessionId, + "-n", + "main", + "-x", + "120", + "-y", + "40", + ]); + const target = `${sessionId}:main.0`; + const sendText = async (text: string) => { + await execFileAsync("tmux", ["-S", socketPath, "send-keys", "-t", target, "-l", text]); + await execFileAsync("tmux", ["-S", socketPath, "send-keys", "-t", target, "C-m"]); + }; + const capture = async () => { + const { stdout } = await execFileAsync("tmux", ["-S", socketPath, "capture-pane", "-p", "-t", target]); + return stdout.toString(); + }; + const panePid = async () => { + const { stdout } = await execFileAsync("tmux", [ + "-S", + socketPath, + "display-message", + "-p", + "-t", + target, + "#{pane_pid}", + ]); + const pid = Number(stdout.toString().trim()); + if (!Number.isFinite(pid)) { + throw new Error("Failed to resolve tmux pane pid"); + } + return pid; + }; + const kill = async () => { + try { + await execFileAsync("tmux", ["-S", socketPath, "kill-server"]); + } catch { + // ignore + } + }; + return { socketPath, sessionId, target, sendText, capture, panePid, kill }; +} + +export async function waitForPaneOutput( + session: TmuxSession, + predicate: (output: string) => boolean, + timeoutMs: number +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const output = await session.capture(); + if (predicate(output)) return output; + await delay(200); + } + const output = await session.capture(); + throw new Error("Timed out waiting for tmux output"); +} + +function parsePsStart(value: string): number | undefined { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? undefined : parsed; +} + +export async function waitForCodexPid(options: { + startAfterMs: number; + timeoutMs: number; +}): Promise { + const start = Date.now(); + while (Date.now() - start < options.timeoutMs) { + const { stdout } = await execFileAsync("ps", ["-ax", "-o", "pid=,lstart=,command="]); + const lines = stdout.toString().split("\n"); + let best: { pid: number; startedAt: number } | null = null; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parts = trimmed.split(/\s+/); + if (parts.length < 7) continue; + const pid = Number(parts[0]); + const startStr = parts.slice(1, 6).join(" "); + const startedAt = parsePsStart(startStr); + const cmd = parts.slice(6).join(" "); + if (!cmd.includes("codex")) continue; + if (!startedAt || startedAt < options.startAfterMs - 2000) continue; + if (!best || startedAt > best.startedAt) { + best = { pid, startedAt }; + } + } + if (best) return best.pid; + await delay(200); + } + throw new Error("Timed out waiting for codex pid"); +} + +export async function startCodexInteractiveInTmux(options: { + session: TmuxSession; + projectDir: string; + codexHome: string; + codexRoot: string; + codexBin?: string; + debugEnv?: string; +}): Promise { + const codexBin = options.codexBin || "codex"; + const debugEnv = options.debugEnv ?? "RUST_LOG=debug"; + const cmd = + `env HOME=${shellQuote(options.codexRoot)} ` + + `CODEX_HOME=${shellQuote(options.codexHome)} ` + + `${debugEnv} ${shellQuote(codexBin)} ` + + "--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check " + + `-C ${shellQuote(options.projectDir)}`; + await options.session.sendText(cmd); +} + +async function collectSessionFiles(dir: string): Promise> { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const files: Array<{ path: string; mtimeMs: number }> = []; + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await collectSessionFiles(full))); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + const stat = await fs.stat(full); + files.push({ path: full, mtimeMs: stat.mtimeMs }); + } + return files; +} + +export async function waitForSessionFile( + codexHome: string, + timeoutMs: number, + afterMs?: number +): Promise { + const sessionsDir = path.join(codexHome, "sessions"); + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const fullPaths = await collectSessionFiles(sessionsDir); + if (fullPaths.length > 0) { + const filtered = + typeof afterMs === "number" + ? fullPaths.filter((entry) => entry.mtimeMs >= afterMs) + : fullPaths; + if (filtered.length > 0) { + filtered.sort((a, b) => b.mtimeMs - a.mtimeMs); + return filtered[0].path; + } + } + } catch { + // ignore + } + await delay(200); + } + throw new Error(`Timed out waiting for codex session file in ${sessionsDir}`); +} + +export async function fetchSnapshot(port: number): Promise { + const res = await fetch(`http://127.0.0.1:${port}/api/snapshot`); + if (!res.ok) { + throw new Error(`Snapshot request failed: ${res.status}`); + } + return res.json(); +} + +export async function delay(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} From 7d908ae002b811d6badc6533b19206a06b933e60 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:04:57 -0500 Subject: [PATCH 15/40] fix(codex): track item work as open calls --- src/codexLogs.ts | 40 ++++++++++++++++++++------ src/scan.ts | 2 -- tests/integration/codexLogs.test.ts | 44 +++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 17c1ac7..3728e5a 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -737,7 +737,6 @@ export async function updateTail( const finalizeEnd = (ts: number, { clearReview = false }: { clearReview?: boolean } = {}) => { if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_FINALIZE_END"; - debugger; } if (clearReview) state.reviewMode = false; state.turnOpen = false; @@ -752,18 +751,15 @@ export async function updateTail( state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_PENDING_END"; - debugger; } }; const expireInFlight = () => { if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_EXPIRE_CHECK"; - debugger; } if (!state.inFlight) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; - if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; if (state.pendingEndAt) { const elapsed = nowMs - state.pendingEndAt; const forceEndMs = inflightTimeoutMs > 0 ? inflightTimeoutMs : defaultInflightTimeoutMs; @@ -795,7 +791,6 @@ export async function updateTail( ) { if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_EXPIRE_TIMEOUT"; - debugger; } state.inFlight = false; state.inFlightStart = false; @@ -964,6 +959,8 @@ export async function updateTail( const itemId = typeof itemIdRaw === "string" ? itemIdRaw : undefined; const itemStatusRaw = item?.status || item?.state; const itemStatus = typeof itemStatusRaw === "string" ? itemStatusRaw : undefined; + const itemStatusLower = itemStatus ? itemStatus.toLowerCase() : undefined; + const openCallId = callId || itemId; state.recentEvents?.push({ ts, type: typeStrRaw || "event", @@ -987,6 +984,7 @@ export async function updateTail( const isTurnStart = combinedType.includes("turn.started"); const isResponseDelta = responseDeltaTypes.has(typeStr) || responseDeltaTypes.has(payloadType); const isItemStarted = typeStr === "item.started" || payloadType === "item.started"; + const isItemCompleted = typeStr === "item.completed" || payloadType === "item.completed"; const isReviewEnter = payloadType === "entered_review_mode"; const isReviewExit = payloadType === "exited_review_mode"; const itemStartWorkTypes = new Set([ @@ -997,6 +995,16 @@ export async function updateTail( "file_edit", "file_write", ]); + const itemEndStatuses = new Set([ + "completed", + "failed", + "errored", + "canceled", + "cancelled", + "aborted", + "interrupted", + "stopped", + ]); if (typeof type === "string") { if (isTurnStart) { clearEndMarkers(); @@ -1020,6 +1028,10 @@ export async function updateTail( } if (isItemStarted && itemStartWorkTypes.has(itemTypeLower)) { clearEndMarkers(); + if (openCallId) { + if (!state.openCallIds) state.openCallIds = new Set(); + state.openCallIds.add(openCallId); + } if (canSignal) { state.turnOpen = true; state.inFlight = true; @@ -1028,10 +1040,23 @@ export async function updateTail( } if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_WORK_START"; - debugger; } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } + const itemEnded = + (isItemCompleted || (itemStatusLower && itemEndStatuses.has(itemStatusLower))) && + itemStartWorkTypes.has(itemTypeLower); + if (itemEnded && openCallId) { + if (state.openCallIds) { + state.openCallIds.delete(openCallId); + } + if (process.env.CODEX_TEST_HOOKS === "1") { + "TEST_HOOK_WORK_END"; + } + if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { + finalizeEnd(state.pendingEndAt); + } + } if (isResponseDelta) { clearEndMarkers(); state.turnOpen = true; @@ -1111,7 +1136,6 @@ export async function updateTail( if (callId) state.openCallIds.add(callId); if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_TOOL_START"; - debugger; } recordToolSignal(ts); if (canSignal) { @@ -1137,7 +1161,6 @@ export async function updateTail( } if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_TOOL_END"; - debugger; } recordToolSignal(ts); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); @@ -1248,7 +1271,6 @@ export async function updateTail( if (kind && workKinds.has(kind) && !isItemStarted) { if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_WORK_END"; - debugger; } state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } diff --git a/src/scan.ts b/src/scan.ts index 90fc30a..b5b3407 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -1540,7 +1540,6 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { await fs.rm(dir, { recursive: true, force: true }); }); +test("pending end waits for item completion", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { + type: "item.started", + ts: 1, + item: { id: "item_1", type: "command_execution" }, + }, + { type: "response.completed", ts: 2 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.equal(stateStart.openCallIds?.size ?? 0, 1); + assert.ok(stateStart.pendingEndAt); + assert.equal(stateStart.inFlight, true); + + const output = { + type: "item.completed", + ts: 3, + item: { id: "item_1", type: "command_execution", status: "completed" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + Date.now = () => 3_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + assert.equal(stateLater.pendingEndAt, undefined); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + test("tool output without call_id does not retain open call", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); From 72aaea8b599ab77aa7d5e623d956b77c909243b6 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:10:48 -0500 Subject: [PATCH 16/40] fix(codex): keep in-flight for item work --- src/codexLogs.ts | 38 ++++++++++++++++++------- tests/integration/codexLogs.test.ts | 43 +++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 10 deletions(-) diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 3728e5a..8297cb6 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -45,6 +45,7 @@ interface TailState { pendingEndAt?: number; lastEndAt?: number; lastToolSignalAt?: number; + openItemCount?: number; openCallIds?: Set; lastCommand?: EventSummary; lastEdit?: EventSummary; @@ -746,6 +747,7 @@ export async function updateTail( state.lastEndAt = ts; clearActivitySignals(); if (state.openCallIds) state.openCallIds.clear(); + state.openItemCount = 0; }; const deferEnd = (ts: number) => { state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); @@ -769,7 +771,8 @@ export async function updateTail( } } if (state.reviewMode) return; - if (state.openCallIds && state.openCallIds.size > 0) return; + const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); + if (openCallCount > 0) return; if (state.lastEndAt) { state.inFlight = false; state.inFlightStart = false; @@ -799,6 +802,7 @@ export async function updateTail( state.lastEndAt = nowMs; clearActivitySignals(); if (state.openCallIds) state.openCallIds.clear(); + state.openItemCount = 0; } }; @@ -813,6 +817,7 @@ export async function updateTail( state.lastToolSignalAt = undefined; state.lastInFlightSignalAt = undefined; if (state.openCallIds) state.openCallIds.clear(); + state.openItemCount = 0; } state.lastEventAt = undefined; state.lastActivityAt = undefined; @@ -840,6 +845,7 @@ export async function updateTail( state.openCallIds = undefined; state.lastInFlightSignalAt = undefined; state.lastToolSignalAt = undefined; + state.openItemCount = undefined; } if (stat.size === state.offset) { @@ -1031,6 +1037,8 @@ export async function updateTail( if (openCallId) { if (!state.openCallIds) state.openCallIds = new Set(); state.openCallIds.add(openCallId); + } else { + state.openItemCount = (state.openItemCount ?? 0) + 1; } if (canSignal) { state.turnOpen = true; @@ -1046,14 +1054,20 @@ export async function updateTail( const itemEnded = (isItemCompleted || (itemStatusLower && itemEndStatuses.has(itemStatusLower))) && itemStartWorkTypes.has(itemTypeLower); - if (itemEnded && openCallId) { - if (state.openCallIds) { - state.openCallIds.delete(openCallId); + if (itemEnded) { + if (openCallId) { + if (state.openCallIds) { + state.openCallIds.delete(openCallId); + } + } else if ((state.openItemCount ?? 0) > 0) { + state.openItemCount = (state.openItemCount ?? 0) - 1; } if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_WORK_END"; } - if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { + const openCallCount = + (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); + if (openCallCount === 0 && state.pendingEndAt && !state.reviewMode) { finalizeEnd(state.pendingEndAt); } } @@ -1190,7 +1204,8 @@ export async function updateTail( } } if (isResponseEnd) { - const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + const hasOpenCalls = + (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0) > 0; if (hasOpenCalls || state.reviewMode) { deferEnd(ts); state.turnOpen = false; @@ -1236,7 +1251,8 @@ export async function updateTail( } } if (itemTypeIsTurnAbort) { - const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + const hasOpenCalls = + (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0) > 0; if (hasOpenCalls || state.reviewMode) { deferEnd(ts); state.turnOpen = false; @@ -1245,7 +1261,8 @@ export async function updateTail( } } if (isTurnEnd) { - const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; + const hasOpenCalls = + (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0) > 0; if (hasOpenCalls || state.reviewMode) { deferEnd(ts); state.turnOpen = false; @@ -1334,7 +1351,8 @@ export async function updateTail( } if (state.pendingEndAt) { - if ((state.openCallIds?.size ?? 0) === 0 && !state.turnOpen) { + const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); + if (openCallCount === 0 && !state.turnOpen) { finalizeEnd(state.pendingEndAt); } } @@ -1393,7 +1411,7 @@ export function summarizeTail(state: TailState): { lastTool: state.lastTool?.summary, lastPrompt: state.lastPrompt?.summary, }; - const openCallCount = state.openCallIds?.size ?? 0; + const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); const hasOpenCalls = openCallCount > 0; const inFlight = state.inFlight || state.reviewMode || hasOpenCalls; return { diff --git a/tests/integration/codexLogs.test.ts b/tests/integration/codexLogs.test.ts index dd92711..fb5ab25 100644 --- a/tests/integration/codexLogs.test.ts +++ b/tests/integration/codexLogs.test.ts @@ -1085,6 +1085,49 @@ test("pending end waits for item completion", async () => { await fs.rm(dir, { recursive: true, force: true }); }); +test("pending end waits for item completion without id", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + const lines = [ + { + type: "item.started", + ts: 1, + item: { type: "command_execution" }, + }, + { type: "response.completed", ts: 2 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + Date.now = () => 2_000; + const stateStart = await updateTail(file); + assert.ok(stateStart); + assert.ok(stateStart.pendingEndAt); + assert.equal(stateStart.inFlight, true); + + const output = { + type: "item.completed", + ts: 3, + item: { type: "command_execution", status: "completed" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + Date.now = () => 3_000; + const stateLater = await updateTail(file); + assert.ok(stateLater); + assert.equal(stateLater.pendingEndAt, undefined); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + + Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + await fs.rm(dir, { recursive: true, force: true }); +}); + test("tool output without call_id does not retain open call", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); From 505152141c5d8f8f0fa4eb312c91d28c3d56c235 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:52:45 -0500 Subject: [PATCH 17/40] fix(codex): avoid end flicker between tools --- src/codexLogs.ts | 14 ------------ src/scan.ts | 3 ++- tests/integration/codexLogs.test.ts | 33 +++++++++++++++++++++++------ 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 8297cb6..5d919c8 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -1065,11 +1065,6 @@ export async function updateTail( if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_WORK_END"; } - const openCallCount = - (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); - if (openCallCount === 0 && state.pendingEndAt && !state.reviewMode) { - finalizeEnd(state.pendingEndAt); - } } if (isResponseDelta) { clearEndMarkers(); @@ -1178,9 +1173,6 @@ export async function updateTail( } recordToolSignal(ts); state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - if ((state.openCallIds?.size ?? 0) === 0 && state.pendingEndAt && !state.reviewMode) { - finalizeEnd(state.pendingEndAt); - } } if (payloadType === "reasoning") { state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); @@ -1350,12 +1342,6 @@ export async function updateTail( } } - if (state.pendingEndAt) { - const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); - if (openCallCount === 0 && !state.turnOpen) { - finalizeEnd(state.pendingEndAt); - } - } state.offset = stat.size; expireInFlight(); diff --git a/src/scan.ts b/src/scan.ts index b5b3407..23f5dd0 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -2177,7 +2177,8 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { Date.now = () => 3_000; const stateLater = await updateTail(file); assert.ok(stateLater); - assert.equal(stateLater.pendingEndAt, undefined); + assert.ok(stateLater.pendingEndAt); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); + assert.equal(summaryLater.inFlight, true); + + Date.now = () => 6_000; + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + assert.equal(stateEnd.pendingEndAt, undefined); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; @@ -1075,9 +1082,16 @@ test("pending end waits for item completion", async () => { Date.now = () => 3_000; const stateLater = await updateTail(file); assert.ok(stateLater); - assert.equal(stateLater.pendingEndAt, undefined); + assert.ok(stateLater.pendingEndAt); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); + assert.equal(summaryLater.inFlight, true); + + Date.now = () => 6_000; + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + assert.equal(stateEnd.pendingEndAt, undefined); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; @@ -1118,9 +1132,16 @@ test("pending end waits for item completion without id", async () => { Date.now = () => 3_000; const stateLater = await updateTail(file); assert.ok(stateLater); - assert.equal(stateLater.pendingEndAt, undefined); + assert.ok(stateLater.pendingEndAt); const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); + assert.equal(summaryLater.inFlight, true); + + Date.now = () => 6_000; + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + assert.equal(stateEnd.pendingEndAt, undefined); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); Date.now = originalNow; delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; From d36f78e606273da7e18d5216e5315d3636159e70 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 05:04:59 -0500 Subject: [PATCH 18/40] fix(codex): defer end until quiet window --- src/codexLogs.ts | 39 +++++---------------- tests/integration/codexLogs.test.ts | 53 +++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 5d919c8..ee61db2 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -1089,13 +1089,8 @@ export async function updateTail( } if (isReviewExit) { state.reviewMode = false; - const hasOpenCalls = (state.openCallIds?.size ?? 0) > 0; - if (hasOpenCalls) { - deferEnd(ts); - state.turnOpen = false; - } else { - finalizeEnd(ts, { clearReview: false }); - } + deferEnd(ts); + state.turnOpen = false; state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } } @@ -1196,14 +1191,8 @@ export async function updateTail( } } if (isResponseEnd) { - const hasOpenCalls = - (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0) > 0; - if (hasOpenCalls || state.reviewMode) { - deferEnd(ts); - state.turnOpen = false; - } else { - finalizeEnd(ts); - } + deferEnd(ts); + state.turnOpen = false; } const itemTypeIsAgentReasoning = itemTypeLower.includes("agent_reasoning"); const itemTypeIsAgentMessage = itemTypeLower.includes("agent_message"); @@ -1243,24 +1232,12 @@ export async function updateTail( } } if (itemTypeIsTurnAbort) { - const hasOpenCalls = - (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0) > 0; - if (hasOpenCalls || state.reviewMode) { - deferEnd(ts); - state.turnOpen = false; - } else { - finalizeEnd(ts, { clearReview: false }); - } + deferEnd(ts); + state.turnOpen = false; } if (isTurnEnd) { - const hasOpenCalls = - (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0) > 0; - if (hasOpenCalls || state.reviewMode) { - deferEnd(ts); - state.turnOpen = false; - } else { - finalizeEnd(ts); - } + deferEnd(ts); + state.turnOpen = false; } if (summary) { diff --git a/tests/integration/codexLogs.test.ts b/tests/integration/codexLogs.test.ts index 50b5fbd..1a88350 100644 --- a/tests/integration/codexLogs.test.ts +++ b/tests/integration/codexLogs.test.ts @@ -634,6 +634,7 @@ test("stays active with periodic signals and turns idle after explicit end", asy const pulseEveryMs = 300; const pollEveryMs = 50; const durationMs = 1500; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "200"; const first = { type: "response.output_text.delta", @@ -669,8 +670,15 @@ test("stays active with periodic signals and turns idle after explicit end", asy const endState = await updateTail(file); assert.ok(endState); const endSummary = summarizeTail(endState); - assert.equal(endSummary.inFlight, undefined); + assert.equal(endSummary.inFlight, true); + await new Promise((resolve) => setTimeout(resolve, 250)); + const finalState = await updateTail(file); + assert.ok(finalState); + const finalSummary = summarizeTail(finalState); + assert.equal(finalSummary.inFlight, undefined); + + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; await fs.rm(dir, { recursive: true, force: true }); }); @@ -737,10 +745,11 @@ test("parses trailing codex event without newline", async () => { await fs.rm(dir, { recursive: true, force: true }); }); -test("response.completed clears in-flight even without turn end", async () => { +test("response.completed holds in-flight until timeout", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; Date.now = () => 1_000; const started = { @@ -760,11 +769,17 @@ test("response.completed clears in-flight even without turn end", async () => { }; await fs.appendFile(file, `${JSON.stringify(completed)}\n`); - Date.now = () => 5_000; + Date.now = () => 1_500; const second = await updateTail(file); assert.ok(second); const secondSummary = summarizeTail(second); - assert.equal(secondSummary.inFlight, undefined); + assert.equal(secondSummary.inFlight, true); + + Date.now = () => 5_000; + const third = await updateTail(file); + assert.ok(third); + const thirdSummary = summarizeTail(third); + assert.equal(thirdSummary.inFlight, undefined); const turnCompleted = { type: "turn.completed", @@ -772,12 +787,13 @@ test("response.completed clears in-flight even without turn end", async () => { }; await fs.appendFile(file, `${JSON.stringify(turnCompleted)}\n`); Date.now = () => 3_000; - const third = await updateTail(file); - assert.ok(third); - const thirdSummary = summarizeTail(third); - assert.equal(thirdSummary.inFlight, undefined); + const fourth = await updateTail(file); + assert.ok(fourth); + const fourthSummary = summarizeTail(fourth); + assert.equal(fourthSummary.inFlight, undefined); Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; await fs.rm(dir, { recursive: true, force: true }); }); @@ -815,7 +831,7 @@ test("review mode keeps in-flight until exited", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; @@ -854,9 +870,16 @@ test("review mode keeps in-flight until exited", async () => { const stateExit = await updateTail(file); assert.ok(stateExit); const summaryExit = summarizeTail(stateExit); - assert.equal(summaryExit.inFlight, undefined); + assert.equal(summaryExit.inFlight, true); + + Date.now = () => 14_000; + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; await fs.rm(dir, { recursive: true, force: true }); }); @@ -1260,6 +1283,7 @@ test("turn.completed clears in-flight after response completion", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); const originalNow = Date.now; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; Date.now = () => 1_000; const start = [ @@ -1291,9 +1315,16 @@ test("turn.completed clears in-flight after response completion", async () => { const stateEnd = await updateTail(file); assert.ok(stateEnd); const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, undefined); + assert.equal(summaryEnd.inFlight, true); + + Date.now = () => 6_000; + const stateFinal = await updateTail(file); + assert.ok(stateFinal); + const summaryFinal = summarizeTail(stateFinal); + assert.equal(summaryFinal.inFlight, undefined); Date.now = originalNow; + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; await fs.rm(dir, { recursive: true, force: true }); }); From 802c8a881ada39c726afbef96629d88f268f0209 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Tue, 3 Feb 2026 18:12:22 -0500 Subject: [PATCH 19/40] fix(codex): keep in-flight while pending end --- src/codexLogs.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/codexLogs.ts b/src/codexLogs.ts index ee61db2..ad49569 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -760,7 +760,7 @@ export async function updateTail( if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_EXPIRE_CHECK"; } - if (!state.inFlight) return; + if (!state.inFlight && !state.pendingEndAt) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; if (state.pendingEndAt) { const elapsed = nowMs - state.pendingEndAt; @@ -1376,7 +1376,8 @@ export function summarizeTail(state: TailState): { }; const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); const hasOpenCalls = openCallCount > 0; - const inFlight = state.inFlight || state.reviewMode || hasOpenCalls; + const hasPendingEnd = typeof state.pendingEndAt === "number"; + const inFlight = state.inFlight || state.reviewMode || hasOpenCalls || hasPendingEnd; return { doing, title, From 507900eac83939f2c1beb8b1b6993ea0eada6f9f Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 14:59:46 -0500 Subject: [PATCH 20/40] fix(codex): stabilize jsonl activity tracking --- .gitignore | 2 + AGENTS.md | 26 +- docs/api-authentication.md | 4 +- docs/architecture.md | 2 +- src/claudeHook.ts | 2 +- src/cli/setup.ts | 72 +- src/codex/lifecycleGraph.ts | 343 +++++ src/codex/types.ts | 1 + src/codexLogs.ts | 164 ++- src/codexNotify.ts | 37 +- src/dedupe.ts | 38 +- src/scan.ts | 386 +++-- src/server.ts | 50 +- src/services/codexEvents.ts | 151 -- tests/integration/claudeHook.test.ts | 23 +- tests/integration/codexLogs.test.ts | 1268 ++++++++++------- tests/integration/codexNotifyHook.test.ts | 37 +- tests/integration/opencodeActivity.test.ts | 92 +- tests/integration/opencodeApi.test.ts | 46 +- .../opencodeSessionActivity.test.ts | 54 +- tests/unit/codexEventState.test.ts | 14 + tests/unit/codexEventStore.test.ts | 211 +-- tests/unit/codexScan.test.ts | 93 +- 23 files changed, 1932 insertions(+), 1184 deletions(-) create mode 100644 src/codex/lifecycleGraph.ts delete mode 100644 src/services/codexEvents.ts diff --git a/.gitignore b/.gitignore index f0dd596..217df7d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ dist/ test-results/ playwright-report/ tmp/ +.worktrees/ +.codex/ .DS_Store .env .env.local diff --git a/AGENTS.md b/AGENTS.md index 66fd91e..225a717 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,6 +199,30 @@ SAFE ALTERNATIVE: Provide a legal, safe approach to the same goal. ## Verification - Default: `npm run build` for type-checking. +- After any changes, run `npm test` and `npm run build`. + +## Completion protocol (non-negotiable) +- Gate order: 1) Build gate, 2) Evidence gate, 3) Regression gate. +- Build gate: `npm run build` and `npm test` must pass with zero failures, and report the actual summary output. +- Evidence gate: Every state-change fix must include a reproduction artifact. +- Flicker fixes require a poller log (JSONL) showing zero `active->idle->active` transitions within the hold window during a live agent run. +- State logic fixes require a before/after snapshot diff showing the incorrect state and the corrected state. +- Event parsing fixes require a test case using real JSONL from a captured session, not synthetic data. +- If an artifact cannot be produced, report the blocker and do not claim the fix works. +- Regression gate: Confirm that no previously-passing test now fails and no unrelated behavior changed. +- If unrelated files are modified, flag them explicitly and do not commit them. + +## Completion rules +- `npm test` success must include the summary line, not a claim. +- "Flicker count: 0" must include the log file path and polling parameters used. +- If validation is blocked (hooks not firing, file missing, process not found), report it as a blocker. +- Do not say "if you want, I can validate." Validate first and report results. +- Do not ask for a next step after a fix. Run the evidence gate and report pass or fail. + +## Decision protocol +- If project instructions or prior conversation already answered the question, do not ask again. Act. +- If two options exist and one is clearly safer under the "if unsure, stay active" principle, take it and explain why. +- If progress is genuinely blocked, state the specific blocker in one sentence and stop. ## Configuration - `CONSENSUS_HOST`, `CONSENSUS_PORT`, `CONSENSUS_POLL_MS`, `CONSENSUS_CODEX_HOME`, `CONSENSUS_PROCESS_MATCH`, `CONSENSUS_REDACT_PII`. @@ -225,4 +249,4 @@ You are a coding agent integrated with ByteRover via MCP (Model Context Protocol --- Generated by ByteRover CLI for Codex - \ No newline at end of file + diff --git a/docs/api-authentication.md b/docs/api-authentication.md index f47915e..42a4de3 100644 --- a/docs/api-authentication.md +++ b/docs/api-authentication.md @@ -12,7 +12,7 @@ This document records how Consensus exposes HTTP/WS APIs today and how callers p |-------|--------|---------|----------------|-------| | `/api/snapshot` | `GET` | Returns the last snapshot emitted by the scan loop. The React-less canvas UI polls this endpoint to rebuild the agent map. | None | The handler runs `scanCodexProcesses` and pushes a JSON payload (ts + agents). | | `/health` | `GET` | Basic JSON health check for monitoring. | None | Always responds `{ ok: true }`. | -| `/api/codex-event` | `POST` | Codex notify hook forwards Codex events into the server. | None | Payload validated against `CodexEventSchema`; rejects with `400` on schema mismatch. | +| `/api/codex-event` | `POST` | Codex notify hook triggers a fast scan. | None | Payload validated against `CodexEventSchema`; rejects with `400` on schema mismatch. Events are not merged into activity state. | | `/api/claude-event` | `POST` | Claude Code hooks post lifecycle events. | None | Schema validated via `ClaudeEventSchema`; `dist/claudeHook.js` reads stdin and forwards to this endpoint. | | `/__debug/activity` | `POST` | Toggles extra activity logging (guarded by localhost). | None | Accepts `enable` via query or JSON body. | | `/__dev/reload` | `GET (SSE)` | Development reload stream for browser clients. | None | Only available when `CONSENSUS_LIVE_RELOAD=1`. | @@ -20,7 +20,7 @@ This document records how Consensus exposes HTTP/WS APIs today and how callers p The UI also opens a WebSocket (handled by `ws` in `src/server.ts`) but the WebSocket connection is only permitted from the same origin as the served static files. ## Client expectations -- Codex notify hooks call `noun codex config set -g notify` with the Consensus endpoint (`/api/codex-event`) and expect no authentication steps beyond the default localhost requirement. +- Codex notify hooks call `noun codex config set -g notify` with the Consensus endpoint (`/api/codex-event`) and expect no authentication steps beyond the default localhost requirement. Codex in-flight state is derived from session JSONL logs (e.g. `~/.codex/sessions/.../*.jsonl`); the webhook only triggers faster scans. - Claude Code hooks run `dist/claudeHook.js` which POSTs a minimal JSON event directly to `/api/claude-event` from the hook process; it neither signs the request nor retries if the hook fails. - The browser UI fetches `/api/snapshot` and opens the WebSocket without extra headers. diff --git a/docs/architecture.md b/docs/architecture.md index b6fb85c..734a9f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ ## Data flow 1) `scan.ts` enumerates OS processes and collects CPU/memory stats. 2) `codexLogs.ts` scans `CODEX_HOME/sessions/` for recent JSONL logs. -3) `server.ts` ingests Codex notify + Claude hook events into in-memory stores. +3) `server.ts` accepts Codex notify hooks to trigger fast scans and ingests Claude hook events into in-memory stores. 4) `server.ts` polls snapshots and pushes updates over WebSocket. 5) `public/src` renders the isometric map in a canvas (see `public/src/components/CanvasScene.tsx`). diff --git a/src/claudeHook.ts b/src/claudeHook.ts index 3bf5546..db648fe 100644 --- a/src/claudeHook.ts +++ b/src/claudeHook.ts @@ -67,7 +67,7 @@ const program = pipe( if (!payload || typeof payload !== "object") return Effect.succeed(null); const event = normalizePayload(payload); if (!event) return Effect.succeed(null); - return Effect.promise(() => + return Effect.tryPromise(() => fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 20e7ea6..017ef4c 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -80,49 +80,65 @@ export async function setupCodexHook(): Promise { // Generate notify script path (compiled JS in dist) const notifyScript = path.resolve(__dirname, "..", "codexNotify.js"); - const notifyLine = `notify = ["node", "${notifyScript}", "http://127.0.0.1:${consensusPort}/api/codex-event"]`; + const notifyLine = `notify = ["node", "${notifyScript}", "http://127.0.0.1:${consensusPort}/api/codex-event"] # consensus-cli`; const notificationsLine = - 'notifications = ["thread.started", "turn.started", "agent-turn-complete", "item.started", "item.completed"]'; + 'notifications = ["agent-turn-complete", "approval-requested"] # consensus-cli'; - // Remove old consensus notify lines - const filtered = existingContent - .split("\n") - .filter((line) => { - const trimmed = line.trim(); - if (!trimmed.startsWith("notify =")) return true; - if (trimmed.includes("/api/codex-event")) return false; - return true; - }); + const rawLines = existingContent.split(/\r?\n/); + const filtered: string[] = []; + for (let i = 0; i < rawLines.length; i += 1) { + const line = rawLines[i]; + const trimmed = line.trim(); + const nextTrimmed = rawLines[i + 1]?.trim() ?? ""; + const isConsensusComment = + trimmed.startsWith("#") && trimmed.includes("Added by consensus-cli"); + const isNotifyLine = trimmed.startsWith("notify =") && trimmed.includes("/api/codex-event"); + if (isNotifyLine) continue; + if (isConsensusComment && nextTrimmed.startsWith("notify =") && nextTrimmed.includes("/api/codex-event")) { + continue; + } + filtered.push(line); + } - const hasTuiSection = filtered.some((line) => line.trim() === "[tui]"); + const hasNotifyHook = filtered.some((line) => { + const trimmed = line.trim(); + return trimmed.startsWith("notify =") && trimmed.includes("/api/codex-event"); + }); - const withoutNotifications = filtered.filter( - (line) => !line.trim().startsWith("notifications =") - ); - const lines: string[] = withoutNotifications.filter((line) => line.trim() !== ""); - lines.push(""); - lines.push("# Added by consensus-cli"); - lines.push(notifyLine); - if (!hasTuiSection) { + const lines = [...filtered]; + if (!hasNotifyHook) { + if (lines.length && lines[lines.length - 1].trim() !== "") lines.push(""); + lines.push("# Added by consensus-cli"); + lines.push(notifyLine); + } + + const tuiIndex = lines.findIndex((line) => line.trim() === "[tui]"); + if (tuiIndex === -1) { lines.push(""); lines.push("[tui]"); lines.push(notificationsLine); } else { - const tuiIndex = lines.findIndex((line) => line.trim() === "[tui]"); - const insertAt = (() => { - if (tuiIndex === -1) return lines.length; + const sectionEnd = (() => { for (let i = tuiIndex + 1; i < lines.length; i += 1) { const trimmed = lines[i].trim(); - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - return i; - } + if (trimmed.startsWith("[") && trimmed.endsWith("]")) return i; } return lines.length; })(); - lines.splice(insertAt, 0, notificationsLine); + const notificationsIndex = (() => { + for (let i = tuiIndex + 1; i < sectionEnd; i += 1) { + if (lines[i].trim().startsWith("notifications =")) return i; + } + return -1; + })(); + if (notificationsIndex === -1) { + lines.splice(sectionEnd, 0, notificationsLine); + } else if (lines[notificationsIndex].includes("consensus-cli")) { + lines[notificationsIndex] = notificationsLine; + } } - const newContent = lines.join("\n") + "\n"; + const newContent = lines.join("\n").replace(/\n*$/, "\n"); // Write config await fs.writeFile(configPath, newContent, "utf-8"); diff --git a/src/codex/lifecycleGraph.ts b/src/codex/lifecycleGraph.ts new file mode 100644 index 0000000..e8603f2 --- /dev/null +++ b/src/codex/lifecycleGraph.ts @@ -0,0 +1,343 @@ +import path from "node:path"; + +export type LifecycleEventKind = + | "agent_start" + | "agent_stop" + | "tool_start" + | "tool_end" + | "approval_wait" + | "approval_resolved"; + +export type ToolKey = string; + +export type ThreadLifecycleSummary = { + lastTool?: string; + lastCommand?: string; + lastMessage?: string; + lastPrompt?: string; +}; + +export interface ThreadLifecycleState { + readonly threadId: string; + turnOpen: boolean; + reviewMode: boolean; + awaitingApproval: boolean; + pendingEndAt?: number; + lastEndAt?: number; + lastActivityAt?: number; + lastSignalAt?: number; + openToolIds: Set; + openAnonTools: number; + lastSummary?: ThreadLifecycleSummary; + lastUpdatedAt: number; +} + +export type ThreadLifecycleSnapshot = { + threadId: string; + inFlight: boolean; + openCallCount: number; + lastActivityAt?: number; + reason: string; + endedAt?: number; +}; + +function resolveMs(raw: string | undefined, fallback: number): number { + if (raw === undefined || raw.trim() === "") return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return parsed; +} + +function maxDefined(a: number | undefined, b: number | undefined): number | undefined { + if (typeof a !== "number") return typeof b === "number" ? b : undefined; + if (typeof b !== "number") return a; + return Math.max(a, b); +} + +function noteRecent(set: Set, id: string, cap: number, state: ThreadLifecycleState): void { + if (set.has(id)) { + // Refresh insertion order. + set.delete(id); + set.add(id); + return; + } + set.add(id); + if (set.size <= cap) return; + const overflow = set.size - cap; + let removed = 0; + for (const existing of set) { + set.delete(existing); + removed += 1; + if (removed >= overflow) break; + } + if (removed > 0) state.openAnonTools += removed; +} + +export class CodexLifecycleGraph { + private threads = new Map(); + private threadIdByPath = new Map(); + private pathsByThreadId = new Map>(); + + ensureThread(threadId: string, nowMs: number = Date.now()): ThreadLifecycleState { + const existing = this.threads.get(threadId); + if (existing) return existing; + const created: ThreadLifecycleState = { + threadId, + turnOpen: false, + reviewMode: false, + awaitingApproval: false, + openToolIds: new Set(), + openAnonTools: 0, + lastUpdatedAt: nowMs, + }; + this.threads.set(threadId, created); + return created; + } + + linkPath(sessionPath: string, threadId: string): void { + if (!sessionPath) return; + const resolved = path.resolve(sessionPath); + const existing = this.threadIdByPath.get(resolved); + if (existing && existing !== threadId) { + const paths = this.pathsByThreadId.get(existing); + if (paths) { + paths.delete(resolved); + if (paths.size === 0) this.pathsByThreadId.delete(existing); + } + } + this.threadIdByPath.set(resolved, threadId); + const bucket = this.pathsByThreadId.get(threadId) ?? new Set(); + bucket.add(resolved); + this.pathsByThreadId.set(threadId, bucket); + } + + resolveThreadId(sessionPath: string): string | undefined { + return this.threadIdByPath.get(path.resolve(sessionPath)); + } + + ingestFileSignal(threadId: string, signalAt: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.lastSignalAt = Math.max(state.lastSignalAt || 0, signalAt); + state.lastUpdatedAt = nowMs; + } + + ingestSummary( + threadId: string, + update: Partial, + nowMs: number = Date.now() + ): void { + const state = this.ensureThread(threadId, nowMs); + state.lastSummary = { ...(state.lastSummary ?? {}), ...update }; + state.lastUpdatedAt = nowMs; + } + + ingestActivity(threadId: string, ts: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestAgentStart(threadId: string, ts: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.turnOpen = true; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.awaitingApproval = false; + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestAgentStop(threadId: string, ts: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); + state.turnOpen = false; + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestNotifyEnd(threadId: string, ts: number, nowMs: number = Date.now()): void { + this.ingestAgentStop(threadId, ts, nowMs); + } + + ingestReviewMode(threadId: string, enabled: boolean, ts: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.reviewMode = enabled; + if (enabled) { + state.turnOpen = true; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + } else { + state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); + state.turnOpen = false; + } + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestApprovalWait(threadId: string, ts: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.awaitingApproval = true; + state.turnOpen = true; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestApprovalResolved(threadId: string, ts: number, nowMs: number = Date.now()): void { + const state = this.ensureThread(threadId, nowMs); + state.awaitingApproval = false; + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestToolStart( + threadId: string, + toolId: string | undefined, + ts: number, + nowMs: number = Date.now() + ): void { + const state = this.ensureThread(threadId, nowMs); + if (toolId) { + noteRecent(state.openToolIds, toolId, 500, state); + } else { + state.openAnonTools += 1; + } + state.turnOpen = true; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + ingestToolEnd( + threadId: string, + toolId: string | undefined, + ts: number, + nowMs: number = Date.now() + ): void { + const state = this.ensureThread(threadId, nowMs); + if (toolId) { + if (state.openToolIds.delete(toolId)) { + // ok + } else if (state.openAnonTools > 0) { + // Best-effort: end may correspond to an overflowed tool id. + state.openAnonTools -= 1; + } + } else if (state.openAnonTools > 0) { + state.openAnonTools -= 1; + } else if (state.openToolIds.size > 0) { + // Best-effort: some tool output events omit identifiers; close one open tool. + const first = state.openToolIds.values().next().value; + if (typeof first === "string") { + state.openToolIds.delete(first); + } + } + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + state.lastUpdatedAt = nowMs; + } + + resetThread(threadId: string): void { + this.dropThread(threadId); + } + + getThreadSnapshot(threadId: string, nowMs: number = Date.now()): ThreadLifecycleSnapshot | null { + const state = this.threads.get(threadId); + if (!state) return null; + + const inFlightTimeoutMs = resolveMs( + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS, + 2500 + ); + const staleFileMs = resolveMs(process.env.CONSENSUS_CODEX_STALE_FILE_MS, 120000); + const graceMs = resolveMs(process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS, 0); + + const lastSignalAt = state.lastSignalAt ?? state.lastActivityAt; + const signalStale = + typeof lastSignalAt === "number" && + Number.isFinite(inFlightTimeoutMs) && + inFlightTimeoutMs > 0 && + nowMs - lastSignalAt >= inFlightTimeoutMs; + const staleAnchor = + state.lastSignalAt ?? state.lastActivityAt ?? state.lastUpdatedAt; + const staleFile = + typeof staleAnchor === "number" && + Number.isFinite(staleFileMs) && + staleFileMs > 0 && + nowMs - staleAnchor > staleFileMs; + if (staleFile) { + this.dropThread(threadId); + return { + threadId, + inFlight: false, + openCallCount: 0, + lastActivityAt: undefined, + reason: "stale_timeout", + }; + } + + const openCallCount = (state.openToolIds?.size ?? 0) + (state.openAnonTools ?? 0); + const lastActivityAt = maxDefined(state.lastActivityAt, state.lastSignalAt); + + const pendingEndAt = state.pendingEndAt; + const pendingActive = + typeof pendingEndAt === "number" && + openCallCount === 0 && + !state.reviewMode && + !state.awaitingApproval; + + const lastSignalForFinalize = maxDefined(state.lastSignalAt, state.lastActivityAt) ?? 0; + const canFinalize = + typeof pendingEndAt === "number" && + pendingActive && + (nowMs - pendingEndAt >= graceMs) && + lastSignalForFinalize <= pendingEndAt; + + const endedAt = canFinalize ? pendingEndAt : undefined; + + if (canFinalize) { + state.lastEndAt = pendingEndAt; + } + + const inFlight = + state.awaitingApproval || + state.reviewMode || + openCallCount > 0 || + (!signalStale && !canFinalize && !!state.turnOpen) || + (!signalStale && !canFinalize && typeof pendingEndAt === "number"); + + const reason = (() => { + if (canFinalize) return "ended"; + if (signalStale && !state.awaitingApproval && !state.reviewMode && openCallCount === 0) { + return "stale_timeout"; + } + if (state.awaitingApproval) return "approval"; + if (state.reviewMode) return "review"; + if (openCallCount > 0) return "tool_open"; + if (!canFinalize && typeof pendingEndAt === "number") return "pending_end"; + if (!canFinalize && state.turnOpen) return "turn_open"; + return "idle"; + })(); + + return { + threadId, + inFlight, + openCallCount, + lastActivityAt, + reason, + endedAt, + }; + } + + private dropThread(threadId: string): void { + this.threads.delete(threadId); + const paths = this.pathsByThreadId.get(threadId); + if (!paths) return; + for (const resolved of paths) { + this.threadIdByPath.delete(resolved); + } + this.pathsByThreadId.delete(threadId); + } +} + +export const codexLifecycleGraph = new CodexLifecycleGraph(); diff --git a/src/codex/types.ts b/src/codex/types.ts index 5df7645..19616a5 100644 --- a/src/codex/types.ts +++ b/src/codex/types.ts @@ -7,6 +7,7 @@ export const CodexEventType = Schema.Literal( "thread.started", "turn.started", "agent-turn-complete", + "approval-requested", "item.started", "item.completed" ); diff --git a/src/codexLogs.ts b/src/codexLogs.ts index ad49569..89320b0 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -2,13 +2,19 @@ import fs from "fs"; import fsp from "fs/promises"; import os from "os"; import path from "path"; +import { StringDecoder } from "node:string_decoder"; import type { EventSummary, WorkSummary } from "./types.js"; import { redactText } from "./redact.js"; const SESSION_WINDOW_MS = 30 * 60 * 1000; const SESSION_SCAN_INTERVAL_MS = 500; const SESSION_ID_SCAN_INTERVAL_MS = 60000; -const MAX_READ_BYTES = 512 * 1024; +const TAIL_CHUNK_BYTES = 64 * 1024; +const TAIL_MAX_BYTES_PER_UPDATE = 2 * 1024 * 1024; +const MAX_READ_BYTES = TAIL_MAX_BYTES_PER_UPDATE; +const TAIL_PARSE_ERROR_MAX_BYTES = 16 * 1024; +const FAST_TYPE_BYTES = 256; +const FAST_PREFIX_BYTES = 512; const MAX_EVENTS = 50; const SESSION_META_READ_BYTES = 16 * 1024; const SESSION_META_RESYNC_MS = 10000; @@ -17,6 +23,91 @@ const NOTIFY_POLL_MS = 1000; const SESSION_CWD_CHECK_MAX = 256; const isDebugActivity = () => process.env.CONSENSUS_DEBUG_ACTIVITY === "1"; +const RESPONSE_START_RE = /(?:turn|response)\.(started|in_progress|running)/i; +const RESPONSE_END_RE = + /response\.(completed|failed|errored|canceled|cancelled|aborted|interrupted|stopped)/i; +const TURN_END_RE = + /turn\.(completed|failed|errored|canceled|cancelled|aborted|interrupted|stopped)/i; +const RESPONSE_ITEM_DELTA_TYPES = [ + "response.output_text.delta", + "response.function_call_arguments.delta", + "response.content_part.delta", + "response.text.delta", +] as const; +const ITEM_START_WORK_TYPES = new Set([ + "command_execution", + "mcp_tool_call", + "tool_call", + "file_change", + "file_edit", + "file_write", +]); +const ITEM_END_STATUSES = new Set([ + "completed", + "failed", + "errored", + "canceled", + "cancelled", + "aborted", + "interrupted", + "stopped", +]); +const WORK_KINDS = new Set(["command", "edit", "tool", "message"]); + +function fastExtractTopType(line: string): string | undefined { + const prefix = line.slice(0, FAST_TYPE_BYTES); + const typeIndex = prefix.indexOf('"type"'); + if (typeIndex === -1) return undefined; + const colon = prefix.indexOf(":", typeIndex); + if (colon === -1) return undefined; + const quote = prefix.indexOf('"', colon); + if (quote === -1) return undefined; + const end = prefix.indexOf('"', quote + 1); + if (end === -1) return undefined; + return prefix.slice(quote + 1, end); +} + +function shouldParseJsonLine(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) return false; + if (!trimmed.startsWith("{")) return false; + const prefix = trimmed.slice(0, FAST_PREFIX_BYTES); + const topType = fastExtractTopType(prefix); + if (!topType) { + return trimmed.length <= TAIL_PARSE_ERROR_MAX_BYTES && prefix.includes('"error"'); + } + const typeLower = topType.toLowerCase(); + if (typeLower.includes(".delta")) return false; + if (typeLower === "response_item") { + for (const deltaType of RESPONSE_ITEM_DELTA_TYPES) { + if (prefix.includes(deltaType)) return false; + } + return true; + } + if (typeLower === "event_msg") { + const lower = prefix.toLowerCase(); + return ( + lower.includes("token_count") || + lower.includes("agent_reasoning") || + lower.includes("agent_message") || + lower.includes("user_message") || + lower.includes("entered_review_mode") || + lower.includes("exited_review_mode") || + lower.includes("approval") + ); + } + if (typeLower === "session_meta") return true; + if ( + typeLower.startsWith("thread.") || + typeLower.startsWith("turn.") || + typeLower.startsWith("response.") || + typeLower.startsWith("item.") + ) { + return true; + } + return trimmed.length <= TAIL_PARSE_ERROR_MAX_BYTES && prefix.includes('"error"'); +} + function logDebug(message: string): void { if (!isDebugActivity()) return; process.stderr.write(`[consensus][codex] ${message}\n`); @@ -31,6 +122,8 @@ interface TailState { path: string; offset: number; partial: string; + decoder?: StringDecoder; + needsCatchUp?: boolean; events: EventSummary[]; recentEvents?: CodexEventLite[]; lastEventAt?: number; @@ -664,7 +757,7 @@ function summarizeEvent(ev: any): { return { kind: "other", isError, model, type }; } -export async function updateTail( +async function updateTailLegacy( sessionPath: string, options?: { keepStale?: boolean } ): Promise { @@ -762,16 +855,18 @@ export async function updateTail( } if (!state.inFlight && !state.pendingEndAt) return; if (!Number.isFinite(inflightTimeoutMs) || inflightTimeoutMs <= 0) return; + const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); if (state.pendingEndAt) { + if (openCallCount > 0) return; const elapsed = nowMs - state.pendingEndAt; const forceEndMs = inflightTimeoutMs > 0 ? inflightTimeoutMs : defaultInflightTimeoutMs; if (elapsed >= forceEndMs) { finalizeEnd(state.pendingEndAt); - return; } + return; } if (state.reviewMode) return; - const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); + if (state.turnOpen) return; if (openCallCount > 0) return; if (state.lastEndAt) { state.inFlight = false; @@ -780,9 +875,6 @@ export async function updateTail( clearActivitySignals(); return; } - if (fileFresh) { - return; - } const lastSignal = state.lastInFlightSignalAt ?? state.lastIngestAt ?? @@ -987,7 +1079,8 @@ export async function updateTail( const isTurnEnd = /turn\.(completed|failed|errored|canceled|cancelled|aborted|interrupted|stopped)/i.test( combinedType ); - const isTurnStart = combinedType.includes("turn.started"); + const isTurnStart = + combinedType.includes("turn.started") || combinedType.includes("thread.started"); const isResponseDelta = responseDeltaTypes.has(typeStr) || responseDeltaTypes.has(payloadType); const isItemStarted = typeStr === "item.started" || payloadType === "item.started"; const isItemCompleted = typeStr === "item.completed" || payloadType === "item.completed"; @@ -1104,13 +1197,13 @@ export async function updateTail( } } if (payloadType.includes("user_message") || payloadRole === "user") { + state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); if (canSignal) { clearEndMarkers(); state.turnOpen = true; - state.inFlight = true; - state.inFlightStart = true; - markInFlightSignal(); - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); + if (state.inFlight) { + markInFlightSignal(); + } } } if (payloadType === "token_count") { @@ -1137,7 +1230,11 @@ export async function updateTail( payloadType === "tool"); if (isToolCall) { if (!state.openCallIds) state.openCallIds = new Set(); - if (callId) state.openCallIds.add(callId); + if (callId) { + state.openCallIds.add(callId); + } else { + state.openItemCount = (state.openItemCount ?? 0) + 1; + } if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_TOOL_START"; } @@ -1162,6 +1259,11 @@ export async function updateTail( if (isToolOutput) { if (state.openCallIds && callId) { state.openCallIds.delete(callId); + } else if (state.openCallIds && state.openCallIds.size > 0) { + const first = state.openCallIds.values().next().value as string | undefined; + if (first) state.openCallIds.delete(first); + } else if ((state.openItemCount ?? 0) > 0) { + state.openItemCount = (state.openItemCount ?? 0) - 1; } if (process.env.CODEX_TEST_HOOKS === "1") { "TEST_HOOK_TOOL_END"; @@ -1215,10 +1317,11 @@ export async function updateTail( if (itemTypeIsUserMessage || payloadIsUserMessage) { state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); if (canSignal) { + clearEndMarkers(); state.turnOpen = true; - state.inFlight = true; - state.inFlightStart = true; - markInFlightSignal(); + if (state.inFlight) { + markInFlightSignal(); + } } } if (itemTypeIsAgentMessage || payloadIsAgentMessage) { @@ -1337,6 +1440,13 @@ export async function updateTail( return state; } +export async function updateTail( + sessionPath: string, + options?: { keepStale?: boolean } +): Promise { + return updateTailLegacy(sessionPath, options); +} + export function summarizeTail(state: TailState): { doing?: string; title?: string; @@ -1377,7 +1487,25 @@ export function summarizeTail(state: TailState): { const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); const hasOpenCalls = openCallCount > 0; const hasPendingEnd = typeof state.pendingEndAt === "number"; - const inFlight = state.inFlight || state.reviewMode || hasOpenCalls || hasPendingEnd; + const inFlight = + state.inFlight || state.reviewMode || hasOpenCalls || hasPendingEnd || state.turnOpen; + if (isDebugActivity()) { + if (state.turnOpen || state.inFlight || hasOpenCalls || hasPendingEnd) { + logDebug( + `summary session=${path.basename(state.path)} ` + + `turnOpen=${state.turnOpen ? 1 : 0} ` + + `inFlight=${state.inFlight ? 1 : 0} ` + + `openCalls=${openCallCount} pendingEnd=${hasPendingEnd ? 1 : 0} ` + + `lastActivity=${state.lastActivityAt ?? "?"}` + ); + } + if (state.turnOpen && !inFlight) { + logDebug( + `summary mismatch session=${path.basename(state.path)} turnOpen=1 inFlight=0` + ); + } + } + const lastActivityAt = state.lastActivityAt; return { doing, title, @@ -1386,7 +1514,7 @@ export function summarizeTail(state: TailState): { hasError, summary, lastEventAt, - lastActivityAt: state.lastActivityAt, + lastActivityAt, lastPromptAt: state.lastPrompt?.ts, lastInFlightSignalAt: state.lastInFlightSignalAt, lastIngestAt: state.lastIngestAt, diff --git a/src/codexNotify.ts b/src/codexNotify.ts index d3f3910..0eda804 100644 --- a/src/codexNotify.ts +++ b/src/codexNotify.ts @@ -1,6 +1,33 @@ #!/usr/bin/env node import { Effect } from "effect"; import { pathToFileURL } from "node:url"; +import os from "node:os"; +import path from "node:path"; + +function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): string { + const override = env.CONSENSUS_CODEX_HOME || env.CODEX_HOME; + if (!override) return path.join(os.homedir(), ".codex"); + if (override === "~") { + return os.homedir(); + } + if (override.startsWith("~/")) { + return path.join(os.homedir(), override.slice(2)); + } + return path.resolve(override); +} + +const appendNotifyPayload = (payload: NotifyPayload) => + Effect.tryPromise({ + try: async () => { + if (!payload) return; + const fs = await import("node:fs/promises"); + const codexHome = resolveCodexHome(); + const notifyPath = path.join(codexHome, "consensus", "codex-notify.jsonl"); + await fs.mkdir(path.dirname(notifyPath), { recursive: true }); + await fs.appendFile(notifyPath, `${JSON.stringify(payload)}\n`, "utf8"); + }, + catch: () => undefined, + }); export type NotifyPayload = Record | null; @@ -125,12 +152,16 @@ export const runCodexNotify = ( catch: () => "", }); const payloadText = argPayload || stdinPayload; - - if (!endpoint || !payloadText) return false; + if (!payloadText) return false; const payload = normalizePayload(payloadText); + if (!payload) return false; + + yield* appendNotifyPayload(payload); + + if (!endpoint) return true; const event = extractWebhookEvent(payload); - if (!event) return false; + if (!event) return true; yield* postEvent(endpoint, event, payload, fetchImpl); diff --git a/src/dedupe.ts b/src/dedupe.ts index 5f490f9..3f8b6a5 100644 --- a/src/dedupe.ts +++ b/src/dedupe.ts @@ -6,6 +6,13 @@ const STATE_RANK: Record = { idle: 1, }; +const isDebugDedupe = () => process.env.CONSENSUS_DEBUG_DEDUPE === "1"; + +function logDedupe(message: string): void { + if (!isDebugDedupe()) return; + process.stderr.write(`[consensus][dedupe] ${Date.now()} ${message}\n`); +} + function isServerKind(kind: AgentKind): boolean { return kind.endsWith("server") || kind === "app-server"; } @@ -38,6 +45,21 @@ function pickBetter(a: AgentSnapshot, b: AgentSnapshot): AgentSnapshot { return a; } +function pickReason(a: AgentSnapshot, b: AgentSnapshot): string { + const rankA = STATE_RANK[a.state] ?? 0; + const rankB = STATE_RANK[b.state] ?? 0; + if (rankA !== rankB) return "state"; + const eventA = a.lastEventAt ?? 0; + const eventB = b.lastEventAt ?? 0; + if (eventA !== eventB) return "lastEventAt"; + if (a.cpu !== b.cpu) return "cpu"; + if (a.mem !== b.mem) return "mem"; + const startA = a.startedAt ?? 0; + const startB = b.startedAt ?? 0; + if (startA !== startB) return "startedAt"; + return "tie"; +} + export function dedupeAgents(agents: AgentSnapshot[]): AgentSnapshot[] { const byKey = new Map(); for (const agent of agents) { @@ -47,7 +69,21 @@ export function dedupeAgents(agents: AgentSnapshot[]): AgentSnapshot[] { byKey.set(key, agent); continue; } - byKey.set(key, pickBetter(existing, agent)); + const winner = pickBetter(existing, agent); + if (isDebugDedupe()) { + const reason = pickReason(existing, agent); + const left = `${existing.identity ?? existing.sessionPath ?? `pid:${existing.pid}`}`; + const right = `${agent.identity ?? agent.sessionPath ?? `pid:${agent.pid}`}`; + const kept = winner === existing ? left : right; + const dropped = winner === existing ? right : left; + logDedupe( + `key=${key} reason=${reason} kept=${kept} dropped=${dropped} ` + + `states=${existing.state}/${agent.state} ` + + `eventAt=${existing.lastEventAt ?? "?"}/${agent.lastEventAt ?? "?"} ` + + `activityAt=${existing.lastActivityAt ?? "?"}/${agent.lastActivityAt ?? "?"}` + ); + } + byKey.set(key, winner); } return [...byKey.values()]; } diff --git a/src/scan.ts b/src/scan.ts index 23f5dd0..5b79615 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -23,12 +23,8 @@ import { summarizeTail, updateTail, getTailState, - getSessionMeta, getSessionStartMsFromPath, } from "./codexLogs.js"; -import { codexEventStore } from "./services/codexEvents.js"; -import type { SessionFile } from "./codexLogs.js"; - import { getOpenCodeSessions, getOpenCodeSessionActivity, type OpenCodeSessionResult } from "./opencodeApi.js"; import { ensureOpenCodeServer } from "./opencodeServer.js"; import { @@ -60,6 +56,11 @@ import { runPromise, } from "./observability/index.js"; +type SessionFile = { + path: string; + mtimeMs: number; +}; + const dirtySessionPaths = new Set(); const execFileAsync = promisify(execFile); const fsp = fs.promises; @@ -136,15 +137,22 @@ const opencodeSessionByPidCache = new Map< number, { sessionId: string; lastSeenAt: number } >(); +const pidIdentityCache = new Map(); const START_MS_EPSILON_MS = 1000; const isDebugActivity = () => process.env.CONSENSUS_DEBUG_ACTIVITY === "1"; +const isDebugSession = () => process.env.CONSENSUS_DEBUG_SESSION === "1"; function logActivityDecision(message: string): void { if (!isDebugActivity()) return; process.stderr.write(`[consensus][activity] ${message}\n`); } +function logSessionDecision(message: string): void { + if (!isDebugSession()) return; + process.stderr.write(`[consensus][session] ${Date.now()} ${message}\n`); +} + function isStartMsMismatch(cached?: number, current?: number): boolean { if (typeof cached !== "number" || typeof current !== "number") return false; return Math.abs(cached - current) > START_MS_EPSILON_MS; @@ -191,22 +199,6 @@ export function markSessionDirty(sessionPath: string): void { dirtySessionPaths.add(sessionPath); } -export interface CodexNotifyEndGateInput { - tailAllowsNotifyEnd: boolean; - notifyEndIsFresh: boolean; - tailEndAt?: number; - tailInFlight?: boolean; -} - -export function shouldApplyCodexNotifyEnd(input: CodexNotifyEndGateInput): boolean { - return ( - input.tailAllowsNotifyEnd && - input.notifyEndIsFresh && - !input.tailEndAt && - !input.tailInFlight - ); -} - function consumeDirtySessions(): Set { const dirty = new Set(dirtySessionPaths); dirtySessionPaths.clear(); @@ -821,6 +813,14 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0; + if (isDebugSession()) { + logSessionDecision( + `scan mode=${mode} fastCache=${shouldUseProcessCache ? 1 : 0} ` + + `processCacheAge=${now - processCache.at} ` + + `processCount=${processCache.processes.length} ` + + `jsonlByPid=${processCache.jsonlByPid?.size ?? 0}` + ); + } let processes: PsProcess[] = []; let usage: Record = {}; @@ -829,6 +829,12 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); if (shouldUseProcessCache) { + if (isDebugSession()) { + logSessionDecision( + `using cached processes count=${processCache.processes.length} ` + + `jsonlByPid=${processCache.jsonlByPid?.size ?? 0}` + ); + } processes = processCache.processes; usage = processCache.usage; cwds = processCache.cwds; @@ -1271,6 +1277,10 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); const normalizeSessionPath = (value?: string): string | undefined => value ? path.resolve(value) : undefined; + const sessionsRoot = path.join(codexHome, "sessions"); + const isCodexSessionPath = (value?: string): boolean => { + if (!value) return false; + const normalized = normalizeSessionPath(value); + if (!normalized) return false; + return normalized.startsWith(`${sessionsRoot}${path.sep}`); + }; const jsonlPathSet = new Set(); const jsonlPaths = Array.from( @@ -1297,9 +1315,9 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); if (jsonlByPid.size > 0) { @@ -1338,7 +1356,16 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0 && + now - stat.mtimeMs > codexStaleFileMs + ) { + pidSessionCache.delete(proc.pid); + cachedEntry = undefined; + } else { + cachedSession = { path: cachedEntry.path, mtimeMs: stat.mtimeMs }; + } } catch { pidSessionCache.delete(proc.pid); } @@ -1346,40 +1373,77 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise !!value && isCodexSessionPath(value)) + ) + ); + const mappedJsonl = pickNewestJsonl(jsonlPaths, jsonlMtimes); const startMsForCwd = typeof startMs === "number" && now - startMs < 5 * 60_000 ? startMs : undefined; const cwdSession = cwdRaw - ? await findSessionByCwd(sessions, cwdRaw, startMsForCwd, usedSessionPaths) + ? ((await findSessionByCwd( + sessions, + cwdRaw, + startMsForCwd, + usedSessionPaths + )) as SessionFile | undefined) : undefined; const mappedSession = mappedJsonl ? { path: mappedJsonl, mtimeMs: jsonlMtimes.get(mappedJsonl) ?? now } : undefined; - let session = mappedSession; - session = - (sessionId && sessions.find((item) => item.path.includes(sessionId))) || - (sessionId ? await findSessionById(codexHome, sessionId) : undefined) || - session || - cwdSession || - cachedSession; - if (cwdSession && mappedSession) { + const sessionFromId = + sessionId && sessions.find((item) => item.path.includes(sessionId)); + const sessionFromFind = sessionId ? await findSessionById(codexHome, sessionId) : undefined; + const sessionFromMapped = mappedSession; + const sessionFromCwd = cwdSession; + const sessionFromCache = cachedSession; + let session = + sessionFromId || + sessionFromFind || + sessionFromMapped || + sessionFromCwd || + sessionFromCache; + let sessionSource: string = + (sessionFromId && "sessionId") || + (sessionFromFind && "findSessionById") || + (sessionFromMapped && "mapped") || + (sessionFromCwd && "cwd") || + (sessionFromCache && "cached") || + "none"; + const pinnedSession = + cachedSession && isCodexSessionPath(cachedSession.path) + ? cachedSession + : undefined; + if (pinnedSession) { + session = pinnedSession; + sessionSource = "pinned"; + } + if (!pinnedSession && cwdSession && mappedSession) { const mappedMtime = mappedSession.mtimeMs ?? 0; const cwdMtime = cwdSession.mtimeMs ?? 0; if (cwdMtime > mappedMtime + 1000) { session = cwdSession; + sessionSource = "cwd-preferred"; } } - const hasExplicitSession = !!sessionId || !!mappedJsonl; + const hasExplicitSession = !!sessionId || !!mappedJsonl || !!cwdSession; + const hasPinnedSession = !!pinnedSession; const allowReuse = hasExplicitSession || /\bresume\b/i.test(cmdRaw); + const allowReusePinned = allowReuse || hasPinnedSession; const initialSessionPath = normalizeSessionPath(session?.path); let reuseBlocked = false; - if (initialSessionPath && usedSessionPaths.has(initialSessionPath) && !allowReuse) { - const alternate = cwdSession; - const alternatePath = normalizeSessionPath(alternate?.path); - if (alternate && alternatePath && alternatePath !== initialSessionPath) { - session = alternate; + if ( + initialSessionPath && + usedSessionPaths.has(initialSessionPath) && + !allowReusePinned + ) { + const cwdSessionValue = cwdSession as SessionFile | undefined; + const alternatePath = normalizeSessionPath(cwdSessionValue?.path); + if (cwdSessionValue && alternatePath && alternatePath !== initialSessionPath) { + session = cwdSessionValue; + sessionSource = "cwd-alternate"; } else { reuseBlocked = true; } @@ -1387,11 +1451,42 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { + logSessionDecision( + `pid=${proc.pid} missing sessionPath with jsonls=${jsonlPaths.length} ` + ); + } + } codexContexts.push({ proc, cpu, @@ -1403,11 +1498,11 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise(); - const cachedTails = new Map>>(); const tailOptionsByPath = new Map(); if (includeActivity) { if (dirtySessions) { @@ -1419,18 +1514,25 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise prevTail.lastMtimeMs)); + if (shouldTail) { + tailTargets.add(sessionPath); + tailOptionsByPath.set(sessionPath, { keepStale: true }); } } } @@ -1444,11 +1546,11 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise>>([ - ...cachedTails.entries(), - ...tailEntries, - ]); + endProfile(tailsTimer, { updated: tailTargets.size }); + const tailsByPath = new Map>>(tailEntries); + for (const [sessionPath, tail] of tailsByPath) { + if (tail?.needsCatchUp) markSessionDirty(sessionPath); + } for (const ctx of codexContexts) { const { proc, cpu, mem, startMs, cmdRaw, cwdRaw, session, sessionId, reuseBlocked } = ctx; let doing: string | undefined; @@ -1467,7 +1569,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise | undefined; if (sessionPath) { - const tail = includeActivity ? tailsByPath.get(sessionPath) : getTailState(sessionPath); + const tail = includeActivity + ? tailsByPath.get(sessionPath) ?? getTailState(sessionPath) + : getTailState(sessionPath); if (tail) { - const tailSummary = summarizeTail(tail); + tailSummary = summarizeTail(tail); doing = tailSummary.doing; events = tailSummary.events; model = tailSummary.model; hasError = tailSummary.hasError; title = normalizeTitle(tailSummary.title); summary = tailSummary.summary; - tailInFlight = !!tailSummary.inFlight; - tailActivityAt = tailSummary.lastActivityAt; tailEventAt = tailSummary.lastEventAt; - tailInFlightSignalAt = tailSummary.lastInFlightSignalAt; - tailIngestAt = tailSummary.lastIngestAt; - tailEndAt = tailSummary.lastEndAt; - tailReviewMode = !!tailSummary.reviewMode; - tailOpenCallCount = tailSummary.openCallCount ?? 0; } } - // Merge notify + JSONL tail events (no CPU/mtime heuristics) - const tailActivityAtCandidate = - typeof tailActivityAt === "number" - ? tailActivityAt - : typeof tailEventAt === "number" - ? tailEventAt - : undefined; - inFlight = eventInFlight || tailInFlight; - if (process.env.CODEX_TEST_HOOKS === "1") { - "TEST_HOOK_INFLIGHT_MERGE"; - } - const mergedActivityAt = Math.max( - typeof eventActivityAt === "number" ? eventActivityAt : 0, - typeof tailActivityAtCandidate === "number" ? tailActivityAtCandidate : 0 - ); - lastActivityAt = mergedActivityAt > 0 ? mergedActivityAt : undefined; - lastEventAt = tailEventAt ?? eventActivityAt; + // JSONL tail is the single source of truth for in-flight/activity. + inFlight = !!tailSummary?.inFlight; + lastActivityAt = tailSummary?.lastActivityAt; + lastEventAt = tailEventAt; + const activityAt = lastActivityAt ?? lastEventAt; if (!doing) { doing = @@ -1561,11 +1627,47 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise ${sessionIdentity} ` + + `reuseBlocked=${reuseBlocked ? 1 : 0} ` + + `sessionPath=${redactedSessionPath ? path.basename(redactedSessionPath) : "none"}` + ); + } + if ( + sessionIdentity.startsWith("pid:") && + prevIdentity && + !prevIdentity.startsWith("pid:") + ) { + logSessionDecision( + `pid=${proc.pid} identity downgrade to pid-only prev=${prevIdentity}` + ); + } + } + pidIdentityCache.set(proc.pid, sessionIdentity); if (sessionPath) { + const prevPinned = pidSessionCache.get(proc.pid); + if ( + prevPinned && + prevPinned.path !== sessionPath && + !isStartMsMismatch(prevPinned.startMs, startMs) + ) { + logActivityDecision( + `codex session switch pid=${proc.pid} ` + + `${path.basename(prevPinned.path)} -> ${path.basename(sessionPath)}` + ); + } pidSessionCache.set(proc.pid, { path: sessionPath, lastSeenAt: now, startMs }); } const id = `${proc.pid}`; @@ -1574,63 +1676,35 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise= tailActivityAtCandidate); - const notifyShouldEnd = shouldApplyCodexNotifyEnd({ - tailAllowsNotifyEnd, - notifyEndIsFresh, - tailEndAt, - tailInFlight, - }); - if (process.env.CODEX_TEST_HOOKS === "1") { - "TEST_HOOK_NOTIFY_END_APPLY"; - } - if (notifyShouldEnd) { - inFlight = false; - } - const explicitEndAt = tailEndAt ?? (notifyShouldEnd ? notifyEndAt : undefined); - const effectiveHoldMs = explicitEndAt ? 0 : codexHoldMs; - const effectiveIdleMs = inFlight ? 0 : codexEventIdleMs; const eventState = deriveCodexEventState({ inFlight, - lastActivityAt: activityAt, + lastActivityAt: lastActivityAt, hasError, now, holdMs: effectiveHoldMs, idleMs: effectiveIdleMs, }); state = eventState.state; - if (!hasNotify) { - reason = eventState.reason?.startsWith("event_") - ? eventState.reason.replace("event_", "tail_") - : "tail"; - } else { - reason = eventState.reason || "event"; + reason = eventState.reason || "event"; + if ( + eventState.reason === "event_hold" && + typeof lastActivityAt === "number" && + codexHoldMs > 0 + ) { + bumpNextTickAt(lastActivityAt + codexHoldMs); } } - if ( - reason === "event_hold" && - typeof activityAt === "number" && - codexHoldMs > 0 - ) { - bumpNextTickAt(activityAt + codexHoldMs); - } const prevState = cached?.lastState; if (prevState && prevState !== state) { metricEffects.push(recordActivityTransition("codex", prevState, state, reason)); @@ -1638,8 +1712,8 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise ${state} pid=${proc.pid} reason=${reason} ` + `inFlight=${inFlight ? 1 : 0} ` + - `lastActivity=${activityAt ?? "?"} ` + - `eventStore=${hasNotify ? "yes" : "no"}` + `lastActivity=${lastActivityAt ?? "?"} ` + + `tail=${hasTail ? "yes" : "no"}` ); } // Don't track CPU for Codex - we're event-driven now diff --git a/src/server.ts b/src/server.ts index 42011a1..acf80ef 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,7 +11,6 @@ import { Effect, Exit, Fiber } from "effect"; import { scanCodexProcesses, markSessionDirty } from "./scan.js"; import { resolveCodexHome } from "./codexLogs.js"; import { onOpenCodeEvent, stopOpenCodeEventStream } from "./opencodeEvents.js"; -import { codexEventStore } from "./services/codexEvents.js"; import { CodexEventSchema } from "./codex/types.js"; import { ClaudeEventSchema } from "./claude/types.js"; import { handleClaudeEventEffect } from "./services/claudeEvents.js"; @@ -353,17 +352,17 @@ app.post("/api/codex-event", express.json(), (req, res) => { const effect = Effect.gen(function* () { // Decode and validate event const decodeResult = Schema.decodeUnknownEither(CodexEventSchema)(req.body); - + if (decodeResult._tag === "Left") { const error = ParseResult.TreeFormatter.formatErrorSync(decodeResult.left); - res.status(400).json({ - ok: false, + res.status(400).json({ + ok: false, error: "Invalid event schema", - details: error + details: error }); return 400; } - + const event = decodeResult.right; if (process.env.CONSENSUS_CODEX_NOTIFY_DEBUG === "1") { @@ -371,20 +370,25 @@ app.post("/api/codex-event", express.json(), (req, res) => { `[consensus] codex event type=${event.type} thread=${event.threadId}\n` ); } - - // Store event (sync for immediate availability) - codexEventStore.handleEvent(event); - + // Trigger fast scan to update UI requestTick("fast"); - + res.json({ ok: true, received: event.type }); return 200; }); - + runHttpEffect(req, res, "/api/codex-event", effect); }); +app.get("/api/codex-event", (_req, res) => { + res.status(405).json({ + ok: false, + error: "Method Not Allowed", + message: "This endpoint expects a POST request with an event payload.", + }); +}); + // Claude webhook endpoint - receives events from Claude hooks app.post("/api/claude-event", express.json(), (req, res) => { const effect = Effect.gen(function* () { @@ -411,6 +415,14 @@ app.post("/api/claude-event", express.json(), (req, res) => { runHttpEffect(req, res, "/api/claude-event", effect); }); +app.get("/api/claude-event", (_req, res) => { + res.status(405).json({ + ok: false, + error: "Method Not Allowed", + message: "This endpoint expects a POST request with an event payload.", + }); +}); + let lastSnapshot: SnapshotPayload = { ts: Date.now(), agents: [] }; let lastBaseSnapshot: SnapshotPayload = lastSnapshot; let scanning = false; @@ -485,8 +497,8 @@ function logStateChanges(prev: SnapshotPayload, next: SnapshotPayload): void { if (!prevAgent || prevAgent.state !== agent.state) { logDebug( `state ${key} ${prevAgent?.state ?? "none"} -> ${agent.state} ` + - `pid=${agent.pid ?? "?"} lastEventAt=${agent.lastEventAt ?? "?"} ` + - `doing=${agent.doing ?? "?"}` + `pid=${agent.pid ?? "?"} lastEventAt=${agent.lastEventAt ?? "?"} ` + + `doing=${agent.doing ?? "?"}` ); } } @@ -737,11 +749,11 @@ function startCodexWatcher(): void { if (!fs.existsSync(codexSessionsDir)) return; const watchOptions = codexWatchPoll ? { - ignoreInitial: true, - usePolling: true, - interval: codexWatchInterval, - binaryInterval: codexWatchBinaryInterval, - } + ignoreInitial: true, + usePolling: true, + interval: codexWatchInterval, + binaryInterval: codexWatchBinaryInterval, + } : { ignoreInitial: true }; codexWatcher = chokidar.watch( path.join(codexSessionsDir, "**/*.jsonl"), diff --git a/src/services/codexEvents.ts b/src/services/codexEvents.ts deleted file mode 100644 index 591542f..0000000 --- a/src/services/codexEvents.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Context, Effect, Layer, Ref, Option } from "effect"; -import type { CodexEvent, ThreadState } from "../codex/types.js"; - -/** - * Service for managing Codex events and thread state - * Uses Effect for async operations and state management - */ -export class CodexEventService extends Context.Tag("CodexEventService")< - CodexEventService, - { - readonly handleEvent: (event: CodexEvent) => Effect.Effect; - readonly getThreadState: (threadId: string) => Effect.Effect>; - readonly getAllActiveThreads: () => Effect.Effect>; - } ->() {} - -/** - * Live implementation of CodexEventService - * Uses Ref for concurrent state management - */ -export const CodexEventServiceLive = Layer.effect( - CodexEventService, - Effect.gen(function* () { - const stateRef = yield* Ref.make(new Map()); - - const handleEvent = (event: CodexEvent): Effect.Effect => - Effect.gen(function* () { - yield* Ref.update(stateRef, (map) => { - const current = map.get(event.threadId); - const turnIdStr = event.turnId?.toString() ?? ""; - - switch (event.type) { - case "thread.started": - case "turn.started": - return new Map(map).set(event.threadId, { - inFlight: true, - lastActivityAt: event.timestamp, - activeItems: current?.activeItems ?? new Set() - }); - - case "item.started": { - const items = new Set(current?.activeItems ?? []); - if (turnIdStr) items.add(turnIdStr); - return new Map(map).set(event.threadId, { - inFlight: true, - lastActivityAt: event.timestamp, - activeItems: items - }); - } - - case "item.completed": { - const items = new Set(current?.activeItems ?? []); - items.delete(turnIdStr); - return new Map(map).set(event.threadId, { - inFlight: items.size > 0, - lastActivityAt: event.timestamp, - activeItems: items - }); - } - - case "agent-turn-complete": - return new Map(map).set(event.threadId, { - inFlight: false, - lastActivityAt: event.timestamp, - activeItems: new Set() - }); - - default: - // Exhaustive check - should never reach here - return map; - } - }); - - yield* Effect.log(`[Codex] ${event.type} thread=${event.threadId}`); - }); - - const getThreadState = (threadId: string) => - Ref.get(stateRef).pipe( - Effect.map((map) => Option.fromNullable(map.get(threadId))) - ); - - const getAllActiveThreads = () => Ref.get(stateRef); - - return { handleEvent, getThreadState, getAllActiveThreads }; - }) -); - -/** - * Singleton store for non-Effect code (scan.ts) - * Maintains same state as the Effect service - */ -class CodexEventStore { - private state = new Map(); - - handleEvent(event: CodexEvent): void { - const current = this.state.get(event.threadId); - const turnIdStr = event.turnId?.toString() ?? ""; - - switch (event.type) { - case "thread.started": - case "turn.started": - this.state.set(event.threadId, { - inFlight: true, - lastActivityAt: event.timestamp, - activeItems: current?.activeItems ?? new Set() - }); - break; - - case "item.started": { - const items = new Set(current?.activeItems ?? []); - if (turnIdStr) items.add(turnIdStr); - this.state.set(event.threadId, { - inFlight: true, - lastActivityAt: event.timestamp, - activeItems: items - }); - break; - } - - case "item.completed": { - const items = new Set(current?.activeItems ?? []); - items.delete(turnIdStr); - this.state.set(event.threadId, { - inFlight: items.size > 0, - lastActivityAt: event.timestamp, - activeItems: items - }); - break; - } - - case "agent-turn-complete": - this.state.set(event.threadId, { - inFlight: false, - lastActivityAt: event.timestamp, - activeItems: new Set() - }); - break; - } - } - - getThreadState(threadId: string): ThreadState | undefined { - return this.state.get(threadId); - } - - getAllThreads(): ReadonlyMap { - return this.state; - } -} - -// Export singleton instance for use in scan.ts -export const codexEventStore = new CodexEventStore(); diff --git a/tests/integration/claudeHook.test.ts b/tests/integration/claudeHook.test.ts index c7d707a..ee7ed6a 100644 --- a/tests/integration/claudeHook.test.ts +++ b/tests/integration/claudeHook.test.ts @@ -35,8 +35,13 @@ async function startServer(): Promise<{ res.end(); }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); + await new Promise((resolve, reject) => { + const onError = (err: unknown) => reject(err); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); }); const address = server.address(); @@ -44,8 +49,18 @@ async function startServer(): Promise<{ return { server, port, received }; } -test("claude hook forwards payload to consensus endpoint", async () => { - const { server, port, received } = await startServer(); +test("claude hook forwards payload to consensus endpoint", async (t) => { + let started: Awaited> | undefined; + try { + started = await startServer(); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return; + } + throw err; + } + const { server, port, received } = started; const script = path.join(process.cwd(), "src", "claudeHook.ts"); const endpoint = `http://127.0.0.1:${port}/api/claude-event`; const payload = { diff --git a/tests/integration/codexLogs.test.ts b/tests/integration/codexLogs.test.ts index 1a88350..648a172 100644 --- a/tests/integration/codexLogs.test.ts +++ b/tests/integration/codexLogs.test.ts @@ -6,6 +6,10 @@ import path from "node:path"; import { updateTail, summarizeTail, findSessionByCwd } from "../../src/codexLogs.ts"; import { getSessionStartMsFromPath, pickSessionForProcess } from "../../src/codexLogs.ts"; +function makeSessionMeta(id: string, cwd: string, ts: number) { + return { type: "session_meta", ts, payload: { cwd, id } }; +} + test("summarizes codex exec session logs", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); @@ -153,11 +157,16 @@ test("pickSessionForProcess uses session start over mtime", () => { test("treats user_message as in-flight activity", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); + const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now), + }, { type: "event_msg", - timestamp: "2026-01-29T20:00:00.000Z", + ts: now + 1, payload: { type: "user_message", role: "user", content: "Run tests" }, }, ]; @@ -215,9 +224,13 @@ test("marks prompts and assistant responses as activity", async () => { test("treats user-only prompts as in-flight activity", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "response_item", ts: now, @@ -236,6 +249,10 @@ test("treats user-only prompts as in-flight activity", async () => { content: [{ type: "input_text", text: "still waiting" }], }, }, + { + type: "response.started", + ts: now + 2, + }, ]; await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); @@ -255,28 +272,25 @@ test("treats user-only prompts as in-flight activity", async () => { test("session metadata does not start in-flight work", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); - const lines = [ - { - type: "session_meta", - ts: now, - payload: { cwd: "/tmp/project", id: "session-meta" }, - }, - { - type: "thread.started", - ts: now + 1, - item: { type: "prompt", input: "hello" }, - }, - ]; - - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - const state = await updateTail(file); - assert.ok(state); + await fs.writeFile(file, `${JSON.stringify(makeSessionMeta(threadId, "/tmp/project", now))}\n`); + const first = await updateTail(file); + assert.ok(first); + const firstSummary = summarizeTail(first); + assert.equal(firstSummary.inFlight, undefined); - const summary = summarizeTail(state); - assert.equal(summary.inFlight, undefined); + const started = { + type: "thread.started", + ts: now + 1, + item: { type: "prompt", input: "hello" }, + }; + await fs.appendFile(file, `${JSON.stringify(started)}\n`); + const second = await updateTail(file); + assert.ok(second); + const secondSummary = summarizeTail(second); + assert.equal(secondSummary.inFlight, true); await fs.rm(dir, { recursive: true, force: true }); }); @@ -321,9 +335,13 @@ test("summarizes response_item payloads for tools and commands", async () => { test("marks response_item tool work as in-flight", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "response_item", ts: now, @@ -359,9 +377,13 @@ test("marks response_item tool work as in-flight", async () => { test("assistant message does not end in-flight when no open calls", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "response_item", ts: now, @@ -433,12 +455,20 @@ test("treats tool response items without names as activity", async () => { test("treats output response delta events as activity", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ { - type: "response.output_text.delta", + ...makeSessionMeta(threadId, dir, now - 10), + }, + { + type: "response.started", ts: now, + }, + { + type: "response.output_text.delta", + ts: now + 1, delta: { text: "hi" }, }, ]; @@ -483,9 +513,13 @@ test("ignores input_text delta events for in-flight/activity", async () => { test("treats turn.started as in-flight activity", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "turn.started", ts: now, @@ -509,9 +543,13 @@ test("treats turn.started as in-flight activity", async () => { test("treats item.started as in-flight activity", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "item.started", ts: now, @@ -571,11 +609,16 @@ test("treats reasoning payloads as activity without exposing content", async () test("agent_message starts in-flight activity", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); + const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "event_msg", - timestamp: 1, + ts: now, payload: { type: "agent_reasoning", text: "Working through the steps", @@ -583,7 +626,7 @@ test("agent_message starts in-flight activity", async () => { }, { type: "event_msg", - timestamp: 2, + ts: now + 1, payload: { type: "agent_message", }, @@ -631,25 +674,28 @@ test("treats event_msg text payloads as assistant messages", async () => { test("stays active with periodic signals and turns idle after explicit end", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const pulseEveryMs = 300; + const threadId = path.basename(dir); + const pulseEveryMs = 100; const pollEveryMs = 50; - const durationMs = 1500; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "200"; - - const first = { - type: "response.output_text.delta", - ts: Date.now(), - delta: { text: "hi" }, - }; - await fs.writeFile(file, `${JSON.stringify(first)}\n`); + const durationMs = 600; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "250"; + + const startedAt = Date.now(); + const seed = [ + makeSessionMeta(threadId, dir, startedAt - 10), + { type: "response.started", ts: startedAt }, + { type: "response.output_text.delta", ts: startedAt + 1, delta: { text: "hi" } }, + ]; + await fs.writeFile(file, `${seed.map((line) => JSON.stringify(line)).join("\n")}\n`); - const start = Date.now(); - let nextPulse = start + pulseEveryMs; - while (Date.now() - start < durationMs) { - if (Date.now() >= nextPulse) { + const loopStart = Date.now(); + let nextPulse = loopStart + pulseEveryMs; + while (Date.now() - loopStart < durationMs) { + const now = Date.now(); + if (now >= nextPulse) { const line = { type: "response.output_text.delta", - ts: Date.now(), + ts: now, delta: { text: "tick" }, }; await fs.appendFile(file, `${JSON.stringify(line)}\n`); @@ -672,7 +718,7 @@ test("stays active with periodic signals and turns idle after explicit end", asy const endSummary = summarizeTail(endState); assert.equal(endSummary.inFlight, true); - await new Promise((resolve) => setTimeout(resolve, 250)); + await new Promise((resolve) => setTimeout(resolve, 400)); const finalState = await updateTail(file); assert.ok(finalState); const finalSummary = summarizeTail(finalState); @@ -685,9 +731,13 @@ test("stays active with periodic signals and turns idle after explicit end", asy test("does not expire in-flight without explicit end", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "response_item", ts: now, @@ -716,6 +766,87 @@ test("does not expire in-flight without explicit end", async () => { await fs.rm(dir, { recursive: true, force: true }); }); +test("open tool calls keep in-flight across timeouts until closed", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + const originalFresh = process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; + + try { + const now = Date.now(); + const startLines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, + { + type: "response_item", + ts: now, + payload: { type: "function_call", name: "toolA", call_id: "call_a" }, + }, + { + type: "response_item", + ts: now + 1, + payload: { type: "function_call", name: "toolB", call_id: "call_b" }, + }, + ]; + await fs.writeFile(file, `${startLines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const state = await updateTail(file); + assert.ok(state); + const summary = summarizeTail(state); + assert.equal(summary.inFlight, true); + assert.equal(summary.openCallCount, 2); + + await new Promise((resolve) => setTimeout(resolve, 60)); + const stateAfter = await updateTail(file); + assert.ok(stateAfter); + const summaryAfter = summarizeTail(stateAfter); + assert.equal(summaryAfter.inFlight, true); + assert.equal(summaryAfter.openCallCount, 2); + + const endLines = [ + { + type: "response_item", + ts: Date.now(), + payload: { type: "tool_call_output", call_id: "call_a" }, + }, + { + type: "response_item", + ts: Date.now() + 1, + payload: { type: "tool_call_output", call_id: "call_b" }, + }, + ]; + await fs.appendFile(file, `${endLines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const ingestState = await updateTail(file); + assert.ok(ingestState); + const ingestSummary = summarizeTail(ingestState); + assert.equal(ingestSummary.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 60)); + const stateFinal = await updateTail(file); + assert.ok(stateFinal); + const summaryFinal = summarizeTail(stateFinal); + assert.equal(summaryFinal.openCallCount, 0); + assert.equal(summaryFinal.inFlight, true); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + if (originalFresh === undefined) { + delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; + } else { + process.env.CONSENSUS_CODEX_FILE_FRESH_MS = originalFresh; + } + await fs.rm(dir, { recursive: true, force: true }); + } +}); + test("parses trailing codex event without newline", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); @@ -748,584 +879,621 @@ test("parses trailing codex event without newline", async () => { test("response.completed holds in-flight until timeout", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - Date.now = () => 1_000; - - const started = { - type: "response.started", - ts: 1, - }; - await fs.writeFile(file, `${JSON.stringify(started)}\n`); - - const first = await updateTail(file); - assert.ok(first); - const firstSummary = summarizeTail(first); - assert.equal(firstSummary.inFlight, true); - - const completed = { - type: "response.completed", - ts: 2, - }; - await fs.appendFile(file, `${JSON.stringify(completed)}\n`); - - Date.now = () => 1_500; - const second = await updateTail(file); - assert.ok(second); - const secondSummary = summarizeTail(second); - assert.equal(secondSummary.inFlight, true); - - Date.now = () => 5_000; - const third = await updateTail(file); - assert.ok(third); - const thirdSummary = summarizeTail(third); - assert.equal(thirdSummary.inFlight, undefined); - - const turnCompleted = { - type: "turn.completed", - ts: 3, - }; - await fs.appendFile(file, `${JSON.stringify(turnCompleted)}\n`); - Date.now = () => 3_000; - const fourth = await updateTail(file); - assert.ok(fourth); - const fourthSummary = summarizeTail(fourth); - assert.equal(fourthSummary.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + const originalGrace = process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = "1000"; + + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "response.started", ts: now }, + { type: "response.completed", ts: now + 1 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const first = await updateTail(file); + assert.ok(first); + const firstSummary = summarizeTail(first); + assert.equal(firstSummary.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const later = await updateTail(file); + assert.ok(later); + const laterSummary = summarizeTail(later); + assert.equal(laterSummary.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + if (originalGrace === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = originalGrace; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("does not expire in-flight codex state without explicit end when disabled", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "0"; - const lines = [ - { - type: "response.started", - ts: 1, - }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 1_500; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 12_500; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - await fs.rm(dir, { recursive: true, force: true }); + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "response.started", ts: now }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 50)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("review mode keeps in-flight until exited", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const entered = { - type: "event_msg", - ts: 1, - payload: { - type: "entered_review_mode", - target: { type: "uncommittedChanges" }, - }, - }; - await fs.writeFile(file, `${JSON.stringify(entered)}\n`); - - Date.now = () => 1_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 10_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - - const exited = { - type: "event_msg", - ts: 11, - payload: { - type: "exited_review_mode", - }, - }; - await fs.appendFile(file, `${JSON.stringify(exited)}\n`); - - Date.now = () => 11_000; - const stateExit = await updateTail(file); - assert.ok(stateExit); - const summaryExit = summarizeTail(stateExit); - assert.equal(summaryExit.inFlight, true); - - Date.now = () => 14_000; - const stateEnd = await updateTail(file); - assert.ok(stateEnd); - const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, undefined); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + try { + const now = Date.now(); + const entered = { + type: "event_msg", + ts: now, + payload: { + type: "entered_review_mode", + target: { type: "uncommittedChanges" }, + }, + }; + await fs.writeFile( + file, + `${JSON.stringify(makeSessionMeta(threadId, dir, now - 10))}\n${JSON.stringify(entered)}\n` + ); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + + const exited = { + type: "event_msg", + ts: Date.now(), + payload: { + type: "exited_review_mode", + }, + }; + await fs.appendFile(file, `${JSON.stringify(exited)}\n`); + + const stateExit = await updateTail(file); + assert.ok(stateExit); + const summaryExit = summarizeTail(stateExit); + assert.equal(summaryExit.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); -test("forces end after timeout when tool outputs never arrive", async () => { +test("open tool calls keep in-flight across timeout after end marker", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const lines = [ - { type: "response.started", ts: 1 }, - { type: "response_item", ts: 2, payload: { type: "function_call", name: "tool", call_id: "call_1" } }, - { type: "response.completed", ts: 3 }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 2_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 6_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "response.started", ts: now }, + { type: "response_item", ts: now + 1, payload: { type: "function_call", name: "tool", call_id: "call_1" } }, + { type: "response.completed", ts: now + 2 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 1); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + assert.equal(summaryLater.openCallCount, 1); + + const output = { + type: "response_item", + ts: Date.now(), + payload: { type: "function_call_output", call_id: "call_1" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + const stateClosed = await updateTail(file); + assert.ok(stateClosed); + const summaryClosed = summarizeTail(stateClosed); + assert.equal(summaryClosed.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); -test("turnOpen expires after timeout without explicit end", async () => { +test("turnOpen stays active without explicit end", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const lines = [ - { type: "turn.started", ts: 1 }, - { type: "event_msg", ts: 2, payload: { type: "agent_message", message: "working" } }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 2_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 6_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const lines = [makeSessionMeta(threadId, dir, now - 10), { type: "turn.started", ts: now }]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("open tool call keeps in-flight without new events", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "3000"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const start = { - type: "response_item", - ts: 1, - payload: { - type: "function_call", - name: "mcp__brv__brv-query", - call_id: "call_1", - arguments: "{}", - }, - }; - await fs.writeFile(file, `${JSON.stringify(start)}\n`); - - Date.now = () => 1_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 10_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const start = [ + makeSessionMeta(threadId, dir, now - 10), + { + type: "response_item", + ts: now, + payload: { + type: "function_call", + name: "mcp__brv__brv-query", + call_id: "call_1", + arguments: "{}", + }, + }, + ]; + await fs.writeFile(file, `${start.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 1); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + assert.equal(summaryLater.openCallCount, 1); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); -test("tool call without call_id times out without open calls", async () => { +test("tool call without call_id closes on tool output without call_id", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const start = { - type: "response_item", - ts: 1, - payload: { - type: "function_call", - arguments: "{}", - }, - }; - await fs.writeFile(file, `${JSON.stringify(start)}\n`); - - Date.now = () => 1_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - assert.equal(stateStart.openCallIds?.size ?? 0, 0); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 5_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const start = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "response_item", ts: now, payload: { type: "function_call", arguments: "{}" } }, + ]; + await fs.writeFile(file, `${start.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 1); + + const output = { type: "response_item", ts: now + 1, payload: { type: "function_call_output" } }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + const stateClose = await updateTail(file); + assert.ok(stateClose); + const summaryClose = summarizeTail(stateClose); + assert.equal(summaryClose.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("pending end waits for tool output to finish", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const lines = [ - { + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "response_item", ts: now, payload: { type: "function_call", name: "tool", call_id: "call_1" } }, + { type: "response.completed", ts: now + 1 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 1); + + const output = { type: "response_item", - ts: 1, - payload: { type: "function_call", name: "tool", call_id: "call_1" }, - }, - { type: "response.completed", ts: 2 }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 2_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - assert.ok(stateStart.pendingEndAt); - assert.equal(stateStart.inFlight, true); - - const output = { - type: "response_item", - ts: 3, - payload: { type: "function_call_output", call_id: "call_1" }, - }; - await fs.appendFile(file, `${JSON.stringify(output)}\n`); - - Date.now = () => 3_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - assert.ok(stateLater.pendingEndAt); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - - Date.now = () => 6_000; - const stateEnd = await updateTail(file); - assert.ok(stateEnd); - assert.equal(stateEnd.pendingEndAt, undefined); - const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + ts: now + 2, + payload: { type: "function_call_output", call_id: "call_1" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + assert.equal(summaryLater.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("pending end waits for item completion", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const lines = [ - { - type: "item.started", - ts: 1, - item: { id: "item_1", type: "command_execution" }, - }, - { type: "response.completed", ts: 2 }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 2_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - assert.equal(stateStart.openCallIds?.size ?? 0, 1); - assert.ok(stateStart.pendingEndAt); - assert.equal(stateStart.inFlight, true); - - const output = { - type: "item.completed", - ts: 3, - item: { id: "item_1", type: "command_execution", status: "completed" }, - }; - await fs.appendFile(file, `${JSON.stringify(output)}\n`); - - Date.now = () => 3_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - assert.ok(stateLater.pendingEndAt); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - - Date.now = () => 6_000; - const stateEnd = await updateTail(file); - assert.ok(stateEnd); - assert.equal(stateEnd.pendingEndAt, undefined); - const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "item.started", ts: now, item: { id: "item_1", type: "command_execution" } }, + { type: "response.completed", ts: now + 1 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 1); + + const output = { + type: "item.completed", + ts: now + 2, + item: { id: "item_1", type: "command_execution", status: "completed" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + assert.equal(summaryLater.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("pending end waits for item completion without id", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const lines = [ - { - type: "item.started", - ts: 1, - item: { type: "command_execution" }, - }, - { type: "response.completed", ts: 2 }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 2_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - assert.ok(stateStart.pendingEndAt); - assert.equal(stateStart.inFlight, true); - - const output = { - type: "item.completed", - ts: 3, - item: { type: "command_execution", status: "completed" }, - }; - await fs.appendFile(file, `${JSON.stringify(output)}\n`); - - Date.now = () => 3_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - assert.ok(stateLater.pendingEndAt); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - - Date.now = () => 6_000; - const stateEnd = await updateTail(file); - assert.ok(stateEnd); - assert.equal(stateEnd.pendingEndAt, undefined); - const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "item.started", ts: now, item: { type: "command_execution" } }, + { type: "response.completed", ts: now + 1 }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 1); + + const output = { + type: "item.completed", + ts: now + 2, + item: { type: "command_execution", status: "completed" }, + }; + await fs.appendFile(file, `${JSON.stringify(output)}\n`); + + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + assert.equal(summaryLater.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("tool output without call_id does not retain open call", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - - const lines = [ - { - type: "response_item", - ts: 1, - payload: { type: "function_call", name: "tool", call_id: "call_1" }, - }, - { type: "response.completed", ts: 2 }, - { - type: "response_item", - ts: 3, - payload: { type: "function_call_output" }, - }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 3_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - assert.equal(stateStart.openCallIds?.size ?? 0, 1); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 10_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - assert.equal(stateLater.openCallIds?.size ?? 0, 0); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + + try { + const now = Date.now(); + const lines = [ + makeSessionMeta(threadId, dir, now - 10), + { type: "response_item", ts: now, payload: { type: "function_call", name: "tool", call_id: "call_1" } }, + { type: "response.completed", ts: now + 1 }, + { type: "response_item", ts: now + 2, payload: { type: "function_call_output" } }, + ]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + assert.equal(summaryStart.openCallCount, 0); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("keeps in-flight codex state within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - const lines = [ - { - type: "response.started", - ts: 1, - }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 1_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 2_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "200"; - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + try { + const now = Date.now(); + const lines = [makeSessionMeta(threadId, dir, now - 10), { type: "response.started", ts: now }]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("invalid in-flight timeout keeps in-flight within timeout window", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "not-a-number"; - delete process.env.CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS; - process.env.CONSENSUS_CODEX_FILE_FRESH_MS = "0"; - const lines = [ - { - type: "response.started", - ts: 1, - }, - ]; - await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 1_000; - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - Date.now = () => 2_000; - const stateLater = await updateTail(file); - assert.ok(stateLater); - const summaryLater = summarizeTail(stateLater); - assert.equal(summaryLater.inFlight, true); - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - delete process.env.CONSENSUS_CODEX_FILE_FRESH_MS; - await fs.rm(dir, { recursive: true, force: true }); + try { + const now = Date.now(); + const lines = [makeSessionMeta(threadId, dir, now - 10), { type: "response.started", ts: now }]; + await fs.writeFile(file, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 100)); + const stateLater = await updateTail(file); + assert.ok(stateLater); + const summaryLater = summarizeTail(stateLater); + assert.equal(summaryLater.inFlight, true); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("turn.completed clears in-flight after response completion", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); - const originalNow = Date.now; - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "2500"; - Date.now = () => 1_000; - - const start = [ - { - type: "response.started", - ts: 1, - }, - ]; - await fs.writeFile(file, `${start.map((line) => JSON.stringify(line)).join("\n")}\n`); - - const stateStart = await updateTail(file); - assert.ok(stateStart); - const summaryStart = summarizeTail(stateStart); - assert.equal(summaryStart.inFlight, true); - - const end = [ - { - type: "response.completed", - ts: 2, - }, - { - type: "turn.completed", - ts: 3, - }, - ]; - await fs.appendFile(file, `${end.map((line) => JSON.stringify(line)).join("\n")}\n`); - - Date.now = () => 3_000; - const stateEnd = await updateTail(file); - assert.ok(stateEnd); - const summaryEnd = summarizeTail(stateEnd); - assert.equal(summaryEnd.inFlight, true); - - Date.now = () => 6_000; - const stateFinal = await updateTail(file); - assert.ok(stateFinal); - const summaryFinal = summarizeTail(stateFinal); - assert.equal(summaryFinal.inFlight, undefined); - - Date.now = originalNow; - delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; - await fs.rm(dir, { recursive: true, force: true }); + const threadId = path.basename(dir); + const originalTimeout = process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + const originalGrace = process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = "50"; + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = "1000"; + + try { + const now = Date.now(); + const start = [makeSessionMeta(threadId, dir, now - 10), { type: "response.started", ts: now }]; + await fs.writeFile(file, `${start.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateStart = await updateTail(file); + assert.ok(stateStart); + const summaryStart = summarizeTail(stateStart); + assert.equal(summaryStart.inFlight, true); + + const end = [ + { type: "response.completed", ts: now + 1 }, + { type: "turn.completed", ts: now + 2 }, + ]; + await fs.appendFile(file, `${end.map((line) => JSON.stringify(line)).join("\n")}\n`); + + const stateEnd = await updateTail(file); + assert.ok(stateEnd); + const summaryEnd = summarizeTail(stateEnd); + assert.equal(summaryEnd.inFlight, true); + + await new Promise((resolve) => setTimeout(resolve, 80)); + const stateFinal = await updateTail(file); + assert.ok(stateFinal); + const summaryFinal = summarizeTail(stateFinal); + assert.equal(summaryFinal.inFlight, undefined); + } finally { + if (originalTimeout === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS = originalTimeout; + } + if (originalGrace === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = originalGrace; + } + await fs.rm(dir, { recursive: true, force: true }); + } }); test("does not treat response.input_text.delta as in-flight activity", async () => { @@ -1352,9 +1520,13 @@ test("does not treat response.input_text.delta as in-flight activity", async () test("assistant message does not clear in-flight state", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); const file = path.join(dir, "session.jsonl"); + const threadId = path.basename(dir); const now = Date.now(); const lines = [ + { + ...makeSessionMeta(threadId, dir, now - 10), + }, { type: "response.started", ts: now, diff --git a/tests/integration/codexNotifyHook.test.ts b/tests/integration/codexNotifyHook.test.ts index 22f7185..c154d99 100644 --- a/tests/integration/codexNotifyHook.test.ts +++ b/tests/integration/codexNotifyHook.test.ts @@ -35,8 +35,13 @@ async function startServer(): Promise<{ res.end(); }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); + await new Promise((resolve, reject) => { + const onError = (err: unknown) => reject(err); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); }); const address = server.address(); @@ -44,8 +49,18 @@ async function startServer(): Promise<{ return { server, port, received }; } -test("codex notify forwards payload to endpoint", async () => { - const { server, port, received } = await startServer(); +test("codex notify forwards payload to endpoint", async (t) => { + let started: Awaited> | undefined; + try { + started = await startServer(); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return; + } + throw err; + } + const { server, port, received } = started; const endpoint = `http://127.0.0.1:${port}/api/codex-event`; const payload = { type: "turn.started", @@ -76,8 +91,18 @@ test("codex notify forwards payload to endpoint", async () => { } }); -test("codex notify forwards argv payload to endpoint", async () => { - const { server, port, received } = await startServer(); +test("codex notify forwards argv payload to endpoint", async (t) => { + let started: Awaited> | undefined; + try { + started = await startServer(); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return; + } + throw err; + } + const { server, port, received } = started; const endpoint = `http://127.0.0.1:${port}/api/codex-event`; const payload = { type: "turn.started", diff --git a/tests/integration/opencodeActivity.test.ts b/tests/integration/opencodeActivity.test.ts index 5fb9a46..00178cf 100644 --- a/tests/integration/opencodeActivity.test.ts +++ b/tests/integration/opencodeActivity.test.ts @@ -13,12 +13,15 @@ function createMockServer( statusCode = 200, contentType = "application/json" ): Promise<{ server: http.Server; port: number; close: () => Promise }> { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { res.writeHead(statusCode, { "Content-Type": contentType }); res.end(JSON.stringify(responseData)); }); + const onError = (err: unknown) => reject(err); + server.once("error", onError); server.listen(0, "127.0.0.1", () => { + server.off("error", onError); const addr = server.address() as { port: number }; resolve({ server, @@ -29,7 +32,24 @@ function createMockServer( }); } -test("getOpenCodeSessionActivity returns inFlight=true for incomplete assistant message", async () => { +async function createMockServerOrSkip( + t: any, + responseData: unknown, + statusCode = 200, + contentType = "application/json" +): Promise<{ server: http.Server; port: number; close: () => Promise } | null> { + try { + return await createMockServer(responseData, statusCode, contentType); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return null; + } + throw err; + } +} + +test("getOpenCodeSessionActivity returns inFlight=true for incomplete assistant message", async (t) => { const messages = [ { info: { @@ -41,7 +61,8 @@ test("getOpenCodeSessionActivity returns inFlight=true for incomplete assistant parts: [], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -54,7 +75,7 @@ test("getOpenCodeSessionActivity returns inFlight=true for incomplete assistant } }); -test("getOpenCodeSessionActivity returns inFlight=false for completed assistant message", async () => { +test("getOpenCodeSessionActivity returns inFlight=false for completed assistant message", async (t) => { const messages = [ { info: { @@ -66,7 +87,8 @@ test("getOpenCodeSessionActivity returns inFlight=false for completed assistant parts: [], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -79,7 +101,7 @@ test("getOpenCodeSessionActivity returns inFlight=false for completed assistant } }); -test("getOpenCodeSessionActivity returns inFlight=true for pending tool call", async () => { +test("getOpenCodeSessionActivity returns inFlight=true for pending tool call", async (t) => { const messages = [ { info: { @@ -94,7 +116,8 @@ test("getOpenCodeSessionActivity returns inFlight=true for pending tool call", a ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -107,7 +130,7 @@ test("getOpenCodeSessionActivity returns inFlight=true for pending tool call", a } }); -test("getOpenCodeSessionActivity returns inFlight=true for running tool call", async () => { +test("getOpenCodeSessionActivity returns inFlight=true for running tool call", async (t) => { const messages = [ { info: { @@ -121,7 +144,8 @@ test("getOpenCodeSessionActivity returns inFlight=true for running tool call", a ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -134,7 +158,7 @@ test("getOpenCodeSessionActivity returns inFlight=true for running tool call", a } }); -test("getOpenCodeSessionActivity returns inFlight=false when all tools completed", async () => { +test("getOpenCodeSessionActivity returns inFlight=false when all tools completed", async (t) => { const messages = [ { info: { @@ -149,7 +173,8 @@ test("getOpenCodeSessionActivity returns inFlight=false when all tools completed ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -162,7 +187,7 @@ test("getOpenCodeSessionActivity returns inFlight=false when all tools completed } }); -test("getOpenCodeSessionActivity returns inFlight=true for incomplete part (no end time)", async () => { +test("getOpenCodeSessionActivity returns inFlight=true for incomplete part (no end time)", async (t) => { const messages = [ { info: { @@ -176,7 +201,8 @@ test("getOpenCodeSessionActivity returns inFlight=true for incomplete part (no e ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -189,7 +215,7 @@ test("getOpenCodeSessionActivity returns inFlight=true for incomplete part (no e } }); -test("getOpenCodeSessionActivity returns inFlight=false when message completed but parts incomplete", async () => { +test("getOpenCodeSessionActivity returns inFlight=false when message completed but parts incomplete", async (t) => { const now = Date.now(); const messages = [ { @@ -204,7 +230,8 @@ test("getOpenCodeSessionActivity returns inFlight=false when message completed b ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -217,8 +244,9 @@ test("getOpenCodeSessionActivity returns inFlight=false when message completed b } }); -test("getOpenCodeSessionActivity handles empty messages array", async () => { - const mock = await createMockServer([]); +test("getOpenCodeSessionActivity handles empty messages array", async (t) => { + const mock = await createMockServerOrSkip(t, []); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -231,8 +259,9 @@ test("getOpenCodeSessionActivity handles empty messages array", async () => { } }); -test("getOpenCodeSessionActivity handles non-200 response", async () => { - const mock = await createMockServer({ error: "not found" }, 404); +test("getOpenCodeSessionActivity handles non-200 response", async (t) => { + const mock = await createMockServerOrSkip(t, { error: "not found" }, 404); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -246,8 +275,9 @@ test("getOpenCodeSessionActivity handles non-200 response", async () => { } }); -test("getOpenCodeSessionActivity handles non-JSON response", async () => { - const mock = await createMockServer("not json", 200, "text/plain"); +test("getOpenCodeSessionActivity handles non-JSON response", async (t) => { + const mock = await createMockServerOrSkip(t, "not json", 200, "text/plain"); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -271,7 +301,7 @@ test("getOpenCodeSessionActivity handles connection timeout", async () => { assert.equal(result.inFlight, false); }); -test("getOpenCodeSessionActivity tracks lastActivityAt correctly", async () => { +test("getOpenCodeSessionActivity tracks lastActivityAt correctly", async (t) => { const now = Date.now(); const messages = [ { @@ -294,7 +324,8 @@ test("getOpenCodeSessionActivity tracks lastActivityAt correctly", async () => { ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -307,7 +338,7 @@ test("getOpenCodeSessionActivity tracks lastActivityAt correctly", async () => { } }); -test("getOpenCodeSessionActivity detects real-world pending tool scenario", async () => { +test("getOpenCodeSessionActivity detects real-world pending tool scenario", async (t) => { // Exact structure from the bug report const messages = [ { @@ -341,7 +372,8 @@ test("getOpenCodeSessionActivity detects real-world pending tool scenario", asyn ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -354,7 +386,7 @@ test("getOpenCodeSessionActivity detects real-world pending tool scenario", asyn } }); -test("message API inFlight stays active even when status is idle", async () => { +test("message API inFlight stays active even when status is idle", async (t) => { const now = Date.now(); const messages = [ { @@ -367,7 +399,8 @@ test("message API inFlight stays active even when status is idle", async () => { parts: [], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const activity = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, @@ -390,7 +423,7 @@ test("message API inFlight stays active even when status is idle", async () => { } }); -test("getOpenCodeSessionActivity handles completed message with completed tools", async () => { +test("getOpenCodeSessionActivity handles completed message with completed tools", async (t) => { const now = Date.now(); const messages = [ { @@ -408,7 +441,8 @@ test("getOpenCodeSessionActivity handles completed message with completed tools" ], }, ]; - const mock = await createMockServer(messages); + const mock = await createMockServerOrSkip(t, messages); + if (!mock) return; try { const result = await getOpenCodeSessionActivity("ses_test", "127.0.0.1", mock.port, { silent: true, diff --git a/tests/integration/opencodeApi.test.ts b/tests/integration/opencodeApi.test.ts index d9aa927..dd6f240 100644 --- a/tests/integration/opencodeApi.test.ts +++ b/tests/integration/opencodeApi.test.ts @@ -14,8 +14,13 @@ async function startServer(): Promise<{ server: http.Server; port: number }> { res.end(); }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); + await new Promise((resolve, reject) => { + const onError = (err: unknown) => reject(err); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); }); const address = server.address(); @@ -35,8 +40,13 @@ async function startTextServer(): Promise<{ server: http.Server; port: number }> res.end(); }); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); + await new Promise((resolve, reject) => { + const onError = (err: unknown) => reject(err); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); }); const address = server.address(); @@ -44,8 +54,18 @@ async function startTextServer(): Promise<{ server: http.Server; port: number }> return { server, port }; } -test("getOpenCodeSessions returns sessions from API", async () => { - const { server, port } = await startServer(); +test("getOpenCodeSessions returns sessions from API", async (t) => { + let started: Awaited> | undefined; + try { + started = await startServer(); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return; + } + throw err; + } + const { server, port } = started; try { const result = await getOpenCodeSessions("127.0.0.1", port, { timeoutMs: 2000 }); assert.equal(result.ok, true); @@ -56,8 +76,18 @@ test("getOpenCodeSessions returns sessions from API", async () => { } }); -test("getOpenCodeSessions returns ok false on non-JSON response", async () => { - const { server, port } = await startTextServer(); +test("getOpenCodeSessions returns ok false on non-JSON response", async (t) => { + let started: Awaited> | undefined; + try { + started = await startTextServer(); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return; + } + throw err; + } + const { server, port } = started; try { const result = await getOpenCodeSessions("127.0.0.1", port, { timeoutMs: 2000, diff --git a/tests/integration/opencodeSessionActivity.test.ts b/tests/integration/opencodeSessionActivity.test.ts index e093738..39f16b4 100644 --- a/tests/integration/opencodeSessionActivity.test.ts +++ b/tests/integration/opencodeSessionActivity.test.ts @@ -12,20 +12,40 @@ async function startServer( handler: (req: http.IncomingMessage, res: http.ServerResponse) => void ): Promise { const server = http.createServer(handler); - await new Promise((resolve) => { - server.listen(0, "127.0.0.1", resolve); + await new Promise((resolve, reject) => { + const onError = (err: unknown) => reject(err); + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.off("error", onError); + resolve(); + }); }); const address = server.address(); const port = typeof address === "object" && address ? address.port : 0; return { server, port }; } +async function startServerOrSkip( + t: any, + handler: (req: http.IncomingMessage, res: http.ServerResponse) => void +): Promise { + try { + return await startServer(handler); + } catch (err: any) { + if (err?.code === "EPERM") { + t.skip("Sandbox blocks listen(127.0.0.1) for integration tests"); + return null; + } + throw err; + } +} + async function closeServer(server: http.Server): Promise { await new Promise((resolve) => server.close(() => resolve())); } -test("getOpenCodeSessionActivity detects in-flight assistant message", async () => { - const { server, port } = await startServer((req, res) => { +test("getOpenCodeSessionActivity detects in-flight assistant message", async (t) => { + const started = await startServerOrSkip(t, (req, res) => { if (req.url === "/session/s1/message") { res.setHeader("Content-Type", "application/json"); res.end( @@ -39,6 +59,8 @@ test("getOpenCodeSessionActivity detects in-flight assistant message", async () res.statusCode = 404; res.end(); }); + if (!started) return; + const { server, port } = started; try { const result = await getOpenCodeSessionActivity("s1", "127.0.0.1", port, { @@ -61,8 +83,8 @@ process.on("exit", () => { } }); -test("getOpenCodeSessionActivity treats pending tool as in-flight", async () => { - const { server, port } = await startServer((req, res) => { +test("getOpenCodeSessionActivity treats pending tool as in-flight", async (t) => { + const started = await startServerOrSkip(t, (req, res) => { if (req.url === "/session/s2/message") { res.setHeader("Content-Type", "application/json"); res.end( @@ -78,6 +100,8 @@ test("getOpenCodeSessionActivity treats pending tool as in-flight", async () => res.statusCode = 404; res.end(); }); + if (!started) return; + const { server, port } = started; try { const result = await getOpenCodeSessionActivity("s2", "127.0.0.1", port, { @@ -92,9 +116,9 @@ test("getOpenCodeSessionActivity treats pending tool as in-flight", async () => } }); -test("getOpenCodeSessionActivity treats recent user message as in-flight", async () => { +test("getOpenCodeSessionActivity treats recent user message as in-flight", async (t) => { const created = Date.now(); - const { server, port } = await startServer((req, res) => { + const started = await startServerOrSkip(t, (req, res) => { if (req.url === "/session/s5/message") { res.setHeader("Content-Type", "application/json"); res.end( @@ -107,6 +131,8 @@ test("getOpenCodeSessionActivity treats recent user message as in-flight", async res.statusCode = 404; res.end(); }); + if (!started) return; + const { server, port } = started; try { const result = await getOpenCodeSessionActivity("s5", "127.0.0.1", port, { @@ -121,8 +147,8 @@ test("getOpenCodeSessionActivity treats recent user message as in-flight", async } }); -test("getOpenCodeSessionActivity returns ok false on non-JSON response", async () => { - const { server, port } = await startServer((req, res) => { +test("getOpenCodeSessionActivity returns ok false on non-JSON response", async (t) => { + const started = await startServerOrSkip(t, (req, res) => { if (req.url === "/session/s3/message") { res.statusCode = 200; res.setHeader("Content-Type", "text/plain"); @@ -132,6 +158,8 @@ test("getOpenCodeSessionActivity returns ok false on non-JSON response", async ( res.statusCode = 404; res.end(); }); + if (!started) return; + const { server, port } = started; try { const result = await getOpenCodeSessionActivity("s3", "127.0.0.1", port, { @@ -146,8 +174,8 @@ test("getOpenCodeSessionActivity returns ok false on non-JSON response", async ( } }); -test("getOpenCodeSessionActivity returns ok false on non-200 response", async () => { - const { server, port } = await startServer((req, res) => { +test("getOpenCodeSessionActivity returns ok false on non-200 response", async (t) => { + const started = await startServerOrSkip(t, (req, res) => { if (req.url === "/session/s4/message") { res.statusCode = 500; res.end(); @@ -156,6 +184,8 @@ test("getOpenCodeSessionActivity returns ok false on non-200 response", async () res.statusCode = 404; res.end(); }); + if (!started) return; + const { server, port } = started; try { const result = await getOpenCodeSessionActivity("s4", "127.0.0.1", port, { diff --git a/tests/unit/codexEventState.test.ts b/tests/unit/codexEventState.test.ts index e26533d..64de5bb 100644 --- a/tests/unit/codexEventState.test.ts +++ b/tests/unit/codexEventState.test.ts @@ -70,6 +70,20 @@ test("codex event state: idle after hold window", () => { assert.equal(result.reason, "event_idle"); }); +test("codex event state: holdMs=0 yields immediate idle when not inFlight", () => { + const now = 10_000; + const result = deriveCodexEventState({ + inFlight: false, + lastActivityAt: now - 1, + hasError: false, + now, + holdMs: 0, + idleMs: 20000, + }); + assert.equal(result.state, "idle"); + assert.equal(result.reason, "event_idle"); +}); + test("codex event state: stale inFlight times out", () => { const now = Date.now(); const result = deriveCodexEventState({ diff --git a/tests/unit/codexEventStore.test.ts b/tests/unit/codexEventStore.test.ts index 86727be..8448bfe 100644 --- a/tests/unit/codexEventStore.test.ts +++ b/tests/unit/codexEventStore.test.ts @@ -1,162 +1,69 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { codexEventStore } from "../../src/services/codexEvents.js"; -import type { CodexEvent } from "../../src/codex/types.js"; +import { CodexLifecycleGraph } from "../../src/codex/lifecycleGraph.js"; -test("event store tracks thread inFlight state", () => { - const threadId = "thread-abc-123"; - - // Initial state - no events - let state = codexEventStore.getThreadState(threadId); - assert.strictEqual(state, undefined); - - // Turn started - codexEventStore.handleEvent({ - type: "turn.started", - threadId, - turnId: "turn-1", - timestamp: Date.now() - } as CodexEvent); - - state = codexEventStore.getThreadState(threadId); - assert.ok(state); - assert.strictEqual(state?.inFlight, true); - - // Turn completed - codexEventStore.handleEvent({ - type: "agent-turn-complete", - threadId, - turnId: "turn-1", - timestamp: Date.now() - } as CodexEvent); - - state = codexEventStore.getThreadState(threadId); - assert.ok(state); - assert.strictEqual(state?.inFlight, false); -}); +function withEnv(vars: Record, fn: () => void): void { + const prior: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prior[key] = process.env[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} -test("event store tracks item-level activity", () => { - const threadId = "thread-def-456"; - - // Multiple items started - codexEventStore.handleEvent({ - type: "item.started", - threadId, - turnId: "item-1", - timestamp: Date.now() - } as CodexEvent); - - codexEventStore.handleEvent({ - type: "item.started", - threadId, - turnId: "item-2", - timestamp: Date.now() - } as CodexEvent); - - let state = codexEventStore.getThreadState(threadId); - assert.strictEqual(state?.inFlight, true); - - // One item completes - still in flight - codexEventStore.handleEvent({ - type: "item.completed", - threadId, - turnId: "item-1", - timestamp: Date.now() - } as CodexEvent); - - state = codexEventStore.getThreadState(threadId); - assert.strictEqual(state?.inFlight, true); - - // Last item completes - idle - codexEventStore.handleEvent({ - type: "item.completed", - threadId, - turnId: "item-2", - timestamp: Date.now() - } as CodexEvent); - - state = codexEventStore.getThreadState(threadId); - assert.strictEqual(state?.inFlight, false); -}); +test("lifecycle graph tracks open tool calls", () => { + const graph = new CodexLifecycleGraph(); + const threadId = "thread-tools"; -test("event store updates lastActivityAt on every event", () => { - const threadId = "thread-ghi-789"; - const ts1 = 1000000; - const ts2 = 2000000; - - codexEventStore.handleEvent({ - type: "turn.started", - threadId, - turnId: "turn-1", - timestamp: ts1 - } as CodexEvent); - - let state = codexEventStore.getThreadState(threadId); - assert.strictEqual(state?.lastActivityAt, ts1); - - codexEventStore.handleEvent({ - type: "agent-turn-complete", - threadId, - turnId: "turn-1", - timestamp: ts2 - } as CodexEvent); - - state = codexEventStore.getThreadState(threadId); - assert.strictEqual(state?.lastActivityAt, ts2); -}); + graph.ingestToolStart(threadId, "call_1", 1000, 1000); + let snap = graph.getThreadSnapshot(threadId, 1000); + assert.equal(snap?.openCallCount, 1); + assert.equal(snap?.inFlight, true); + assert.equal(snap?.reason, "tool_open"); -test("event store handles multiple threads independently", () => { - const thread1 = "thread-1"; - const thread2 = "thread-2"; - - codexEventStore.handleEvent({ - type: "turn.started", - threadId: thread1, - turnId: "turn-1", - timestamp: Date.now() - } as CodexEvent); - - codexEventStore.handleEvent({ - type: "agent-turn-complete", - threadId: thread2, - turnId: "turn-2", - timestamp: Date.now() - } as CodexEvent); - - const state1 = codexEventStore.getThreadState(thread1); - const state2 = codexEventStore.getThreadState(thread2); - - assert.strictEqual(state1?.inFlight, true); - assert.strictEqual(state2?.inFlight, false); + graph.ingestToolEnd(threadId, "call_1", 1100, 1100); + snap = graph.getThreadSnapshot(threadId, 1100); + assert.equal(snap?.openCallCount, 0); + assert.equal(snap?.inFlight, true); + assert.equal(snap?.reason, "turn_open"); }); -test("event store thread isolation", () => { - const allThreads = codexEventStore.getAllThreads(); - - // Clear any existing state - for (const [threadId, state] of allThreads) { - if (state) { - codexEventStore.handleEvent({ - type: "agent-turn-complete", - threadId, - timestamp: Date.now() - } as CodexEvent); +test("pending end finalizes only after grace and no open calls", () => { + withEnv( + { + CONSENSUS_CODEX_INFLIGHT_GRACE_MS: "1000", + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "2500", + }, + () => { + const graph = new CodexLifecycleGraph(); + const threadId = "thread-pending-end"; + + graph.ingestAgentStart(threadId, 1000, 1000); + graph.ingestAgentStop(threadId, 2000, 2000); + + const pending = graph.getThreadSnapshot(threadId, 2500); + assert.equal(pending?.inFlight, true); + assert.equal(pending?.reason, "pending_end"); + assert.equal(pending?.endedAt, undefined); + + const ended = graph.getThreadSnapshot(threadId, 3100); + assert.equal(ended?.inFlight, false); + assert.equal(ended?.reason, "ended"); + assert.equal(ended?.endedAt, 2000); } - } - - const threadA = "isolated-thread-a"; - const threadB = "isolated-thread-b"; - - codexEventStore.handleEvent({ - type: "turn.started", - threadId: threadA, - turnId: "turn-a", - timestamp: Date.now() - } as CodexEvent); - - const stateA = codexEventStore.getThreadState(threadA); - const stateB = codexEventStore.getThreadState(threadB); - - assert.strictEqual(stateA?.inFlight, true); - assert.strictEqual(stateB, undefined); + ); }); diff --git a/tests/unit/codexScan.test.ts b/tests/unit/codexScan.test.ts index 2b4178c..8402f0b 100644 --- a/tests/unit/codexScan.test.ts +++ b/tests/unit/codexScan.test.ts @@ -1,53 +1,58 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { shouldApplyCodexNotifyEnd } from "../../src/scan.ts"; +import { CodexLifecycleGraph } from "../../src/codex/lifecycleGraph.js"; -test("notify end does not override tail in-flight", () => { - const shouldEnd = shouldApplyCodexNotifyEnd({ - tailAllowsNotifyEnd: true, - notifyEndIsFresh: true, - tailEndAt: undefined, - tailInFlight: true, - }); - assert.equal(shouldEnd, false); -}); +test("lifecycle graph finalizes end after grace window", () => { + const previousGrace = process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = "1000"; + try { + const graph = new CodexLifecycleGraph(); + const threadId = "thread-grace"; -test("notify end applies when tail is idle", () => { - const shouldEnd = shouldApplyCodexNotifyEnd({ - tailAllowsNotifyEnd: true, - notifyEndIsFresh: true, - tailEndAt: undefined, - tailInFlight: false, - }); - assert.equal(shouldEnd, true); -}); + graph.ingestAgentStart(threadId, 1000, 1000); + graph.ingestNotifyEnd(threadId, 2000, 2000); -test("notify end does not apply when tail has explicit end", () => { - const shouldEnd = shouldApplyCodexNotifyEnd({ - tailAllowsNotifyEnd: true, - notifyEndIsFresh: true, - tailEndAt: Date.now(), - tailInFlight: false, - }); - assert.equal(shouldEnd, false); -}); + const pending = graph.getThreadSnapshot(threadId, 2500); + assert.equal(pending?.inFlight, true); + assert.equal(pending?.endedAt, undefined); -test("notify end does not apply when tail disallows notify end", () => { - const shouldEnd = shouldApplyCodexNotifyEnd({ - tailAllowsNotifyEnd: false, - notifyEndIsFresh: true, - tailEndAt: undefined, - tailInFlight: false, - }); - assert.equal(shouldEnd, false); + const ended = graph.getThreadSnapshot(threadId, 3100); + assert.equal(ended?.inFlight, false); + assert.equal(ended?.endedAt, 2000); + } finally { + if (previousGrace === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = previousGrace; + } + } }); -test("notify end does not apply when notify end is not fresh", () => { - const shouldEnd = shouldApplyCodexNotifyEnd({ - tailAllowsNotifyEnd: true, - notifyEndIsFresh: false, - tailEndAt: undefined, - tailInFlight: false, - }); - assert.equal(shouldEnd, false); +test("lifecycle graph does not finalize while tools are open", () => { + const previousGrace = process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = "0"; + try { + const graph = new CodexLifecycleGraph(); + const threadId = "thread-tools-open"; + + graph.ingestToolStart(threadId, "call_1", 1000, 1000); + graph.ingestNotifyEnd(threadId, 2000, 2000); + const pendingWithTool = graph.getThreadSnapshot(threadId, 2000); + assert.equal(pendingWithTool?.openCallCount, 1); + assert.equal(pendingWithTool?.inFlight, true); + + graph.ingestToolEnd(threadId, "call_1", 2500, 2500); + // Simulate a later explicit turn end marker after tool output. + graph.ingestAgentStop(threadId, 2600, 2600); + const ended = graph.getThreadSnapshot(threadId, 2600); + assert.equal(ended?.openCallCount, 0); + assert.equal(ended?.inFlight, false); + assert.equal(ended?.endedAt, 2600); + } finally { + if (previousGrace === undefined) { + delete process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; + } else { + process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = previousGrace; + } + } }); From 29b898cfb47893780414b5415ee83cb7438de6b7 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:55:36 -0500 Subject: [PATCH 21/40] refactor(codex): extract session assignment and add flicker harness --- scripts/flicker-detect.js | 235 +++++++++++++++++++++ scripts/mock-snapshot-server.js | 96 +++++++++ src/codexLogs.ts | 13 ++ src/codexSessionAssign.ts | 126 +++++++++++ src/scan.ts | 149 +++++-------- tests/integration/codexNotifyWrite.test.ts | 75 +++++++ tests/unit/sessionPin.test.ts | 96 +++++++++ tests/unit/stateTimeout.test.ts | 225 ++++++++++++++++++++ 8 files changed, 920 insertions(+), 95 deletions(-) create mode 100644 scripts/flicker-detect.js create mode 100644 scripts/mock-snapshot-server.js create mode 100644 src/codexSessionAssign.ts create mode 100644 tests/integration/codexNotifyWrite.test.ts create mode 100644 tests/unit/sessionPin.test.ts create mode 100644 tests/unit/stateTimeout.test.ts diff --git a/scripts/flicker-detect.js b/scripts/flicker-detect.js new file mode 100644 index 0000000..7913979 --- /dev/null +++ b/scripts/flicker-detect.js @@ -0,0 +1,235 @@ +#!/usr/bin/env node +import fs from "node:fs/promises"; +import path from "node:path"; + +const ACTIVE_STATES = new Set(["active", "error"]); + +function parseArgs(argv) { + const args = { + endpoint: "http://127.0.0.1:8787/api/snapshot", + intervalMs: 250, + durationMs: 120000, + windowMs: 10000, + out: "tmp/flicker-summary.json", + verbose: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const raw = argv[i]; + if (raw === "--help" || raw === "-h") { + args.help = true; + continue; + } + if (raw === "--verbose" || raw === "-v") { + args.verbose = true; + continue; + } + const next = argv[i + 1]; + if (!next || next.startsWith("-")) continue; + if (raw === "--endpoint") { + args.endpoint = next; + i += 1; + continue; + } + if (raw === "--interval-ms") { + args.intervalMs = Number(next); + i += 1; + continue; + } + if (raw === "--duration-ms") { + args.durationMs = Number(next); + i += 1; + continue; + } + if (raw === "--window-ms") { + args.windowMs = Number(next); + i += 1; + continue; + } + if (raw === "--out") { + args.out = next; + i += 1; + continue; + } + } + + if (!Number.isFinite(args.intervalMs) || args.intervalMs <= 0) args.intervalMs = 250; + if (!Number.isFinite(args.durationMs) || args.durationMs <= 0) args.durationMs = 120000; + if (!Number.isFinite(args.windowMs) || args.windowMs <= 0) args.windowMs = 10000; + + return args; +} + +function agentIdentity(agent) { + return ( + agent?.identity || + agent?.sessionPath || + agent?.sessionId || + agent?.id || + (typeof agent?.pid === "number" ? `pid:${agent.pid}` : "unknown") + ); +} + +function normalizeState(state) { + const value = typeof state === "string" ? state.trim().toLowerCase() : "idle"; + return ACTIVE_STATES.has(value) ? "active" : "idle"; +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function usage() { + return [ + "Usage: node scripts/flicker-detect.js [options]", + "", + "Options:", + " --endpoint Snapshot endpoint (default: http://127.0.0.1:8787/api/snapshot)", + " --interval-ms Poll interval (default: 250)", + " --duration-ms Total run duration (default: 120000)", + " --window-ms Flicker window for active->idle->active (default: 10000)", + " --out Write JSON summary (default: tmp/flicker-summary.json)", + " --verbose, -v Print transitions as they occur", + "", + ].join("\n"); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + process.stdout.write(usage()); + return 0; + } + + const startedAt = Date.now(); + const endAt = startedAt + args.durationMs; + + const perAgent = new Map(); + const errors = []; + let polls = 0; + let okPolls = 0; + + while (Date.now() < endAt) { + const tickStartedAt = Date.now(); + polls += 1; + let payload; + try { + const res = await fetch(args.endpoint, { headers: { Accept: "application/json" } }); + if (!res.ok) { + throw new Error(`snapshot ${res.status}`); + } + payload = await res.json(); + okPolls += 1; + } catch (err) { + errors.push({ ts: Date.now(), error: String(err) }); + const elapsed = Date.now() - tickStartedAt; + await sleep(Math.max(0, args.intervalMs - elapsed)); + continue; + } + + const agents = Array.isArray(payload?.agents) ? payload.agents : []; + const seen = new Set(); + + for (const agent of agents) { + const id = String(agentIdentity(agent)); + seen.add(id); + const state = normalizeState(agent?.state); + let entry = perAgent.get(id); + if (!entry) { + entry = { + identity: id, + lastState: state, + lastSeenAt: Date.now(), + missingTicks: 0, + transitions: [], + flickers: [], + flickerCount: 0, + lastActiveToIdleAt: undefined, + }; + perAgent.set(id, entry); + continue; + } + + entry.lastSeenAt = Date.now(); + const prev = entry.lastState; + if (prev !== state) { + const ts = Date.now(); + entry.transitions.push({ ts, from: prev, to: state }); + if (args.verbose) { + process.stdout.write(`[${new Date(ts).toISOString()}] ${id} ${prev} -> ${state}\n`); + } + if (prev === "active" && state === "idle") { + entry.lastActiveToIdleAt = ts; + } else if (prev === "idle" && state === "active") { + const idleAt = entry.lastActiveToIdleAt; + if (typeof idleAt === "number") { + const deltaMs = ts - idleAt; + if (deltaMs <= args.windowMs) { + entry.flickerCount += 1; + entry.flickers.push({ idleAt, activeAt: ts, deltaMs }); + } + } + entry.lastActiveToIdleAt = undefined; + } + entry.lastState = state; + } + } + + for (const entry of perAgent.values()) { + if (!seen.has(entry.identity)) { + entry.missingTicks += 1; + } + } + + const elapsed = Date.now() - tickStartedAt; + await sleep(Math.max(0, args.intervalMs - elapsed)); + } + + const finishedAt = Date.now(); + const agentSummaries = Array.from(perAgent.values()) + .map((entry) => ({ + identity: entry.identity, + lastState: entry.lastState, + lastSeenAt: entry.lastSeenAt, + missingTicks: entry.missingTicks, + transitionCount: entry.transitions.length, + transitions: entry.transitions, + flickerCount: entry.flickerCount, + flickers: entry.flickers, + })) + .sort((a, b) => b.flickerCount - a.flickerCount || a.identity.localeCompare(b.identity)); + + const totalFlickerCount = agentSummaries.reduce((acc, a) => acc + (a.flickerCount || 0), 0); + const summary = { + endpoint: args.endpoint, + intervalMs: args.intervalMs, + durationMs: args.durationMs, + windowMs: args.windowMs, + startedAt, + finishedAt, + polls, + okPolls, + errorCount: errors.length, + errors, + totalFlickerCount, + agents: agentSummaries, + }; + + const outPath = path.resolve(args.out); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + + if (okPolls === 0) return 2; + if (totalFlickerCount > 0) return 1; + return 0; +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((err) => { + process.stderr.write(`${String(err)}\n`); + process.exitCode = 2; + }); + diff --git a/scripts/mock-snapshot-server.js b/scripts/mock-snapshot-server.js new file mode 100644 index 0000000..545a7c9 --- /dev/null +++ b/scripts/mock-snapshot-server.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node +import http from "node:http"; + +function parseArgs(argv) { + const args = { + port: 8799, + mode: "clean", + }; + + for (let i = 0; i < argv.length; i += 1) { + const raw = argv[i]; + const next = argv[i + 1]; + if (!next || next.startsWith("-")) continue; + if (raw === "--port") { + args.port = Number(next); + i += 1; + continue; + } + if (raw === "--mode") { + args.mode = String(next); + i += 1; + continue; + } + } + + if (!Number.isFinite(args.port) || args.port <= 0) args.port = 8799; + if (args.mode !== "clean" && args.mode !== "flicker") args.mode = "clean"; + return args; +} + +function buildAgents(mode, elapsedMs) { + const base = (identity, state) => ({ + id: identity, + identity, + kind: "tui", + cpu: 0, + mem: 0, + state, + }); + + if (mode === "flicker") { + const aState = elapsedMs < 1000 ? "active" : elapsedMs < 2000 ? "idle" : "active"; + return [base("mock:a", aState), base("mock:b", "idle"), base("mock:c", "idle")]; + } + + const t = elapsedMs; + let a = "idle"; + let b = "idle"; + let c = "idle"; + if (t < 5000) { + a = "active"; + } else if (t < 16000) { + b = "active"; + } else if (t < 22000) { + a = "active"; + c = "active"; + } + return [base("mock:a", a), base("mock:b", b), base("mock:c", c)]; +} + +function json(res, status, body) { + const payload = JSON.stringify(body); + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Content-Length", Buffer.byteLength(payload)); + res.end(payload); +} + +const args = parseArgs(process.argv.slice(2)); +const startedAt = Date.now(); + +const server = http.createServer((req, res) => { + const url = req.url || "/"; + if (url === "/health") { + json(res, 200, { ok: true }); + return; + } + if (url === "/api/snapshot") { + const now = Date.now(); + json(res, 200, { + ts: now, + agents: buildAgents(args.mode, now - startedAt), + }); + return; + } + json(res, 404, { error: "not_found" }); +}); + +server.listen(args.port, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : args.port; + process.stdout.write( + `mock-snapshot-server mode=${args.mode} listening http://127.0.0.1:${port}\n` + ); +}); + diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 89320b0..62fb044 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -858,6 +858,19 @@ async function updateTailLegacy( const openCallCount = (state.openCallIds?.size ?? 0) + (state.openItemCount ?? 0); if (state.pendingEndAt) { if (openCallCount > 0) return; + // Use event timestamps (not ingest time) so we don't cancel the end marker + // on the same tick it was observed. + const lastSignalAfterEnd = + state.lastToolSignalAt ?? state.lastActivityAt ?? state.lastEventAt; + // If anything new arrived after the end marker, cancel the pending end. + // This prevents active->idle->active flicker when tool output lands after response end. + if ( + typeof lastSignalAfterEnd === "number" && + lastSignalAfterEnd > state.pendingEndAt + ) { + clearEndMarkers(); + return; + } const elapsed = nowMs - state.pendingEndAt; const forceEndMs = inflightTimeoutMs > 0 ? inflightTimeoutMs : defaultInflightTimeoutMs; if (elapsed >= forceEndMs) { diff --git a/src/codexSessionAssign.ts b/src/codexSessionAssign.ts new file mode 100644 index 0000000..d0af7d3 --- /dev/null +++ b/src/codexSessionAssign.ts @@ -0,0 +1,126 @@ +import nodePath from "node:path"; + +export type SessionFile = { + path: string; + mtimeMs: number; +}; + +export type CodexSessionSelection = { + session?: SessionFile; + source: + | "sessionId" + | "findSessionById" + | "mapped" + | "cwd" + | "cwd-preferred" + | "cwd-alternate" + | "cwd-fallback" + | "pinned" + | "none"; + reuseBlocked: boolean; + pinnedPath?: string; +}; + +const normalizeSessionPath = (value?: string): string | undefined => + value ? nodePath.resolve(value) : undefined; + +export function shouldDropPinnedSessionByMtime(input: { + now: number; + mtimeMs: number; + staleFileMs: number; +}): boolean { + const { now, mtimeMs, staleFileMs } = input; + if (!Number.isFinite(staleFileMs) || staleFileMs <= 0) return false; + return now - mtimeMs > staleFileMs; +} + +export function deriveCodexSessionIdentity(input: { + pid: number; + reuseBlocked: boolean; + sessionId?: string; + sessionPath?: string; +}): string { + if (input.reuseBlocked) return `pid:${input.pid}`; + if (input.sessionId) return `codex:${input.sessionId}`; + if (input.sessionPath) return `codex:${input.sessionPath}`; + return `pid:${input.pid}`; +} + +export function selectCodexSessionForProcess(input: { + cmdRaw: string; + sessionId?: string; + sessionFromId?: SessionFile; + sessionFromFind?: SessionFile; + mappedSession?: SessionFile; + cwdSession?: SessionFile; + cachedSession?: SessionFile; + usedSessionPaths: Set; +}): CodexSessionSelection { + const sessionFromId: SessionFile | undefined = input.sessionFromId; + const sessionFromFind: SessionFile | undefined = input.sessionFromFind; + const sessionFromMapped: SessionFile | undefined = input.mappedSession; + const sessionFromCwd: SessionFile | undefined = input.cwdSession; + const sessionFromCache: SessionFile | undefined = input.cachedSession; + + const explicitSession = + sessionFromId || sessionFromFind || sessionFromMapped || sessionFromCwd; + + let session = explicitSession || sessionFromCache; + let source: CodexSessionSelection["source"] = "none"; + if (sessionFromId) source = "sessionId"; + else if (sessionFromFind) source = "findSessionById"; + else if (sessionFromMapped) source = "mapped"; + else if (sessionFromCwd) source = "cwd"; + else if (sessionFromCache) source = "pinned"; + + const chosenPath = normalizeSessionPath(session?.path); + const cachedPath = normalizeSessionPath(sessionFromCache?.path); + const pinnedPath = + cachedPath && chosenPath && cachedPath === chosenPath ? cachedPath : undefined; + const hasPinnedSession = !!pinnedPath; + + if (!hasPinnedSession && sessionFromCwd && sessionFromMapped) { + const mappedMtime = sessionFromMapped.mtimeMs ?? 0; + const cwdMtime = sessionFromCwd.mtimeMs ?? 0; + if (cwdMtime > mappedMtime + 1000) { + session = sessionFromCwd; + source = "cwd-preferred"; + } + } + + // If we have an explicit session signal (id/mapped/cwd) avoid treating the cached path + // as authoritative unless it's already the chosen path. + const allowReuse = !!input.sessionId || /\bresume\b/i.test(input.cmdRaw); + const allowReusePinned = allowReuse || hasPinnedSession; + const initialSessionPath = normalizeSessionPath(session?.path); + + let reuseBlocked = false; + if ( + initialSessionPath && + input.usedSessionPaths.has(initialSessionPath) && + !allowReusePinned + ) { + const alternatePath = sessionFromCwd + ? normalizeSessionPath(sessionFromCwd.path) + : undefined; + if (sessionFromCwd && alternatePath && alternatePath !== initialSessionPath) { + session = sessionFromCwd; + source = "cwd-alternate"; + } else { + reuseBlocked = true; + } + } + + if (!session) { + session = sessionFromCwd; + reuseBlocked = false; + if (session) source = "cwd-fallback"; + } + + return { + session, + source, + reuseBlocked, + pinnedPath, + }; +} diff --git a/src/scan.ts b/src/scan.ts index 5b79615..248d9e2 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -13,7 +13,6 @@ import type { SnapshotPayload, WorkSummary, } from "./types.js"; -import { deriveCodexEventState } from "./codexState.js"; import { listRecentSessions, findSessionById, @@ -25,6 +24,11 @@ import { getTailState, getSessionStartMsFromPath, } from "./codexLogs.js"; +import { + deriveCodexSessionIdentity, + selectCodexSessionForProcess, + shouldDropPinnedSessionByMtime, +} from "./codexSessionAssign.js"; import { getOpenCodeSessions, getOpenCodeSessionActivity, type OpenCodeSessionResult } from "./opencodeApi.js"; import { ensureOpenCodeServer } from "./opencodeServer.js"; import { @@ -1357,9 +1361,11 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0 && - now - stat.mtimeMs > codexStaleFileMs + shouldDropPinnedSessionByMtime({ + now, + mtimeMs: stat.mtimeMs, + staleFileMs: codexStaleFileMs, + }) ) { pidSessionCache.delete(proc.pid); cachedEntry = undefined; @@ -1393,73 +1399,30 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise item.path.includes(sessionId)); + const sessionFromId = sessionId + ? sessions.find((item) => item.path.includes(sessionId)) + : undefined; const sessionFromFind = sessionId ? await findSessionById(codexHome, sessionId) : undefined; - const sessionFromMapped = mappedSession; - const sessionFromCwd = cwdSession; - const sessionFromCache = cachedSession; - let session = - sessionFromId || - sessionFromFind || - sessionFromMapped || - sessionFromCwd || - sessionFromCache; - let sessionSource: string = - (sessionFromId && "sessionId") || - (sessionFromFind && "findSessionById") || - (sessionFromMapped && "mapped") || - (sessionFromCwd && "cwd") || - (sessionFromCache && "cached") || - "none"; - const pinnedSession = - cachedSession && isCodexSessionPath(cachedSession.path) - ? cachedSession - : undefined; - if (pinnedSession) { - session = pinnedSession; - sessionSource = "pinned"; - } - if (!pinnedSession && cwdSession && mappedSession) { - const mappedMtime = mappedSession.mtimeMs ?? 0; - const cwdMtime = cwdSession.mtimeMs ?? 0; - if (cwdMtime > mappedMtime + 1000) { - session = cwdSession; - sessionSource = "cwd-preferred"; - } - } - const hasExplicitSession = !!sessionId || !!mappedJsonl || !!cwdSession; - const hasPinnedSession = !!pinnedSession; - const allowReuse = hasExplicitSession || /\bresume\b/i.test(cmdRaw); - const allowReusePinned = allowReuse || hasPinnedSession; - const initialSessionPath = normalizeSessionPath(session?.path); - let reuseBlocked = false; - if ( - initialSessionPath && - usedSessionPaths.has(initialSessionPath) && - !allowReusePinned - ) { - const cwdSessionValue = cwdSession as SessionFile | undefined; - const alternatePath = normalizeSessionPath(cwdSessionValue?.path); - if (cwdSessionValue && alternatePath && alternatePath !== initialSessionPath) { - session = cwdSessionValue; - sessionSource = "cwd-alternate"; - } else { - reuseBlocked = true; - } - } - if (!session) { - session = cwdSession; - reuseBlocked = false; - if (session) sessionSource = "cwd-fallback"; - } + const selection = selectCodexSessionForProcess({ + cmdRaw, + sessionId, + sessionFromId, + sessionFromFind, + mappedSession, + cwdSession, + cachedSession, + usedSessionPaths, + }); + const session = selection.session as SessionFile | undefined; + const sessionSource = selection.source; + const reuseBlocked = selection.reuseBlocked; const sessionPath = normalizeSessionPath(session?.path); if (sessionPath) { usedSessionPaths.add(sessionPath); } - const pinnedPath = hasPinnedSession - ? normalizeSessionPath(cachedSession?.path) - : undefined; + const pinnedPath = selection.pinnedPath; + const hasExplicitSession = !!sessionId || !!mappedJsonl || !!cwdSession; + const hasPinnedSession = !!pinnedPath; if (isDebugSession()) { const cmdShort = shortenCmd(cmdRaw); const mappedLabel = mappedSession?.path ? path.basename(mappedSession.path) : "none"; @@ -1475,12 +1438,6 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0) { logSessionDecision( `pid=${proc.pid} missing sessionPath with jsonls=${jsonlPaths.length} ` @@ -1627,15 +1584,13 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise 0 - ) { - bumpNextTickAt(lastActivityAt + codexHoldMs); + state = activity.state; + reason = activity.reason || "event"; + if (activity.reason === "hold" && typeof activity.lastActiveAt === "number" && effectiveHoldMs > 0) { + bumpNextTickAt(activity.lastActiveAt + effectiveHoldMs); } } const prevState = cached?.lastState; @@ -1718,7 +1672,12 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise; +}): Promise { + const scriptPath = path.join(input.repoRoot, "src", "codexNotify.ts"); + const argvPayload = JSON.stringify(input.payload); + + await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + ["--import", "tsx", scriptPath, "", argvPayload], + { + cwd: input.repoRoot, + env: { ...process.env, CONSENSUS_CODEX_HOME: input.codexHome }, + stdio: ["ignore", "ignore", "pipe"], + } + ); + + const stderrChunks: Buffer[] = []; + child.stderr?.on("data", (chunk) => stderrChunks.push(Buffer.from(chunk))); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) return resolve(); + const stderr = Buffer.concat(stderrChunks).toString("utf8"); + reject(new Error(`codexNotify.ts exited with code=${code}\n${stderr}`)); + }); + }); +} + +test("codex notify appends payload to CONSENSUS_CODEX_HOME/consensus/codex-notify.jsonl", async (t) => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(__dirname, "../.."); + + const codexHome = await mkdtemp(path.join(os.tmpdir(), "consensus-codex-home-")); + t.after(async () => { + await rm(codexHome, { recursive: true, force: true }); + }); + + const notifyPath = path.join(codexHome, "consensus", "codex-notify.jsonl"); + + const payload1 = { + type: "turn.started", + "thread-id": "thread-write", + "turn-id": "turn-1", + }; + await runCodexNotifyScript({ repoRoot, codexHome, payload: payload1 }); + + const text1 = await readFile(notifyPath, "utf8"); + const lines1 = text1.trimEnd().split("\n"); + assert.equal(lines1.length, 1); + assert.deepEqual(JSON.parse(lines1[0] || "null"), payload1); + + const payload2 = { + type: "turn.completed", + "thread-id": "thread-write", + "turn-id": "turn-1", + }; + await runCodexNotifyScript({ repoRoot, codexHome, payload: payload2 }); + + const text2 = await readFile(notifyPath, "utf8"); + const lines2 = text2.trimEnd().split("\n"); + assert.equal(lines2.length, 2); + assert.deepEqual(JSON.parse(lines2[0] || "null"), payload1); + assert.deepEqual(JSON.parse(lines2[1] || "null"), payload2); +}); + diff --git a/tests/unit/sessionPin.test.ts b/tests/unit/sessionPin.test.ts new file mode 100644 index 0000000..7a619e9 --- /dev/null +++ b/tests/unit/sessionPin.test.ts @@ -0,0 +1,96 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { + deriveCodexSessionIdentity, + selectCodexSessionForProcess, + shouldDropPinnedSessionByMtime, +} from "../../src/codexSessionAssign.ts"; + +test("pinned session stays pinned across consecutive scan ticks", () => { + const cachedSession = { path: "/tmp/codex-sessions/a/log.jsonl", mtimeMs: 1234 }; + const expectedPinned = path.resolve(cachedSession.path); + + for (let tick = 0; tick < 10; tick += 1) { + const usedSessionPaths = new Set(); + const selection = selectCodexSessionForProcess({ + cmdRaw: "codex", + cachedSession, + usedSessionPaths, + }); + + assert.equal(selection.session?.path, cachedSession.path); + assert.equal(selection.pinnedPath, expectedPinned); + assert.equal(selection.reuseBlocked, false); + } +}); + +test("stale pinned session is dropped by mtime and a newer session can be selected", () => { + const now = 10_000; + const staleFileMs = 1000; + const staleMtime = now - 2000; + assert.equal( + shouldDropPinnedSessionByMtime({ now, mtimeMs: staleMtime, staleFileMs }), + true + ); + + const mappedSession = { path: "/tmp/codex-sessions/b/log.jsonl", mtimeMs: now }; + const selection = selectCodexSessionForProcess({ + cmdRaw: "codex", + mappedSession, + cachedSession: undefined, + usedSessionPaths: new Set(), + }); + + assert.equal(selection.session?.path, mappedSession.path); + assert.equal(selection.pinnedPath, undefined); + assert.equal(selection.reuseBlocked, false); +}); + +test("reuseBlocked prevents a second pid from claiming an already-used session path", () => { + const shared = { path: "/tmp/codex-sessions/shared/log.jsonl", mtimeMs: 1 }; + const usedSessionPaths = new Set([path.resolve(shared.path)]); + + const selection = selectCodexSessionForProcess({ + cmdRaw: "codex", + mappedSession: shared, + usedSessionPaths, + }); + + assert.equal(selection.session?.path, shared.path); + assert.equal(selection.reuseBlocked, true); + + const identity = deriveCodexSessionIdentity({ + pid: 2000, + reuseBlocked: selection.reuseBlocked, + sessionId: undefined, + sessionPath: shared.path, + }); + assert.equal(identity, "pid:2000"); +}); + +test("codex session identity is stable across ticks and falls back when session disappears", () => { + const sessionPath = "/tmp/codex-sessions/a/log.jsonl"; + const identity1 = deriveCodexSessionIdentity({ + pid: 1000, + reuseBlocked: false, + sessionId: undefined, + sessionPath, + }); + const identity2 = deriveCodexSessionIdentity({ + pid: 1000, + reuseBlocked: false, + sessionId: undefined, + sessionPath, + }); + assert.equal(identity1, identity2); + + const identity3 = deriveCodexSessionIdentity({ + pid: 1000, + reuseBlocked: false, + sessionId: undefined, + sessionPath: undefined, + }); + assert.equal(identity3, "pid:1000"); +}); + diff --git a/tests/unit/stateTimeout.test.ts b/tests/unit/stateTimeout.test.ts new file mode 100644 index 0000000..18b3ba2 --- /dev/null +++ b/tests/unit/stateTimeout.test.ts @@ -0,0 +1,225 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile, appendFile } from "node:fs/promises"; +import { updateTail, summarizeTail } from "../../src/codexLogs.ts"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const makeEvent = (event: Record) => `${JSON.stringify(event)}\n`; + +async function setupSessionFile(): Promise<{ + dir: string; + sessionPath: string; + cleanup: () => Promise; +}> { + const dir = await mkdtemp(path.join(os.tmpdir(), "consensus-codex-timeout-")); + const sessionPath = path.join(dir, "session.jsonl"); + await writeFile(sessionPath, "", "utf8"); + return { + dir, + sessionPath, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +function withEnv(vars: Record, fn: () => Promise | void): Promise { + const prev: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prev[key] = process.env[key]; + process.env[key] = value; + } + const restore = () => { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + try { + const result = fn(); + if (result && typeof (result as Promise).then === "function") { + return (result as Promise).finally(restore); + } + restore(); + return Promise.resolve(); + } catch (err) { + restore(); + return Promise.reject(err); + } +} + +test("pendingEnd clears when tool output arrives before timeout", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "100", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ type: "turn.started", ts: base }) + + makeEvent({ type: "response.completed", ts: base + 1 }), + "utf8" + ); + + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + + await appendFile( + sessionPath, + makeEvent({ + type: "response_item", + ts: base + 2, + payload: { type: "response.function_call_output", call_id: "call_1" }, + }), + "utf8" + ); + + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(second.pendingEndAt, undefined); + assert.equal(summarizeTail(second).inFlight, true); + } finally { + await cleanup(); + } + } + ); +}); + +test("pendingEnd expires when no new signals arrive", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "80", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ type: "turn.started", ts: base }) + + makeEvent({ type: "response.completed", ts: base + 1 }), + "utf8" + ); + + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + assert.equal(summarizeTail(first).inFlight, true); + + await sleep(120); + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(second.pendingEndAt, undefined); + assert.equal(summarizeTail(second).inFlight, undefined); + assert.equal(typeof second.lastEndAt, "number"); + } finally { + await cleanup(); + } + } + ); +}); + +test("pendingEnd does not finalize if tool output lands after timeout but before tick", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "60", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ type: "turn.started", ts: base }) + + makeEvent({ type: "response.completed", ts: base + 1 }), + "utf8" + ); + + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + + // Wait beyond timeout; tool output arrives after the grace window but before the next scan tick. + await sleep(90); + const toolTs = Date.now(); + await appendFile( + sessionPath, + makeEvent({ + type: "response_item", + ts: toolTs, + payload: { type: "response.function_call_output", call_id: "call_2" }, + }), + "utf8" + ); + + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(second.pendingEndAt, undefined); + assert.equal(summarizeTail(second).inFlight, true); + } finally { + await cleanup(); + } + } + ); +}); + +test("turnOpen suppresses stale timeout until an explicit end marker arrives", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "60", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile(sessionPath, makeEvent({ type: "turn.started", ts: base }), "utf8"); + const started = await updateTail(sessionPath); + assert.ok(started); + assert.equal(started.turnOpen, true); + assert.equal(summarizeTail(started).inFlight, true); + + // Even after the timeout window, we stay in-flight while turnOpen=true. + await sleep(120); + const stillOpen = await updateTail(sessionPath); + assert.ok(stillOpen); + assert.equal(stillOpen.turnOpen, true); + assert.equal(summarizeTail(stillOpen).inFlight, true); + + // Once we see an explicit end marker and it ages out, in-flight can finalize. + const endTs = Date.now(); + await appendFile(sessionPath, makeEvent({ type: "turn.completed", ts: endTs }), "utf8"); + const pending = await updateTail(sessionPath); + assert.ok(pending); + assert.equal(pending.turnOpen, false); + assert.equal(typeof pending.pendingEndAt, "number"); + + await sleep(120); + const ended = await updateTail(sessionPath); + assert.ok(ended); + assert.equal(summarizeTail(ended).inFlight, undefined); + } finally { + await cleanup(); + } + } + ); +}); + From 691ca5694f543ba0193f319199e04eb67f515622 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 16:55:53 -0500 Subject: [PATCH 22/40] docs: add codex data flow and state notes --- docs/api-authentication.md | 2 +- docs/configuration.md | 32 +++++++++++---- docs/data-flow.md | 46 +++++++++++++++++++++ docs/session-selection.md | 83 +++++++++++++++++++++++++++++++++++++ docs/state-transitions.md | 84 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 docs/data-flow.md create mode 100644 docs/session-selection.md create mode 100644 docs/state-transitions.md diff --git a/docs/api-authentication.md b/docs/api-authentication.md index 42a4de3..a691eeb 100644 --- a/docs/api-authentication.md +++ b/docs/api-authentication.md @@ -25,7 +25,7 @@ The UI also opens a WebSocket (handled by `ws` in `src/server.ts`) but the WebSo - The browser UI fetches `/api/snapshot` and opens the WebSocket without extra headers. ## Hardening guidance -1. Any time the server binds to a non-localhost address (custom `CONSENSUS_HOST`), add auth before enabling the port in `docs/constitution.md`'s sense. A simple opt-in token header (e.g., `CONSENSUS_API_TOKEN`) or Mutual TLS would be appropriate. +1. Any time the server binds to a non-localhost address (custom `CONSENSUS_HOST`), add auth before enabling the port in `docs/constitution.md`'s sense. A simple opt-in token header (configured via an environment variable) or Mutual TLS would be appropriate. 2. When introducing authentication, keep the current schema validation for Codex/Claude events so that invalid or replayed payloads are rejected even before checking credentials. 3. For debugging or automation endpoints (`/__debug/activity`, `/__dev/reload`), gate them behind the same token or a separate debug-only header to keep the trust boundary intact. 4. Document the chosen auth pattern once implemented (update this file). If more than localhost access is required, pair it with firewall rules or SSH tunnels that still keep secrets off disk. diff --git a/docs/configuration.md b/docs/configuration.md index b46223d..df00c81 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,9 +9,6 @@ All configuration is via environment variables. - `CONSENSUS_PORT` - Default: `8787` - Port for the HTTP server. -- `CONSENSUS_UI_PORT` - - Default: `5173` - - Port for the Vite dev server when running `npm run dev`. - `CONSENSUS_POLL_MS` - Default: `250` - Poll interval for process presence scans. @@ -56,6 +53,12 @@ All configuration is via environment variables. - Intended for wiring Codex TUI `notify` hook to consensus without manual setup. - Use `CONSENSUS_CODEX_NOTIFY_INSTALL_TIMEOUT_MS` to cap install time (default 5000). - Set to `0`, `false`, or `off` to disable the auto-install. +- `CONSENSUS_CODEX_NOTIFY_INSTALL_TIMEOUT_MS` + - Default: `5000` (min `2000`) + - Timeout (ms) for the notify-hook auto-install attempt. +- `CONSENSUS_CODEX_NOTIFY_DEBUG` + - Default: unset + - Set to `1` to append debug lines to `~/.consensus/codex-notify-debug.jsonl`. - `CONSENSUS_CODEX_WATCH_POLL` - Default: enabled - Set to `0` to disable polling for Codex JSONL watch events. Polling is the default because native FS events are unreliable for these files. @@ -101,6 +104,15 @@ All configuration is via environment variables. - `CONSENSUS_PROCESS_MATCH` - Default: unset - Regex to match process name or command line. +- `CONSENSUS_DEBUG_ACTIVITY` + - Default: unset + - Set to `1` to log scan decisions and activity transitions (debug only). +- `CONSENSUS_DEBUG_SESSION` + - Default: unset + - Set to `1` to log Codex/OpenCode session selection decisions (debug only). +- `CONSENSUS_DEBUG_DEDUPE` + - Default: unset + - Set to `1` to log agent de-duplication decisions (debug only). - `CONSENSUS_DEBUG_OPENCODE` - Default: unset - Set to `1` to log OpenCode server discovery (debug only). @@ -110,9 +122,12 @@ All configuration is via environment variables. - `ACTIVITY_TEST_MODE` - Default: disabled - Set to `1` to enable test-only activity injection endpoints under `/__test`. +- `CONSENSUS_CODEX_EVENT_IDLE_MS` + - Default: `20000` + - Codex event window (ms) before dropping to idle in the JSONL-tail scanner. - `CONSENSUS_CODEX_EVENT_ACTIVE_MS` - - Default: `30000` - - Codex event window before dropping to idle. + - Default: `30000` (typed config default; not used by the JSONL-tail scanner) + - Legacy Codex activity window (ms) for CPU/telemetry paths. - `CONSENSUS_CODEX_MTIME_ACTIVE_MS` - Default: `750` - Treat recent Codex JSONL file mtime as activity within this window (bridges log write lag). @@ -120,8 +135,8 @@ All configuration is via environment variables. - Default: `2000` - Codex hold window after activity. Set to `0` to disable (immediate transitions may flicker). - `CONSENSUS_CODEX_INFLIGHT_GRACE_MS` - - Default: `750` - - Codex in-flight grace window after the last in-flight signal (prevents brief idle flicker). + - Default: `0` + - Grace window (ms) before finalizing an explicit end marker (lifecycle graph / notify path). - `CONSENSUS_CODEX_STRICT_INFLIGHT` - Default: disabled - Set to `1` to require explicit in-flight signals from logs (disables CPU/mtime bridging). @@ -165,6 +180,9 @@ All configuration is via environment variables. - `CONSENSUS_CPU_ACTIVE` - Default: `1` - CPU threshold for marking an agent active. +- `CONSENSUS_CODEX_CPU_ACTIVE` + - Default: `1` (fallback when `CONSENSUS_CPU_ACTIVE` is unset) + - Codex-specific CPU threshold for legacy CPU-based detection paths. - `CONSENSUS_CLAUDE_CPU_ACTIVE` - Default: `1` - Claude CPU threshold override. diff --git a/docs/data-flow.md b/docs/data-flow.md new file mode 100644 index 0000000..033690b --- /dev/null +++ b/docs/data-flow.md @@ -0,0 +1,46 @@ +# Codex JSONL Data Flow + +This document describes how Codex activity becomes a Consensus snapshot. + +## Writers: Codex CLI session logs + +- Writer: the Codex CLI (`codex`) writes append-only JSON Lines (`*.jsonl`) session logs. +- Default location: `~/.codex/sessions/**.jsonl` (often bucketed by date, e.g. `~/.codex/sessions/2026/02/03/rollout-...jsonl`). +- Config: the base directory is resolved from `CONSENSUS_CODEX_HOME` (and falls back to `CODEX_HOME`) by `resolveCodexHome` in `src/codexLogs.ts`. + +## Readers: Consensus tailer (`updateTail`) + +- Reader: Consensus maps running Codex processes to session JSONL files in `scanCodexProcesses` (`src/scan.ts`). +- Tailer: selected session files are tailed and parsed by `updateTail(...)` in `src/codexLogs.ts`. +- State held per file: `updateTail` maintains a per-path tail state (byte offset, partial line buffer, parsed events, `lastActivityAt`, `inFlight`, etc.) that is summarized via `summarizeTail(...)` for snapshots. + +## Webhook: `/api/codex-event` is a trigger only + +- Endpoint: `POST /api/codex-event` in `src/server.ts`. +- Role: validates the incoming webhook payload and triggers a fast scan (`requestTick("fast")`). +- Non-role: it does not merge webhook payloads into agent/session state. State still comes from JSONL tails. + +## Notify log: `codex-notify.jsonl` (append-only) + +- Writer: `src/codexNotify.ts` appends the raw notify payload (one JSON object per line) to: + - `~/.codex/consensus/codex-notify.jsonl` +- Reader: Consensus reads the tail of this file opportunistically (see `hydrateTailNotify(...)` and `loadNotifyEvents(...)` in `src/codexLogs.ts`) to attach recent notify timestamps to the current thread/turn. +- Scope: this is supplemental metadata; it does not replace session JSONL as the activity authority. + +## Session pinning: PID to session path cache + +Session selection is heuristic (session id in the command line, open `*.jsonl` paths from `lsof`, cwd matching). To prevent oscillation across scan ticks, Consensus pins the chosen mapping: + +- Cache: `pidSessionCache` in `src/scan.ts` stores `pid -> { path, startMs, lastSeenAt }`. +- Stale release: pinned entries are released when: + - the PID start time changes (PID reuse/restart), + - the pinned file disappears, or + - the pinned file's mtime is older than `CONSENSUS_CODEX_STALE_FILE_MS` (defaults documented in `docs/configuration.md`). + +## Single source of truth (SSOT) + +Consensus treats the session JSONL tail as authoritative: + +- Authority: `summarizeTail(...)` in `src/codexLogs.ts` is the source of truth for `inFlight` and `lastActivityAt`. +- Consumer: `src/scan.ts` derives Codex agent state from `inFlight` and `lastActivityAt` (plus hold/idle windows). No other signal is allowed to override JSONL tail state. + diff --git a/docs/session-selection.md b/docs/session-selection.md new file mode 100644 index 0000000..26366ee --- /dev/null +++ b/docs/session-selection.md @@ -0,0 +1,83 @@ +# Codex Session Selection + +This document describes how `scanCodexProcesses` (`src/scan.ts`) chooses a Codex session JSONL file for each PID and how pinning prevents mid-run switching. + +## Inputs Per PID + +During each scan tick, Consensus gathers candidate session information for each Codex process: + +- Command line (`proc.cmd`) + - `sessionId` extracted from `--session ` style flags when present. + - `resume` keyword (enables reuse). +- `lsof`-derived JSONL paths + - `jsonlByPid[pid]` plus child-process JSONL paths. + - `mappedSession`: newest JSONL path among those candidates. +- Process cwd + - `cwdSession`: best match based on cwd and recency (`findSessionByCwd`). +- PID pin cache + - `pidSessionCache[pid]` contains `{ path, startMs, lastSeenAt }`. + - `cachedSession`: only used when the cached file still exists and is not stale (`CONSENSUS_CODEX_STALE_FILE_MS`) and the PID start time did not change. + +## Decision Tree + +```mermaid +flowchart TD + A["Start: pid + cmd + cwd + lsof jsonl paths"] --> B["Extract sessionId from cmd"] + A --> C["Pick mappedSession from lsof jsonl paths"] + A --> D["Find cwdSession from session meta/cwd match"] + A --> E["Load cachedSession from pidSessionCache (startMs match, file exists, not stale)"] + + B --> F["sessionFromId (sessionId match in sessions list)"] + B --> G["sessionFromFind (findSessionById)"] + + F --> H["Candidate selection"] + G --> H + C --> H + D --> H + E --> H + + H --> I{"Any explicit signal?\n(sessionId/mapped/cwd)"} + I -- "yes" --> J["Choose explicit session\n(sessionFromId > sessionFromFind > mappedSession > cwdSession)"] + I -- "no" --> K["Choose cachedSession (pinned) if present"] + + J --> L{"cwdSession + mappedSession\nand not pinned?"} + K --> L + L -- "yes, cwd mtime > mapped + 1s" --> M["Override to cwdSession (cwd-preferred)"] + L -- "no" --> N["Keep chosen session"] + + N --> O{"Already used by another PID\nthis tick and reuse not allowed?"} + M --> O + O -- "yes, alternate cwdSession available" --> P["Switch to cwdSession (cwd-alternate)"] + O -- "yes, no alternate" --> Q["reuseBlocked=true (keep pid identity)"] + O -- "no" --> R["reuseBlocked=false"] + + P --> S["Return session + pinnedPath(if cached==chosen)"] + Q --> S + R --> S +``` + +## Pinning Semantics + +Pinning is a scan-to-scan stability mechanism, not a separate selection source: + +- On each tick, once a `sessionPath` is chosen, `pidSessionCache[pid]` is updated with that path. +- On the next tick, the cached session is offered back as `cachedSession`. +- `pinnedPath` is only set when the cached path is the chosen session path. This avoids a stale cached path silently overriding explicit signals. + +Pins are released when: + +- PID start time changes (PID reuse/restart), or +- cached file is missing, or +- cached file mtime is older than `CONSENSUS_CODEX_STALE_FILE_MS`. + +## Identity Stability + +Codex agent identity uses stable identifiers to avoid UI flicker: + +1. If `reuseBlocked=true` then identity is `pid:`. +2. Else if `sessionId` exists then identity is `codex:`. +3. Else if `sessionPath` exists then identity is `codex:`. +4. Else identity is `pid:`. + +This is implemented by `deriveCodexSessionIdentity` (`src/codexSessionAssign.ts`). + diff --git a/docs/state-transitions.md b/docs/state-transitions.md new file mode 100644 index 0000000..e5c3689 --- /dev/null +++ b/docs/state-transitions.md @@ -0,0 +1,84 @@ +# Codex Tail State Transitions (`updateTailLegacy`) + +This document records the effective state machine implemented by `updateTailLegacy` (`src/codexLogs.ts`) for Codex session JSONL tails. + +## State Fields (focus) + +- `inFlight` (boolean) + - "Hard" in-flight flag set by start/activity signals and cleared only by expiration or `finalizeEnd`. +- `turnOpen` (boolean) + - Whether a turn/response is considered open. Suppresses stale expiration when true. +- `pendingEndAt` (number ms timestamp) + - End marker observed (response/turn completed, review exit). Used to delay finalization. +- `reviewMode` (boolean) + - Entered/exited via `entered_review_mode` / `exited_review_mode` payload types. +- `openCallIds` (`Set`) and `openItemCount` (number) + - Tracks open tool/item work. Any non-zero "open call count" prevents expiration. + +## Computed Summary (`summarizeTail`) + +`summarizeTail` treats a session as in-flight if *any* of the following are true: + +- `state.inFlight` +- `state.reviewMode` +- `openCallCount > 0` (`openCallIds.size + openItemCount`) +- `pendingEndAt` is set +- `turnOpen` is true + +This means `summary.inFlight` can remain true even when `state.inFlight` is false (for example, while `pendingEndAt` is set). + +## Transition Table + +Each row describes: trigger, conditions, and how it mutates `inFlight`, `turnOpen`, `pendingEndAt`, `reviewMode`, and open-call tracking. + +| Trigger | Condition | State Changes | +|---|---|---| +| File missing | `stat()` throws | Returns `null` (no state). | +| File stale | `now - mtimeMs > CONSENSUS_CODEX_STALE_FILE_MS` and `keepStale=false` | Clears `inFlight`, `turnOpen`, `reviewMode`, `pendingEndAt`, `lastEndAt`, open-call tracking; clears activity timestamps. | +| File truncated | `stat.size < state.offset` | Resets offset/partial/events/summary fields; clears `inFlight`, `turnOpen`, `reviewMode`, `pendingEndAt`, open-call tracking. | +| Turn start | `type/payload` includes `turn.started` or `thread.started` | `turnOpen=true`; `pendingEndAt` cleared; if signal is fresh: `inFlight=true`, `inFlightStart=true`, `lastInFlightSignalAt=now`; `lastActivityAt=max(lastActivityAt, ts)`. | +| Response start | `turn.started` or `response.started` or `*.running` | Same as turn start. | +| Response delta | response delta type | `turnOpen=true`; clears end markers; if fresh: `inFlight=true`, `inFlightStart=true`, mark signal; bumps `lastActivityAt`. | +| Work item started | `item.started` and `item.type` in work types | Clears end markers; adds `callId/itemId` to `openCallIds` or increments `openItemCount`; if fresh: `turnOpen=true`, `inFlight=true`; bumps `lastActivityAt`. | +| Work item ended | `item.completed` or terminal status for work types | Removes `callId/itemId` from `openCallIds` or decrements `openItemCount` best-effort. | +| Tool call start (`response_item`) | payload type indicates function/tool call (not output) | Adds `callId` to `openCallIds` or increments `openItemCount`; clears end markers; if fresh: `turnOpen=true`, `inFlight=true`; bumps `lastActivityAt`; records `lastToolSignalAt`. | +| Tool output (`response_item`) | payload type indicates `*_call_output` | Removes a matching `callId` (or best-effort closes one); bumps `lastActivityAt`; records `lastToolSignalAt`. Does not clear end markers directly. | +| Review enter | payload type `entered_review_mode` | `reviewMode=true`; `turnOpen=true`; clears end markers; `inFlight=true`; bumps `lastActivityAt`. | +| Review exit | payload type `exited_review_mode` | `reviewMode=false`; `pendingEndAt=max(pendingEndAt, ts)`; `turnOpen=false`; bumps `lastActivityAt`. | +| Turn abort | item type includes `turn_aborted` | `pendingEndAt=max(pendingEndAt, ts)`; `turnOpen=false`. | +| Turn end | `turn.completed|failed|...` | `pendingEndAt=max(pendingEndAt, ts)`; `turnOpen=false`. | +| Response end | `response.completed|failed|...` | `pendingEndAt=max(pendingEndAt, ts)`; `turnOpen=false`. | + +## Expiration Logic (`expireInFlight`) + +`expireInFlight()` runs on every `updateTailLegacy` call, including when no new bytes were read. + +### Pending End Path + +If `pendingEndAt` is set: + +1. If `openCallCount > 0`, do nothing. +2. If a later activity timestamp exists (`lastToolSignalAt` or `lastActivityAt` or `lastEventAt` greater than `pendingEndAt`), cancel the pending end (`pendingEndAt` cleared). +3. Else, if `now - pendingEndAt >= CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`, finalize: + - `inFlight=false`, `turnOpen=false`, `pendingEndAt=undefined`, `lastEndAt=pendingEndAt` + - clears `lastInFlightSignalAt`, `lastIngestAt` + - clears open-call tracking + +### Stale In-Flight Path + +If `pendingEndAt` is not set: + +1. If `reviewMode=true`, do nothing. +2. If `turnOpen=true`, do nothing (explicitly suppresses stale expiration). +3. If `openCallCount > 0`, do nothing. +4. If `lastEndAt` is set, clears `inFlight` and activity markers. +5. Else, if `now - lastSignal >= CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS`, expire: + - `inFlight=false`, `turnOpen=false`, `pendingEndAt=undefined`, `lastEndAt=now` + - clears activity markers and open-call tracking. + +## Findings (Unreachable/Contradictory States) + +- `reviewMode=true` is sticky without an explicit `exited_review_mode` event. While in `reviewMode`, stale expiration is suppressed, so the session can remain in-flight indefinitely until a review-exit event or file reset occurs. +- `openCallIds/openItemCount` are sticky if corresponding end/output events are missed. Any open-call count prevents both pending-end and stale expiration. This is consistent with the "if unsure, stay active" policy, but it can also create ghost activity if logs are incomplete. +- A single `turn.started` event can match both the explicit "turn start" branch and the "response start" regex branch. This is redundant but not contradictory (both branches set the same state). + From f1cf5d2bd90b219be7151e294261111ead01886c Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:51:56 -0500 Subject: [PATCH 23/40] fix(server): make /api/snapshot cached by default --- docs/api-authentication.md | 6 ++-- src/cli/setup.ts | 4 +-- src/server.ts | 63 ++++++++++++++++++++++++++------------ 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/docs/api-authentication.md b/docs/api-authentication.md index a691eeb..327101b 100644 --- a/docs/api-authentication.md +++ b/docs/api-authentication.md @@ -10,7 +10,7 @@ This document records how Consensus exposes HTTP/WS APIs today and how callers p ## Endpoint summary | Route | Method | Purpose | Authentication | Notes | |-------|--------|---------|----------------|-------| -| `/api/snapshot` | `GET` | Returns the last snapshot emitted by the scan loop. The React-less canvas UI polls this endpoint to rebuild the agent map. | None | The handler runs `scanCodexProcesses` and pushes a JSON payload (ts + agents). | +| `/api/snapshot` | `GET` | Returns the last snapshot emitted by the scan loop (cached). | None | Use `?mode=full` for a synchronous full scan (bounded by `CONSENSUS_SCAN_TIMEOUT_MS`). Use `?refresh=1` to request a scan tick without blocking the response. | | `/health` | `GET` | Basic JSON health check for monitoring. | None | Always responds `{ ok: true }`. | | `/api/codex-event` | `POST` | Codex notify hook triggers a fast scan. | None | Payload validated against `CodexEventSchema`; rejects with `400` on schema mismatch. Events are not merged into activity state. | | `/api/claude-event` | `POST` | Claude Code hooks post lifecycle events. | None | Schema validated via `ClaudeEventSchema`; `dist/claudeHook.js` reads stdin and forwards to this endpoint. | @@ -20,9 +20,9 @@ This document records how Consensus exposes HTTP/WS APIs today and how callers p The UI also opens a WebSocket (handled by `ws` in `src/server.ts`) but the WebSocket connection is only permitted from the same origin as the served static files. ## Client expectations -- Codex notify hooks call `noun codex config set -g notify` with the Consensus endpoint (`/api/codex-event`) and expect no authentication steps beyond the default localhost requirement. Codex in-flight state is derived from session JSONL logs (e.g. `~/.codex/sessions/.../*.jsonl`); the webhook only triggers faster scans. +- Codex notify hooks call `codex config set -g notify` with the Consensus endpoint (`/api/codex-event`) and expect no authentication steps beyond the default localhost requirement. Codex in-flight state is derived from session JSONL logs (e.g. `~/.codex/sessions/.../*.jsonl`); the webhook only triggers faster scans. - Claude Code hooks run `dist/claudeHook.js` which POSTs a minimal JSON event directly to `/api/claude-event` from the hook process; it neither signs the request nor retries if the hook fails. -- The browser UI fetches `/api/snapshot` and opens the WebSocket without extra headers. +- The browser UI connects over WebSocket for snapshots and deltas. `/api/snapshot` is primarily for polling tools and debugging. ## Hardening guidance 1. Any time the server binds to a non-localhost address (custom `CONSENSUS_HOST`), add auth before enabling the port in `docs/constitution.md`'s sense. A simple opt-in token header (configured via an environment variable) or Mutual TLS would be appropriate. diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 017ef4c..a0eba16 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -182,8 +182,8 @@ export async function runSetup(): Promise { console.log(""); } - console.log("Consensus requires Codex's notify hook for accurate activity detection."); - console.log("Without it, session state will be unreliable or unavailable."); + console.log("Consensus can install Codex's notify hook to trigger faster UI updates."); + console.log("Activity state is still derived from Codex session JSONL logs."); console.log(""); console.log("Installing Codex notify hook..."); diff --git a/src/server.ts b/src/server.ts index acf80ef..23fce6a 100644 --- a/src/server.ts +++ b/src/server.ts @@ -291,25 +291,50 @@ function runHttpEffect( } app.get("/api/snapshot", (req, res) => { + const mode = + typeof req.query.mode === "string" + ? req.query.mode + : Array.isArray(req.query.mode) + ? req.query.mode[0] + : undefined; + const wantsFull = + mode === "full" || req.query.full === "1" || req.query.full === "true"; + const wantsRefresh = + req.query.refresh === "1" || req.query.refresh === "true"; + + if (wantsRefresh) { + requestTick(wantsFull ? "full" : "fast"); + } + + if (!wantsFull) { + res.setHeader("X-Consensus-Snapshot", "cached"); + res.json(lastSnapshot); + return; + } + const effect = Effect.tryPromise({ - try: () => scanCodexProcesses({ mode: "full" }), + try: (signal) => scanCodexProcesses({ mode: "full", signal }), catch: (err) => err as Error, - }).pipe( - Effect.tap((snapshot) => - Effect.sync(() => { - lastBaseSnapshot = snapshot; - lastSnapshot = applyTestOverrides(snapshot); - res.json(lastSnapshot); - }) - ), - Effect.as(200), - Effect.tapError(() => recordError("http_snapshot")), - Effect.catchAll(() => - Effect.sync(() => { - res.status(500).json({ error: "scan_failed" }); - }).pipe(Effect.as(500)) - ) - ); + }) + .pipe(Effect.timeout(`${scanTimeoutMs} millis`)) + .pipe( + Effect.tap((snapshot) => + Effect.sync(() => { + lastBaseSnapshot = snapshot; + lastSnapshot = applyTestOverrides(snapshot); + res.setHeader("X-Consensus-Snapshot", "full"); + res.json(lastSnapshot); + }) + ), + Effect.as(200), + Effect.tapError(() => recordError("http_snapshot")), + Effect.catchAll(() => + Effect.sync(() => { + res.setHeader("X-Consensus-Snapshot", "cached-fallback"); + res.json(lastSnapshot); + }).pipe(Effect.as(200)) + ) + ); runHttpEffect(req, res, "/api/snapshot", effect); }); @@ -856,8 +881,8 @@ function installCodexNotifyHook(): void { const runtime = runFork( Effect.scoped( Effect.gen(function* () { - // Note: Codex file watcher removed - using webhook-based events instead - // Legacy notify hook install kept for backward compatibility + // Legacy notify hook install kept for backward compatibility. + // Current Codex activity state comes from session JSONL tails; webhooks/watcher only trigger scans. yield* Effect.sync(() => installCodexNotifyHook()); yield* Effect.acquireRelease( Effect.sync(() => startReloadWatcher()), From 0f901d105b8f888773669ae30553790cd2e42e17 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:52:02 -0500 Subject: [PATCH 24/40] chore(qa): add flicker transition logs and optional video --- playwright.config.ts | 2 + scripts/flicker-detect.js | 82 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 1631d61..5d138fb 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig } from "@playwright/test"; const PORT = process.env.CONSENSUS_PORT || "8787"; +const RECORD_VIDEO = process.env.PW_VIDEO === "1"; export default defineConfig({ testDir: "./e2e", @@ -9,6 +10,7 @@ export default defineConfig({ use: { baseURL: `http://127.0.0.1:${PORT}`, headless: true, + video: RECORD_VIDEO ? "on" : "off", }, webServer: { command: diff --git a/scripts/flicker-detect.js b/scripts/flicker-detect.js index 7913979..c5d562d 100644 --- a/scripts/flicker-detect.js +++ b/scripts/flicker-detect.js @@ -6,11 +6,13 @@ const ACTIVE_STATES = new Set(["active", "error"]); function parseArgs(argv) { const args = { - endpoint: "http://127.0.0.1:8787/api/snapshot", + endpoint: "http://127.0.0.1:8787/api/snapshot?cached=1", intervalMs: 250, durationMs: 120000, windowMs: 10000, out: "tmp/flicker-summary.json", + outJsonl: "", + maxIntervalFactor: 2, verbose: false, }; @@ -51,11 +53,24 @@ function parseArgs(argv) { i += 1; continue; } + if (raw === "--out-jsonl") { + args.outJsonl = next; + i += 1; + continue; + } + if (raw === "--max-interval-factor") { + args.maxIntervalFactor = Number(next); + i += 1; + continue; + } } if (!Number.isFinite(args.intervalMs) || args.intervalMs <= 0) args.intervalMs = 250; if (!Number.isFinite(args.durationMs) || args.durationMs <= 0) args.durationMs = 120000; if (!Number.isFinite(args.windowMs) || args.windowMs <= 0) args.windowMs = 10000; + if (!Number.isFinite(args.maxIntervalFactor) || args.maxIntervalFactor <= 0) { + args.maxIntervalFactor = 2; + } return args; } @@ -84,11 +99,13 @@ function usage() { "Usage: node scripts/flicker-detect.js [options]", "", "Options:", - " --endpoint Snapshot endpoint (default: http://127.0.0.1:8787/api/snapshot)", + " --endpoint Snapshot endpoint (default: http://127.0.0.1:8787/api/snapshot?cached=1)", " --interval-ms Poll interval (default: 250)", " --duration-ms Total run duration (default: 120000)", " --window-ms Flicker window for active->idle->active (default: 10000)", " --out Write JSON summary (default: tmp/flicker-summary.json)", + " --out-jsonl Write JSONL transition log (default: .transitions.jsonl)", + " --max-interval-factor Fail if effective interval exceeds intervalMs * factor (default: 2)", " --verbose, -v Print transitions as they occur", "", ].join("\n"); @@ -105,6 +122,7 @@ async function main() { const endAt = startedAt + args.durationMs; const perAgent = new Map(); + const transitionsLog = []; const errors = []; let polls = 0; let okPolls = 0; @@ -138,7 +156,9 @@ async function main() { if (!entry) { entry = { identity: id, - lastState: state, + // Treat first sighting as a transition from idle -> current state so we can + // build a complete active window in the output. + lastState: "idle", lastSeenAt: Date.now(), missingTicks: 0, transitions: [], @@ -147,7 +167,6 @@ async function main() { lastActiveToIdleAt: undefined, }; perAgent.set(id, entry); - continue; } entry.lastSeenAt = Date.now(); @@ -155,6 +174,7 @@ async function main() { if (prev !== state) { const ts = Date.now(); entry.transitions.push({ ts, from: prev, to: state }); + transitionsLog.push({ ts, identity: id, from: prev, to: state }); if (args.verbose) { process.stdout.write(`[${new Date(ts).toISOString()}] ${id} ${prev} -> ${state}\n`); } @@ -175,9 +195,28 @@ async function main() { } } + const tickFinishedAt = Date.now(); for (const entry of perAgent.values()) { if (!seen.has(entry.identity)) { entry.missingTicks += 1; + if (entry.lastState !== "idle") { + const prev = entry.lastState; + entry.transitions.push({ ts: tickFinishedAt, from: prev, to: "idle", missing: true }); + transitionsLog.push({ + ts: tickFinishedAt, + identity: entry.identity, + from: prev, + to: "idle", + missing: true, + }); + if (args.verbose) { + process.stdout.write( + `[${new Date(tickFinishedAt).toISOString()}] ${entry.identity} ${prev} -> idle (missing)\n` + ); + } + entry.lastActiveToIdleAt = tickFinishedAt; + entry.lastState = "idle"; + } } } @@ -186,6 +225,17 @@ async function main() { } const finishedAt = Date.now(); + const effectiveIntervalMs = okPolls > 0 ? (finishedAt - startedAt) / okPolls : undefined; + const intervalBudgetMs = args.intervalMs * args.maxIntervalFactor; + const samplingOk = + typeof effectiveIntervalMs === "number" + ? effectiveIntervalMs <= intervalBudgetMs + : false; + const samplingReason = samplingOk + ? undefined + : okPolls === 0 + ? "no_successful_polls" + : `effective_interval_ms_exceeds_budget:${Math.round(effectiveIntervalMs || 0)}>${Math.round(intervalBudgetMs)}`; const agentSummaries = Array.from(perAgent.values()) .map((entry) => ({ identity: entry.identity, @@ -200,26 +250,49 @@ async function main() { .sort((a, b) => b.flickerCount - a.flickerCount || a.identity.localeCompare(b.identity)); const totalFlickerCount = agentSummaries.reduce((acc, a) => acc + (a.flickerCount || 0), 0); + const transitionsOut = + args.outJsonl && args.outJsonl.trim() + ? args.outJsonl + : args.out.endsWith(".json") + ? args.out.replace(/\.json$/, ".transitions.jsonl") + : `${args.out}.transitions.jsonl`; const summary = { endpoint: args.endpoint, intervalMs: args.intervalMs, durationMs: args.durationMs, windowMs: args.windowMs, + maxIntervalFactor: args.maxIntervalFactor, startedAt, finishedAt, polls, okPolls, + effectiveIntervalMs, + samplingOk, + samplingReason, errorCount: errors.length, errors, totalFlickerCount, agents: agentSummaries, + transitionsPath: transitionsOut, }; const outPath = path.resolve(args.out); await fs.mkdir(path.dirname(outPath), { recursive: true }); await fs.writeFile(outPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8"); + const transitionsPath = path.resolve(transitionsOut); + await fs.mkdir(path.dirname(transitionsPath), { recursive: true }); + const transitionsBody = + transitionsLog.length > 0 + ? transitionsLog.map((entry) => JSON.stringify(entry)).join("\n") + "\n" + : ""; + await fs.writeFile( + transitionsPath, + transitionsBody, + "utf8" + ); if (okPolls === 0) return 2; + if (!samplingOk) return 2; if (totalFlickerCount > 0) return 1; return 0; } @@ -232,4 +305,3 @@ main() process.stderr.write(`${String(err)}\n`); process.exitCode = 2; }); - From e88c62ab6e536e246e0f5f0f2beea68d4894fadb Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:33:01 -0500 Subject: [PATCH 25/40] chore(qa): add codex TUI/video demo scenarios --- e2e/ui/codexActivityDemo.pw.ts | 154 +++++++++++++++++++++++++++++++ e2e/ui/codexTuiLiveDemo.pw.ts | 163 +++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 e2e/ui/codexActivityDemo.pw.ts create mode 100644 e2e/ui/codexTuiLiveDemo.pw.ts diff --git a/e2e/ui/codexActivityDemo.pw.ts b/e2e/ui/codexActivityDemo.pw.ts new file mode 100644 index 0000000..322e33e --- /dev/null +++ b/e2e/ui/codexActivityDemo.pw.ts @@ -0,0 +1,154 @@ +import { test, expect } from "@playwright/test"; +import { gotoMock, makeAgent, makeSnapshot, setMockSnapshot } from "./helpers"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +test("codex agents transition idle -> active -> idle (video demo)", async ({ page }) => { + // This is a QA artifact: a long-running deterministic UI demo intended for video capture. + // Run with: PW_VIDEO=1 npx playwright test e2e/ui/codexActivityDemo.pw.ts + test.setTimeout(120_000); + + if (process.env.RUN_CODEX_UI_DEMO !== "1") { + test.skip(true, "Set RUN_CODEX_UI_DEMO=1 to run the long-running UI demo."); + return; + } + + await gotoMock(page); + + const idle = "idle" as const; + const active = "active" as const; + + const codexA = makeAgent({ + id: "codex-a", + pid: 11001, + title: "Codex A", + cmd: "codex exec -C demo-a", + cmdShort: "codex exec -C demo-a", + kind: "exec", + cpu: 0, + mem: 90_000_000, + state: idle, + doing: "idle", + summary: { current: "idle" }, + }); + const codexB = makeAgent({ + id: "codex-b", + pid: 11002, + title: "Codex B", + cmd: "codex exec -C demo-b", + cmdShort: "codex exec -C demo-b", + kind: "exec", + cpu: 0, + mem: 90_000_000, + state: idle, + doing: "idle", + summary: { current: "idle" }, + }); + const codexC = makeAgent({ + id: "codex-c", + pid: 11003, + title: "Codex C", + cmd: "codex exec -C demo-c", + cmdShort: "codex exec -C demo-c", + kind: "exec", + cpu: 0, + mem: 90_000_000, + state: idle, + doing: "idle", + summary: { current: "idle" }, + }); + + // Start empty (shows "No agents detected."), then introduce three idle Codex agents. + await setMockSnapshot(page, makeSnapshot([])); + await sleep(1500); + await setMockSnapshot(page, makeSnapshot([codexA, codexB, codexC])); + + await expect(page.locator("#active-list .lane-item")).toHaveCount(3); + await sleep(2000); + + const start = Date.now(); + const demoMs = 30_000; + while (Date.now() - start < demoMs) { + const elapsed = Date.now() - start; + + // A: active 0s-12s + const aActive = elapsed < 12_000; + // B: active 6s-20s + const bActive = elapsed >= 6_000 && elapsed < 20_000; + // C: active 14s-28s + const cActive = elapsed >= 14_000 && elapsed < 28_000; + + const step = Math.floor(elapsed / 2000); + + const aSummary = aActive + ? [ + "tool: Context7 resolve-library-id (react)", + "tool: Context7 query-docs #1 (useEffect cleanup)", + "tool: Context7 query-docs #2 (useEffect vs useMemo)", + "tool: Context7 query-docs #3 (Rules of Hooks)", + "note: search 1 completed", + "tool: web search #1 (Codex CLI notify hook docs)", + ][step % 6] + : "idle"; + + const bSummary = bActive + ? [ + "tool: web search #1 (Otel exporter config.toml)", + "tool: web search #2 (agent-turn-complete payload fields)", + "tool: Context7 query-docs #1 (cleanup examples)", + "note: search 2 completed", + ][step % 4] + : "idle"; + + const cSummary = cActive + ? [ + "tool: grep (find hook config)", + "tool: parse (session jsonl)", + "tool: summarize (state transitions)", + "tool: build (npm run build)", + ][step % 4] + : "idle"; + + await setMockSnapshot( + page, + makeSnapshot([ + { + ...codexA, + cpu: aActive ? 6 : 0, + state: aActive ? active : idle, + doing: aActive ? "thinking" : "idle", + summary: { current: aSummary }, + }, + { + ...codexB, + cpu: bActive ? 5 : 0, + state: bActive ? active : idle, + doing: bActive ? "thinking" : "idle", + summary: { current: bSummary }, + }, + { + ...codexC, + cpu: cActive ? 7 : 0, + state: cActive ? active : idle, + doing: cActive ? "thinking" : "idle", + summary: { current: cSummary }, + }, + ]) + ); + + await sleep(1000); + } + + // End all idle so the clip clearly shows the completion state. + await setMockSnapshot( + page, + makeSnapshot([ + { ...codexA, cpu: 0, state: idle, doing: "idle", summary: { current: "idle" } }, + { ...codexB, cpu: 0, state: idle, doing: "idle", summary: { current: "idle" } }, + { ...codexC, cpu: 0, state: idle, doing: "idle", summary: { current: "idle" } }, + ]) + ); + await sleep(1500); +}); diff --git a/e2e/ui/codexTuiLiveDemo.pw.ts b/e2e/ui/codexTuiLiveDemo.pw.ts new file mode 100644 index 0000000..b987d30 --- /dev/null +++ b/e2e/ui/codexTuiLiveDemo.pw.ts @@ -0,0 +1,163 @@ +import { test, expect } from "@playwright/test"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; + +const execFileAsync = promisify(execFile); + +async function isTmuxAvailable(): Promise { + try { + await execFileAsync("tmux", ["-V"]); + return true; + } catch { + return false; + } +} + +async function isCodexAvailable(): Promise { + const bin = process.env.CODEX_BIN || "codex"; + try { + await execFileAsync(bin, ["--version"]); + return true; + } catch { + return false; + } +} + +async function tmuxNewSession(session: string, command: string): Promise { + await execFileAsync("tmux", [ + "new-session", + "-d", + "-s", + session, + "-x", + "140", + "-y", + "45", + command, + ]); +} + +async function tmuxSend(session: string, text: string): Promise { + // `send-keys` with Enter at end to submit the prompt. + await execFileAsync("tmux", ["send-keys", "-t", session, text, "Enter"]); +} + +async function tmuxKill(session: string): Promise { + try { + await execFileAsync("tmux", ["kill-session", "-t", session]); + } catch { + // ignore + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function codexCommand(cwd: string): string { + const bin = process.env.CODEX_BIN || "codex"; + // Include `-C ` so CONSENSUS_PROCESS_MATCH can target these processes deterministically. + return `${bin} --dangerously-bypass-approvals-and-sandbox -C ${JSON.stringify( + cwd + )}`; +} + +test("live demo: 3 codex TUI sessions go idle -> active -> idle (30s capture)", async ({ page }, testInfo) => { + test.setTimeout(240_000); + + if (process.env.RUN_LIVE_CODEX !== "1") { + test.skip(true, "Set RUN_LIVE_CODEX=1 to run live Codex TUI demo."); + return; + } + if (!(await isTmuxAvailable())) { + test.skip(true, "tmux not available"); + return; + } + if (!(await isCodexAvailable())) { + test.skip(true, "codex binary not available"); + return; + } + + const root = await mkdtemp(path.join(os.tmpdir(), "consensus-tui-demo-")); + + const dirs = { + a: path.join(root, "demo-a"), + b: path.join(root, "demo-b"), + c: path.join(root, "demo-c"), + }; + await mkdir(dirs.a, { recursive: true }); + await mkdir(dirs.b, { recursive: true }); + await mkdir(dirs.c, { recursive: true }); + await writeFile(path.join(dirs.a, "README.md"), "demo-a\n", "utf8"); + await writeFile(path.join(dirs.b, "README.md"), "demo-b\n", "utf8"); + await writeFile(path.join(dirs.c, "README.md"), "demo-c\n", "utf8"); + + const prefix = `consensus-tui-${testInfo.testId.slice(0, 8)}`; + const sessions = { + a: `${prefix}-a`, + b: `${prefix}-b`, + c: `${prefix}-c`, + }; + + try { + await page.setViewportSize({ width: 1280, height: 720 }); + await page.goto("/"); + + // Capture the "empty" state for a moment. + await expect(page.locator("#active-list")).toBeVisible(); + await sleep(1500); + + // Start three interactive Codex TUI sessions. + await tmuxNewSession(sessions.a, codexCommand(dirs.a)); + await tmuxNewSession(sessions.b, codexCommand(dirs.b)); + await tmuxNewSession(sessions.c, codexCommand(dirs.c)); + + // Wait for agents to appear in the UI list (server side polling is 250ms). + // Note: if OpenCode is enabled, this list may contain non-Codex sessions too. + await page.waitForFunction(() => { + return document.querySelectorAll("#active-list .lane-item").length >= 3; + }, undefined, { timeout: 30_000 }); + + // Give the TUIs a moment to settle before firing prompts. + await sleep(2000); + + // Trigger staggered tool-heavy work. `sleep` keeps a tool open long enough to visualize. + await tmuxSend( + sessions.a, + [ + "Run bash: sleep 2.", + "Then run bash: ls.", + 'Then reply with exactly: "A done".', + ].join(" ") + ); + await sleep(600); + await tmuxSend( + sessions.b, + [ + "Run bash: sleep 3.", + 'Then run bash: echo "search 1 completed".', + 'Then reply with exactly: "B done".', + ].join(" ") + ); + await sleep(600); + await tmuxSend( + sessions.c, + [ + "Run bash: sleep 4.", + "Then run bash: pwd.", + 'Then reply with exactly: "C done".', + ].join(" ") + ); + + // Let the scan loop observe activity and then settle back to idle. + await sleep(30_000); + } finally { + await tmuxKill(sessions.a); + await tmuxKill(sessions.b); + await tmuxKill(sessions.c); + await rm(root, { recursive: true, force: true }); + } +}); From 06687854deb7f9c471cf8787db4ae1b5d75cb8e6 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:33:07 -0500 Subject: [PATCH 26/40] docs: add postmortem for codex activity flicker --- docs/postmortem-codex-activity-flicker.md | 110 ++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/postmortem-codex-activity-flicker.md diff --git a/docs/postmortem-codex-activity-flicker.md b/docs/postmortem-codex-activity-flicker.md new file mode 100644 index 0000000..5df0a56 --- /dev/null +++ b/docs/postmortem-codex-activity-flicker.md @@ -0,0 +1,110 @@ +# Postmortem: Codex Activity Flicker (Idle/Active Oscillation) + +- Date: 2026-02-05 to 2026-02-06 +- Area: Codex activity detection and agent lane rendering (`/api/snapshot`, JSONL tailer, session selection) +- Symptom: "active" animation flickered off between tool calls and in the middle of a single interactive Codex session. + +## Summary + +Consensus was intermittently showing a Codex agent as `idle` between tool calls even though the agent was still actively running work. The user-visible symptom was UI flicker (active, idle, active) during a single assistant flight. + +The primary root cause was inconsistent session log tailing caused by session file switching between scan ticks (a PID being mapped to different `*.jsonl` files over time) plus timeout/grace logic that could temporarily end `inFlight` when no new bytes were observed. + +Remediation standardized on a single source of truth (Codex session JSONL tails) for `inFlight` and `lastActivityAt`, added PID-to-session pinning with safe stale release, hardened grace/timeout behavior via tests, and added evidence tooling (flicker detector + video demo) to prevent regressions. + +## Impact + +- UI: false negatives for "agent active" during long tool chains or during quiet gaps between tool events. +- Observability: inconsistent `inFlight` made it hard to trust snapshots, and masked real activity behind transient idle windows. + +## Root Causes + +1. **Session file switching across scan ticks** + - `scanCodexProcesses` can discover multiple candidate JSONL files (session id, `lsof`, cwd heuristics). + - Without stable pinning, the chosen file could oscillate between candidates, which reset tail offsets/state and produced apparent state changes (including `active->idle->active`). + +2. **`inFlight` timeout/grace interacting with "no new bytes" periods** + - Tool chains can have short gaps with no new session JSONL output. + - If the tailer relies only on file freshness/bytes, those gaps can trip `pendingEndAt` paths unless explicitly suppressed by an open turn (`turnOpen`) or cleared by late-arriving activity. + +3. **Attempted multi-source merge (webhook + notify graph + JSONL) introduced conflict** + - Earlier iterations tried merging webhook-derived state, notify hook state, and JSONL tail state. + - Codex notify hooks were not emitting the full lifecycle events required to represent multi-tool concurrency reliably, so the graph could not be SSOT. + - The merge logic created disagreement windows that manifested as flicker. + +## Contributing Factors + +- "Done" was implicitly treated as "compiles and tests pass" instead of "reproduced and measured fixed". +- `/api/snapshot` historically performed synchronous scanning on each request, which made high-frequency polling both expensive and jittery for evidence collection. +- Local environment quirks during QA: + - Codex CLI did not support `--skip-git-repo-check` (early tmux runs exited immediately). + - tmux `base-index` was `1`, so targeting `session:0.0` failed until switching to name-based targets. + +## Remediation (What Changed) + +### Single Source of Truth + +- SSOT: Codex session JSONL tail (`summarizeTail(...)` in `src/codexLogs.ts`) is authoritative for: + - `inFlight` + - `lastActivityAt` +- Webhook/notify inputs only trigger faster scanning or attach metadata. They do not override JSONL-derived state. + - See `docs/data-flow.md`. + +### Session Pinning and Selection Stability + +- PID-to-session pinning prevents mid-run session switching. +- Explicit session signals (session id mapping, cwd-derived session) take priority over cached pinning. +- Pins are released only on safe stale conditions (mtime too old, file missing, PID restart). + - See `docs/session-selection.md`. + +### Timeout/Grace Hardening + +- Added focused tests for the `pendingEndAt` timeout path and `turnOpen` suppression. + - `tests/unit/stateTimeout.test.ts` + +### Snapshot Endpoint for High-Frequency Polling + +- `/api/snapshot` returns cached snapshots by default to support 250ms pollers safely. +- `?mode=full` runs a bounded full scan; `?refresh=1` requests a scan tick without blocking the response. + - `src/server.ts` + - `docs/api-authentication.md` + +### Evidence Tooling + +- `scripts/flicker-detect.js`: + - Polls `/api/snapshot` at 250ms, logs transitions, computes flicker (`active->idle->active` within a window). + - Writes both a JSON summary and a JSONL transition log. +- Playwright demos (manual, env-gated): + - `e2e/ui/codexTuiLiveDemo.pw.ts` (interactive Codex TUI sessions via tmux; video capture via `PW_VIDEO=1`) + - `e2e/ui/codexActivityDemo.pw.ts` (mock mode demo for video capture; deterministic) + +## Validation and Evidence + +### Automated gates + +- Build gate: `npm run build` +- Regression gate: `npm test` (unit + integration) +- Evidence gate: flicker detector + video demo (paths are gitignored; reproduction commands are below). + +### Reproduction commands + +Flicker detector: + +```bash +node scripts/flicker-detect.js --interval-ms 250 --duration-ms 120000 --window-ms 10000 \ + --out tmp/flicker-summary.json --out-jsonl tmp/flicker-summary.transitions.jsonl +``` + +Interactive TUI video demo (manual): + +```bash +RUN_LIVE_CODEX=1 PW_VIDEO=1 CONSENSUS_PROCESS_MATCH=consensus-tui-demo- \ + CONSENSUS_OPENCODE_EVENTS=0 CONSENSUS_OPENCODE_AUTOSTART=0 \ + npx playwright test e2e/ui/codexTuiLiveDemo.pw.ts +``` + +## Follow-ups + +- Consider adding a CI lane that runs `scripts/flicker-detect.js` against mock mode (deterministic) to gate future flicker regressions. +- Keep the "Completion protocol" in `AGENTS.md` enforced: evidence artifacts are required for state-change fixes. + From 2cc155f213cdc1f0d2ebbe6a2728384570353245 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:38:50 -0500 Subject: [PATCH 27/40] docs: codify feature verification workflow (tests, evidence, demo video) --- AGENTS.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 78 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 225a717..f154fae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ SAFE ALTERNATIVE: Provide a legal, safe approach to the same goal. ## Verification - Default: `npm run build` for type-checking. -- After any changes, run `npm test` and `npm run build`. +- After any changes, run `npm run build` and `npm test`. ## Completion protocol (non-negotiable) - Gate order: 1) Build gate, 2) Evidence gate, 3) Regression gate. @@ -212,6 +212,82 @@ SAFE ALTERNATIVE: Provide a legal, safe approach to the same goal. - Regression gate: Confirm that no previously-passing test now fails and no unrelated behavior changed. - If unrelated files are modified, flag them explicitly and do not commit them. +## Feature verification workflow (step-by-step) +This section applies to every feature and every behavior-changing bug fix. + +### 1) Define success criteria (before coding) +1. Write down: + - What should change (user-visible behavior and/or API behavior). + - How to reproduce the old behavior. + - What artifact will prove the fix (log, snapshot diff, test, video). +2. If the change is UI-visible, define what must be shown in the demo video: + - Start state + - Active state + - End state + +### 2) Tests-first (before implementation) +1. Add or update tests that fail on the current behavior: + - Unit tests for pure state/logic. + - Integration tests for parsing (prefer real captured JSONL fixtures). + - E2E tests for UI invariants (fast, deterministic). +2. Run: + - `npm run build` + - `npm test` +3. Confirm the new/updated tests fail for the expected reason before implementing the fix. + +### 3) Implement the feature/fix +1. Make the minimal code changes required to satisfy the tests and success criteria. +2. Keep scope tight. If unrelated changes are discovered, isolate them or stop and report. + +### 4) Build gate (hard requirement) +1. Run: + - `npm run build` + - `npm test` +2. In the PR description or review comment, paste: + - The final `vite build` summary line (e.g. `✓ built in ...ms`). + - The final Node test summary lines (`# pass ... # fail 0`). + +### 5) Evidence gate (hard requirement) +Produce artifacts that prove the change, not just that it compiles. + +#### 5a) Activity/flicker fixes (required when touching inFlight/lastActivity/session selection) +1. Run the poller: + - `node scripts/flicker-detect.js --interval-ms 250 --duration-ms 120000 --window-ms 10000 --out tmp/flicker-summary.json` +2. Required artifacts: + - `tmp/flicker-summary.json` + - `tmp/flicker-summary.transitions.jsonl` (written automatically, or via `--out-jsonl`) +3. Required report fields: + - Polling parameters (interval, duration, window). + - `totalFlickerCount` from the JSON summary. + - File paths of the JSON and JSONL artifacts. + +#### 5b) Snapshot/state fixes (required when touching scan/tail logic) +1. Capture a "before" and "after" snapshot (or a minimal diff) that shows the incorrect state and corrected state. +2. Attach the diff as a PR comment or a file under `tmp/` (gitignored) and reference its path. + +### 6) Demo video gate (hard requirement for every feature PR) +Every feature PR must include a 30-second demo video showing the feature working end-to-end. + +#### 6a) Deterministic UI demo (preferred default) +1. Create or update an env-gated Playwright demo test under `e2e/ui/`: + - Example pattern: `e2e/ui/Demo.pw.ts` + - It must `test.skip` unless an explicit env var is set. +2. Record video: + - `PW_VIDEO=1 RUN_CODEX_UI_DEMO=1 npx playwright test e2e/ui/codexActivityDemo.pw.ts` +3. Locate the recorded `video.webm` under `test-results/` (gitignored). +4. Trim/copy a 30-second share artifact into `tmp/` (gitignored). If `ffmpeg` is available: + - `ffmpeg -y -i -t 30 -c:v libx264 -pix_fmt yuv420p tmp/-demo-30s.mp4` +5. Manually review the video and confirm it shows the intended start/active/end states with no regressions. + +#### 6b) Live Codex TUI demo (required for Codex activity work) +1. Use the live demo harness: + - `PW_VIDEO=1 RUN_LIVE_CODEX=1 CONSENSUS_PROCESS_MATCH=consensus-tui-demo- CONSENSUS_OPENCODE_EVENTS=0 CONSENSUS_OPENCODE_AUTOSTART=0 npx playwright test e2e/ui/codexTuiLiveDemo.pw.ts` +2. Produce a 30-second share artifact in `tmp/` (mp4 or webm) and review it. + +#### 6c) PR requirement +1. Upload the video to the PR (GitHub comment attachment or equivalent). +2. Also record the local path in the PR for traceability (e.g. `tmp/-demo-30s.mp4`). + ## Completion rules - `npm test` success must include the summary line, not a claim. - "Flicker count: 0" must include the log file path and polling parameters used. @@ -228,7 +304,7 @@ SAFE ALTERNATIVE: Provide a legal, safe approach to the same goal. - `CONSENSUS_HOST`, `CONSENSUS_PORT`, `CONSENSUS_POLL_MS`, `CONSENSUS_CODEX_HOME`, `CONSENSUS_PROCESS_MATCH`, `CONSENSUS_REDACT_PII`. ## Do not -- Add non-trivial tests unless explicitly requested. +- Ship behavior changes without tests. Features and behavior-changing bug fixes must add or update tests. - Introduce large UI frameworks or build tooling. From d00a7da196215d7d21524b77745b23180a4e25d2 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:51:14 -0500 Subject: [PATCH 28/40] fix(config): unify poll interval and opencode timeout defaults --- src/config/intervals.ts | 20 ++++++++++++++++++++ src/scan.ts | 5 +++-- src/server.ts | 11 +++++++---- tests/unit/configIntervals.test.ts | 20 ++++++++++++++++++++ 4 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/config/intervals.ts create mode 100644 tests/unit/configIntervals.test.ts diff --git a/src/config/intervals.ts b/src/config/intervals.ts new file mode 100644 index 0000000..1c2d701 --- /dev/null +++ b/src/config/intervals.ts @@ -0,0 +1,20 @@ +export function resolvePollMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.CONSENSUS_POLL_MS; + const parsed = + raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw); + const value = Number.isFinite(parsed) ? parsed : 250; + // Keep scanning responsive but avoid pathological tight loops. + return Math.max(50, value); +} + +export function resolveOpenCodeTimeoutMs( + env: NodeJS.ProcessEnv = process.env +): number { + const raw = env.CONSENSUS_OPENCODE_TIMEOUT_MS; + const parsed = + raw === undefined || raw.trim() === "" ? Number.NaN : Number(raw); + const value = Number.isFinite(parsed) ? parsed : 5000; + // 0 disables the timeout in opencodeApi.ts; allow it if explicitly set. + if (value === 0) return 0; + return Math.max(1, value); +} diff --git a/src/scan.ts b/src/scan.ts index 248d9e2..388f19a 100644 --- a/src/scan.ts +++ b/src/scan.ts @@ -54,6 +54,7 @@ import { getClaudeActivityByCwd, getClaudeActivityBySession } from "./services/c import { parseOpenCodeCommand, summarizeOpenCodeCommand } from "./opencodeCmd.js"; import { redactText } from "./redact.js"; import { dedupeAgents } from "./dedupe.js"; +import { resolveOpenCodeTimeoutMs, resolvePollMs } from "./config/intervals.js"; import { recordActivityCount, recordActivityTransition, @@ -784,7 +785,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise { if (value === undefined) return fallback; const parsed = Number(value); @@ -984,7 +985,7 @@ export async function scanCodexProcesses(options: ScanOptions = {}): Promise opencodePollMs; diff --git a/src/server.ts b/src/server.ts index 23fce6a..f37a3cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ import { Schema, ParseResult } from "effect"; import type { SnapshotPayload, AgentSnapshot, SnapshotMeta } from "./types.js"; import { registerActivityTestRoutes } from "./server/activityTestRoutes.js"; import { normalizeCodexNotifyInstall } from "./codexNotifyInstall.js"; +import { resolvePollMs } from "./config/intervals.js"; import { annotateSpan, disposeObservability, @@ -131,7 +132,7 @@ function sendDeltaEnvelope(socket: WebSocket, ops: DeltaOp[]): void { const port = Number(process.env.CONSENSUS_PORT || 8787); const host = process.env.CONSENSUS_HOST || "127.0.0.1"; -const pollMs = Math.max(50, Number(process.env.CONSENSUS_POLL_MS || 250)); +const pollMs = resolvePollMs(); const scanTimeoutMs = Math.max( 500, Number(process.env.CONSENSUS_SCAN_TIMEOUT_MS || 5000) @@ -884,6 +885,10 @@ const runtime = runFork( // Legacy notify hook install kept for backward compatibility. // Current Codex activity state comes from session JSONL tails; webhooks/watcher only trigger scans. yield* Effect.sync(() => installCodexNotifyHook()); + yield* Effect.acquireRelease( + Effect.sync(() => startCodexWatcher()), + () => Effect.promise(() => Promise.resolve(stopCodexWatcher())) + ); yield* Effect.acquireRelease( Effect.sync(() => startReloadWatcher()), () => Effect.promise(() => Promise.resolve(stopReloadWatcher())) @@ -898,9 +903,7 @@ const runtime = runFork( }).pipe( Effect.catchAll((err) => Effect.sync(() => { - process.stderr.write( - `[consensus] runtime error: ${String(err)}\n` - ); + process.stderr.write(`[consensus] runtime error: ${String(err)}\n`); }) ) ) diff --git a/tests/unit/configIntervals.test.ts b/tests/unit/configIntervals.test.ts new file mode 100644 index 0000000..20ec457 --- /dev/null +++ b/tests/unit/configIntervals.test.ts @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveOpenCodeTimeoutMs, resolvePollMs } from "../../src/config/intervals.ts"; + +test("resolvePollMs defaults to 250 and clamps to min 50", () => { + assert.equal(resolvePollMs({} as NodeJS.ProcessEnv), 250); + assert.equal(resolvePollMs({ CONSENSUS_POLL_MS: "" } as NodeJS.ProcessEnv), 250); + assert.equal(resolvePollMs({ CONSENSUS_POLL_MS: "0" } as NodeJS.ProcessEnv), 50); + assert.equal(resolvePollMs({ CONSENSUS_POLL_MS: "10" } as NodeJS.ProcessEnv), 50); + assert.equal(resolvePollMs({ CONSENSUS_POLL_MS: "250" } as NodeJS.ProcessEnv), 250); +}); + +test("resolveOpenCodeTimeoutMs defaults to 5000 when unset", () => { + assert.equal(resolveOpenCodeTimeoutMs({} as NodeJS.ProcessEnv), 5000); + assert.equal( + resolveOpenCodeTimeoutMs({ CONSENSUS_OPENCODE_TIMEOUT_MS: "" } as NodeJS.ProcessEnv), + 5000 + ); +}); + From 44622cde861f17bb64119c03b4be2625f3e85d28 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:51:51 -0500 Subject: [PATCH 29/40] fix(ui): handle pid=0 and mock snapshot query params --- public/src/components/AgentPanel.tsx | 2 +- scripts/mock-snapshot-server.js | 11 ++- tests/integration/mockSnapshotServer.test.ts | 90 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/integration/mockSnapshotServer.test.ts diff --git a/public/src/components/AgentPanel.tsx b/public/src/components/AgentPanel.tsx index 11c16a7..b219ff6 100644 --- a/public/src/components/AgentPanel.tsx +++ b/public/src/components/AgentPanel.tsx @@ -60,7 +60,7 @@ export function AgentPanel({ agent, showMetadata, onClose }: AgentPanelProps) { pid {typeof agent.pid === 'number' ? agent.pid : '—'}
- {!agent.pid && agent.sessionPath && ( + {typeof agent.pid !== 'number' && agent.sessionPath && (
session {agent.sessionPath.replace(/^opencode:/, '')} diff --git a/scripts/mock-snapshot-server.js b/scripts/mock-snapshot-server.js index 545a7c9..f578343 100644 --- a/scripts/mock-snapshot-server.js +++ b/scripts/mock-snapshot-server.js @@ -71,11 +71,17 @@ const startedAt = Date.now(); const server = http.createServer((req, res) => { const url = req.url || "/"; - if (url === "/health") { + let pathname = url; + try { + pathname = new URL(url, "http://127.0.0.1").pathname; + } catch { + pathname = url.split("?")[0] || "/"; + } + if (pathname === "/health") { json(res, 200, { ok: true }); return; } - if (url === "/api/snapshot") { + if (pathname === "/api/snapshot") { const now = Date.now(); json(res, 200, { ts: now, @@ -93,4 +99,3 @@ server.listen(args.port, "127.0.0.1", () => { `mock-snapshot-server mode=${args.mode} listening http://127.0.0.1:${port}\n` ); }); - diff --git a/tests/integration/mockSnapshotServer.test.ts b/tests/integration/mockSnapshotServer.test.ts new file mode 100644 index 0000000..d962554 --- /dev/null +++ b/tests/integration/mockSnapshotServer.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import net from "node:net"; + +async function getFreePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + if (typeof addr !== "object" || !addr) { + server.close(() => reject(new Error("Failed to allocate port"))); + return; + } + const port = addr.port; + server.close(() => resolve(port)); + }); + }); +} + +async function waitForOutput( + child: ChildProcessWithoutNullStreams, + pattern: RegExp, + timeoutMs = 5000 +): Promise { + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const timer = setTimeout(() => { + const text = Buffer.concat(chunks).toString("utf8"); + cleanup(() => + reject(new Error(`Timed out waiting for output: ${pattern}\n${text}`)) + ); + }, timeoutMs); + const onData = (chunk: Buffer) => { + chunks.push(Buffer.from(chunk)); + const text = Buffer.concat(chunks).toString("utf8"); + if (pattern.test(text)) cleanup(resolve); + }; + const onExit = (code: number | null) => { + cleanup(() => + reject(new Error(`mock-snapshot-server exited early code=${code ?? 0}`)) + ); + }; + const cleanup = (done: () => void) => { + clearTimeout(timer); + child.stdout.off("data", onData); + child.stderr.off("data", onData); + child.off("exit", onExit); + done(); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + child.once("exit", onExit); + }); +} + +test("mock snapshot server accepts query params on /api/snapshot", async (t) => { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(__dirname, "../.."); + const scriptPath = path.join(repoRoot, "scripts", "mock-snapshot-server.js"); + const port = await getFreePort(); + + const child = spawn( + process.execPath, + [scriptPath, "--port", String(port), "--mode", "clean"], + { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + } + ) as ChildProcessWithoutNullStreams; + + t.after(() => { + try { + child.kill("SIGTERM"); + } catch { + // ignore + } + }); + + await waitForOutput(child, /mock-snapshot-server .* listening/i); + + const response = await fetch(`http://127.0.0.1:${port}/api/snapshot?cached=1`); + assert.equal(response.status, 200); + const payload = (await response.json()) as { ts?: unknown; agents?: unknown }; + assert.equal(typeof payload.ts, "number"); + assert.ok(Array.isArray(payload.agents)); +}); From 0bcd27d5a67c53b9260e61eb107396a4335c9d4a Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:52:30 -0500 Subject: [PATCH 30/40] test(codex): remove lifecycle graph and cover jsonl tail --- src/codex/lifecycleGraph.ts | 343 ----------------------------- tests/unit/codexEventStore.test.ts | 125 ++++++----- tests/unit/codexScan.test.ts | 132 +++++++---- 3 files changed, 159 insertions(+), 441 deletions(-) delete mode 100644 src/codex/lifecycleGraph.ts diff --git a/src/codex/lifecycleGraph.ts b/src/codex/lifecycleGraph.ts deleted file mode 100644 index e8603f2..0000000 --- a/src/codex/lifecycleGraph.ts +++ /dev/null @@ -1,343 +0,0 @@ -import path from "node:path"; - -export type LifecycleEventKind = - | "agent_start" - | "agent_stop" - | "tool_start" - | "tool_end" - | "approval_wait" - | "approval_resolved"; - -export type ToolKey = string; - -export type ThreadLifecycleSummary = { - lastTool?: string; - lastCommand?: string; - lastMessage?: string; - lastPrompt?: string; -}; - -export interface ThreadLifecycleState { - readonly threadId: string; - turnOpen: boolean; - reviewMode: boolean; - awaitingApproval: boolean; - pendingEndAt?: number; - lastEndAt?: number; - lastActivityAt?: number; - lastSignalAt?: number; - openToolIds: Set; - openAnonTools: number; - lastSummary?: ThreadLifecycleSummary; - lastUpdatedAt: number; -} - -export type ThreadLifecycleSnapshot = { - threadId: string; - inFlight: boolean; - openCallCount: number; - lastActivityAt?: number; - reason: string; - endedAt?: number; -}; - -function resolveMs(raw: string | undefined, fallback: number): number { - if (raw === undefined || raw.trim() === "") return fallback; - const parsed = Number(raw); - if (!Number.isFinite(parsed)) return fallback; - return parsed; -} - -function maxDefined(a: number | undefined, b: number | undefined): number | undefined { - if (typeof a !== "number") return typeof b === "number" ? b : undefined; - if (typeof b !== "number") return a; - return Math.max(a, b); -} - -function noteRecent(set: Set, id: string, cap: number, state: ThreadLifecycleState): void { - if (set.has(id)) { - // Refresh insertion order. - set.delete(id); - set.add(id); - return; - } - set.add(id); - if (set.size <= cap) return; - const overflow = set.size - cap; - let removed = 0; - for (const existing of set) { - set.delete(existing); - removed += 1; - if (removed >= overflow) break; - } - if (removed > 0) state.openAnonTools += removed; -} - -export class CodexLifecycleGraph { - private threads = new Map(); - private threadIdByPath = new Map(); - private pathsByThreadId = new Map>(); - - ensureThread(threadId: string, nowMs: number = Date.now()): ThreadLifecycleState { - const existing = this.threads.get(threadId); - if (existing) return existing; - const created: ThreadLifecycleState = { - threadId, - turnOpen: false, - reviewMode: false, - awaitingApproval: false, - openToolIds: new Set(), - openAnonTools: 0, - lastUpdatedAt: nowMs, - }; - this.threads.set(threadId, created); - return created; - } - - linkPath(sessionPath: string, threadId: string): void { - if (!sessionPath) return; - const resolved = path.resolve(sessionPath); - const existing = this.threadIdByPath.get(resolved); - if (existing && existing !== threadId) { - const paths = this.pathsByThreadId.get(existing); - if (paths) { - paths.delete(resolved); - if (paths.size === 0) this.pathsByThreadId.delete(existing); - } - } - this.threadIdByPath.set(resolved, threadId); - const bucket = this.pathsByThreadId.get(threadId) ?? new Set(); - bucket.add(resolved); - this.pathsByThreadId.set(threadId, bucket); - } - - resolveThreadId(sessionPath: string): string | undefined { - return this.threadIdByPath.get(path.resolve(sessionPath)); - } - - ingestFileSignal(threadId: string, signalAt: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.lastSignalAt = Math.max(state.lastSignalAt || 0, signalAt); - state.lastUpdatedAt = nowMs; - } - - ingestSummary( - threadId: string, - update: Partial, - nowMs: number = Date.now() - ): void { - const state = this.ensureThread(threadId, nowMs); - state.lastSummary = { ...(state.lastSummary ?? {}), ...update }; - state.lastUpdatedAt = nowMs; - } - - ingestActivity(threadId: string, ts: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestAgentStart(threadId: string, ts: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.turnOpen = true; - state.pendingEndAt = undefined; - state.lastEndAt = undefined; - state.awaitingApproval = false; - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestAgentStop(threadId: string, ts: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); - state.turnOpen = false; - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestNotifyEnd(threadId: string, ts: number, nowMs: number = Date.now()): void { - this.ingestAgentStop(threadId, ts, nowMs); - } - - ingestReviewMode(threadId: string, enabled: boolean, ts: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.reviewMode = enabled; - if (enabled) { - state.turnOpen = true; - state.pendingEndAt = undefined; - state.lastEndAt = undefined; - } else { - state.pendingEndAt = Math.max(state.pendingEndAt || 0, ts); - state.turnOpen = false; - } - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestApprovalWait(threadId: string, ts: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.awaitingApproval = true; - state.turnOpen = true; - state.pendingEndAt = undefined; - state.lastEndAt = undefined; - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestApprovalResolved(threadId: string, ts: number, nowMs: number = Date.now()): void { - const state = this.ensureThread(threadId, nowMs); - state.awaitingApproval = false; - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestToolStart( - threadId: string, - toolId: string | undefined, - ts: number, - nowMs: number = Date.now() - ): void { - const state = this.ensureThread(threadId, nowMs); - if (toolId) { - noteRecent(state.openToolIds, toolId, 500, state); - } else { - state.openAnonTools += 1; - } - state.turnOpen = true; - state.pendingEndAt = undefined; - state.lastEndAt = undefined; - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - ingestToolEnd( - threadId: string, - toolId: string | undefined, - ts: number, - nowMs: number = Date.now() - ): void { - const state = this.ensureThread(threadId, nowMs); - if (toolId) { - if (state.openToolIds.delete(toolId)) { - // ok - } else if (state.openAnonTools > 0) { - // Best-effort: end may correspond to an overflowed tool id. - state.openAnonTools -= 1; - } - } else if (state.openAnonTools > 0) { - state.openAnonTools -= 1; - } else if (state.openToolIds.size > 0) { - // Best-effort: some tool output events omit identifiers; close one open tool. - const first = state.openToolIds.values().next().value; - if (typeof first === "string") { - state.openToolIds.delete(first); - } - } - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); - state.lastUpdatedAt = nowMs; - } - - resetThread(threadId: string): void { - this.dropThread(threadId); - } - - getThreadSnapshot(threadId: string, nowMs: number = Date.now()): ThreadLifecycleSnapshot | null { - const state = this.threads.get(threadId); - if (!state) return null; - - const inFlightTimeoutMs = resolveMs( - process.env.CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS, - 2500 - ); - const staleFileMs = resolveMs(process.env.CONSENSUS_CODEX_STALE_FILE_MS, 120000); - const graceMs = resolveMs(process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS, 0); - - const lastSignalAt = state.lastSignalAt ?? state.lastActivityAt; - const signalStale = - typeof lastSignalAt === "number" && - Number.isFinite(inFlightTimeoutMs) && - inFlightTimeoutMs > 0 && - nowMs - lastSignalAt >= inFlightTimeoutMs; - const staleAnchor = - state.lastSignalAt ?? state.lastActivityAt ?? state.lastUpdatedAt; - const staleFile = - typeof staleAnchor === "number" && - Number.isFinite(staleFileMs) && - staleFileMs > 0 && - nowMs - staleAnchor > staleFileMs; - if (staleFile) { - this.dropThread(threadId); - return { - threadId, - inFlight: false, - openCallCount: 0, - lastActivityAt: undefined, - reason: "stale_timeout", - }; - } - - const openCallCount = (state.openToolIds?.size ?? 0) + (state.openAnonTools ?? 0); - const lastActivityAt = maxDefined(state.lastActivityAt, state.lastSignalAt); - - const pendingEndAt = state.pendingEndAt; - const pendingActive = - typeof pendingEndAt === "number" && - openCallCount === 0 && - !state.reviewMode && - !state.awaitingApproval; - - const lastSignalForFinalize = maxDefined(state.lastSignalAt, state.lastActivityAt) ?? 0; - const canFinalize = - typeof pendingEndAt === "number" && - pendingActive && - (nowMs - pendingEndAt >= graceMs) && - lastSignalForFinalize <= pendingEndAt; - - const endedAt = canFinalize ? pendingEndAt : undefined; - - if (canFinalize) { - state.lastEndAt = pendingEndAt; - } - - const inFlight = - state.awaitingApproval || - state.reviewMode || - openCallCount > 0 || - (!signalStale && !canFinalize && !!state.turnOpen) || - (!signalStale && !canFinalize && typeof pendingEndAt === "number"); - - const reason = (() => { - if (canFinalize) return "ended"; - if (signalStale && !state.awaitingApproval && !state.reviewMode && openCallCount === 0) { - return "stale_timeout"; - } - if (state.awaitingApproval) return "approval"; - if (state.reviewMode) return "review"; - if (openCallCount > 0) return "tool_open"; - if (!canFinalize && typeof pendingEndAt === "number") return "pending_end"; - if (!canFinalize && state.turnOpen) return "turn_open"; - return "idle"; - })(); - - return { - threadId, - inFlight, - openCallCount, - lastActivityAt, - reason, - endedAt, - }; - } - - private dropThread(threadId: string): void { - this.threads.delete(threadId); - const paths = this.pathsByThreadId.get(threadId); - if (!paths) return; - for (const resolved of paths) { - this.threadIdByPath.delete(resolved); - } - this.pathsByThreadId.delete(threadId); - } -} - -export const codexLifecycleGraph = new CodexLifecycleGraph(); diff --git a/tests/unit/codexEventStore.test.ts b/tests/unit/codexEventStore.test.ts index 8448bfe..76d3f99 100644 --- a/tests/unit/codexEventStore.test.ts +++ b/tests/unit/codexEventStore.test.ts @@ -1,69 +1,90 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { CodexLifecycleGraph } from "../../src/codex/lifecycleGraph.js"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile, appendFile } from "node:fs/promises"; +import { updateTail, summarizeTail } from "../../src/codexLogs.ts"; -function withEnv(vars: Record, fn: () => void): void { - const prior: Record = {}; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const makeEvent = (event: Record) => `${JSON.stringify(event)}\n`; + +async function setupSessionFile(): Promise<{ + dir: string; + sessionPath: string; + cleanup: () => Promise; +}> { + const dir = await mkdtemp(path.join(os.tmpdir(), "consensus-codex-store-")); + const sessionPath = path.join(dir, "session.jsonl"); + await writeFile(sessionPath, "", "utf8"); + return { + dir, + sessionPath, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +function withEnv( + vars: Record, + fn: () => Promise | void +): Promise { + const prev: Record = {}; for (const [key, value] of Object.entries(vars)) { - prior[key] = process.env[key]; - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } + prev[key] = process.env[key]; + process.env[key] = value; } + const restore = () => { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; try { - fn(); - } finally { - for (const [key, value] of Object.entries(prior)) { - if (value === undefined) { - delete process.env[key]; - } else { - process.env[key] = value; - } + const result = fn(); + if (result && typeof (result as Promise).then === "function") { + return (result as Promise).finally(restore); } + restore(); + return Promise.resolve(); + } catch (err) { + restore(); + return Promise.reject(err); } } -test("lifecycle graph tracks open tool calls", () => { - const graph = new CodexLifecycleGraph(); - const threadId = "thread-tools"; - - graph.ingestToolStart(threadId, "call_1", 1000, 1000); - let snap = graph.getThreadSnapshot(threadId, 1000); - assert.equal(snap?.openCallCount, 1); - assert.equal(snap?.inFlight, true); - assert.equal(snap?.reason, "tool_open"); - - graph.ingestToolEnd(threadId, "call_1", 1100, 1100); - snap = graph.getThreadSnapshot(threadId, 1100); - assert.equal(snap?.openCallCount, 0); - assert.equal(snap?.inFlight, true); - assert.equal(snap?.reason, "turn_open"); -}); - -test("pending end finalizes only after grace and no open calls", () => { - withEnv( +test("pending end expires when no new signals arrive (jsonl tail SSOT)", async () => { + await withEnv( { - CONSENSUS_CODEX_INFLIGHT_GRACE_MS: "1000", - CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "2500", + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "60", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", }, - () => { - const graph = new CodexLifecycleGraph(); - const threadId = "thread-pending-end"; - - graph.ingestAgentStart(threadId, 1000, 1000); - graph.ingestAgentStop(threadId, 2000, 2000); + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ type: "turn.started", ts: base }) + + makeEvent({ type: "turn.completed", ts: base + 1 }), + "utf8" + ); - const pending = graph.getThreadSnapshot(threadId, 2500); - assert.equal(pending?.inFlight, true); - assert.equal(pending?.reason, "pending_end"); - assert.equal(pending?.endedAt, undefined); + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + assert.equal(summarizeTail(first).inFlight, true); - const ended = graph.getThreadSnapshot(threadId, 3100); - assert.equal(ended?.inFlight, false); - assert.equal(ended?.reason, "ended"); - assert.equal(ended?.endedAt, 2000); + await sleep(140); + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(second.pendingEndAt, undefined); + assert.equal(summarizeTail(second).inFlight, undefined); + } finally { + await cleanup(); + } } ); }); diff --git a/tests/unit/codexScan.test.ts b/tests/unit/codexScan.test.ts index 8402f0b..7b117ba 100644 --- a/tests/unit/codexScan.test.ts +++ b/tests/unit/codexScan.test.ts @@ -1,58 +1,98 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { CodexLifecycleGraph } from "../../src/codex/lifecycleGraph.js"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile, appendFile } from "node:fs/promises"; +import { updateTail, summarizeTail } from "../../src/codexLogs.ts"; -test("lifecycle graph finalizes end after grace window", () => { - const previousGrace = process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; - process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = "1000"; - try { - const graph = new CodexLifecycleGraph(); - const threadId = "thread-grace"; - - graph.ingestAgentStart(threadId, 1000, 1000); - graph.ingestNotifyEnd(threadId, 2000, 2000); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const makeEvent = (event: Record) => `${JSON.stringify(event)}\n`; - const pending = graph.getThreadSnapshot(threadId, 2500); - assert.equal(pending?.inFlight, true); - assert.equal(pending?.endedAt, undefined); +async function setupSessionFile(): Promise<{ + dir: string; + sessionPath: string; + cleanup: () => Promise; +}> { + const dir = await mkdtemp(path.join(os.tmpdir(), "consensus-codex-scan-")); + const sessionPath = path.join(dir, "session.jsonl"); + await writeFile(sessionPath, "", "utf8"); + return { + dir, + sessionPath, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} - const ended = graph.getThreadSnapshot(threadId, 3100); - assert.equal(ended?.inFlight, false); - assert.equal(ended?.endedAt, 2000); - } finally { - if (previousGrace === undefined) { - delete process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; - } else { - process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = previousGrace; +function withEnv( + vars: Record, + fn: () => Promise | void +): Promise { + const prev: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prev[key] = process.env[key]; + process.env[key] = value; + } + const restore = () => { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + try { + const result = fn(); + if (result && typeof (result as Promise).then === "function") { + return (result as Promise).finally(restore); } + restore(); + return Promise.resolve(); + } catch (err) { + restore(); + return Promise.reject(err); } -}); +} -test("lifecycle graph does not finalize while tools are open", () => { - const previousGrace = process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; - process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = "0"; - try { - const graph = new CodexLifecycleGraph(); - const threadId = "thread-tools-open"; +test("open tool call keeps inFlight even after pending end timeout", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "50", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ type: "turn.started", ts: base }) + + makeEvent({ + type: "response_item", + ts: base + 1, + payload: { type: "response.function_call", call_id: "call_1" }, + }) + + makeEvent({ type: "response.completed", ts: base + 2 }), + "utf8" + ); - graph.ingestToolStart(threadId, "call_1", 1000, 1000); - graph.ingestNotifyEnd(threadId, 2000, 2000); - const pendingWithTool = graph.getThreadSnapshot(threadId, 2000); - assert.equal(pendingWithTool?.openCallCount, 1); - assert.equal(pendingWithTool?.inFlight, true); + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + assert.equal(first.openCallIds?.has("call_1"), true); + assert.equal(summarizeTail(first).inFlight, true); - graph.ingestToolEnd(threadId, "call_1", 2500, 2500); - // Simulate a later explicit turn end marker after tool output. - graph.ingestAgentStop(threadId, 2600, 2600); - const ended = graph.getThreadSnapshot(threadId, 2600); - assert.equal(ended?.openCallCount, 0); - assert.equal(ended?.inFlight, false); - assert.equal(ended?.endedAt, 2600); - } finally { - if (previousGrace === undefined) { - delete process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS; - } else { - process.env.CONSENSUS_CODEX_INFLIGHT_GRACE_MS = previousGrace; + await sleep(120); + const second = await updateTail(sessionPath); + assert.ok(second); + // With an open call, pending end should not finalize even after timeout. + assert.equal(typeof second.pendingEndAt, "number"); + assert.equal(second.openCallIds?.has("call_1"), true); + assert.equal(summarizeTail(second).inFlight, true); + } finally { + await cleanup(); + } } - } + ); }); From d7635b6a8b01f30fdf130926c2eac6b98a0c9530 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:53:07 -0500 Subject: [PATCH 31/40] chore(dev): harden dev runner and fix worktree helper --- dev/dev.mjs | 29 +++++++++++++++++------------ dev/worktrees.sh | 5 +++-- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/dev/dev.mjs b/dev/dev.mjs index 32aa873..200b17f 100644 --- a/dev/dev.mjs +++ b/dev/dev.mjs @@ -32,6 +32,15 @@ if (!fs.existsSync(vitePath)) { } const children = []; +const OUTPUT_CAP = 32_768; + +const shutdown = (code = 0) => { + for (const child of children) { + if (child.killed) continue; + child.kill("SIGTERM"); + } + process.exit(code); +}; const spawnChild = (label, args) => { const child = spawn(process.execPath, args, { stdio: "inherit" }); @@ -85,9 +94,12 @@ const startServer = async () => { let output = ""; let running = false; + let settled = false; const onData = (chunk) => { const text = chunk.toString(); - output += text; + if (!settled) { + output = (output + text).slice(-OUTPUT_CAP); + } process.stdout.write(text); }; child.stdout?.on("data", onData); @@ -101,7 +113,6 @@ const startServer = async () => { }); const result = await new Promise((resolve) => { - let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; @@ -159,16 +170,18 @@ const startVite = async (serverPort) => { ); let output = ""; + let settled = false; const onData = (chunk) => { const text = chunk.toString(); - output += text; + if (!settled) { + output = (output + text).slice(-OUTPUT_CAP); + } process.stdout.write(text); }; child.stdout?.on("data", onData); child.stderr?.on("data", onData); const result = await new Promise((resolve) => { - let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; @@ -210,14 +223,6 @@ const main = async () => { void main(); -const shutdown = (code = 0) => { - for (const child of children) { - if (child.killed) continue; - child.kill("SIGTERM"); - } - process.exit(code); -}; - tsc.on("exit", (code) => shutdown(code ?? 0)); process.on("SIGINT", () => shutdown(0)); diff --git a/dev/worktrees.sh b/dev/worktrees.sh index a174860..23ee7c0 100644 --- a/dev/worktrees.sh +++ b/dev/worktrees.sh @@ -180,7 +180,7 @@ gwt() { } gwe() { - local common main cur branch git_bin env_bin pwd_bin + local common main cur branch git_bin env_bin pwd_bin cwd common="$(_gwt_git_common_dir)" || { echo "gwe: not inside a git repository" >&2 return 1 @@ -196,8 +196,9 @@ gwe() { pwd_bin="$(_gwt_pwd_bin)" main="$(cd "$common/.." && "$pwd_bin" -P)" + cwd="$("$pwd_bin" -P)" cur="$("$env_bin" -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \ - "$git_bin" -C "$main" rev-parse --show-toplevel 2>/dev/null)" + "$git_bin" -C "$cwd" rev-parse --show-toplevel 2>/dev/null)" if [ -z "$cur" ]; then return 1 fi From d620c67558445d39b1777b987731ac5876c324a1 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:08:37 -0500 Subject: [PATCH 32/40] docs: align CONSENSUS_POLL_MS default --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 800b179..6d36bdd 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ consensus dev server running on http://127.0.0.1:8787 ## Configuration - `CONSENSUS_HOST`: bind address (default `127.0.0.1`). - `CONSENSUS_PORT`: server port (default `8787`). -- `CONSENSUS_POLL_MS`: process presence polling interval in ms (default `500`). +- `CONSENSUS_POLL_MS`: process presence polling interval in ms (default `250`, min `50`). - `CONSENSUS_SCAN_TIMEOUT_MS`: scan timeout in ms (default `5000`). - `CONSENSUS_SCAN_STALL_MS`: scan stall warning threshold in ms (default `60%` of timeout, min `250`). - `CONSENSUS_SCAN_STALL_CHECK_MS`: scan stall check interval in ms (default `min(1000, stallMs)`, min `250`). From 6d47e53d78c928ea52e10eb5753192a7168c8cd9 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 22:47:35 -0500 Subject: [PATCH 33/40] fix(codex): tail catch-up and delta fast-path - Tail Codex JSONL incrementally when log growth exceeds MAX_READ_BYTES and surface catch-up via TailState.needsCatchUp. - Apply shouldParseJsonLine() before JSON.parse to skip noisy delta lines while still recording ingest time. - Add integration coverage for needsCatchUp behavior. --- src/codexLogs.ts | 38 ++++++++++----------- tests/integration/codexTailCatchUp.test.ts | 39 ++++++++++++++++++++++ 2 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 tests/integration/codexTailCatchUp.test.ts diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 62fb044..19a578c 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -932,6 +932,8 @@ async function updateTailLegacy( if (stat.size < state.offset) { state.offset = 0; state.partial = ""; + state.decoder = new StringDecoder("utf8"); + state.needsCatchUp = false; state.events = []; state.lastEventAt = undefined; state.reviewMode = false; @@ -954,27 +956,24 @@ async function updateTailLegacy( } if (stat.size === state.offset) { + state.needsCatchUp = false; expireInFlight(); tailStates.set(sessionPath, state); return state; } const prevOffset = state.offset; - let readStart = prevOffset; - let trimmed = false; - const delta = stat.size - prevOffset; - if (delta > MAX_READ_BYTES) { - readStart = Math.max(0, stat.size - MAX_READ_BYTES); - trimmed = true; - state.partial = ""; - } + const readStart = prevOffset; + const delta = stat.size - readStart; + const readLength = Math.min(delta, MAX_READ_BYTES); + state.needsCatchUp = delta > MAX_READ_BYTES; - if (stat.size <= readStart) { + if (readLength <= 0) { + state.needsCatchUp = false; tailStates.set(sessionPath, state); return state; } - const readLength = stat.size - readStart; const buffer = Buffer.alloc(readLength); try { const handle = await fsp.open(sessionPath, "r"); @@ -984,13 +983,8 @@ async function updateTailLegacy( return null; } - let text = buffer.toString("utf8"); - if (trimmed) { - const firstNewline = text.indexOf("\n"); - if (firstNewline !== -1) { - text = text.slice(firstNewline + 1); - } - } + if (!state.decoder) state.decoder = new StringDecoder("utf8"); + const text = state.decoder.write(buffer); const combined = state.partial + text; const lines = combined.split(/\r?\n/); @@ -1007,7 +1001,7 @@ async function updateTailLegacy( ]); const workKinds = new Set(["command", "edit", "tool", "message"]); const processLine = (line: string): boolean => { - if (!line.trim()) return false; + if (!shouldParseJsonLine(line)) return false; let ev: any; try { ev = JSON.parse(line); @@ -1416,19 +1410,22 @@ async function updateTailLegacy( }; const prevInFlight = state.inFlight; + let ingestedAny = false; let parsedAny = false; for (const line of lines) { + if (line.trim()) ingestedAny = true; if (processLine(line)) parsedAny = true; } const candidate = state.partial.trim(); if (candidate.startsWith("{") && candidate.endsWith("}")) { if (processLine(candidate)) { parsedAny = true; + ingestedAny = true; state.partial = ""; } } - if (parsedAny) { + if (ingestedAny) { state.lastIngestAt = nowMs; if (state.inFlight) { markInFlightSignal(); @@ -1436,7 +1433,8 @@ async function updateTailLegacy( } - state.offset = stat.size; + state.offset = readStart + readLength; + state.needsCatchUp = state.offset < stat.size; expireInFlight(); tailStates.set(sessionPath, state); if (prevInFlight !== state.inFlight) { diff --git a/tests/integration/codexTailCatchUp.test.ts b/tests/integration/codexTailCatchUp.test.ts new file mode 100644 index 0000000..f502656 --- /dev/null +++ b/tests/integration/codexTailCatchUp.test.ts @@ -0,0 +1,39 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { updateTail } from "../../src/codexLogs.ts"; + +test("updateTail sets needsCatchUp and advances offset incrementally for large deltas", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + const file = path.join(dir, "session.jsonl"); + + // Make a delta-only file (skipped by shouldParseJsonLine) that exceeds MAX_READ_BYTES. + // This exercises the "catch up across multiple updates" path without needing to JSON.parse every line. + const deltaPayload = "x".repeat(1024); + const line = `{"type":"response.output_text.delta","delta":"${deltaPayload}","ts":0}\n`; + const minSize = 2 * 1024 * 1024 + 4096; + const lineBytes = Buffer.byteLength(line, "utf8"); + const lineCount = Math.ceil(minSize / lineBytes); + await fs.writeFile(file, line.repeat(lineCount), "utf8"); + + const stat = await fs.stat(file); + assert.ok(stat.size > 2 * 1024 * 1024, "fixture file should exceed read cap"); + + const first = await updateTail(file); + assert.ok(first, "tail state should be returned"); + assert.equal(first.needsCatchUp, true); + assert.ok(typeof first.lastIngestAt === "number"); + assert.ok(first.offset > 0); + assert.ok(first.offset < stat.size); + + const second = await updateTail(file); + assert.ok(second, "tail state should still be returned"); + const stat2 = await fs.stat(file); + assert.equal(second.needsCatchUp, false); + assert.equal(second.offset, stat2.size); + + await fs.rm(dir, { recursive: true, force: true }); +}); + From 3ae830945fd4becab445442bc03f04d77df9b55c Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:12:13 -0500 Subject: [PATCH 34/40] fix(activity): finalize TUI sessions and harden opencode stale cutoff --- e2e/ui/codexTuiLiveDemo.pw.ts | 116 ++++++++++-------- src/codexLogs.ts | 16 ++- src/opencodeApi.ts | 12 +- .../opencodeSessionActivity.test.ts | 40 ++++++ tests/unit/stateTimeout.test.ts | 95 ++++++++++++++ 5 files changed, 225 insertions(+), 54 deletions(-) diff --git a/e2e/ui/codexTuiLiveDemo.pw.ts b/e2e/ui/codexTuiLiveDemo.pw.ts index b987d30..a3aa923 100644 --- a/e2e/ui/codexTuiLiveDemo.pw.ts +++ b/e2e/ui/codexTuiLiveDemo.pw.ts @@ -40,11 +40,6 @@ async function tmuxNewSession(session: string, command: string): Promise { ]); } -async function tmuxSend(session: string, text: string): Promise { - // `send-keys` with Enter at end to submit the prompt. - await execFileAsync("tmux", ["send-keys", "-t", session, text, "Enter"]); -} - async function tmuxKill(session: string): Promise { try { await execFileAsync("tmux", ["kill-session", "-t", session]); @@ -57,12 +52,17 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } -function codexCommand(cwd: string): string { +function codexCommand(cwd: string, prompt?: string): string { const bin = process.env.CODEX_BIN || "codex"; - // Include `-C ` so CONSENSUS_PROCESS_MATCH can target these processes deterministically. - return `${bin} --dangerously-bypass-approvals-and-sandbox -C ${JSON.stringify( - cwd - )}`; + const parts = [ + bin, + "--dangerously-bypass-approvals-and-sandbox", + // Include `-C ` so CONSENSUS_PROCESS_MATCH can target these processes deterministically. + "-C", + JSON.stringify(cwd), + ]; + if (prompt) parts.push(JSON.stringify(prompt)); + return parts.join(" "); } test("live demo: 3 codex TUI sessions go idle -> active -> idle (30s capture)", async ({ page }, testInfo) => { @@ -106,14 +106,36 @@ test("live demo: 3 codex TUI sessions go idle -> active -> idle (30s capture)", await page.setViewportSize({ width: 1280, height: 720 }); await page.goto("/"); - // Capture the "empty" state for a moment. + const demoStartedAt = Date.now(); + + // Capture the "empty" state for a moment (requires CONSENSUS_PROCESS_MATCH to be set). await expect(page.locator("#active-list")).toBeVisible(); - await sleep(1500); + await page.waitForFunction(() => { + return document.querySelectorAll("#active-list .lane-item").length === 0; + }, undefined, { timeout: 15_000 }); + await sleep(2000); - // Start three interactive Codex TUI sessions. - await tmuxNewSession(sessions.a, codexCommand(dirs.a)); - await tmuxNewSession(sessions.b, codexCommand(dirs.b)); - await tmuxNewSession(sessions.c, codexCommand(dirs.c)); + const promptA = [ + "Run bash: sleep 2.", + "Then run bash: ls.", + 'Then reply with exactly: "A done".', + ].join(" "); + const promptB = [ + "Run bash: sleep 3.", + 'Then run bash: echo "search 1 completed".', + 'Then reply with exactly: "B done".', + ].join(" "); + const promptC = [ + "Run bash: sleep 4.", + "Then run bash: pwd.", + 'Then reply with exactly: "C done".', + ].join(" "); + + // Start three interactive Codex TUI sessions with an initial prompt. + // This avoids relying on UI keybindings for "submit" in the TUI. + await tmuxNewSession(sessions.a, codexCommand(dirs.a, promptA)); + await tmuxNewSession(sessions.b, codexCommand(dirs.b, promptB)); + await tmuxNewSession(sessions.c, codexCommand(dirs.c, promptC)); // Wait for agents to appear in the UI list (server side polling is 250ms). // Note: if OpenCode is enabled, this list may contain non-Codex sessions too. @@ -121,39 +143,35 @@ test("live demo: 3 codex TUI sessions go idle -> active -> idle (30s capture)", return document.querySelectorAll("#active-list .lane-item").length >= 3; }, undefined, { timeout: 30_000 }); - // Give the TUIs a moment to settle before firing prompts. - await sleep(2000); - - // Trigger staggered tool-heavy work. `sleep` keeps a tool open long enough to visualize. - await tmuxSend( - sessions.a, - [ - "Run bash: sleep 2.", - "Then run bash: ls.", - 'Then reply with exactly: "A done".', - ].join(" ") - ); - await sleep(600); - await tmuxSend( - sessions.b, - [ - "Run bash: sleep 3.", - 'Then run bash: echo "search 1 completed".', - 'Then reply with exactly: "B done".', - ].join(" ") - ); - await sleep(600); - await tmuxSend( - sessions.c, - [ - "Run bash: sleep 4.", - "Then run bash: pwd.", - 'Then reply with exactly: "C done".', - ].join(" ") - ); - - // Let the scan loop observe activity and then settle back to idle. - await sleep(30_000); + // Ensure each session becomes active at least once. + await page.evaluate(() => { + (window as any).__consensusBusySeen = new Set(); + }); + await page.waitForFunction(() => { + const seen = (window as any).__consensusBusySeen as Set | undefined; + if (!seen) return false; + const items = Array.from(document.querySelectorAll("#active-list .lane-item")); + for (let i = 0; i < items.length; i += 1) { + if (items[i]?.getAttribute("aria-busy") === "true") seen.add(i); + } + return seen.size >= 3; + }, undefined, { timeout: 90_000, polling: 500 }); + + // Ensure sessions return to idle within the capture window. + await page.waitForFunction(() => { + return ( + document.querySelectorAll('#active-list .lane-item[aria-busy="true"]') + .length === 0 + ); + }, undefined, { timeout: 120_000 }); + + // Keep the capture running long enough to visually confirm idle state. + await sleep(10_000); + + const elapsed = Date.now() - demoStartedAt; + if (elapsed < 30_000) { + await sleep(30_000 - elapsed); + } } finally { await tmuxKill(sessions.a); await tmuxKill(sessions.b); diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 19a578c..42fd2fc 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -860,8 +860,11 @@ async function updateTailLegacy( if (openCallCount > 0) return; // Use event timestamps (not ingest time) so we don't cancel the end marker // on the same tick it was observed. - const lastSignalAfterEnd = - state.lastToolSignalAt ?? state.lastActivityAt ?? state.lastEventAt; + const lastSignalAfterEnd = Math.max( + typeof state.lastToolSignalAt === "number" ? state.lastToolSignalAt : 0, + typeof state.lastActivityAt === "number" ? state.lastActivityAt : 0, + typeof state.lastEventAt === "number" ? state.lastEventAt : 0 + ); // If anything new arrived after the end marker, cancel the pending end. // This prevents active->idle->active flicker when tool output lands after response end. if ( @@ -1290,6 +1293,12 @@ async function updateTailLegacy( if (canSignal && state.inFlight) { markInFlightSignal(); } + // Codex TUI logs don't reliably emit turn/response completion events. + // Treat the assistant message as an "end marker" and finalize after a short timeout + // unless new activity lands after this point. + if (canSignal) { + deferEnd(ts); + } } } if (isAssistant && !isToolCall && payloadType !== "message") { @@ -1339,6 +1348,9 @@ async function updateTailLegacy( state.inFlightStart = true; } markInFlightSignal(); + // Same as the response_item assistant-message case above. + // For interactive TUI sessions, this provides a reliable end marker. + deferEnd(ts); } } if (itemTypeIsTurnAbort) { diff --git a/src/opencodeApi.ts b/src/opencodeApi.ts index 47324d4..ddf3f6b 100644 --- a/src/opencodeApi.ts +++ b/src/opencodeApi.ts @@ -409,11 +409,17 @@ export async function getOpenCodeSessionActivity( } if (inFlight && Number.isFinite(staleMs) && staleMs > 0) { - if (typeof inFlightSignalAt === "number") { - if (Date.now() - inFlightSignalAt > staleMs) { + const signalAt = + typeof inFlightSignalAt === "number" + ? inFlightSignalAt + : typeof latestActivityAt === "number" + ? latestActivityAt + : undefined; + if (typeof signalAt === "number") { + if (Date.now() - signalAt > staleMs) { inFlight = false; } - } else if (typeof latestActivityAt !== "number") { + } else { inFlight = false; } } diff --git a/tests/integration/opencodeSessionActivity.test.ts b/tests/integration/opencodeSessionActivity.test.ts index 39f16b4..59a4a2e 100644 --- a/tests/integration/opencodeSessionActivity.test.ts +++ b/tests/integration/opencodeSessionActivity.test.ts @@ -116,6 +116,46 @@ test("getOpenCodeSessionActivity treats pending tool as in-flight", async (t) => } }); +test("getOpenCodeSessionActivity clears stale in-flight when pending tool has no part.time.start", async (t) => { + const previous = process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS; + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = "50"; + const created = Date.now() - 10_000; + + const started = await startServerOrSkip(t, (req, res) => { + if (req.url === "/session/s6/message") { + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify([ + { + info: { role: "assistant", time: { created, completed: created } }, + parts: [{ type: "tool", state: { status: "running" } }], + }, + ]) + ); + return; + } + res.statusCode = 404; + res.end(); + }); + if (!started) { + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = previous; + return; + } + const { server, port } = started; + + try { + const result = await getOpenCodeSessionActivity("s6", "127.0.0.1", port, { + timeoutMs: 2000, + silent: true, + }); + assert.equal(result.ok, true); + assert.equal(result.inFlight, false); + } finally { + process.env.CONSENSUS_OPENCODE_INFLIGHT_STALE_MS = previous; + await closeServer(server); + } +}); + test("getOpenCodeSessionActivity treats recent user message as in-flight", async (t) => { const created = Date.now(); const started = await startServerOrSkip(t, (req, res) => { diff --git a/tests/unit/stateTimeout.test.ts b/tests/unit/stateTimeout.test.ts index 18b3ba2..5e14fa6 100644 --- a/tests/unit/stateTimeout.test.ts +++ b/tests/unit/stateTimeout.test.ts @@ -223,3 +223,98 @@ test("turnOpen suppresses stale timeout until an explicit end marker arrives", a ); }); +test("assistant message acts as an end marker for TUI sessions (no explicit completion events)", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "80", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ + type: "event_msg", + ts: base, + payload: { type: "agent_message", content: "done" }, + }) + + makeEvent({ + type: "response_item", + ts: base + 1, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "done" }], + }, + }), + "utf8" + ); + + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + assert.equal(summarizeTail(first).inFlight, true); + + await sleep(120); + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(second.pendingEndAt, undefined); + assert.equal(summarizeTail(second).inFlight, undefined); + } finally { + await cleanup(); + } + } + ); +}); + +test("pendingEnd clears when non-tool activity lands after pending end marker", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "200", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ + type: "response_item", + ts: base, + payload: { type: "response.function_call_output", call_id: "call_1" }, + }) + + makeEvent({ type: "response.completed", ts: base + 1 }), + "utf8" + ); + + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + + await appendFile( + sessionPath, + makeEvent({ + type: "event_msg", + ts: base + 2, + payload: { type: "agent_reasoning", content: "more work" }, + }), + "utf8" + ); + + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(second.pendingEndAt, undefined); + assert.equal(summarizeTail(second).inFlight, true); + } finally { + await cleanup(); + } + } + ); +}); From 65b2b43753956dee40499918be018bcad51b8aef Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:49:15 -0500 Subject: [PATCH 35/40] fix(codex): ignore token_count for pending-end markers --- src/codexLogs.ts | 9 ++----- tests/unit/stateTimeout.test.ts | 46 +++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 42fd2fc..22b4c0e 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -862,15 +862,11 @@ async function updateTailLegacy( // on the same tick it was observed. const lastSignalAfterEnd = Math.max( typeof state.lastToolSignalAt === "number" ? state.lastToolSignalAt : 0, - typeof state.lastActivityAt === "number" ? state.lastActivityAt : 0, - typeof state.lastEventAt === "number" ? state.lastEventAt : 0 + typeof state.lastActivityAt === "number" ? state.lastActivityAt : 0 ); // If anything new arrived after the end marker, cancel the pending end. // This prevents active->idle->active flicker when tool output lands after response end. - if ( - typeof lastSignalAfterEnd === "number" && - lastSignalAfterEnd > state.pendingEndAt - ) { + if (lastSignalAfterEnd > state.pendingEndAt) { clearEndMarkers(); return; } @@ -1219,7 +1215,6 @@ async function updateTailLegacy( if (payloadType === "token_count") { if (canSignal && (state.inFlight || state.turnOpen)) { markInFlightSignal(); - state.lastActivityAt = Math.max(state.lastActivityAt || 0, ts); } } if (type === "response_item") { diff --git a/tests/unit/stateTimeout.test.ts b/tests/unit/stateTimeout.test.ts index 5e14fa6..617661f 100644 --- a/tests/unit/stateTimeout.test.ts +++ b/tests/unit/stateTimeout.test.ts @@ -318,3 +318,49 @@ test("pendingEnd clears when non-tool activity lands after pending end marker", } ); }); + +test("token_count after assistant message does not cancel pending end", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "80", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "0", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const base = Date.now(); + await appendFile( + sessionPath, + makeEvent({ + type: "response_item", + ts: base, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "done" }], + }, + }) + + makeEvent({ + type: "event_msg", + ts: base + 1, + payload: { type: "token_count", total: 123 }, + }), + "utf8" + ); + + const first = await updateTail(sessionPath); + assert.ok(first); + assert.equal(typeof first.pendingEndAt, "number"); + + await sleep(120); + const second = await updateTail(sessionPath); + assert.ok(second); + assert.equal(summarizeTail(second).inFlight, undefined); + } finally { + await cleanup(); + } + } + ); +}); From c98983cf1aed76292be7ea9da715a921d56c193f Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:49:20 -0500 Subject: [PATCH 36/40] fix(setup): merge required codex notifications --- src/cli/setup.ts | 47 +++++++++++++++++++++++++++++-- tests/unit/setupCodexHook.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/cli/setup.ts b/src/cli/setup.ts index a0eba16..ae364a8 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -83,6 +83,44 @@ export async function setupCodexHook(): Promise { const notifyLine = `notify = ["node", "${notifyScript}", "http://127.0.0.1:${consensusPort}/api/codex-event"] # consensus-cli`; const notificationsLine = 'notifications = ["agent-turn-complete", "approval-requested"] # consensus-cli'; + const requiredNotifications = ["agent-turn-complete", "approval-requested"]; + + function parseTomlStringArrayLine(line: string): string[] | null { + const beforeComment = line.split("#")[0] ?? ""; + const match = beforeComment.match(/notifications\s*=\s*\[(.*)\]/); + if (!match) return null; + const inner = match[1]?.trim() ?? ""; + if (!inner) return []; + + // Parse quoted TOML strings. This intentionally ignores non-string entries. + const values: string[] = []; + const re = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; + let m: RegExpExecArray | null; + while ((m = re.exec(inner))) { + const raw = typeof m[1] === "string" ? m[1] : m[2]; + values.push(raw.replace(/\\(["'\\])/g, "$1")); + } + if (values.length === 0) return null; + return values; + } + + function mergeNotifications(existing: string[] | null): string { + const merged: string[] = []; + const seen = new Set(); + for (const value of existing ?? []) { + if (!seen.has(value)) { + seen.add(value); + merged.push(value); + } + } + for (const value of requiredNotifications) { + if (!seen.has(value)) { + seen.add(value); + merged.push(value); + } + } + return `notifications = [${merged.map((value) => JSON.stringify(value)).join(", ")}] # consensus-cli`; + } const rawLines = existingContent.split(/\r?\n/); const filtered: string[] = []; @@ -133,8 +171,13 @@ export async function setupCodexHook(): Promise { })(); if (notificationsIndex === -1) { lines.splice(sectionEnd, 0, notificationsLine); - } else if (lines[notificationsIndex].includes("consensus-cli")) { - lines[notificationsIndex] = notificationsLine; + } else { + const parsed = parseTomlStringArrayLine(lines[notificationsIndex] ?? ""); + if (parsed === null) { + lines[notificationsIndex] = mergeNotifications(null); + } else { + lines[notificationsIndex] = mergeNotifications(parsed); + } } } diff --git a/tests/unit/setupCodexHook.test.ts b/tests/unit/setupCodexHook.test.ts index 0e91a2c..7dcf69a 100644 --- a/tests/unit/setupCodexHook.test.ts +++ b/tests/unit/setupCodexHook.test.ts @@ -43,3 +43,39 @@ test("setupCodexHook inserts notifications inside existing [tui] block", async ( } await fs.rm(tempHome, { recursive: true, force: true }); }); + +test("setupCodexHook merges required notifications into an existing notifications line", async () => { + const originalHome = process.env.HOME; + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "consensus-")); + process.env.HOME = tempHome; + + const codexDir = path.join(tempHome, ".codex"); + await fs.mkdir(codexDir, { recursive: true }); + const configPath = path.join(codexDir, "config.toml"); + const existing = [ + "[tui]", + "notifications = [\"approval-requested\", \"custom-event\"]", + "theme = \"dark\"", + "", + ].join("\n"); + await fs.writeFile(configPath, existing, "utf-8"); + + await setupCodexHook(); + const updated = await fs.readFile(configPath, "utf-8"); + + const notificationsLines = updated + .split(/\r?\n/) + .filter((line) => line.trim().startsWith("notifications =")); + assert.equal(notificationsLines.length, 1); + const merged = notificationsLines[0] ?? ""; + assert.ok(merged.includes("\"custom-event\"")); + assert.ok(merged.includes("\"approval-requested\"")); + assert.ok(merged.includes("\"agent-turn-complete\"")); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + await fs.rm(tempHome, { recursive: true, force: true }); +}); From 2d1c9bdb3a1109c58322de23618c34fec8955ccb Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:49:26 -0500 Subject: [PATCH 37/40] fix(ui): avoid spatial cell-key wrapping --- public/src/lib/layout.ts | 36 ++++++++++++++++++-------------- tests/unit/layoutCellKey.test.ts | 15 +++++++++++++ 2 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 tests/unit/layoutCellKey.test.ts diff --git a/public/src/lib/layout.ts b/public/src/lib/layout.ts index 57b6825..bb14376 100644 --- a/public/src/lib/layout.ts +++ b/public/src/lib/layout.ts @@ -9,9 +9,9 @@ const CELL_W = TILE_W; const CELL_H = TILE_H; const MAX_PULSE = 7; const MAX_LAYOUT_HEIGHT = 120 + MAX_PULSE; -const CELL_OFFSET = 32768; type CellBucket = string | string[]; +export type CellKey = string; interface BoundsRect { left: number; @@ -21,7 +21,7 @@ interface BoundsRect { } interface SpatialIndex { - cells: Map; + cells: Map; bounds: Map; } @@ -38,7 +38,7 @@ interface SpiralState { interface GroupState { anchor: Coordinate; spiral: SpiralState; - freeStack: number[]; + freeStack: CellKey[]; } export interface LayoutState { @@ -95,26 +95,30 @@ function worldToCellY(y: number): number { return Math.floor(y / CELL_H); } -function packCell(cx: number, cy: number): number { - const ux = (cx + CELL_OFFSET) & 0xffff; - const uy = (cy + CELL_OFFSET) & 0xffff; - return ((ux << 16) | uy) >>> 0; +export function cellKey(cx: number, cy: number): CellKey { + // String keys avoid integer range wrapping/collisions as the layout grows. + return `${cx},${cy}`; } -function unpackCell(key: number): { cx: number; cy: number } { - const ux = key >>> 16; - const uy = key & 0xffff; - return { cx: ux - CELL_OFFSET, cy: uy - CELL_OFFSET }; +export function unpackCell(key: CellKey): { cx: number; cy: number } { + const comma = key.indexOf(","); + if (comma === -1) return { cx: 0, cy: 0 }; + const cx = Number.parseInt(key.slice(0, comma), 10); + const cy = Number.parseInt(key.slice(comma + 1), 10); + return { + cx: Number.isFinite(cx) ? cx : 0, + cy: Number.isFinite(cy) ? cy : 0, + }; } function cellToWorld(cx: number, cy: number): Coordinate { return { x: cx * GRID_SCALE, y: cy * GRID_SCALE }; } -function gridKeyFromWorld(coord: Coordinate): number { +function gridKeyFromWorld(coord: Coordinate): CellKey { const cx = Math.round(coord.x / GRID_SCALE); const cy = Math.round(coord.y / GRID_SCALE); - return packCell(cx, cy); + return cellKey(cx, cy); } function bucketAdd(bucket: CellBucket | undefined, id: string): CellBucket { @@ -178,7 +182,7 @@ function indexAgent(id: string, coord: Coordinate, spatial: SpatialIndex): void const range = cellRangeForBounds(bounds); for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { - const key = packCell(cx, cy); + const key = cellKey(cx, cy); const bucket = spatial.cells.get(key); spatial.cells.set(key, bucketAdd(bucket, id)); } @@ -192,7 +196,7 @@ function unindexAgent(id: string, spatial: SpatialIndex): void { const range = cellRangeForBounds(bounds); for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { - const key = packCell(cx, cy); + const key = cellKey(cx, cy); const bucket = spatial.cells.get(key); const next = bucketRemove(bucket, id); if (next === undefined) { @@ -210,7 +214,7 @@ function hasCollision(bounds: BoundsRect, spatial: SpatialIndex): boolean { const range = cellRangeForBounds(bounds); for (let cx = range.minCx; cx <= range.maxCx; cx += 1) { for (let cy = range.minCy; cy <= range.maxCy; cy += 1) { - const key = packCell(cx, cy); + const key = cellKey(cx, cy); const bucket = spatial.cells.get(key); if (!bucket) continue; if (typeof bucket === 'string') { diff --git a/tests/unit/layoutCellKey.test.ts b/tests/unit/layoutCellKey.test.ts new file mode 100644 index 0000000..cbdd918 --- /dev/null +++ b/tests/unit/layoutCellKey.test.ts @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { cellKey, unpackCell } from "../../public/src/lib/layout.ts"; + +test("cellKey does not wrap for large coordinates (no 16-bit truncation)", async () => { + const a = cellKey(0, 0); + const b = cellKey(65536, 0); + assert.notEqual(a, b); +}); + +test("unpackCell round-trips large signed coordinates", async () => { + const key = cellKey(-123456, 789012); + assert.deepEqual(unpackCell(key), { cx: -123456, cy: 789012 }); +}); + From c5d17d9600cda23cedfb25c08b88919aebeb2403 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Thu, 5 Feb 2026 23:57:17 -0500 Subject: [PATCH 38/40] test(ui): filter live TUI demo lane by match query --- e2e/ui/codexTuiLiveDemo.pw.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/e2e/ui/codexTuiLiveDemo.pw.ts b/e2e/ui/codexTuiLiveDemo.pw.ts index a3aa923..7cbb071 100644 --- a/e2e/ui/codexTuiLiveDemo.pw.ts +++ b/e2e/ui/codexTuiLiveDemo.pw.ts @@ -108,6 +108,10 @@ test("live demo: 3 codex TUI sessions go idle -> active -> idle (30s capture)", const demoStartedAt = Date.now(); + // Filter the lane to only the demo Codex sessions, so OpenCode sessions in the + // developer environment do not interfere with the "empty -> 3 agents" capture. + await page.getByLabel("Search metadata").fill("consensus-tui-demo-"); + // Capture the "empty" state for a moment (requires CONSENSUS_PROCESS_MATCH to be set). await expect(page.locator("#active-list")).toBeVisible(); await page.waitForFunction(() => { From cf91d423b8ee405a89cd6a8fe871199c4555e440 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:07:33 -0500 Subject: [PATCH 39/40] fix(test): make npm test portable across shells --- package.json | 6 +-- scripts/run-node-tests.js | 93 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 scripts/run-node-tests.js diff --git a/package.json b/package.json index cd2e35c..bad2081 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "scan": "node dist/scan.js", "tail": "node dist/tail.js", "test": "npm run test:unit && npm run test:integration", - "test:unit": "node --test --import tsx tests/unit/**/*.test.ts", - "test:integration": "node --test --import tsx tests/integration/**/*.test.ts", - "test:watch": "node --test --watch --import tsx tests/unit/**/*.test.ts tests/integration/**/*.test.ts", + "test:unit": "node scripts/run-node-tests.js tests/unit", + "test:integration": "node scripts/run-node-tests.js tests/integration", + "test:watch": "node scripts/run-node-tests.js --watch tests/unit tests/integration", "test:ui": "playwright test", "test:ui:watch": "playwright test --watch", "prepublishOnly": "npm run build" diff --git a/scripts/run-node-tests.js b/scripts/run-node-tests.js new file mode 100644 index 0000000..f6f3001 --- /dev/null +++ b/scripts/run-node-tests.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; +import { spawn } from "node:child_process"; + +const TEST_FILE_RE = /\.(test|spec)\.ts$/; + +function usage() { + return [ + "Usage: node scripts/run-node-tests.js [--watch] ", + "", + "Recursively finds *.test.ts / *.spec.ts under each provided path and runs:", + " node --test --import tsx ", + "", + "Options:", + " --watch Pass --watch to node --test", + "", + ].join("\n"); +} + +async function listTestFiles(entryPath) { + const results = []; + const st = await stat(entryPath); + if (st.isFile()) { + if (TEST_FILE_RE.test(entryPath)) results.push(entryPath); + return results; + } + if (!st.isDirectory()) return results; + + const entries = await readdir(entryPath, { withFileTypes: true }); + for (const entry of entries) { + const nextPath = path.join(entryPath, entry.name); + if (entry.isDirectory()) { + results.push(...(await listTestFiles(nextPath))); + continue; + } + if (entry.isFile() && TEST_FILE_RE.test(entry.name)) { + results.push(nextPath); + } + } + return results; +} + +async function main() { + const argv = process.argv.slice(2); + const watchIdx = argv.indexOf("--watch"); + const watch = watchIdx !== -1; + if (watch) argv.splice(watchIdx, 1); + + if (argv.includes("--help") || argv.includes("-h")) { + process.stdout.write(usage()); + return 0; + } + + if (argv.length === 0) { + process.stderr.write("Missing test path(s).\n"); + process.stderr.write(`${usage()}\n`); + return 1; + } + + const roots = argv.map((p) => path.resolve(p)); + const files = []; + for (const root of roots) { + files.push(...(await listTestFiles(root))); + } + + const unique = Array.from(new Set(files)).sort((a, b) => a.localeCompare(b)); + if (unique.length === 0) { + process.stderr.write(`No test files found under: ${roots.join(", ")}\n`); + return 1; + } + + const args = ["--test"]; + if (watch) args.push("--watch"); + args.push("--import", "tsx", ...unique); + + const child = spawn(process.execPath, args, { stdio: "inherit" }); + return await new Promise((resolve) => { + child.on("exit", (code) => resolve(code ?? 1)); + child.on("error", () => resolve(1)); + }); +} + +main() + .then((code) => { + process.exitCode = code; + }) + .catch((err) => { + process.stderr.write(`${String(err)}\n`); + process.exitCode = 1; + }); + From 92321259cb17fd8dc574092339087959db5c6184 Mon Sep 17 00:00:00 2001 From: integrate-your-mind <43152353+integrate-your-mind@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:48:59 -0500 Subject: [PATCH 40/40] fix(codex): clear stale tail markers --- docs/postmortem-codex-activity-flicker.md | 18 ++++- src/codexLogs.ts | 25 ++++--- tests/unit/staleFileCleanup.test.ts | 90 +++++++++++++++++++++++ 3 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 tests/unit/staleFileCleanup.test.ts diff --git a/docs/postmortem-codex-activity-flicker.md b/docs/postmortem-codex-activity-flicker.md index 5df0a56..7784761 100644 --- a/docs/postmortem-codex-activity-flicker.md +++ b/docs/postmortem-codex-activity-flicker.md @@ -12,6 +12,14 @@ The primary root cause was inconsistent session log tailing caused by session fi Remediation standardized on a single source of truth (Codex session JSONL tails) for `inFlight` and `lastActivityAt`, added PID-to-session pinning with safe stale release, hardened grace/timeout behavior via tests, and added evidence tooling (flicker detector + video demo) to prevent regressions. +## Timeline (Execution) + +- Symptom reproduced: active animation flickered off between tool calls during a single interactive Codex session. +- Early iteration failure mode: multi-source merges (webhook + notify hook + JSONL) produced disagreement windows and flicker. +- Stabilization changes landed: JSONL tail as SSOT, PID-to-session pinning, forced tail updates for pinned sessions, and stricter end-marker handling to avoid false idle transitions. +- Validation added: a flicker poller (`scripts/flicker-detect.js`) plus env-gated Playwright demos that record a 30s video of interactive TUI sessions transitioning `idle -> active -> idle`. +- Follow-up fix (review-driven): stale session cleanup now clears all end markers/open-call tracking even when `state.inFlight` is already false, preventing ghost in-flight state from lingering. + ## Impact - UI: false negatives for "agent active" during long tool chains or during quiet gaps between tool events. @@ -103,8 +111,16 @@ RUN_LIVE_CODEX=1 PW_VIDEO=1 CONSENSUS_PROCESS_MATCH=consensus-tui-demo- \ npx playwright test e2e/ui/codexTuiLiveDemo.pw.ts ``` +### Example artifacts (local paths, gitignored) + +- Flicker summary: + - `tmp/flicker-summary.json` + - `tmp/flicker-summary.transitions.jsonl` +- Demo video: + - Raw Playwright video under `test-results/**/video.webm` + - 30s share artifact under `tmp/` (example: `tmp/codexTuiLiveDemo-30s.mp4`) + ## Follow-ups - Consider adding a CI lane that runs `scripts/flicker-detect.js` against mock mode (deterministic) to gate future flicker regressions. - Keep the "Completion protocol" in `AGENTS.md` enforced: evidence artifacts are required for state-change fixes. - diff --git a/src/codexLogs.ts b/src/codexLogs.ts index 22b4c0e..908105a 100644 --- a/src/codexLogs.ts +++ b/src/codexLogs.ts @@ -911,18 +911,19 @@ async function updateTailLegacy( }; if (isStaleFile) { - if (state.inFlight) { - state.inFlight = false; - state.inFlightStart = false; - state.turnOpen = false; - state.reviewMode = false; - state.pendingEndAt = undefined; - state.lastEndAt = undefined; - state.lastToolSignalAt = undefined; - state.lastInFlightSignalAt = undefined; - if (state.openCallIds) state.openCallIds.clear(); - state.openItemCount = 0; - } + // Clear all in-flight markers regardless of `state.inFlight`. These markers + // also contribute to `summarizeTail(...).inFlight`, so leaving them set can + // keep stale sessions ghost-active. + state.inFlight = false; + state.inFlightStart = false; + state.turnOpen = false; + state.reviewMode = false; + state.pendingEndAt = undefined; + state.lastEndAt = undefined; + state.lastToolSignalAt = undefined; + state.lastInFlightSignalAt = undefined; + if (state.openCallIds) state.openCallIds.clear(); + state.openItemCount = 0; state.lastEventAt = undefined; state.lastActivityAt = undefined; state.lastIngestAt = undefined; diff --git a/tests/unit/staleFileCleanup.test.ts b/tests/unit/staleFileCleanup.test.ts new file mode 100644 index 0000000..95ffaf7 --- /dev/null +++ b/tests/unit/staleFileCleanup.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm, writeFile, utimes } from "node:fs/promises"; +import { updateTail, summarizeTail } from "../../src/codexLogs.ts"; + +async function setupSessionFile(): Promise<{ + sessionPath: string; + cleanup: () => Promise; +}> { + const dir = await mkdtemp(path.join(os.tmpdir(), "consensus-codex-stale-")); + const sessionPath = path.join(dir, "session.jsonl"); + await writeFile(sessionPath, "", "utf8"); + return { + sessionPath, + cleanup: async () => { + await rm(dir, { recursive: true, force: true }); + }, + }; +} + +function withEnv( + vars: Record, + fn: () => Promise | void +): Promise { + const prev: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prev[key] = process.env[key]; + process.env[key] = value; + } + const restore = () => { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; + try { + const result = fn(); + if (result && typeof (result as Promise).then === "function") { + return (result as Promise).finally(restore); + } + restore(); + return Promise.resolve(); + } catch (err) { + restore(); + return Promise.reject(err); + } +} + +test("stale session file clears pending-end and open-call markers even when inFlight is false", async () => { + await withEnv( + { + CONSENSUS_CODEX_INFLIGHT_TIMEOUT_MS: "100", + CONSENSUS_CODEX_SIGNAL_MAX_AGE_MS: "0", + CONSENSUS_CODEX_FILE_FRESH_MS: "0", + CONSENSUS_CODEX_STALE_FILE_MS: "1", + }, + async () => { + const { sessionPath, cleanup } = await setupSessionFile(); + try { + const state = await updateTail(sessionPath); + assert.ok(state); + + // Simulate a session that was already marked not-inFlight while still holding + // deferred-end markers or open calls. These markers should not survive stale cleanup. + state.inFlight = false; + state.pendingEndAt = Date.now() - 10; + state.turnOpen = true; + state.reviewMode = true; + state.openCallIds = new Set(["call_1"]); + state.openItemCount = 1; + + const past = new Date(Date.now() - 5000); + await utimes(sessionPath, past, past); + + const next = await updateTail(sessionPath); + assert.ok(next); + assert.equal(next.pendingEndAt, undefined); + assert.equal(next.turnOpen, false); + assert.equal(next.reviewMode, false); + assert.equal(next.openCallIds?.size ?? 0, 0); + assert.equal(next.openItemCount ?? 0, 0); + assert.equal(summarizeTail(next).inFlight, undefined); + } finally { + await cleanup(); + } + } + ); +});