import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { watch } from 'chokidar'; import { createHash } from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; import { computeTaskChangePresenceProjectFingerprint, normalizeTaskChangePresenceFilePath, } from './taskChangePresenceUtils'; import { BOARD_TASK_CHANGE_FRESHNESS_DIRNAME, BOARD_TASK_CHANGES_DIRNAME, BOARD_TASK_LOG_FRESHNESS_DIRNAME, BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX, classifyLogSourceWatcherEvent, getRelativeLogSourceParts, isAgentTranscriptFileName, MAX_PENDING_UNKNOWN_ROOT_REFRESH_ATTEMPTS, MAX_PENDING_UNKNOWN_ROOT_SESSIONS, normalizeLogSourceSessionId, PENDING_UNKNOWN_ROOT_SESSION_TTL_MS, TEAM_TASK_LOG_FRESHNESS_DIRNAME, } from './teamLogSourceWatchScope'; import type { TeamLogSourceLiveContext, TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import type { TeamChangeEvent } from '@shared/types'; import type { FSWatcher } from 'chokidar'; const logger = createLogger('Service:TeamLogSourceTracker'); const CONTEXT_REFRESH_DEBOUNCE_MS = 300; const PENDING_CONTEXT_REFRESH_RETRY_MS = 1_000; interface TeamLogSourceSnapshot { projectFingerprint: string | null; logSourceGeneration: string | null; } export type TeamLogSourceTrackingConsumer = | 'change_presence' | 'tool_activity' | 'task_log_stream' | 'member_log_stream' | 'stall_monitor'; interface TrackingState { watcher: FSWatcher | null; projectDir: string | null; activeContext: TeamLogSourceLiveContext | null; scopedSessionIds: Set; pendingUnknownSessionIds: Map; refreshTimer: ReturnType | null; contextRefreshTimer: ReturnType | null; initializePromise: Promise | null; initializeVersion: number | null; recomputePromise: Promise | null; recomputeVersion: number | null; snapshot: TeamLogSourceSnapshot; consumerCounts: Map; lifecycleVersion: number; } interface PendingUnknownSessionCandidate { sessionId: string; expiresAt: number; refreshAttempts: number; } type DecodedFreshnessTaskId = | { kind: 'task-id'; taskId: string } | { kind: 'opaque-safe-segment' } | { kind: 'invalid' }; type TaskFreshnessSignalKind = NonNullable; function isOpaqueSafeTaskIdSegment(segment: string): boolean { return /^task-id-[0-9a-f]{32}$/.test(segment); } function pushUniqueNormalizedPath(paths: string[], candidate: string | undefined): void { if (!candidate || !path.isAbsolute(candidate)) { return; } const normalized = path.normalize(candidate); if (!paths.some((existing) => path.normalize(existing) === normalized)) { paths.push(normalized); } } function getTeamTaskLogFreshnessDir(teamName: string): string { return path.join(getTeamsBasePath(), teamName, TEAM_TASK_LOG_FRESHNESS_DIRNAME); } function pathsOverlap(left: string, right: string): boolean { const normalizedLeft = path.normalize(left); const normalizedRight = path.normalize(right); const leftToRight = path.relative(normalizedLeft, normalizedRight); const rightToLeft = path.relative(normalizedRight, normalizedLeft); return ( !leftToRight || (!leftToRight.startsWith('..') && !path.isAbsolute(leftToRight)) || !rightToLeft || (!rightToLeft.startsWith('..') && !path.isAbsolute(rightToLeft)) ); } export function shouldIgnoreLogSourceWatcherPath( projectDir: string, watchedPath: string, scope?: { scopedSessionIds?: ReadonlySet; pendingRootSessionIds?: ReadonlySet; } ): boolean { const parts = getRelativeLogSourceParts(projectDir, watchedPath); if (!parts) { return false; } const first = parts[0]; if (first === BOARD_TASK_CHANGES_DIRNAME) return true; if (parts.includes('tool-results')) return true; if (parts.includes('memory')) return true; if (first === BOARD_TASK_LOG_FRESHNESS_DIRNAME) return false; if (first === BOARD_TASK_CHANGE_FRESHNESS_DIRNAME) return false; const scopedSessionIds = scope?.scopedSessionIds; if (scopedSessionIds) { if (parts.length === 1) { if (first.endsWith('.jsonl')) { const sessionId = normalizeLogSourceSessionId(first.slice(0, -'.jsonl'.length)); return ( !sessionId || (!scopedSessionIds.has(sessionId) && !scope?.pendingRootSessionIds?.has(sessionId)) ); } return !scopedSessionIds.has(first); } if (!scopedSessionIds.has(first)) { return true; } if (parts[1] === 'subagents') { if (parts.length === 2) return false; if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]); } return true; } if (parts.length >= 2 && parts[1] === 'subagents') { if (parts.length === 2) return false; if (parts.length === 3) return !isAgentTranscriptFileName(parts[2]); return true; } return false; } export class TeamLogSourceTracker { private readonly stateByTeam = new Map(); private emitter: ((event: TeamChangeEvent) => void) | null = null; private readonly changeListeners = new Set<(teamName: string) => void>(); constructor(private readonly logsFinder: TeamMemberLogsFinder) {} setEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void { this.emitter = emitter; } onLogSourceChange(listener: (teamName: string) => void): () => void { this.changeListeners.add(listener); return () => { this.changeListeners.delete(listener); }; } getSnapshot(teamName: string): TeamLogSourceSnapshot | null { const state = this.stateByTeam.get(teamName); return state ? { ...state.snapshot } : null; } async ensureTracking(teamName: string): Promise { return this.enableTracking(teamName, 'change_presence'); } async enableTracking( teamName: string, consumer: TeamLogSourceTrackingConsumer ): Promise { const state = this.getOrCreateState(teamName); const activeConsumerCountBefore = this.getActiveConsumerCount(state); state.consumerCounts.set(consumer, (state.consumerCounts.get(consumer) ?? 0) + 1); if (activeConsumerCountBefore === 0) { state.lifecycleVersion += 1; } if ( state.initializePromise && state.initializeVersion === state.lifecycleVersion && this.getActiveConsumerCount(state) > 0 ) { return state.initializePromise; } if ( activeConsumerCountBefore > 0 && (state.watcher !== null || state.projectDir !== null || state.snapshot.logSourceGeneration !== null) ) { return { ...state.snapshot }; } const initializeVersion = state.lifecycleVersion; const initializePromise = this.initializeTeam(teamName, initializeVersion) .catch((error) => { logger.debug(`Failed to initialize log-source tracker for ${teamName}: ${String(error)}`); return { projectFingerprint: null, logSourceGeneration: null }; }) .finally(() => { const current = this.stateByTeam.get(teamName); if (current?.initializePromise === initializePromise) { current.initializePromise = null; current.initializeVersion = null; } }); state.initializePromise = initializePromise; state.initializeVersion = initializeVersion; return initializePromise; } async dispose(): Promise { await Promise.all([...this.stateByTeam.keys()].map((teamName) => this.stopTracking(teamName))); } private getOrCreateState(teamName: string): TrackingState { const existing = this.stateByTeam.get(teamName); if (existing) { return existing; } const created: TrackingState = { watcher: null, projectDir: null, activeContext: null, scopedSessionIds: new Set(), pendingUnknownSessionIds: new Map(), refreshTimer: null, contextRefreshTimer: null, initializePromise: null, initializeVersion: null, recomputePromise: null, recomputeVersion: null, snapshot: { projectFingerprint: null, logSourceGeneration: null }, consumerCounts: new Map(), lifecycleVersion: 0, }; this.stateByTeam.set(teamName, created); return created; } private getActiveConsumerCount(state: TrackingState): number { let count = 0; for (const value of state.consumerCounts.values()) { count += value; } return count; } async stopTracking(teamName: string): Promise { await this.disableTracking(teamName, 'change_presence'); } async disableTracking( teamName: string, consumer: TeamLogSourceTrackingConsumer ): Promise { const state = this.stateByTeam.get(teamName); if (!state) { return { projectFingerprint: null, logSourceGeneration: null }; } const currentConsumerCount = state.consumerCounts.get(consumer) ?? 0; if (currentConsumerCount > 1) { state.consumerCounts.set(consumer, currentConsumerCount - 1); return { ...state.snapshot }; } if (currentConsumerCount === 1) { state.consumerCounts.delete(consumer); } if (this.getActiveConsumerCount(state) > 0) { return { ...state.snapshot }; } if (currentConsumerCount > 0) { state.lifecycleVersion += 1; } if (state.refreshTimer) { clearTimeout(state.refreshTimer); state.refreshTimer = null; } if (state.contextRefreshTimer) { clearTimeout(state.contextRefreshTimer); state.contextRefreshTimer = null; } if (state.watcher) { await state.watcher.close().catch(() => undefined); state.watcher = null; } state.projectDir = null; state.activeContext = null; state.scopedSessionIds.clear(); state.pendingUnknownSessionIds.clear(); state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; return { ...state.snapshot }; } private isTrackingCurrent(teamName: string, expectedVersion: number): boolean { const state = this.stateByTeam.get(teamName); return ( !!state && this.getActiveConsumerCount(state) > 0 && state.lifecycleVersion === expectedVersion ); } private async initializeTeam( teamName: string, expectedVersion: number ): Promise { const state = this.getOrCreateState(teamName); const previousGeneration = state.snapshot.logSourceGeneration; const context = await this.logsFinder.getLiveLogSourceWatchContext(teamName, { forceRefresh: true, }); if (!this.isTrackingCurrent(teamName, expectedVersion)) { return this.getOrCreateState(teamName).snapshot; } if (!context) { state.activeContext = null; state.scopedSessionIds.clear(); state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; await this.rebuildWatcher(teamName, null, expectedVersion); return state.snapshot; } state.activeContext = context; const snapshot = await this.computeSnapshot(context); if (!this.isTrackingCurrent(teamName, expectedVersion)) { return this.getOrCreateState(teamName).snapshot; } state.snapshot = snapshot; await this.rebuildWatcher(teamName, context, expectedVersion); if ( this.isTrackingCurrent(teamName, expectedVersion) && state.snapshot.logSourceGeneration && previousGeneration !== state.snapshot.logSourceGeneration ) { this.emitLogSourceChange(teamName); } return snapshot; } private async rebuildWatcher( teamName: string, context: TeamLogSourceLiveContext | null, expectedVersion: number ): Promise { const state = this.stateByTeam.get(teamName); if ( !state || this.getActiveConsumerCount(state) === 0 || state.lifecycleVersion !== expectedVersion ) { return; } if (state.watcher) { await state.watcher.close().catch(() => undefined); state.watcher = null; } state.projectDir = context?.projectDir ?? null; state.scopedSessionIds.clear(); if (!context?.projectDir) { return; } if (!this.isTrackingCurrent(teamName, expectedVersion)) { state.projectDir = null; return; } const taskFreshnessRootDirs = this.getTaskFreshnessRootDirs(context); const taskFreshnessDirs = await this.ensureLogSourceFreshnessDirs( teamName, context.projectDir, taskFreshnessRootDirs ).catch((error) => { logger.debug(`Failed to ensure log-source freshness dirs for ${teamName}: ${String(error)}`); return { legacyRootDirs: [path.normalize(context.projectDir)], logSignalDirs: [getTeamTaskLogFreshnessDir(teamName)], }; }); const { targets, scopedSessionIds } = await this.buildScopedWatchTargets( context.projectDir, context.watchSessionIds, this.getPendingUnknownSessionIds(state), taskFreshnessDirs ); if (!this.isTrackingCurrent(teamName, expectedVersion)) { return; } state.scopedSessionIds = scopedSessionIds; state.watcher = watch(targets, { ignoreInitial: true, ignorePermissionErrors: true, followSymlinks: false, depth: 0, ignored: (watchedPath) => { if ( taskFreshnessDirs.logSignalDirs.some((logSignalDir) => pathsOverlap(watchedPath, logSignalDir) ) ) { return false; } return shouldIgnoreLogSourceWatcherPath(context.projectDir, watchedPath, { scopedSessionIds, pendingRootSessionIds: new Set(this.getPendingUnknownSessionIds(state)), }); }, awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50, }, }); const handleWatcherEvent = ( eventName: 'add' | 'change' | 'unlink' | 'addDir' | 'unlinkDir', changedPath?: string ): void => { const current = this.stateByTeam.get(teamName); if ( !changedPath || !current || this.getActiveConsumerCount(current) === 0 || !current.projectDir ) { return; } const eventTaskFreshnessRootDirs = this.getTaskFreshnessRootDirs(current.activeContext); const eventTaskFreshnessDirs = this.getTaskFreshnessDirsForContext( teamName, current.projectDir, eventTaskFreshnessRootDirs ); if ( this.handleTaskFreshnessSignalChangeForDirs(teamName, changedPath, eventTaskFreshnessDirs) ) { return; } const action = classifyLogSourceWatcherEvent({ projectDir: current.projectDir, changedPath, eventName, scopedSessionIds: current.scopedSessionIds, pendingUnknownSessionIds: new Set(current.pendingUnknownSessionIds.keys()), }); if (action.kind === 'task-freshness') { return; } if (action.kind === 'context-refresh') { this.scheduleContextRefresh(teamName, action.candidateSessionId); return; } if (action.kind === 'scoped-recompute') { this.scheduleScopedRecompute(teamName); } }; state.watcher.on('add', (changedPath) => handleWatcherEvent('add', changedPath)); state.watcher.on('change', (changedPath) => handleWatcherEvent('change', changedPath)); state.watcher.on('unlink', (changedPath) => handleWatcherEvent('unlink', changedPath)); state.watcher.on('addDir', (changedPath) => handleWatcherEvent('addDir', changedPath)); state.watcher.on('unlinkDir', (changedPath) => handleWatcherEvent('unlinkDir', changedPath)); state.watcher.on('error', (error) => { logger.warn(`Log-source watcher error for ${teamName}: ${String(error)}`); }); } private getTaskFreshnessRootDirs(context: TeamLogSourceLiveContext | null): string[] { const roots: string[] = []; pushUniqueNormalizedPath(roots, context?.projectDir); pushUniqueNormalizedPath(roots, context?.projectPath); for (const rootDir of context?.taskFreshnessRootDirs ?? []) { pushUniqueNormalizedPath(roots, rootDir); } return roots; } private async ensureLogSourceFreshnessDirs( teamName: string, transcriptProjectDir: string, projectDirs: readonly string[] ): Promise<{ legacyRootDirs: string[]; logSignalDirs: string[] }> { const legacyRootDirs: string[] = []; const logSignalDirs: string[] = []; const normalizedTranscriptProjectDir = path.normalize(transcriptProjectDir); const teamLogFreshnessDir = getTeamTaskLogFreshnessDir(teamName); pushUniqueNormalizedPath(legacyRootDirs, normalizedTranscriptProjectDir); pushUniqueNormalizedPath(logSignalDirs, teamLogFreshnessDir); await Promise.all([ fs.mkdir(teamLogFreshnessDir, { recursive: true }), fs.mkdir(path.join(normalizedTranscriptProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true, }), ]); await Promise.all( projectDirs.map(async (projectDir) => { try { const normalizedProjectDir = path.normalize(projectDir); if (normalizedProjectDir === normalizedTranscriptProjectDir) { return; } if (!(await this.isDirectory(normalizedProjectDir))) { return; } await fs.mkdir(path.join(normalizedProjectDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), { recursive: true, }); pushUniqueNormalizedPath(legacyRootDirs, normalizedProjectDir); } catch (error) { logger.debug(`Failed to ensure task freshness dirs in ${projectDir}: ${String(error)}`); } }) ); return { legacyRootDirs, logSignalDirs }; } private async buildScopedWatchTargets( projectDir: string, confirmedSessionIds: readonly string[], pendingRootSessionIds: readonly string[], taskFreshnessDirs: { legacyRootDirs: readonly string[]; logSignalDirs: readonly string[]; } = { legacyRootDirs: [projectDir], logSignalDirs: [] } ): Promise<{ targets: string[]; scopedSessionIds: Set }> { const targets = new Set(); const scopedSessionIds = new Set(); targets.add(projectDir); for (const logSignalDir of taskFreshnessDirs.logSignalDirs) { targets.add(logSignalDir); } for (const freshnessRootDir of taskFreshnessDirs.legacyRootDirs) { targets.add(path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME)); targets.add(path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME)); } for (const rawSessionId of confirmedSessionIds) { const sessionId = normalizeLogSourceSessionId(rawSessionId); if (!sessionId) { continue; } scopedSessionIds.add(sessionId); const rootTranscript = path.join(projectDir, `${sessionId}.jsonl`); const sessionDir = path.join(projectDir, sessionId); const subagentsDir = path.join(sessionDir, 'subagents'); if (await this.isFile(rootTranscript)) targets.add(rootTranscript); if (await this.isDirectory(sessionDir)) targets.add(sessionDir); if (await this.isDirectory(subagentsDir)) targets.add(subagentsDir); } for (const rawSessionId of pendingRootSessionIds) { const sessionId = normalizeLogSourceSessionId(rawSessionId); if (!sessionId || scopedSessionIds.has(sessionId)) { continue; } const rootTranscript = path.join(projectDir, `${sessionId}.jsonl`); if (await this.isFile(rootTranscript)) targets.add(rootTranscript); } return { targets: [...targets], scopedSessionIds }; } private async isFile(targetPath: string): Promise { try { return (await fs.stat(targetPath)).isFile(); } catch { return false; } } private async isDirectory(targetPath: string): Promise { try { return (await fs.stat(targetPath)).isDirectory(); } catch { return false; } } private getPendingUnknownSessionIds(state: TrackingState): string[] { this.prunePendingUnknownSessions(state); return [...state.pendingUnknownSessionIds.keys()]; } private rememberPendingUnknownSession( state: TrackingState, rawSessionId: string | undefined ): void { const sessionId = normalizeLogSourceSessionId(rawSessionId); if (!sessionId || state.scopedSessionIds.has(sessionId)) { return; } const now = Date.now(); state.pendingUnknownSessionIds.set(sessionId, { sessionId, expiresAt: now + PENDING_UNKNOWN_ROOT_SESSION_TTL_MS, refreshAttempts: state.pendingUnknownSessionIds.get(sessionId)?.refreshAttempts ?? 0, }); while (state.pendingUnknownSessionIds.size > MAX_PENDING_UNKNOWN_ROOT_SESSIONS) { const oldest = [...state.pendingUnknownSessionIds.values()].sort( (left, right) => left.expiresAt - right.expiresAt )[0]; if (!oldest) break; state.pendingUnknownSessionIds.delete(oldest.sessionId); } } private prunePendingUnknownSessions(state: TrackingState): void { const now = Date.now(); for (const [sessionId, candidate] of state.pendingUnknownSessionIds.entries()) { if ( candidate.expiresAt <= now || candidate.refreshAttempts >= MAX_PENDING_UNKNOWN_ROOT_REFRESH_ATTEMPTS ) { state.pendingUnknownSessionIds.delete(sessionId); } } } private markPendingRefreshAttempt(state: TrackingState): void { for (const candidate of state.pendingUnknownSessionIds.values()) { candidate.refreshAttempts += 1; } this.prunePendingUnknownSessions(state); } private removeConfirmedPendingSessions( state: TrackingState, confirmedSessionIds: readonly string[] ): void { for (const rawSessionId of confirmedSessionIds) { const sessionId = normalizeLogSourceSessionId(rawSessionId); if (sessionId) { state.pendingUnknownSessionIds.delete(sessionId); } } } private scheduleScopedRecompute(teamName: string): void { const current = this.stateByTeam.get(teamName); if (!current || this.getActiveConsumerCount(current) === 0) { return; } if (current.refreshTimer) { clearTimeout(current.refreshTimer); } current.refreshTimer = setTimeout(() => { current.refreshTimer = null; void this.recompute(teamName); }, 300); } private scheduleContextRefresh( teamName: string, candidateSessionId?: string, delayMs: number = CONTEXT_REFRESH_DEBOUNCE_MS ): void { const state = this.stateByTeam.get(teamName); if (!state || this.getActiveConsumerCount(state) === 0) { return; } this.rememberPendingUnknownSession(state, candidateSessionId); if (state.contextRefreshTimer) { return; } state.contextRefreshTimer = setTimeout(() => { const current = this.stateByTeam.get(teamName); if (!current) return; current.contextRefreshTimer = null; if (this.getActiveConsumerCount(current) === 0) return; void this.refreshContextAndWatcher(teamName, current.lifecycleVersion); }, delayMs); } private async refreshContextAndWatcher(teamName: string, expectedVersion: number): Promise { const state = this.stateByTeam.get(teamName); if (!state || !this.isTrackingCurrent(teamName, expectedVersion)) { return; } this.markPendingRefreshAttempt(state); const previousGeneration = state.snapshot.logSourceGeneration; const context = await this.logsFinder.getLiveLogSourceWatchContext(teamName, { forceRefresh: true, }); if (!this.isTrackingCurrent(teamName, expectedVersion)) { return; } state.activeContext = context; if (!context) { state.scopedSessionIds.clear(); state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; await this.rebuildWatcher(teamName, null, expectedVersion); return; } this.removeConfirmedPendingSessions(state, context.watchSessionIds); state.snapshot = await this.computeSnapshot(context); if (!this.isTrackingCurrent(teamName, expectedVersion)) { return; } await this.rebuildWatcher(teamName, context, expectedVersion); if ( state.snapshot.logSourceGeneration && previousGeneration !== state.snapshot.logSourceGeneration ) { this.emitLogSourceChange(teamName); } if ( this.isTrackingCurrent(teamName, expectedVersion) && state.pendingUnknownSessionIds.size > 0 ) { this.scheduleContextRefresh(teamName, undefined, PENDING_CONTEXT_REFRESH_RETRY_MS); } } private handleTaskFreshnessSignalChange( teamName: string, changedPath: string, signalDir: string, taskSignalKind: TaskFreshnessSignalKind ): boolean { const relativePath = path.relative(signalDir, changedPath); if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) { return path.normalize(changedPath) === path.normalize(signalDir); } if (relativePath === '.') { return true; } if (relativePath.includes(path.sep)) { return true; } const decoded = this.decodeTaskLogFreshnessTaskId(relativePath); if (decoded.kind === 'invalid') { return true; } if (decoded.kind === 'opaque-safe-segment') { void this.emitTaskFreshnessSignalFromFile(teamName, changedPath, taskSignalKind); return true; } this.emitter?.({ type: 'task-log-change', teamName, taskId: decoded.taskId, taskSignalKind, }); return true; } private decodeTaskLogFreshnessTaskId(fileName: string): DecodedFreshnessTaskId { if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) { return { kind: 'invalid' }; } const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length); if (!encodedTaskId) { return { kind: 'invalid' }; } if (isOpaqueSafeTaskIdSegment(encodedTaskId)) { return { kind: 'opaque-safe-segment' }; } try { const taskId = decodeURIComponent(encodedTaskId); return taskId.trim().length > 0 ? { kind: 'task-id', taskId } : { kind: 'invalid' }; } catch { return { kind: 'invalid' }; } } private async emitTaskFreshnessSignalFromFile( teamName: string, filePath: string, taskSignalKind: TaskFreshnessSignalKind ): Promise { try { const raw = await fs.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw) as Record; const taskId = typeof parsed.taskId === 'string' && parsed.taskId.trim().length > 0 ? parsed.taskId.trim() : null; if (taskId) { this.emitter?.({ type: 'task-log-change', teamName, taskId, taskSignalKind, }); return; } } catch { // Deletions or partially unavailable files still need a team-level refresh. } this.emitLogSourceChange(teamName); } private handleTaskFreshnessSignalChangeForRoots( teamName: string, changedPath: string, taskFreshnessRootDirs: readonly string[] ): boolean { for (const freshnessRootDir of taskFreshnessRootDirs) { if ( this.handleTaskFreshnessSignalChange( teamName, changedPath, path.join(freshnessRootDir, BOARD_TASK_LOG_FRESHNESS_DIRNAME), 'log' ) ) { return true; } if ( this.handleTaskFreshnessSignalChange( teamName, changedPath, path.join(freshnessRootDir, BOARD_TASK_CHANGE_FRESHNESS_DIRNAME), 'change' ) ) { return true; } } return false; } private getTaskFreshnessDirsForContext( teamName: string, projectDir: string, taskFreshnessRootDirs: readonly string[] ): { legacyRootDirs: string[]; logSignalDirs: string[] } { const legacyRootDirs = [...taskFreshnessRootDirs]; pushUniqueNormalizedPath(legacyRootDirs, projectDir); return { legacyRootDirs, logSignalDirs: [getTeamTaskLogFreshnessDir(teamName)], }; } private handleTaskFreshnessSignalChangeForDirs( teamName: string, changedPath: string, taskFreshnessDirs: { legacyRootDirs: readonly string[]; logSignalDirs: readonly string[] } ): boolean { for (const logSignalDir of taskFreshnessDirs.logSignalDirs) { if (this.handleTaskFreshnessSignalChange(teamName, changedPath, logSignalDir, 'log')) { return true; } } return this.handleTaskFreshnessSignalChangeForRoots( teamName, changedPath, taskFreshnessDirs.legacyRootDirs ); } private async recompute(teamName: string): Promise { const state = this.getOrCreateState(teamName); if (this.getActiveConsumerCount(state) === 0) { return state.snapshot; } if ( state.recomputePromise && state.recomputeVersion === state.lifecycleVersion && this.getActiveConsumerCount(state) > 0 ) { return state.recomputePromise; } const recomputeVersion = state.lifecycleVersion; const recomputePromise = (async () => { const previousGeneration = state.snapshot.logSourceGeneration; const context = state.activeContext; if (!context) { state.snapshot = { projectFingerprint: null, logSourceGeneration: null }; } else { state.snapshot = await this.computeSnapshot(context); if (!this.isTrackingCurrent(teamName, recomputeVersion)) { return this.getOrCreateState(teamName).snapshot; } } if ( this.isTrackingCurrent(teamName, recomputeVersion) && previousGeneration && state.snapshot.logSourceGeneration && previousGeneration !== state.snapshot.logSourceGeneration ) { this.emitLogSourceChange(teamName); } return state.snapshot; })().finally(() => { const current = this.stateByTeam.get(teamName); if (current?.recomputePromise === recomputePromise) { current.recomputePromise = null; current.recomputeVersion = null; } }); state.recomputePromise = recomputePromise; state.recomputeVersion = recomputeVersion; return recomputePromise; } private emitLogSourceChange(teamName: string): void { for (const listener of this.changeListeners) { try { listener(teamName); } catch (error) { logger.warn(`Log-source listener failed for ${teamName}: ${String(error)}`); } } this.emitter?.({ type: 'log-source-change', teamName, }); } private async computeSnapshot(context: TeamLogSourceLiveContext): Promise { const projectFingerprint = computeTaskChangePresenceProjectFingerprint(context.projectPath); const parts: string[] = []; const sessionIds = context.watchSessionIds.length > 0 ? context.watchSessionIds : context.sessionIds; for (const rawSessionId of [...sessionIds].sort((a, b) => a.localeCompare(b))) { const sessionId = normalizeLogSourceSessionId(rawSessionId); if (!sessionId) { continue; } const rootLogPath = path.join(context.projectDir, `${sessionId}.jsonl`); const sessionDir = path.join(context.projectDir, sessionId); const subagentsDir = path.join(sessionDir, 'subagents'); parts.push(await this.describePath('root', rootLogPath)); parts.push(await this.describePath('session', sessionDir)); parts.push(await this.describePath('subagents', subagentsDir)); let entries: string[] = []; try { entries = await fs.readdir(subagentsDir); } catch { entries = []; } for (const fileName of entries .filter((entry) => isAgentTranscriptFileName(entry)) .sort((a, b) => a.localeCompare(b))) { parts.push(await this.describePath('subagent-log', path.join(subagentsDir, fileName))); } } if (parts.length === 0) { return { projectFingerprint, logSourceGeneration: null }; } return { projectFingerprint, logSourceGeneration: createHash('sha256').update(parts.join('|')).digest('hex'), }; } private async describePath(kind: string, targetPath: string): Promise { const normalizedPath = normalizeTaskChangePresenceFilePath(targetPath); try { const stats = await fs.stat(targetPath); const type = stats.isDirectory() ? 'dir' : 'file'; return `${kind}:${type}:${normalizedPath}:${stats.size}:${stats.mtimeMs}`; } catch { return `${kind}:missing:${normalizedPath}`; } } }