mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
perf: implement phase 1-3 performance optimizations
Add implementation plans and code structure analysis for a 3-phase performance optimization effort. Refactor core modules to reduce timer overhead, consolidate regex usage, extract exec timeout config, add debouncer utility, and streamline server/schema validation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+35
-40
@@ -15,6 +15,7 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { ActiveBashTool } from './types.js';
|
||||
import { CleanupManager, Debouncer } from './utils/index.js';
|
||||
|
||||
// ========== Configuration Constants ==========
|
||||
|
||||
@@ -145,15 +146,14 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
private _workingDir: string;
|
||||
private _homeDir: string;
|
||||
|
||||
// Track auto-remove timers for cleanup
|
||||
private _autoRemoveTimers: Set<ReturnType<typeof setTimeout>> = new Set();
|
||||
// Centralized resource cleanup for auto-remove timers
|
||||
private cleanup = new CleanupManager();
|
||||
|
||||
// Flag to prevent operations after destroy
|
||||
private _destroyed: boolean = false;
|
||||
|
||||
// Debouncing
|
||||
private _pendingUpdate: boolean = false;
|
||||
private _updateTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _updateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||
|
||||
constructor(config: BashToolParserConfig) {
|
||||
super();
|
||||
@@ -524,13 +524,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Remove completed tool after a short delay to allow UI to show completion
|
||||
const timer = setTimeout(() => {
|
||||
this._autoRemoveTimers.delete(timer);
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
}, 2000);
|
||||
this._autoRemoveTimers.add(timer);
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
2000,
|
||||
{ description: 'auto-remove completed tool' }
|
||||
);
|
||||
}
|
||||
this._lastToolId = null;
|
||||
return;
|
||||
@@ -562,13 +564,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove suggestions after 30 seconds
|
||||
const timer = setTimeout(() => {
|
||||
this._autoRemoveTimers.delete(timer);
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
}, 30000);
|
||||
this._autoRemoveTimers.add(timer);
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
30000,
|
||||
{ description: 'auto-remove suggestion tool' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -599,13 +603,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
this.scheduleUpdate();
|
||||
|
||||
// Auto-remove after 60 seconds
|
||||
const timer = setTimeout(() => {
|
||||
this._autoRemoveTimers.delete(timer);
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
}, 60000);
|
||||
this._autoRemoveTimers.add(timer);
|
||||
this.cleanup.setTimeout(
|
||||
() => {
|
||||
if (this._destroyed) return;
|
||||
this._activeTools.delete(tool.id);
|
||||
this.scheduleUpdate();
|
||||
},
|
||||
60000,
|
||||
{ description: 'auto-remove log file tool' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,13 +681,9 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
* Schedule a debounced update emission.
|
||||
*/
|
||||
private scheduleUpdate(): void {
|
||||
if (this._pendingUpdate) return;
|
||||
|
||||
this._pendingUpdate = true;
|
||||
this._updateTimer = setTimeout(() => {
|
||||
this._pendingUpdate = false;
|
||||
this._updateDeb.schedule(() => {
|
||||
this.emitUpdate();
|
||||
}, EVENT_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -696,15 +698,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
||||
*/
|
||||
destroy(): void {
|
||||
this._destroyed = true;
|
||||
if (this._updateTimer) {
|
||||
clearTimeout(this._updateTimer);
|
||||
this._updateTimer = null;
|
||||
}
|
||||
// Clear all auto-remove timers to prevent orphaned callbacks
|
||||
for (const timer of this._autoRemoveTimers) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this._autoRemoveTimers.clear();
|
||||
this._updateDeb.dispose();
|
||||
this.cleanup.dispose();
|
||||
this._activeTools.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* @fileoverview Shared exec timeout constant.
|
||||
*
|
||||
* Used by CLI resolvers and tmux-manager for execSync/exec calls.
|
||||
*
|
||||
* @module config/exec-timeout
|
||||
*/
|
||||
|
||||
/** Timeout for exec commands (5 seconds) */
|
||||
export const EXEC_TIMEOUT_MS = 5000;
|
||||
+17
-31
@@ -13,6 +13,7 @@ import { watch, type FSWatcher } from 'chokidar';
|
||||
import { basename, extname, relative } from 'node:path';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { ImageDetectedEvent } from './types.js';
|
||||
import { KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -65,11 +66,11 @@ export class ImageWatcher extends EventEmitter {
|
||||
/** Map of sessionId -> working directory path */
|
||||
private sessionDirs = new Map<string, string>();
|
||||
|
||||
/** Debounce timers for rapid image creation (keyed by filePath) */
|
||||
private debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
/** Per-file debouncer for rapid image creation */
|
||||
private fileDeb = new KeyedDebouncer(DEBOUNCE_DELAY_MS);
|
||||
|
||||
/** Track which session owns each debounce timer (for cleanup) */
|
||||
private timerToSession = new Map<string, string>();
|
||||
/** Track which session owns each debounced file (for cleanup) */
|
||||
private fileToSession = new Map<string, string>();
|
||||
|
||||
/** Per-session burst tracking: sessionId -> { count, windowStart } */
|
||||
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
|
||||
@@ -118,11 +119,8 @@ export class ImageWatcher extends EventEmitter {
|
||||
this.sessionDirs.clear();
|
||||
|
||||
// Clear all debounce timers
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.debounceTimers.clear();
|
||||
this.timerToSession.clear();
|
||||
this.fileDeb.dispose();
|
||||
this.fileToSession.clear();
|
||||
this.burstTrackers.clear();
|
||||
}
|
||||
|
||||
@@ -212,20 +210,15 @@ export class ImageWatcher extends EventEmitter {
|
||||
this.sessionDirs.delete(sessionId);
|
||||
|
||||
// Clear any pending debounce timers for this session
|
||||
// Collect keys first to avoid iterator invalidation during deletion
|
||||
const toDelete: string[] = [];
|
||||
for (const [filePath, ownerId] of this.timerToSession) {
|
||||
const toCancel: string[] = [];
|
||||
for (const [filePath, ownerId] of this.fileToSession) {
|
||||
if (ownerId === sessionId) {
|
||||
toDelete.push(filePath);
|
||||
toCancel.push(filePath);
|
||||
}
|
||||
}
|
||||
for (const filePath of toDelete) {
|
||||
const timer = this.debounceTimers.get(filePath);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.debounceTimers.delete(filePath);
|
||||
}
|
||||
this.timerToSession.delete(filePath);
|
||||
for (const filePath of toCancel) {
|
||||
this.fileDeb.cancelKey(filePath);
|
||||
this.fileToSession.delete(filePath);
|
||||
}
|
||||
this.burstTrackers.delete(sessionId);
|
||||
}
|
||||
@@ -269,22 +262,15 @@ export class ImageWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||
const existingTimer = this.debounceTimers.get(filePath);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.debounceTimers.delete(filePath);
|
||||
this.timerToSession.delete(filePath);
|
||||
this.fileDeb.schedule(filePath, () => {
|
||||
this.fileToSession.delete(filePath);
|
||||
this.emitImageDetected(sessionId, filePath);
|
||||
// Increment burst count on actual emission (not on detection)
|
||||
const b = this.burstTrackers.get(sessionId);
|
||||
if (b) b.count++;
|
||||
}, DEBOUNCE_DELAY_MS);
|
||||
});
|
||||
|
||||
this.debounceTimers.set(filePath, timer);
|
||||
this.timerToSession.set(filePath, sessionId);
|
||||
this.fileToSession.set(filePath, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
-11
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import webpush from 'web-push';
|
||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||
import { Debouncer } from './utils/index.js';
|
||||
|
||||
const DATA_DIR = join(homedir(), '.codeman');
|
||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||
@@ -20,7 +21,7 @@ const SAVE_DEBOUNCE_MS = 500;
|
||||
export class PushSubscriptionStore {
|
||||
private vapidKeys: VapidKeys | null = null;
|
||||
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
|
||||
private saveTimer: NodeJS.Timeout | null = null;
|
||||
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||
private _disposed = false;
|
||||
|
||||
constructor() {
|
||||
@@ -149,10 +150,7 @@ export class PushSubscriptionStore {
|
||||
/** Schedule a debounced save */
|
||||
private scheduleSave(): void {
|
||||
if (this._disposed) return;
|
||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||
this.saveTimer = setTimeout(() => {
|
||||
this.flushSave();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
this.saveDeb.schedule(() => this.flushSave());
|
||||
}
|
||||
|
||||
/** Immediately persist subscriptions to disk */
|
||||
@@ -169,11 +167,6 @@ export class PushSubscriptionStore {
|
||||
dispose(): void {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
if (this.saveTimer) {
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = null;
|
||||
}
|
||||
// Final flush
|
||||
this.flushSave();
|
||||
this.saveDeb.flush(() => this.flushSave());
|
||||
}
|
||||
}
|
||||
|
||||
+45
-82
@@ -34,7 +34,14 @@ import {
|
||||
PlanTaskStatus,
|
||||
TddPhase,
|
||||
} from './types.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js';
|
||||
import {
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
CleanupManager,
|
||||
Debouncer,
|
||||
fuzzyPhraseMatch,
|
||||
todoContentHash,
|
||||
stringSimilarity,
|
||||
} from './utils/index.js';
|
||||
import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||
import { MAX_TODOS_PER_SESSION } from './config/map-limits.js';
|
||||
|
||||
@@ -559,17 +566,14 @@ export class RalphTracker extends EventEmitter {
|
||||
/** Timestamp of last cleanup check for throttling */
|
||||
private _lastCleanupTime: number = 0;
|
||||
|
||||
/** Debounce timer for todoUpdate events */
|
||||
private _todoUpdateTimer: NodeJS.Timeout | null = null;
|
||||
/** Centralized resource cleanup for timers/intervals */
|
||||
private cleanup = new CleanupManager();
|
||||
|
||||
/** Debounce timer for loopUpdate events */
|
||||
private _loopUpdateTimer: NodeJS.Timeout | null = null;
|
||||
/** Debouncer for todoUpdate events */
|
||||
private _todoUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||
|
||||
/** Flag indicating pending todoUpdate emission */
|
||||
private _todoUpdatePending: boolean = false;
|
||||
|
||||
/** Flag indicating pending loopUpdate emission */
|
||||
private _loopUpdatePending: boolean = false;
|
||||
/** Debouncer for loopUpdate events */
|
||||
private _loopUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||
|
||||
/** When true, prevents auto-enable on pattern detection */
|
||||
private _autoEnableDisabled: boolean = true;
|
||||
@@ -621,8 +625,8 @@ export class RalphTracker extends EventEmitter {
|
||||
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
|
||||
private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null;
|
||||
|
||||
/** Debounce timer for file change events */
|
||||
private _fixPlanReloadTimer: NodeJS.Timeout | null = null;
|
||||
/** Debouncer for file change events */
|
||||
private _fixPlanReloadDeb = new Debouncer(500);
|
||||
|
||||
/** Path to the @fix_plan.md file being watched */
|
||||
private _fixPlanPath: string | null = null;
|
||||
@@ -665,8 +669,8 @@ export class RalphTracker extends EventEmitter {
|
||||
/** Last observed iteration count for stall detection */
|
||||
private _lastObservedIteration: number = 0;
|
||||
|
||||
/** Timer for iteration stall detection */
|
||||
private _iterationStallTimer: NodeJS.Timeout | null = null;
|
||||
/** CleanupManager registration ID for iteration stall detection interval */
|
||||
private _iterationStallTimerId: string | null = null;
|
||||
|
||||
/** Iteration stall warning threshold (ms) - default 10 minutes */
|
||||
private _iterationStallWarningMs: number = 10 * 60 * 1000;
|
||||
@@ -869,14 +873,9 @@ export class RalphTracker extends EventEmitter {
|
||||
*/
|
||||
private handleFixPlanChange(): void {
|
||||
// Debounce rapid changes (e.g., multiple writes)
|
||||
if (this._fixPlanReloadTimer) {
|
||||
clearTimeout(this._fixPlanReloadTimer);
|
||||
}
|
||||
|
||||
this._fixPlanReloadTimer = setTimeout(() => {
|
||||
this._fixPlanReloadTimer = null;
|
||||
this._fixPlanReloadDeb.schedule(() => {
|
||||
this.loadFixPlanFromDisk();
|
||||
}, 500); // 500ms debounce
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -892,10 +891,7 @@ export class RalphTracker extends EventEmitter {
|
||||
this._fixPlanWatcher.close();
|
||||
this._fixPlanWatcher = null;
|
||||
}
|
||||
if (this._fixPlanReloadTimer) {
|
||||
clearTimeout(this._fixPlanReloadTimer);
|
||||
this._fixPlanReloadTimer = null;
|
||||
}
|
||||
this._fixPlanReloadDeb.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1023,16 +1019,8 @@ export class RalphTracker extends EventEmitter {
|
||||
* Called during reset/fullReset to prevent stale emissions.
|
||||
*/
|
||||
private clearDebounceTimers(): void {
|
||||
if (this._todoUpdateTimer) {
|
||||
clearTimeout(this._todoUpdateTimer);
|
||||
this._todoUpdateTimer = null;
|
||||
}
|
||||
if (this._loopUpdateTimer) {
|
||||
clearTimeout(this._loopUpdateTimer);
|
||||
this._loopUpdateTimer = null;
|
||||
}
|
||||
this._todoUpdatePending = false;
|
||||
this._loopUpdatePending = false;
|
||||
this._todoUpdateDeb.cancel();
|
||||
this._loopUpdateDeb.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1041,19 +1029,9 @@ export class RalphTracker extends EventEmitter {
|
||||
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
||||
*/
|
||||
private emitTodoUpdateDebounced(): void {
|
||||
this._todoUpdatePending = true;
|
||||
|
||||
if (this._todoUpdateTimer) {
|
||||
clearTimeout(this._todoUpdateTimer);
|
||||
}
|
||||
|
||||
this._todoUpdateTimer = setTimeout(() => {
|
||||
if (this._todoUpdatePending) {
|
||||
this._todoUpdatePending = false;
|
||||
this._todoUpdateTimer = null;
|
||||
this.emit('todoUpdate', this.todos);
|
||||
}
|
||||
}, EVENT_DEBOUNCE_MS);
|
||||
this._todoUpdateDeb.schedule(() => {
|
||||
this.emit('todoUpdate', this.todos);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1062,19 +1040,9 @@ export class RalphTracker extends EventEmitter {
|
||||
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
||||
*/
|
||||
private emitLoopUpdateDebounced(): void {
|
||||
this._loopUpdatePending = true;
|
||||
|
||||
if (this._loopUpdateTimer) {
|
||||
clearTimeout(this._loopUpdateTimer);
|
||||
}
|
||||
|
||||
this._loopUpdateTimer = setTimeout(() => {
|
||||
if (this._loopUpdatePending) {
|
||||
this._loopUpdatePending = false;
|
||||
this._loopUpdateTimer = null;
|
||||
this.emit('loopUpdate', this.loopState);
|
||||
}
|
||||
}, EVENT_DEBOUNCE_MS);
|
||||
this._loopUpdateDeb.schedule(() => {
|
||||
this.emit('loopUpdate', this.loopState);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1082,21 +1050,11 @@ export class RalphTracker extends EventEmitter {
|
||||
* Useful for testing or when immediate state sync is needed.
|
||||
*/
|
||||
flushPendingEvents(): void {
|
||||
if (this._todoUpdatePending) {
|
||||
this._todoUpdatePending = false;
|
||||
if (this._todoUpdateTimer) {
|
||||
clearTimeout(this._todoUpdateTimer);
|
||||
this._todoUpdateTimer = null;
|
||||
}
|
||||
this.emit('todoUpdate', this.todos);
|
||||
if (this._todoUpdateDeb.isPending) {
|
||||
this._todoUpdateDeb.flush(() => this.emit('todoUpdate', this.todos));
|
||||
}
|
||||
if (this._loopUpdatePending) {
|
||||
this._loopUpdatePending = false;
|
||||
if (this._loopUpdateTimer) {
|
||||
clearTimeout(this._loopUpdateTimer);
|
||||
this._loopUpdateTimer = null;
|
||||
}
|
||||
this.emit('loopUpdate', this.loopState);
|
||||
if (this._loopUpdateDeb.isPending) {
|
||||
this._loopUpdateDeb.flush(() => this.emit('loopUpdate', this.loopState));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1116,18 +1074,22 @@ export class RalphTracker extends EventEmitter {
|
||||
this._iterationStallWarned = false;
|
||||
|
||||
// Check every minute
|
||||
this._iterationStallTimer = setInterval(() => {
|
||||
this.checkIterationStall();
|
||||
}, 60 * 1000);
|
||||
this._iterationStallTimerId = this.cleanup.setInterval(
|
||||
() => {
|
||||
this.checkIterationStall();
|
||||
},
|
||||
60 * 1000,
|
||||
{ description: 'iteration stall detection' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop iteration stall detection timer.
|
||||
*/
|
||||
stopIterationStallDetection(): void {
|
||||
if (this._iterationStallTimer) {
|
||||
clearInterval(this._iterationStallTimer);
|
||||
this._iterationStallTimer = null;
|
||||
if (this._iterationStallTimerId) {
|
||||
this.cleanup.unregister(this._iterationStallTimerId);
|
||||
this._iterationStallTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3888,7 +3850,8 @@ export class RalphTracker extends EventEmitter {
|
||||
destroy(): void {
|
||||
this.clearDebounceTimers();
|
||||
this.stopWatchingFixPlan();
|
||||
this.stopIterationStallDetection();
|
||||
this._fixPlanReloadDeb.dispose();
|
||||
this.cleanup.dispose();
|
||||
this._todos.clear();
|
||||
this._taskNumberToContent.clear();
|
||||
this._todoStartTimes.clear();
|
||||
|
||||
+153
-164
@@ -41,7 +41,7 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-
|
||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js';
|
||||
import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever, CleanupManager } from './utils/index.js';
|
||||
import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||
import type {
|
||||
RespawnCycleMetrics,
|
||||
@@ -641,26 +641,29 @@ export class RespawnController extends EventEmitter {
|
||||
/** Current state machine state */
|
||||
private _state: RespawnState = 'stopped';
|
||||
|
||||
/** Timer for step delays */
|
||||
private stepTimer: NodeJS.Timeout | null = null;
|
||||
/** Centralized resource cleanup manager for timers */
|
||||
private cleanup = new CleanupManager();
|
||||
|
||||
/** Timer for completion confirmation (Layer 2) */
|
||||
private completionConfirmTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for step delays */
|
||||
private stepTimerId: string | null = null;
|
||||
|
||||
/** Timer for no-output fallback (Layer 5) */
|
||||
private noOutputTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for completion confirmation (Layer 2) */
|
||||
private completionConfirmTimerId: string | null = null;
|
||||
|
||||
/** Timer for periodic detection status updates */
|
||||
private detectionUpdateTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for no-output fallback (Layer 5) */
|
||||
private noOutputTimerId: string | null = null;
|
||||
|
||||
/** Timer ID for periodic detection status updates */
|
||||
private detectionUpdateTimerId: string | null = null;
|
||||
|
||||
/** Cached key fields from last emitted detection status (for dedup) */
|
||||
private lastEmittedDetectionKey: string = '';
|
||||
|
||||
/** Timer for auto-accepting plan mode prompts */
|
||||
private autoAcceptTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for auto-accepting plan mode prompts */
|
||||
private autoAcceptTimerId: string | null = null;
|
||||
|
||||
/** Timer for pre-filter silence detection (triggers AI check) */
|
||||
private preFilterTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for pre-filter silence detection (triggers AI check) */
|
||||
private preFilterTimerId: string | null = null;
|
||||
|
||||
/** Whether any terminal output has been received since start/last-auto-accept */
|
||||
private hasReceivedOutput: boolean = false;
|
||||
@@ -682,8 +685,8 @@ export class RespawnController extends EventEmitter {
|
||||
/** Timestamp when idle_prompt was received */
|
||||
private idlePromptTime: number | null = null;
|
||||
|
||||
/** Timer for short confirmation after hook signal (handles race conditions) */
|
||||
private hookConfirmTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for short confirmation after hook signal (handles race conditions) */
|
||||
private hookConfirmTimerId: string | null = null;
|
||||
|
||||
/** Confirmation delay after hook signal before confirming idle (ms) */
|
||||
private static readonly HOOK_CONFIRM_DELAY_MS = 3000;
|
||||
@@ -718,11 +721,11 @@ export class RespawnController extends EventEmitter {
|
||||
/** Unique ID for current AI check request (to detect stale results) */
|
||||
private _currentAiCheckId: string | null = null;
|
||||
|
||||
/** Timer for /clear step fallback (sends /init if no prompt detected) */
|
||||
private clearFallbackTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for /clear step fallback (sends /init if no prompt detected) */
|
||||
private clearFallbackTimerId: string | null = null;
|
||||
|
||||
/** Timer for step completion confirmation (waits for silence after completion) */
|
||||
private stepConfirmTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for step completion confirmation (waits for silence after completion) */
|
||||
private stepConfirmTimerId: string | null = null;
|
||||
|
||||
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
||||
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
||||
@@ -741,8 +744,8 @@ export class RespawnController extends EventEmitter {
|
||||
/** Timestamp when the current state was entered */
|
||||
private stateEnteredAt: number = 0;
|
||||
|
||||
/** Timer for stuck-state detection */
|
||||
private stuckStateTimer: NodeJS.Timeout | null = null;
|
||||
/** Timer ID for stuck-state detection */
|
||||
private stuckStateTimerId: string | null = null;
|
||||
|
||||
/** Whether a stuck-state warning has been emitted for current state */
|
||||
private stuckStateWarned: boolean = false;
|
||||
@@ -1200,31 +1203,35 @@ export class RespawnController extends EventEmitter {
|
||||
this.stopDetectionUpdates();
|
||||
if (this._state === 'stopped') return;
|
||||
this.lastEmittedDetectionKey = '';
|
||||
this.detectionUpdateTimer = setInterval(() => {
|
||||
try {
|
||||
if (this._state !== 'stopped') {
|
||||
const status = this.getDetectionStatus();
|
||||
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
||||
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
||||
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
||||
if (key !== this.lastEmittedDetectionKey) {
|
||||
this.lastEmittedDetectionKey = key;
|
||||
this.emit('detectionUpdate', status);
|
||||
this.detectionUpdateTimerId = this.cleanup.setInterval(
|
||||
() => {
|
||||
try {
|
||||
if (this._state !== 'stopped') {
|
||||
const status = this.getDetectionStatus();
|
||||
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
||||
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
||||
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
||||
if (key !== this.lastEmittedDetectionKey) {
|
||||
this.lastEmittedDetectionKey = key;
|
||||
this.emit('detectionUpdate', status);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
|
||||
}
|
||||
}, 2000);
|
||||
},
|
||||
2000,
|
||||
{ description: 'detection status updates' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic detection status updates.
|
||||
*/
|
||||
private stopDetectionUpdates(): void {
|
||||
if (this.detectionUpdateTimer) {
|
||||
clearInterval(this.detectionUpdateTimer);
|
||||
this.detectionUpdateTimer = null;
|
||||
if (this.detectionUpdateTimerId) {
|
||||
this.cleanup.unregister(this.detectionUpdateTimerId);
|
||||
this.detectionUpdateTimerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1515,8 +1522,8 @@ export class RespawnController extends EventEmitter {
|
||||
this.lastWorkingPatternTime = now;
|
||||
|
||||
// Cancel hook confirmation timer if running
|
||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected');
|
||||
this.hookConfirmTimer = null;
|
||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimerId, 'working patterns detected');
|
||||
this.hookConfirmTimerId = null;
|
||||
|
||||
// Cancel any pending completion confirmation
|
||||
this.cancelCompletionConfirm();
|
||||
@@ -1650,8 +1657,8 @@ export class RespawnController extends EventEmitter {
|
||||
*/
|
||||
private checkClearComplete(): void {
|
||||
// Clear the fallback timer since we got prompt detection
|
||||
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected');
|
||||
this.clearFallbackTimer = null;
|
||||
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimerId, 'prompt detected');
|
||||
this.clearFallbackTimerId = null;
|
||||
this.logAction('step', '/clear completed');
|
||||
this.emit('stepCompleted', 'clear');
|
||||
|
||||
@@ -1699,11 +1706,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.logAction('step', 'Monitoring if /init triggered work...');
|
||||
|
||||
// Give Claude a moment to start working before checking for idle
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
this.stepTimerId = this.startTrackedTimer(
|
||||
'init-monitor',
|
||||
3000,
|
||||
() => {
|
||||
this.stepTimer = null;
|
||||
this.stepTimerId = null;
|
||||
// If still in monitoring state and no work detected, consider it idle
|
||||
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
||||
this.checkMonitoringInitIdle();
|
||||
@@ -1719,9 +1726,9 @@ export class RespawnController extends EventEmitter {
|
||||
* @fires stepCompleted - With step 'init'
|
||||
*/
|
||||
private checkMonitoringInitIdle(): void {
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
if (this.stepTimerId) {
|
||||
this.cleanup.unregister(this.stepTimerId);
|
||||
this.stepTimerId = null;
|
||||
}
|
||||
this.log('/init did not trigger work, sending kickstart prompt');
|
||||
this.emit('stepCompleted', 'init');
|
||||
@@ -1737,11 +1744,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.terminalBuffer.clear();
|
||||
this.clearWorkingPatternWindow();
|
||||
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
this.stepTimerId = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
this.stepTimerId = null;
|
||||
if (this._state === 'stopped') return;
|
||||
const prompt = this.config.kickstartPrompt!;
|
||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||
@@ -1773,46 +1780,20 @@ export class RespawnController extends EventEmitter {
|
||||
private clearTimers(): void {
|
||||
// Clear tracked timers map first to avoid stale entries during individual cleanup
|
||||
this.activeTimers.clear();
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
}
|
||||
if (this.clearFallbackTimer) {
|
||||
clearTimeout(this.clearFallbackTimer);
|
||||
this.clearFallbackTimer = null;
|
||||
}
|
||||
if (this.completionConfirmTimer) {
|
||||
clearTimeout(this.completionConfirmTimer);
|
||||
this.completionConfirmTimer = null;
|
||||
}
|
||||
if (this.stepConfirmTimer) {
|
||||
clearTimeout(this.stepConfirmTimer);
|
||||
this.stepConfirmTimer = null;
|
||||
}
|
||||
if (this.autoAcceptTimer) {
|
||||
clearTimeout(this.autoAcceptTimer);
|
||||
this.autoAcceptTimer = null;
|
||||
}
|
||||
if (this.preFilterTimer) {
|
||||
clearTimeout(this.preFilterTimer);
|
||||
this.preFilterTimer = null;
|
||||
}
|
||||
if (this.noOutputTimer) {
|
||||
clearTimeout(this.noOutputTimer);
|
||||
this.noOutputTimer = null;
|
||||
}
|
||||
if (this.hookConfirmTimer) {
|
||||
clearTimeout(this.hookConfirmTimer);
|
||||
this.hookConfirmTimer = null;
|
||||
}
|
||||
if (this.stuckStateTimer) {
|
||||
clearInterval(this.stuckStateTimer);
|
||||
this.stuckStateTimer = null;
|
||||
}
|
||||
if (this.detectionUpdateTimer) {
|
||||
clearInterval(this.detectionUpdateTimer);
|
||||
this.detectionUpdateTimer = null;
|
||||
}
|
||||
this.cleanup.dispose();
|
||||
// Reinitialize for reuse (controller can be stopped and restarted)
|
||||
this.cleanup = new CleanupManager();
|
||||
// Null out IDs
|
||||
this.stepTimerId = null;
|
||||
this.completionConfirmTimerId = null;
|
||||
this.noOutputTimerId = null;
|
||||
this.detectionUpdateTimerId = null;
|
||||
this.autoAcceptTimerId = null;
|
||||
this.preFilterTimerId = null;
|
||||
this.hookConfirmTimerId = null;
|
||||
this.clearFallbackTimerId = null;
|
||||
this.stepConfirmTimerId = null;
|
||||
this.stuckStateTimerId = null;
|
||||
}
|
||||
|
||||
// ========== Stuck-State Detection Methods ==========
|
||||
@@ -1826,21 +1807,25 @@ export class RespawnController extends EventEmitter {
|
||||
if (this._state === 'stopped') return;
|
||||
|
||||
// Clear existing timer
|
||||
if (this.stuckStateTimer) {
|
||||
clearInterval(this.stuckStateTimer);
|
||||
this.stuckStateTimer = null;
|
||||
if (this.stuckStateTimerId) {
|
||||
this.cleanup.unregister(this.stuckStateTimerId);
|
||||
this.stuckStateTimerId = null;
|
||||
}
|
||||
|
||||
// Check interval for stuck state
|
||||
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
|
||||
|
||||
this.stuckStateTimer = setInterval(() => {
|
||||
try {
|
||||
this.checkStuckState();
|
||||
} catch (err) {
|
||||
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
|
||||
}
|
||||
}, checkIntervalMs);
|
||||
this.stuckStateTimerId = this.cleanup.setInterval(
|
||||
() => {
|
||||
try {
|
||||
this.checkStuckState();
|
||||
} catch (err) {
|
||||
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
|
||||
}
|
||||
},
|
||||
checkIntervalMs,
|
||||
{ description: 'stuck-state detection' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1981,7 +1966,7 @@ export class RespawnController extends EventEmitter {
|
||||
* Start a tracked timer with UI countdown support.
|
||||
* Emits timerStarted event and tracks the timer for UI display.
|
||||
*/
|
||||
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout {
|
||||
private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): string {
|
||||
const now = Date.now();
|
||||
const endsAt = now + durationMs;
|
||||
|
||||
@@ -1989,19 +1974,23 @@ export class RespawnController extends EventEmitter {
|
||||
this.emit('timerStarted', { name, durationMs, endsAt, reason });
|
||||
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
|
||||
|
||||
return setTimeout(() => {
|
||||
this.activeTimers.delete(name);
|
||||
this.emit('timerCompleted', name);
|
||||
callback();
|
||||
}, durationMs);
|
||||
return this.cleanup.setTimeout(
|
||||
() => {
|
||||
this.activeTimers.delete(name);
|
||||
this.emit('timerCompleted', name);
|
||||
callback();
|
||||
},
|
||||
durationMs,
|
||||
{ description: name }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a tracked timer and emit cancellation event.
|
||||
*/
|
||||
private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void {
|
||||
if (timerRef) {
|
||||
clearTimeout(timerRef);
|
||||
private cancelTrackedTimer(name: string, timerId: string | null, reason?: string): void {
|
||||
if (timerId) {
|
||||
this.cleanup.unregister(timerId);
|
||||
if (this.activeTimers.has(name)) {
|
||||
this.activeTimers.delete(name);
|
||||
this.emit('timerCancelled', name, reason);
|
||||
@@ -2097,14 +2086,14 @@ export class RespawnController extends EventEmitter {
|
||||
* (used when AI check is disabled or has too many errors).
|
||||
*/
|
||||
private startNoOutputTimer(): void {
|
||||
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting');
|
||||
this.noOutputTimer = null;
|
||||
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'restarting');
|
||||
this.noOutputTimerId = null;
|
||||
|
||||
this.noOutputTimer = this.startTrackedTimer(
|
||||
this.noOutputTimerId = this.startTrackedTimer(
|
||||
'no-output-fallback',
|
||||
this.config.noOutputTimeoutMs,
|
||||
() => {
|
||||
this.noOutputTimer = null;
|
||||
this.noOutputTimerId = null;
|
||||
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
|
||||
@@ -2137,17 +2126,17 @@ export class RespawnController extends EventEmitter {
|
||||
* This provides an additional path to AI check even without a completion message.
|
||||
*/
|
||||
private startPreFilterTimer(): void {
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting');
|
||||
this.preFilterTimer = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'restarting');
|
||||
this.preFilterTimerId = null;
|
||||
|
||||
// Only set up pre-filter when AI check is enabled
|
||||
if (!this.config.aiIdleCheckEnabled) return;
|
||||
|
||||
this.preFilterTimer = this.startTrackedTimer(
|
||||
this.preFilterTimerId = this.startTrackedTimer(
|
||||
'pre-filter',
|
||||
this.config.completionConfirmMs,
|
||||
() => {
|
||||
this.preFilterTimer = null;
|
||||
this.preFilterTimerId = null;
|
||||
if (this._state === 'watching') {
|
||||
const now = Date.now();
|
||||
const msSinceOutput = now - this.lastOutputTime;
|
||||
@@ -2252,18 +2241,18 @@ export class RespawnController extends EventEmitter {
|
||||
|
||||
if (result.verdict === 'IDLE') {
|
||||
// Cancel any pending confirmation timers - AI has spoken
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
||||
this.preFilterTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'AI verdict: IDLE');
|
||||
this.completionConfirmTimerId = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'AI verdict: IDLE');
|
||||
this.preFilterTimerId = null;
|
||||
|
||||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||||
this.emit('aiCheckCompleted', result);
|
||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||
} else if (result.verdict === 'WORKING') {
|
||||
// Cancel timers and go to cooldown
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'AI verdict: WORKING');
|
||||
this.completionConfirmTimerId = null;
|
||||
|
||||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||||
this.emit('aiCheckCompleted', result);
|
||||
@@ -2319,14 +2308,14 @@ export class RespawnController extends EventEmitter {
|
||||
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
||||
*/
|
||||
private startAutoAcceptTimer(): void {
|
||||
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting');
|
||||
this.autoAcceptTimer = null;
|
||||
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimerId, 'restarting');
|
||||
this.autoAcceptTimerId = null;
|
||||
|
||||
this.autoAcceptTimer = this.startTrackedTimer(
|
||||
this.autoAcceptTimerId = this.startTrackedTimer(
|
||||
'auto-accept',
|
||||
this.config.autoAcceptDelayMs,
|
||||
() => {
|
||||
this.autoAcceptTimer = null;
|
||||
this.autoAcceptTimerId = null;
|
||||
this.tryAutoAccept();
|
||||
},
|
||||
'plan mode detection'
|
||||
@@ -2338,8 +2327,8 @@ export class RespawnController extends EventEmitter {
|
||||
* Called when a completion message is detected (normal idle flow handles it).
|
||||
*/
|
||||
private cancelAutoAcceptTimer(): void {
|
||||
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled');
|
||||
this.autoAcceptTimer = null;
|
||||
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimerId, 'cancelled');
|
||||
this.autoAcceptTimerId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2500,8 +2489,8 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Cancel completion confirmation - auto-accept takes precedence
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'auto-accept');
|
||||
this.completionConfirmTimerId = null;
|
||||
this.completionMessageTime = null;
|
||||
|
||||
// Ensure we're in watching state (not confirming_idle or ai_checking)
|
||||
@@ -2555,12 +2544,12 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Cancel completion confirm timer - hook takes precedence
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'Stop hook received');
|
||||
this.completionConfirmTimerId = null;
|
||||
|
||||
// Cancel pre-filter timer - hook takes precedence
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received');
|
||||
this.preFilterTimer = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'Stop hook received');
|
||||
this.preFilterTimerId = null;
|
||||
|
||||
// Start short confirmation timer to handle race conditions
|
||||
// (e.g., Stop hook arrives but Claude immediately starts new work)
|
||||
@@ -2594,12 +2583,12 @@ export class RespawnController extends EventEmitter {
|
||||
}
|
||||
|
||||
// Cancel all other detection timers - this is definitive
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received');
|
||||
this.preFilterTimer = null;
|
||||
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received');
|
||||
this.noOutputTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'idle_prompt received');
|
||||
this.completionConfirmTimerId = null;
|
||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'idle_prompt received');
|
||||
this.preFilterTimerId = null;
|
||||
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'idle_prompt received');
|
||||
this.noOutputTimerId = null;
|
||||
|
||||
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
|
||||
// Skip confirmation and go directly to idle
|
||||
@@ -2613,14 +2602,14 @@ export class RespawnController extends EventEmitter {
|
||||
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
|
||||
*/
|
||||
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
|
||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting');
|
||||
this.hookConfirmTimer = null;
|
||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimerId, 'restarting');
|
||||
this.hookConfirmTimerId = null;
|
||||
|
||||
this.hookConfirmTimer = this.startTrackedTimer(
|
||||
this.hookConfirmTimerId = this.startTrackedTimer(
|
||||
'hook-confirm',
|
||||
RespawnController.HOOK_CONFIRM_DELAY_MS,
|
||||
() => {
|
||||
this.hookConfirmTimer = null;
|
||||
this.hookConfirmTimerId = null;
|
||||
|
||||
// Verify we haven't received new output since the hook arrived
|
||||
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
|
||||
@@ -2694,17 +2683,17 @@ export class RespawnController extends EventEmitter {
|
||||
* After completion message, waits for output silence then triggers AI check.
|
||||
*/
|
||||
private startCompletionConfirmTimer(): void {
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'restarting');
|
||||
this.completionConfirmTimerId = null;
|
||||
|
||||
this.setState('confirming_idle');
|
||||
this.logAction('detection', 'Completion message found in output');
|
||||
|
||||
this.completionConfirmTimer = this.startTrackedTimer(
|
||||
this.completionConfirmTimerId = this.startTrackedTimer(
|
||||
'completion-confirm',
|
||||
this.config.completionConfirmMs,
|
||||
() => {
|
||||
this.completionConfirmTimer = null;
|
||||
this.completionConfirmTimerId = null;
|
||||
if (this._state === 'stopped') return;
|
||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||||
@@ -2725,8 +2714,8 @@ export class RespawnController extends EventEmitter {
|
||||
* Cancel completion confirmation if new activity detected.
|
||||
*/
|
||||
private cancelCompletionConfirm(): void {
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected');
|
||||
this.completionConfirmTimer = null;
|
||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'activity detected');
|
||||
this.completionConfirmTimerId = null;
|
||||
if (this._state === 'confirming_idle') {
|
||||
this.setState('watching');
|
||||
this.completionMessageTime = null;
|
||||
@@ -2739,14 +2728,14 @@ export class RespawnController extends EventEmitter {
|
||||
* This ensures Claude has finished processing before we send the next command.
|
||||
*/
|
||||
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
|
||||
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting');
|
||||
this.stepConfirmTimer = null;
|
||||
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimerId, 'restarting');
|
||||
this.stepConfirmTimerId = null;
|
||||
|
||||
this.stepConfirmTimer = this.startTrackedTimer(
|
||||
this.stepConfirmTimerId = this.startTrackedTimer(
|
||||
'step-confirm',
|
||||
this.config.completionConfirmMs,
|
||||
() => {
|
||||
this.stepConfirmTimer = null;
|
||||
this.stepConfirmTimerId = null;
|
||||
if (this._state === 'stopped') return;
|
||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||
|
||||
@@ -2779,8 +2768,8 @@ export class RespawnController extends EventEmitter {
|
||||
* Cancel step confirmation if working patterns detected.
|
||||
*/
|
||||
private cancelStepConfirm(): void {
|
||||
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected');
|
||||
this.stepConfirmTimer = null;
|
||||
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimerId, 'working detected');
|
||||
this.stepConfirmTimerId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2942,11 +2931,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
||||
this.clearWorkingPatternWindow(); // Clear rolling window
|
||||
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
this.stepTimerId = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
this.stepTimerId = null;
|
||||
if (this._state === 'stopped') return;
|
||||
|
||||
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
||||
@@ -2983,11 +2972,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.terminalBuffer.clear();
|
||||
this.clearWorkingPatternWindow();
|
||||
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
this.stepTimerId = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
this.stepTimerId = null;
|
||||
if (this._state === 'stopped') return;
|
||||
this.logAction('command', 'Sending: /clear');
|
||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
@@ -2996,11 +2985,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.promptDetected = false;
|
||||
|
||||
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
||||
this.clearFallbackTimer = this.startTrackedTimer(
|
||||
this.clearFallbackTimerId = this.startTrackedTimer(
|
||||
'clear-fallback',
|
||||
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
|
||||
() => {
|
||||
this.clearFallbackTimer = null;
|
||||
this.clearFallbackTimerId = null;
|
||||
if (this._state === 'waiting_clear') {
|
||||
this.logAction('step', '/clear fallback: proceeding to /init');
|
||||
this.emit('stepCompleted', 'clear');
|
||||
@@ -3027,11 +3016,11 @@ export class RespawnController extends EventEmitter {
|
||||
this.terminalBuffer.clear();
|
||||
this.clearWorkingPatternWindow();
|
||||
|
||||
this.stepTimer = this.startTrackedTimer(
|
||||
this.stepTimerId = this.startTrackedTimer(
|
||||
'step-delay',
|
||||
this.config.interStepDelayMs,
|
||||
async () => {
|
||||
this.stepTimer = null;
|
||||
this.stepTimerId = null;
|
||||
if (this._state === 'stopped') return;
|
||||
this.logAction('command', 'Sending: /init');
|
||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||
|
||||
+12
-25
@@ -28,7 +28,7 @@ import {
|
||||
TokenStats,
|
||||
TokenUsageEntry,
|
||||
} from './types.js';
|
||||
import { MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import { Debouncer, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
|
||||
/** Debounce delay for batching state writes (ms) */
|
||||
const SAVE_DEBOUNCE_MS = 500;
|
||||
@@ -60,7 +60,7 @@ const MAX_CONSECUTIVE_FAILURES = 3;
|
||||
export class StateStore {
|
||||
private state: AppState;
|
||||
private filePath: string;
|
||||
private saveTimeout: NodeJS.Timeout | null = null;
|
||||
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||
private dirty: boolean = false;
|
||||
private dirtySessions = new Set<string>();
|
||||
private cachedSessionJsons = new Map<string, string>();
|
||||
@@ -68,7 +68,7 @@ export class StateStore {
|
||||
// Inner state storage (separate from main state to reduce write frequency)
|
||||
private ralphStates: Map<string, RalphSessionState> = new Map();
|
||||
private ralphStatePath: string;
|
||||
private ralphStateSaveTimeout: NodeJS.Timeout | null = null;
|
||||
private ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||
private ralphStateDirty: boolean = false;
|
||||
|
||||
// Circuit breaker for save failures (prevents hammering disk on persistent errors)
|
||||
@@ -150,14 +150,12 @@ export class StateStore {
|
||||
*/
|
||||
save(): void {
|
||||
this.dirty = true;
|
||||
if (this.saveTimeout) {
|
||||
return; // Already scheduled
|
||||
}
|
||||
this.saveTimeout = setTimeout(() => {
|
||||
if (this.saveDeb.isPending) return; // Already scheduled
|
||||
this.saveDeb.schedule(() => {
|
||||
this.saveNowAsync().catch((err) => {
|
||||
console.error('[StateStore] Async save failed:', err);
|
||||
});
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,10 +240,7 @@ export class StateStore {
|
||||
}
|
||||
|
||||
private async _doSaveAsync(): Promise<void> {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
this.saveDeb.cancel();
|
||||
if (!this.dirty) {
|
||||
return;
|
||||
}
|
||||
@@ -332,10 +327,7 @@ export class StateStore {
|
||||
* Prefer saveNowAsync() for normal operation.
|
||||
*/
|
||||
saveNow(): void {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
this.saveTimeout = null;
|
||||
}
|
||||
this.saveDeb.cancel();
|
||||
if (!this.dirty) {
|
||||
return;
|
||||
}
|
||||
@@ -775,12 +767,10 @@ export class StateStore {
|
||||
// Debounced save for inner states
|
||||
private saveRalphStates(): void {
|
||||
this.ralphStateDirty = true;
|
||||
if (this.ralphStateSaveTimeout) {
|
||||
return; // Already scheduled
|
||||
}
|
||||
this.ralphStateSaveTimeout = setTimeout(() => {
|
||||
if (this.ralphStateSaveDeb.isPending) return; // Already scheduled
|
||||
this.ralphStateSaveDeb.schedule(() => {
|
||||
this.saveRalphStatesNow();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -788,10 +778,7 @@ export class StateStore {
|
||||
* Writes to temp file first, then renames to prevent corruption on crash.
|
||||
*/
|
||||
private saveRalphStatesNow(): void {
|
||||
if (this.ralphStateSaveTimeout) {
|
||||
clearTimeout(this.ralphStateSaveTimeout);
|
||||
this.ralphStateSaveTimeout = null;
|
||||
}
|
||||
this.ralphStateSaveDeb.cancel();
|
||||
if (!this.ralphStateDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
+72
-105
@@ -14,6 +14,7 @@ import { join, basename } from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
||||
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js';
|
||||
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -161,12 +162,11 @@ const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates
|
||||
export class SubagentWatcher extends EventEmitter {
|
||||
private filePositions = new Map<string, number>();
|
||||
private dirWatchers = new Map<string, FSWatcher>();
|
||||
// Per-file debounce timers for directory watcher (replaces per-file FSWatchers)
|
||||
private fileDebouncers = new Map<string, NodeJS.Timeout>();
|
||||
// Per-file debouncer for directory watcher (replaces per-file FSWatchers)
|
||||
private fileDeb = new KeyedDebouncer(FILE_CONTENT_DEBOUNCE_MS);
|
||||
private agentInfo = new Map<string, SubagentInfo>();
|
||||
private idleTimers = new Map<string, NodeJS.Timeout>();
|
||||
private pollInterval: NodeJS.Timeout | null = null;
|
||||
private livenessInterval: NodeJS.Timeout | null = null;
|
||||
private idleDeb = new KeyedDebouncer(IDLE_TIMEOUT_MS);
|
||||
private cleanup = new CleanupManager();
|
||||
private _isRunning = false;
|
||||
private knownSubagentDirs = new Set<string>();
|
||||
// Map of agentId -> Map of toolUseId -> { toolName, timestamp } (for linking tool_result to tool_call)
|
||||
@@ -228,12 +228,16 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// Periodic scan for new subagent directories
|
||||
// Full directory traversal only every FULL_SCAN_EVERY_N_POLLS polls (~5s)
|
||||
// FSWatchers handle known directories between full scans
|
||||
this.pollInterval = setInterval(() => {
|
||||
this._pollCount++;
|
||||
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
|
||||
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
this._pollCount++;
|
||||
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
|
||||
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||
}
|
||||
},
|
||||
POLL_INTERVAL_MS,
|
||||
{ description: 'subagent directory poll' }
|
||||
);
|
||||
|
||||
// Periodic liveness check for active subagents
|
||||
this.startLivenessChecker();
|
||||
@@ -249,54 +253,56 @@ export class SubagentWatcher extends EventEmitter {
|
||||
* 3. Full pgrep scan (expensive, ~500ms) — only for agents that fail tiers 1+2
|
||||
*/
|
||||
private startLivenessChecker(): void {
|
||||
if (this.livenessInterval) return;
|
||||
this.cleanup.setInterval(
|
||||
async () => {
|
||||
// Guard: prevent concurrent liveness checks (avoids duplicate completed events)
|
||||
if (this._isCheckingLiveness) return;
|
||||
this._isCheckingLiveness = true;
|
||||
|
||||
this.livenessInterval = setInterval(async () => {
|
||||
// Guard: prevent concurrent liveness checks (avoids duplicate completed events)
|
||||
if (this._isCheckingLiveness) return;
|
||||
this._isCheckingLiveness = true;
|
||||
try {
|
||||
// Collect agents that need the expensive pgrep scan
|
||||
const needsFullScan: SubagentInfo[] = [];
|
||||
|
||||
try {
|
||||
// Collect agents that need the expensive pgrep scan
|
||||
const needsFullScan: SubagentInfo[] = [];
|
||||
|
||||
for (const [_agentId, info] of this.agentInfo) {
|
||||
if (info.status !== 'active' && info.status !== 'idle') continue;
|
||||
|
||||
// Tier 1: File mtime check (~0.3ms per agent)
|
||||
if (await this.checkSubagentFileAlive(info)) continue;
|
||||
|
||||
// Tier 2: Cached PID check (~0.1ms per agent)
|
||||
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
|
||||
|
||||
// Tiers 1+2 failed — need expensive scan for this agent
|
||||
needsFullScan.push(info);
|
||||
}
|
||||
|
||||
// Tier 3: Full pgrep scan — only if any agents failed cheap checks
|
||||
if (needsFullScan.length > 0) {
|
||||
const pidMap = await this.getClaudePids();
|
||||
|
||||
for (const info of needsFullScan) {
|
||||
// Re-check status in case another check completed this agent
|
||||
for (const [_agentId, info] of this.agentInfo) {
|
||||
if (info.status !== 'active' && info.status !== 'idle') continue;
|
||||
|
||||
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
|
||||
if (!alive) {
|
||||
info.pid = undefined;
|
||||
info.status = 'completed';
|
||||
this.pendingToolCalls.delete(info.agentId);
|
||||
this.emit('subagent:completed', info);
|
||||
// Tier 1: File mtime check (~0.3ms per agent)
|
||||
if (await this.checkSubagentFileAlive(info)) continue;
|
||||
|
||||
// Tier 2: Cached PID check (~0.1ms per agent)
|
||||
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
|
||||
|
||||
// Tiers 1+2 failed — need expensive scan for this agent
|
||||
needsFullScan.push(info);
|
||||
}
|
||||
|
||||
// Tier 3: Full pgrep scan — only if any agents failed cheap checks
|
||||
if (needsFullScan.length > 0) {
|
||||
const pidMap = await this.getClaudePids();
|
||||
|
||||
for (const info of needsFullScan) {
|
||||
// Re-check status in case another check completed this agent
|
||||
if (info.status !== 'active' && info.status !== 'idle') continue;
|
||||
|
||||
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
|
||||
if (!alive) {
|
||||
info.pid = undefined;
|
||||
info.status = 'completed';
|
||||
this.pendingToolCalls.delete(info.agentId);
|
||||
this.emit('subagent:completed', info);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically clean up stale completed agents (older than 24 hours)
|
||||
this.cleanupStaleAgents();
|
||||
} finally {
|
||||
this._isCheckingLiveness = false;
|
||||
}
|
||||
}, LIVENESS_CHECK_MS);
|
||||
// Periodically clean up stale completed agents (older than 24 hours)
|
||||
this.cleanupStaleAgents();
|
||||
} finally {
|
||||
this._isCheckingLiveness = false;
|
||||
}
|
||||
},
|
||||
LIVENESS_CHECK_MS,
|
||||
{ description: 'subagent liveness check' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -418,21 +424,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
stop(): void {
|
||||
this._isRunning = false;
|
||||
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
}
|
||||
|
||||
if (this.livenessInterval) {
|
||||
clearInterval(this.livenessInterval);
|
||||
this.livenessInterval = null;
|
||||
}
|
||||
// Dispose poll and liveness intervals, then re-create for potential restart
|
||||
this.cleanup.dispose();
|
||||
this.cleanup = new CleanupManager();
|
||||
|
||||
// Clear file debouncers
|
||||
for (const timer of this.fileDebouncers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.fileDebouncers.clear();
|
||||
this.fileDeb.dispose();
|
||||
this.fileAgentContext.clear();
|
||||
|
||||
// Remove error handlers before closing watchers to prevent memory leak
|
||||
@@ -447,10 +444,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
this.dirWatchers.clear();
|
||||
|
||||
for (const timer of this.idleTimers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.idleTimers.clear();
|
||||
this.idleDeb.dispose();
|
||||
|
||||
// Clear all state for clean restart
|
||||
this.filePositions.clear();
|
||||
@@ -532,16 +526,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.pendingToolCalls.delete(agentId);
|
||||
this.filePositions.delete(info.filePath);
|
||||
this.fileAgentContext.delete(info.filePath);
|
||||
const debounceTimer = this.fileDebouncers.get(info.filePath);
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
this.fileDebouncers.delete(info.filePath);
|
||||
}
|
||||
const timer = this.idleTimers.get(agentId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.idleTimers.delete(agentId);
|
||||
}
|
||||
this.fileDeb.cancelKey(info.filePath);
|
||||
this.idleDeb.cancelKey(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,9 +620,9 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
return {
|
||||
agentCount: this.agentInfo.size,
|
||||
fileDebouncerCount: this.fileDebouncers.size,
|
||||
fileDebouncerCount: this.fileDeb.size,
|
||||
dirWatcherCount: this.dirWatchers.size,
|
||||
idleTimerCount: this.idleTimers.size,
|
||||
idleTimerCount: this.idleDeb.size,
|
||||
pendingToolCallsCount,
|
||||
knownDirsCount: this.knownSubagentDirs.size,
|
||||
filePositionsCount: this.filePositions.size,
|
||||
@@ -1129,13 +1115,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
if (!filename?.endsWith('.jsonl')) return;
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
// Clear existing debounce for this file
|
||||
const existing = this.fileDebouncers.get(filePath);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
// Debounce 100ms to batch rapid writes
|
||||
const timer = setTimeout(() => {
|
||||
this.fileDebouncers.delete(filePath);
|
||||
this.fileDeb.schedule(filePath, () => {
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
@@ -1145,9 +1126,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
// New file — register it
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
||||
}
|
||||
}, FILE_CONTENT_DEBOUNCE_MS);
|
||||
|
||||
this.fileDebouncers.set(filePath, timer);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle watcher errors to prevent unhandled exceptions
|
||||
@@ -1602,25 +1581,13 @@ export class SubagentWatcher extends EventEmitter {
|
||||
* Reset idle timer for an agent
|
||||
*/
|
||||
private resetIdleTimer(agentId: string): void {
|
||||
const existing = this.idleTimers.get(agentId);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
// Guard against race condition: agent may have been deleted before timer fires
|
||||
this.idleDeb.schedule(agentId, () => {
|
||||
const info = this.agentInfo.get(agentId);
|
||||
if (!info) {
|
||||
// Agent was deleted - clean up timer reference
|
||||
this.idleTimers.delete(agentId);
|
||||
return;
|
||||
}
|
||||
if (!info) return;
|
||||
if (info.status === 'active') {
|
||||
info.status = 'idle';
|
||||
}
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
|
||||
this.idleTimers.set(agentId, timer);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-2
@@ -59,8 +59,7 @@ import { resolveOpenCodeDir } from './utils/opencode-cli-resolver.js';
|
||||
// Timing Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Timeout for exec commands (5 seconds) */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||
|
||||
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||||
const TMUX_CREATION_WAIT_MS = 100;
|
||||
|
||||
@@ -12,9 +12,7 @@ import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { delimiter, dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/** Timeout for exec commands (5 seconds) */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the Claude CLI binary may be installed */
|
||||
const CLAUDE_SEARCH_DIRS = [
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @fileoverview Debounce utilities to replace manual timer management.
|
||||
*
|
||||
* Two variants:
|
||||
* - `Debouncer` — single debounced operation (replaces timer + clearTimeout pattern)
|
||||
* - `KeyedDebouncer` — per-key debouncing (replaces Map<string, Timeout> pattern)
|
||||
*
|
||||
* Both integrate with CleanupManager via dispose().
|
||||
*
|
||||
* @module utils/debouncer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Single-operation debouncer.
|
||||
*
|
||||
* Replaces the common pattern of:
|
||||
* ```
|
||||
* private timer: NodeJS.Timeout | null = null;
|
||||
* debounce(fn) { if (this.timer) clearTimeout(this.timer); this.timer = setTimeout(fn, delay); }
|
||||
* cancel() { if (this.timer) { clearTimeout(this.timer); this.timer = null; } }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* private saveDeb = new Debouncer(500);
|
||||
*
|
||||
* onChange() {
|
||||
* this.saveDeb.schedule(() => this.save());
|
||||
* }
|
||||
*
|
||||
* stop() {
|
||||
* this.saveDeb.dispose();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class Debouncer {
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(private readonly delayMs: number) {}
|
||||
|
||||
/**
|
||||
* Schedule a debounced callback. Resets the timer on each call.
|
||||
* If a previous call is pending, it is cancelled.
|
||||
*/
|
||||
schedule(fn: () => void): void {
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
fn();
|
||||
}, this.delayMs);
|
||||
}
|
||||
|
||||
/** Cancel any pending execution without invoking the callback. */
|
||||
cancel(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a callback is currently pending. */
|
||||
get isPending(): boolean {
|
||||
return this.timer !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending callback and flush immediately.
|
||||
* Useful for shutdown: cancel the timer but run the action now.
|
||||
*
|
||||
* @param fn - The flush function to run (typically the same function passed to schedule)
|
||||
*/
|
||||
flush(fn: () => void): void {
|
||||
this.cancel();
|
||||
fn();
|
||||
}
|
||||
|
||||
/** Alias for cancel() — matches CleanupManager/Disposable convention. */
|
||||
dispose(): void {
|
||||
this.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key debouncer for operations that need independent timers per resource.
|
||||
*
|
||||
* Replaces the common pattern of:
|
||||
* ```
|
||||
* private timers = new Map<string, NodeJS.Timeout>();
|
||||
* debounce(key, fn) {
|
||||
* const existing = this.timers.get(key);
|
||||
* if (existing) clearTimeout(existing);
|
||||
* this.timers.set(key, setTimeout(() => { this.timers.delete(key); fn(); }, delay));
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* private fileDebouncers = new KeyedDebouncer(100);
|
||||
*
|
||||
* onFileChange(path: string) {
|
||||
* this.fileDebouncers.schedule(path, () => this.processFile(path));
|
||||
* }
|
||||
*
|
||||
* stop() {
|
||||
* this.fileDebouncers.dispose();
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class KeyedDebouncer {
|
||||
private timers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
constructor(private readonly delayMs: number) {}
|
||||
|
||||
/**
|
||||
* Schedule a debounced callback for a specific key.
|
||||
* Each key has its own independent timer.
|
||||
*/
|
||||
schedule(key: string, fn: () => void): void {
|
||||
this.cancelKey(key);
|
||||
this.timers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
this.timers.delete(key);
|
||||
fn();
|
||||
}, this.delayMs)
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancel a pending callback for a specific key. */
|
||||
cancelKey(key: string): void {
|
||||
const existing = this.timers.get(key);
|
||||
if (existing) {
|
||||
clearTimeout(existing);
|
||||
this.timers.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a callback is pending for a specific key. */
|
||||
has(key: string): boolean {
|
||||
return this.timers.has(key);
|
||||
}
|
||||
|
||||
/** Number of active timers. */
|
||||
get size(): number {
|
||||
return this.timers.size;
|
||||
}
|
||||
|
||||
/** Get all currently active keys. */
|
||||
keys(): IterableIterator<string> {
|
||||
return this.timers.keys();
|
||||
}
|
||||
|
||||
/** Cancel all pending callbacks. */
|
||||
dispose(): void {
|
||||
for (const timer of this.timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.timers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending callbacks and run a flush function for each active key.
|
||||
* Useful for shutdown: cancel timers but run the action for each pending key.
|
||||
*
|
||||
* @param fn - Called once per active key with the key as argument
|
||||
*/
|
||||
flushAll(fn: (key: string) => void): void {
|
||||
const activeKeys = Array.from(this.timers.keys());
|
||||
this.dispose();
|
||||
for (const key of activeKeys) {
|
||||
fn(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -9,14 +9,19 @@
|
||||
export { BufferAccumulator } from './buffer-accumulator.js';
|
||||
export { LRUMap, type LRUMapOptions } from './lru-map.js';
|
||||
export { CleanupManager, type TimerOptions } from './cleanup-manager.js';
|
||||
export { Debouncer, KeyedDebouncer } from './debouncer.js';
|
||||
export { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
|
||||
export {
|
||||
ANSI_ESCAPE_PATTERN_FULL,
|
||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||
TOKEN_PATTERN,
|
||||
SPINNER_PATTERN,
|
||||
createAnsiPatternFull,
|
||||
createAnsiPatternSimple,
|
||||
stripAnsi,
|
||||
SAFE_PATH_PATTERN,
|
||||
} from './regex-patterns.js';
|
||||
export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
|
||||
export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||
export { assertNever } from './type-safety.js';
|
||||
export { wrapWithNice } from './nice-wrapper.js';
|
||||
|
||||
@@ -11,9 +11,7 @@ import { execSync } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/** Timeout for exec commands (5 seconds) */
|
||||
const EXEC_TIMEOUT_MS = 5000;
|
||||
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||
|
||||
/** Common directories where the OpenCode CLI binary may be installed */
|
||||
const OPENCODE_SEARCH_DIRS = [
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* levenshteinDistance('hello', 'helo') // 1 (one deletion)
|
||||
* levenshteinDistance('COMPLETE', 'COMPLET') // 1 (one deletion)
|
||||
*/
|
||||
export function levenshteinDistance(a: string, b: string): number {
|
||||
function levenshteinDistance(a: string, b: string): number {
|
||||
// Ensure a is the shorter string for space efficiency
|
||||
if (a.length > b.length) {
|
||||
[a, b] = [b, a];
|
||||
@@ -91,22 +91,6 @@ export function stringSimilarity(a: string, b: string): number {
|
||||
return 1 - distance / maxLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two strings are similar within a given threshold.
|
||||
*
|
||||
* @param a - First string
|
||||
* @param b - Second string
|
||||
* @param threshold - Minimum similarity ratio (default: 0.85 = 85% similar)
|
||||
* @returns True if similarity >= threshold
|
||||
*
|
||||
* @example
|
||||
* isSimilar('COMPLETE', 'COMPLET', 0.85) // true (87.5% similar)
|
||||
* isSimilar('COMPLETE', 'DONE', 0.85) // false (0% similar)
|
||||
*/
|
||||
export function isSimilar(a: string, b: string, threshold = 0.85): boolean {
|
||||
return stringSimilarity(a, b) >= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two strings are similar with edit distance tolerance.
|
||||
* More intuitive for short strings than percentage-based threshold.
|
||||
@@ -120,7 +104,7 @@ export function isSimilar(a: string, b: string, threshold = 0.85): boolean {
|
||||
* isSimilarByDistance('COMPLETE', 'COMPLET', 2) // true (distance 1)
|
||||
* isSimilarByDistance('COMPLETE', 'COMP', 2) // false (distance 4)
|
||||
*/
|
||||
export function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
|
||||
function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
|
||||
return levenshteinDistance(a, b) <= maxDistance;
|
||||
}
|
||||
|
||||
@@ -136,7 +120,7 @@ export function isSimilarByDistance(a: string, b: string, maxDistance = 2): bool
|
||||
* normalizePhrase('TASK-DONE') // 'TASKDONE'
|
||||
* normalizePhrase('Task Done') // 'TASKDONE'
|
||||
*/
|
||||
export function normalizePhrase(phrase: string): string {
|
||||
function normalizePhrase(phrase: string): string {
|
||||
return phrase
|
||||
.toUpperCase()
|
||||
.replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
|
||||
|
||||
@@ -506,3 +506,42 @@ export const RalphLoopStartSchema = z.object({
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Inferred Types ==========
|
||||
|
||||
export type CreateSessionInput = z.infer<typeof CreateSessionSchema>;
|
||||
export type RunPromptInput = z.infer<typeof RunPromptSchema>;
|
||||
export type ResizeInput = z.infer<typeof ResizeSchema>;
|
||||
export type CreateCaseInput = z.infer<typeof CreateCaseSchema>;
|
||||
export type QuickStartInput = z.infer<typeof QuickStartSchema>;
|
||||
export type HookEventInput = z.infer<typeof HookEventSchema>;
|
||||
export type RespawnConfigInput = z.infer<typeof RespawnConfigSchema>;
|
||||
export type ConfigUpdateInput = z.infer<typeof ConfigUpdateSchema>;
|
||||
export type SettingsUpdateInput = z.infer<typeof SettingsUpdateSchema>;
|
||||
export type SessionInputWithLimitInput = z.infer<typeof SessionInputWithLimitSchema>;
|
||||
export type SessionNameInput = z.infer<typeof SessionNameSchema>;
|
||||
export type SessionColorInput = z.infer<typeof SessionColorSchema>;
|
||||
export type RalphConfigInput = z.infer<typeof RalphConfigSchema>;
|
||||
export type FixPlanImportInput = z.infer<typeof FixPlanImportSchema>;
|
||||
export type RalphPromptWriteInput = z.infer<typeof RalphPromptWriteSchema>;
|
||||
export type AutoClearInput = z.infer<typeof AutoClearSchema>;
|
||||
export type AutoCompactInput = z.infer<typeof AutoCompactSchema>;
|
||||
export type ImageWatcherInput = z.infer<typeof ImageWatcherSchema>;
|
||||
export type FlickerFilterInput = z.infer<typeof FlickerFilterSchema>;
|
||||
export type QuickRunInput = z.infer<typeof QuickRunSchema>;
|
||||
export type ScheduledRunInput = z.infer<typeof ScheduledRunSchema>;
|
||||
export type LinkCaseInput = z.infer<typeof LinkCaseSchema>;
|
||||
export type GeneratePlanInput = z.infer<typeof GeneratePlanSchema>;
|
||||
export type GeneratePlanDetailedInput = z.infer<typeof GeneratePlanDetailedSchema>;
|
||||
export type CancelPlanInput = z.infer<typeof CancelPlanSchema>;
|
||||
export type PlanTaskUpdateInput = z.infer<typeof PlanTaskUpdateSchema>;
|
||||
export type PlanTaskAddInput = z.infer<typeof PlanTaskAddSchema>;
|
||||
export type CpuLimitInput = z.infer<typeof CpuLimitSchema>;
|
||||
export type ModelConfigUpdateInput = z.infer<typeof ModelConfigUpdateSchema>;
|
||||
export type SubagentWindowStatesInput = z.infer<typeof SubagentWindowStatesSchema>;
|
||||
export type SubagentParentMapInput = z.infer<typeof SubagentParentMapSchema>;
|
||||
export type InteractiveRespawnInput = z.infer<typeof InteractiveRespawnSchema>;
|
||||
export type RespawnEnableInput = z.infer<typeof RespawnEnableSchema>;
|
||||
export type PushSubscribeInput = z.infer<typeof PushSubscribeSchema>;
|
||||
export type PushPreferencesUpdateInput = z.infer<typeof PushPreferencesUpdateSchema>;
|
||||
export type RalphLoopStartInput = z.infer<typeof RalphLoopStartSchema>;
|
||||
|
||||
+53
-79
@@ -123,7 +123,7 @@ import {
|
||||
RalphLoopStartSchema,
|
||||
isValidWorkingDir,
|
||||
} from './schemas.js';
|
||||
import { StaleExpirationMap } from '../utils/index.js';
|
||||
import { CleanupManager, KeyedDebouncer, StaleExpirationMap } from '../utils/index.js';
|
||||
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -420,16 +420,14 @@ export class WebServer extends EventEmitter {
|
||||
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
|
||||
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
||||
});
|
||||
// Scheduled runs cleanup timer
|
||||
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
|
||||
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
||||
private cleanup = new CleanupManager();
|
||||
// SSE event batching
|
||||
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
|
||||
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
|
||||
private taskUpdateBatchTimerId: string | null = null;
|
||||
// State update batching (reduce expensive toDetailedState() serialization)
|
||||
private stateUpdatePending: Set<string> = new Set();
|
||||
private stateUpdateTimer: NodeJS.Timeout | null = null;
|
||||
// SSE client health check timer
|
||||
private sseHealthCheckTimer: NodeJS.Timeout | null = null;
|
||||
private stateUpdateTimerId: string | null = null;
|
||||
// Flag to prevent new timers during shutdown
|
||||
private _isStopping: boolean = false;
|
||||
// Cached light state for SSE init (avoids rebuilding on every reconnect)
|
||||
@@ -439,14 +437,13 @@ export class WebServer extends EventEmitter {
|
||||
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
|
||||
// Token recording for daily stats (track what's been recorded to avoid double-counting)
|
||||
private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
|
||||
private tokenRecordingTimer: NodeJS.Timeout | null = null;
|
||||
// Server startup time for respawn grace period calculation
|
||||
private readonly serverStartTime: number = Date.now();
|
||||
// Pending respawn start timers (for cleanup on shutdown)
|
||||
private pendingRespawnStarts: Map<string, NodeJS.Timeout> = new Map();
|
||||
// Active plan orchestrators (for cancellation via API)
|
||||
private activePlanOrchestrators: Map<string, PlanOrchestrator> = new Map();
|
||||
private persistDebounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
||||
private persistDeb = new KeyedDebouncer(100);
|
||||
// Grace period before starting restored respawn controllers (2 minutes)
|
||||
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
|
||||
// Stored listener handlers for cleanup
|
||||
@@ -4646,18 +4643,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
|
||||
private persistSessionState(session: Session): void {
|
||||
const existing = this.persistDebounceTimers.get(session.id);
|
||||
if (existing) clearTimeout(existing);
|
||||
this.persistDebounceTimers.set(
|
||||
session.id,
|
||||
setTimeout(() => {
|
||||
this.persistDebounceTimers.delete(session.id);
|
||||
// Session may have been removed during debounce
|
||||
if (this.sessions.has(session.id)) {
|
||||
this._persistSessionStateNow(session);
|
||||
}
|
||||
}, 100)
|
||||
);
|
||||
this.persistDeb.schedule(session.id, () => {
|
||||
// Session may have been removed during debounce
|
||||
if (this.sessions.has(session.id)) {
|
||||
this._persistSessionStateNow(session);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Persists full session state including respawn config to state.json */
|
||||
@@ -4845,11 +4836,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
// Clear pending persist-debounce timer (prevents stale closure holding session ref)
|
||||
const pendingPersist = this.persistDebounceTimers.get(sessionId);
|
||||
if (pendingPersist) {
|
||||
clearTimeout(pendingPersist);
|
||||
this.persistDebounceTimers.delete(sessionId);
|
||||
}
|
||||
this.persistDeb.cancelKey(sessionId);
|
||||
|
||||
// Clear batches, per-session timers, and pending state updates
|
||||
this.terminalBatches.delete(sessionId);
|
||||
@@ -5079,11 +5066,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
this.lastTerminalEventTime.delete(session.id);
|
||||
|
||||
// Clear pending persist-debounce timer
|
||||
const pendingPersist = this.persistDebounceTimers.get(session.id);
|
||||
if (pendingPersist) {
|
||||
clearTimeout(pendingPersist);
|
||||
this.persistDebounceTimers.delete(session.id);
|
||||
}
|
||||
this.persistDeb.cancelKey(session.id);
|
||||
|
||||
// Close any active file streams
|
||||
fileStreamManager.closeSessionStreams(session.id);
|
||||
@@ -6000,11 +5983,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const key = `${sessionId}:${task.id}`;
|
||||
this.taskUpdateBatches.set(key, { sessionId, task });
|
||||
|
||||
if (!this.taskUpdateBatchTimer) {
|
||||
this.taskUpdateBatchTimer = setTimeout(() => {
|
||||
this.flushTaskUpdateBatches();
|
||||
this.taskUpdateBatchTimer = null;
|
||||
}, TASK_UPDATE_BATCH_INTERVAL);
|
||||
if (!this.taskUpdateBatchTimerId) {
|
||||
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(
|
||||
() => {
|
||||
this.taskUpdateBatchTimerId = null;
|
||||
this.flushTaskUpdateBatches();
|
||||
},
|
||||
TASK_UPDATE_BATCH_INTERVAL,
|
||||
{ description: 'task update batch flush' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6031,11 +6018,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
|
||||
this.stateUpdatePending.add(sessionId);
|
||||
|
||||
if (!this.stateUpdateTimer) {
|
||||
this.stateUpdateTimer = setTimeout(() => {
|
||||
this.flushStateUpdates();
|
||||
this.stateUpdateTimer = null;
|
||||
}, STATE_UPDATE_DEBOUNCE_INTERVAL);
|
||||
if (!this.stateUpdateTimerId) {
|
||||
this.stateUpdateTimerId = this.cleanup.setTimeout(
|
||||
() => {
|
||||
this.stateUpdateTimerId = null;
|
||||
this.flushStateUpdates();
|
||||
},
|
||||
STATE_UPDATE_DEBOUNCE_INTERVAL,
|
||||
{ description: 'state update debounce flush' }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6226,21 +6217,30 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
|
||||
|
||||
// Start scheduled runs cleanup timer
|
||||
this.scheduledCleanupTimer = setInterval(() => {
|
||||
this.cleanupScheduledRuns();
|
||||
}, SCHEDULED_CLEANUP_INTERVAL);
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
this.cleanupScheduledRuns();
|
||||
},
|
||||
SCHEDULED_CLEANUP_INTERVAL,
|
||||
{ description: 'scheduled runs cleanup' }
|
||||
);
|
||||
|
||||
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
||||
this.sseHealthCheckTimer = setInterval(() => {
|
||||
this.cleanupDeadSSEClients();
|
||||
}, SSE_HEALTH_CHECK_INTERVAL);
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
this.cleanupDeadSSEClients();
|
||||
},
|
||||
SSE_HEALTH_CHECK_INTERVAL,
|
||||
{ description: 'SSE client health check' }
|
||||
);
|
||||
|
||||
// Start token recording timer (every 5 minutes for long-running sessions)
|
||||
this.tokenRecordingTimer = setInterval(
|
||||
this.cleanup.setInterval(
|
||||
() => {
|
||||
this.recordPeriodicTokenUsage();
|
||||
},
|
||||
5 * 60 * 1000
|
||||
5 * 60 * 1000,
|
||||
{ description: 'periodic token recording' }
|
||||
);
|
||||
|
||||
// Start subagent watcher for Claude Code background agent visibility (if enabled)
|
||||
@@ -6531,11 +6531,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Set stopping flag to prevent new timer creation during shutdown
|
||||
this._isStopping = true;
|
||||
|
||||
// Clear SSE health check timer
|
||||
if (this.sseHealthCheckTimer) {
|
||||
clearInterval(this.sseHealthCheckTimer);
|
||||
this.sseHealthCheckTimer = null;
|
||||
}
|
||||
// Dispose all managed timers (intervals + resettable timeouts)
|
||||
this.cleanup.dispose();
|
||||
|
||||
// Gracefully close all SSE connections before clearing
|
||||
for (const client of this.sseClients) {
|
||||
@@ -6558,43 +6555,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
this.terminalBatches.clear();
|
||||
this.terminalBatchSizes.clear();
|
||||
|
||||
if (this.taskUpdateBatchTimer) {
|
||||
clearTimeout(this.taskUpdateBatchTimer);
|
||||
this.taskUpdateBatchTimer = null;
|
||||
}
|
||||
this.taskUpdateBatches.clear();
|
||||
|
||||
if (this.stateUpdateTimer) {
|
||||
clearTimeout(this.stateUpdateTimer);
|
||||
this.stateUpdateTimer = null;
|
||||
}
|
||||
this.stateUpdatePending.clear();
|
||||
|
||||
// Clear token recording timer
|
||||
if (this.tokenRecordingTimer) {
|
||||
clearInterval(this.tokenRecordingTimer);
|
||||
this.tokenRecordingTimer = null;
|
||||
}
|
||||
this.lastRecordedTokens.clear();
|
||||
|
||||
// Clear scheduled cleanup timer
|
||||
if (this.scheduledCleanupTimer) {
|
||||
clearInterval(this.scheduledCleanupTimer);
|
||||
this.scheduledCleanupTimer = null;
|
||||
}
|
||||
|
||||
// Stop multiplexer and flush pending saves
|
||||
this.mux.destroy();
|
||||
|
||||
// Flush any pending persist-debounce timers and persist dirty sessions
|
||||
for (const [sessionId, timer] of this.persistDebounceTimers) {
|
||||
clearTimeout(timer);
|
||||
this.persistDeb.flushAll((sessionId) => {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
this._persistSessionStateNow(session);
|
||||
}
|
||||
}
|
||||
this.persistDebounceTimers.clear();
|
||||
});
|
||||
|
||||
// Clear cached state
|
||||
this.cachedLightState = null;
|
||||
|
||||
Reference in New Issue
Block a user