mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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:
@@ -0,0 +1,951 @@
|
|||||||
|
# Code Structure & Quality Findings
|
||||||
|
|
||||||
|
**Date**: 2026-02-28
|
||||||
|
**Scope**: Full codebase analysis across 5 dimensions: frontend, backend, TypeScript, testing, and utilities/config.
|
||||||
|
|
||||||
|
This document contains detailed findings for agent teams to write implementation plans and execute improvements. Each section includes severity, specific locations, and recommended fixes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Critical: server.ts God Object (6,736 LOC)](#1-critical-serverts-god-object)
|
||||||
|
2. [Critical: app.js Monolith (15,196 LOC)](#2-critical-appjs-monolith)
|
||||||
|
3. [Critical: CleanupManager Unused Despite Existing](#3-critical-cleanupmanager-unused)
|
||||||
|
4. [High: Duplicated Debounce/Timer Patterns](#4-high-duplicated-debouncetimer-patterns)
|
||||||
|
5. [High: Large Domain Files Need Splitting](#5-high-large-domain-files-need-splitting)
|
||||||
|
6. [High: types.ts God File (1,443 LOC)](#6-high-typests-god-file)
|
||||||
|
7. [High: Zod Schemas Duplicate TypeScript Types](#7-high-zod-schemas-duplicate-typescript-types)
|
||||||
|
8. [High: Test Coverage Gaps](#8-high-test-coverage-gaps)
|
||||||
|
9. [High: Duplicated Test Mocks](#9-high-duplicated-test-mocks)
|
||||||
|
10. [Medium: Hardcoded Magic Values](#10-medium-hardcoded-magic-values)
|
||||||
|
11. [Medium: Frontend Global State Monolith](#11-medium-frontend-global-state-monolith)
|
||||||
|
12. [Medium: Frontend Code Duplication](#12-medium-frontend-code-duplication)
|
||||||
|
13. [Medium: Inconsistent Logging](#13-medium-inconsistent-logging)
|
||||||
|
14. [Medium: Utils Barrel Export Gaps](#14-medium-utils-barrel-export-gaps)
|
||||||
|
15. [Medium: Non-Null Assertion Risks](#15-medium-non-null-assertion-risks)
|
||||||
|
16. [Low: Dead Utility Functions](#16-low-dead-utility-functions)
|
||||||
|
17. [Low: No Dependency Injection for File I/O](#17-low-no-dependency-injection-for-file-io)
|
||||||
|
18. [Scorecard & Prioritized Roadmap](#18-scorecard--prioritized-roadmap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Critical: server.ts God Object
|
||||||
|
|
||||||
|
**File**: `src/web/server.ts` (6,736 lines)
|
||||||
|
**Severity**: CRITICAL
|
||||||
|
**Impact**: Hardest file to maintain, test, and extend. Imports 38 modules.
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The `WebServer` class handles everything: HTTP routing (~110 routes), authentication, SSE broadcasting, terminal data batching, state persistence, session lifecycle, respawn orchestration, file serving, tunnel management, plan orchestration, and subagent coordination.
|
||||||
|
|
||||||
|
**Key metrics**:
|
||||||
|
- 40+ private properties (Maps, timers, caches)
|
||||||
|
- 70+ methods
|
||||||
|
- `setupRoutes()` is 2,000+ LOC of inline route handlers
|
||||||
|
- Zero test coverage
|
||||||
|
|
||||||
|
### Current Structure (Bad)
|
||||||
|
|
||||||
|
```
|
||||||
|
WebServer class (6,736 LOC)
|
||||||
|
├── Auth session management (lines 469, 668-698)
|
||||||
|
├── SSE client management (lines 407-408, 5843-5880)
|
||||||
|
├── Terminal data batching (lines 414-416, 5909-5966)
|
||||||
|
├── Task update batching (line 426, 5995-6028)
|
||||||
|
├── State persistence batching (lines 429-430, 6028-6061)
|
||||||
|
├── Respawn lifecycle (lines 445-451, 5425-5534)
|
||||||
|
├── Session cleanup (lines 4769-4961)
|
||||||
|
├── Listener setup (lines 544-643)
|
||||||
|
└── setupRoutes() (lines 645+, 2000+ LOC)
|
||||||
|
├── /api/sessions/* (30+ routes inline)
|
||||||
|
├── /api/respawn/* (7 routes inline)
|
||||||
|
├── /api/subagents/* (7 routes inline)
|
||||||
|
├── /api/plan/* (5 routes inline)
|
||||||
|
├── /api/push/* (4 routes inline)
|
||||||
|
└── ... 60+ more inline
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/web/
|
||||||
|
├── server.ts (~500 LOC - HTTP setup, route registration only)
|
||||||
|
├── routes/
|
||||||
|
│ ├── session-routes.ts (session CRUD, input, resize)
|
||||||
|
│ ├── respawn-routes.ts (respawn control endpoints)
|
||||||
|
│ ├── subagent-routes.ts (background agent tracking)
|
||||||
|
│ ├── plan-routes.ts (plan generation & management)
|
||||||
|
│ ├── push-routes.ts (web push subscriptions)
|
||||||
|
│ ├── mux-routes.ts (tmux management)
|
||||||
|
│ ├── case-routes.ts (case management)
|
||||||
|
│ ├── file-routes.ts (file browsing/serving)
|
||||||
|
│ └── system-routes.ts (status, stats, config, settings)
|
||||||
|
├── middleware/
|
||||||
|
│ ├── auth.ts (Basic Auth + session cookies)
|
||||||
|
│ └── error-handler.ts (centralized error responses)
|
||||||
|
└── services/
|
||||||
|
├── sse-manager.ts (SSE client + broadcast)
|
||||||
|
├── terminal-batcher.ts (60fps terminal batching)
|
||||||
|
└── session-lifecycle.ts (listener setup/teardown)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Duplication in server.ts
|
||||||
|
|
||||||
|
**Error response pattern** repeated 189 times:
|
||||||
|
```typescript
|
||||||
|
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix**: Extract `findSessionOrFail()` middleware:
|
||||||
|
```typescript
|
||||||
|
const findSessionOrFail = (sessionId: string) => {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) throw new NotFoundError('Session not found');
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Event listener setup** copy-pasted for subagent watcher, image watcher, and team watcher (lines 544-643). Same attach/detach pattern duplicated 3 times.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Critical: app.js Monolith
|
||||||
|
|
||||||
|
**File**: `src/web/public/app.js` (15,196 lines)
|
||||||
|
**Severity**: CRITICAL
|
||||||
|
**Impact**: Untestable, hard to navigate, tightly coupled systems.
|
||||||
|
|
||||||
|
### Extractable Modules (by priority)
|
||||||
|
|
||||||
|
| Module | Lines | Current Location | Impact |
|
||||||
|
|--------|-------|------------------|--------|
|
||||||
|
| Mobile handlers (MobileDetection, KeyboardHandler, SwipeHandler) | ~300 | lines 168-620 | High |
|
||||||
|
| Voice input (DeepgramProvider, VoiceInput) | ~830 | lines 631-1471 | High |
|
||||||
|
| NotificationManager | ~450 | lines 2218-2663 | High |
|
||||||
|
| xterm-zerolag-input (inlined copy from packages/) | ~400 | lines 1756-2153 | High |
|
||||||
|
| KeyboardAccessoryBar | ~195 | lines 1480-1680 | Medium |
|
||||||
|
| FocusTrap | ~60 | lines 1690-1748 | Medium |
|
||||||
|
|
||||||
|
### CodemanApp Class (12,000+ LOC)
|
||||||
|
|
||||||
|
The main `CodemanApp` class starting at line 2665 has:
|
||||||
|
- **60+ Maps/Sets** in the constructor (lines 2667-2805)
|
||||||
|
- **18 Map instances** with complex cross-references (subagents, parents, teams, windows)
|
||||||
|
- **10+ monolithic methods** exceeding 100 lines each
|
||||||
|
|
||||||
|
**Largest methods**:
|
||||||
|
| Method | Lines | Size |
|
||||||
|
|--------|-------|------|
|
||||||
|
| `renderAppSettings()` | 14400-14700 | ~300 LOC |
|
||||||
|
| `selectSession()` | 6028-6250 | ~220 LOC |
|
||||||
|
| `batchTerminalWrite()` | 7482-7700 | ~200 LOC |
|
||||||
|
| `renderSessionTabs()` | 5814-6000 | ~180 LOC |
|
||||||
|
| `openSubagentWindow()` | 11927-12100 | ~170 LOC |
|
||||||
|
| `handleInit()` | 5183-5350 | ~170 LOC |
|
||||||
|
|
||||||
|
### Recommended Split
|
||||||
|
|
||||||
|
```
|
||||||
|
src/web/public/
|
||||||
|
├── app.js (~4000 LOC - core app, session mgmt, SSE)
|
||||||
|
├── mobile.js (~300 LOC - MobileDetection, KeyboardHandler, SwipeHandler)
|
||||||
|
├── voice.js (~830 LOC - DeepgramProvider, VoiceInput)
|
||||||
|
├── notifications.js (~450 LOC - NotificationManager)
|
||||||
|
├── keyboard-accessory.js (~200 LOC - KeyboardAccessoryBar)
|
||||||
|
├── api-client.js (~100 LOC - fetch wrapper with error handling)
|
||||||
|
└── config.js (~50 LOC - magic numbers, z-index layers)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Critical: CleanupManager Unused
|
||||||
|
|
||||||
|
**File**: `src/utils/cleanup-manager.ts` (320 lines)
|
||||||
|
**Severity**: CRITICAL
|
||||||
|
**Impact**: Memory leak risk. Well-designed utility exists but is never used. Every file manages cleanup manually.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
`CleanupManager` is exported from the utils barrel but has **0 instantiations** in production code. Instead, every file implements manual cleanup:
|
||||||
|
|
||||||
|
**respawn-controller.ts** (worst offender):
|
||||||
|
```typescript
|
||||||
|
// 11 timer properties, manually cleared in stop()
|
||||||
|
private stepTimer: NodeJS.Timeout | null = null;
|
||||||
|
private completionConfirmTimer: NodeJS.Timeout | null = null;
|
||||||
|
private noOutputTimer: NodeJS.Timeout | null = null;
|
||||||
|
// ... 8 more
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.stepTimer) clearTimeout(this.stepTimer);
|
||||||
|
if (this.completionConfirmTimer) clearTimeout(this.completionConfirmTimer);
|
||||||
|
// ... 9 more clearTimeout/clearInterval calls
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files that should use CleanupManager**:
|
||||||
|
| File | Timer/Listener Count | Current Cleanup |
|
||||||
|
|------|---------------------|-----------------|
|
||||||
|
| `respawn-controller.ts` | 11 timers + intervals | 11 manual clearTimeout/clearInterval |
|
||||||
|
| `web/server.ts` | 6+ timers, debounce map | Manual in stop(), some may leak |
|
||||||
|
| `state-store.ts` | 2 debounce timers | Manual clearTimeout |
|
||||||
|
| `push-store.ts` | 1 save timer | Manual clearTimeout |
|
||||||
|
| `subagent-watcher.ts` | debounce map + watchers | Manual clear + close |
|
||||||
|
| `ralph-tracker.ts` | 3 debounce timers | Manual clear |
|
||||||
|
| `bash-tool-parser.ts` | 1 debounce timer | Manual clear |
|
||||||
|
| `image-watcher.ts` | 1 debounce map | Manual clear |
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Migrate all timer management to use `CleanupManager`. Example for respawn-controller.ts:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Before: 11 fields + 11 clearTimeout calls
|
||||||
|
private stepTimer: NodeJS.Timeout | null = null;
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// After: 1 field, auto-cleanup
|
||||||
|
private cleanup = new CleanupManager();
|
||||||
|
|
||||||
|
startStep() {
|
||||||
|
this.cleanup.setTimeout(() => { ... }, 5000, 'step');
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.cleanup.dispose(); // Clears everything
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. High: Duplicated Debounce/Timer Patterns
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Impact**: 8+ files implement debounce independently. Bug fixes need to be applied everywhere.
|
||||||
|
|
||||||
|
### Pattern Inventory
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pattern 1: Manual timer ref (used in 6 files)
|
||||||
|
private saveTimer: NodeJS.Timeout | null = null;
|
||||||
|
debouncedSave() {
|
||||||
|
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||||||
|
this.saveTimer = setTimeout(() => this.save(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 2: Timer Map (used in 3 files)
|
||||||
|
private fileDebouncers = new Map<string, NodeJS.Timeout>();
|
||||||
|
debounce(key: string) {
|
||||||
|
const existing = this.fileDebouncers.get(key);
|
||||||
|
if (existing) clearTimeout(existing);
|
||||||
|
this.fileDebouncers.set(key, setTimeout(() => { ... }, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern 3: State flag (used in 2 files)
|
||||||
|
private isSaving = false;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Locations
|
||||||
|
|
||||||
|
| File | Debounce Vars | Delay (ms) |
|
||||||
|
|------|---------------|------------|
|
||||||
|
| `state-store.ts` | `saveTimeout`, `ralphStateSaveTimeout` | 500 |
|
||||||
|
| `push-store.ts` | `saveTimer` | 500 |
|
||||||
|
| `web/server.ts` | `persistDebounceTimers` (Map) | 500 |
|
||||||
|
| `subagent-watcher.ts` | `fileDebouncers` (Map) | 100 |
|
||||||
|
| `ralph-tracker.ts` | 3 debounce timers | 50, 30000 |
|
||||||
|
| `bash-tool-parser.ts` | `EVENT_DEBOUNCE_MS` | 50 |
|
||||||
|
| `image-watcher.ts` | debounce map | 200 |
|
||||||
|
| `respawn-controller.ts` | 11 timer fields | various |
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Create a `Debouncer` utility:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/utils/debouncer.ts
|
||||||
|
export class Debouncer {
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(private readonly delayMs: number) {}
|
||||||
|
|
||||||
|
run(fn: () => void): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = setTimeout(fn, this.delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
if (this.timer) clearTimeout(this.timer);
|
||||||
|
this.timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage:
|
||||||
|
private saveDeb = new Debouncer(500);
|
||||||
|
this.saveDeb.run(() => this.save());
|
||||||
|
// cleanup: this.saveDeb.cancel();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. High: Large Domain Files Need Splitting
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Impact**: Complex state machines spanning 3,000+ lines are hard to understand and test.
|
||||||
|
|
||||||
|
### ralph-tracker.ts (3,905 LOC)
|
||||||
|
|
||||||
|
**5 responsibilities mixed**:
|
||||||
|
1. Output Parsing (~900 LOC) - Line-by-line parsing, state extraction
|
||||||
|
2. Todo Management (~700 LOC) - Parsing, dedup, expiry
|
||||||
|
3. Plan Tracking (~800 LOC) - Enhanced plan tasks, checkpoints
|
||||||
|
4. Circuit Breaker (~400 LOC) - State machine for stuck detection
|
||||||
|
5. File Watching (~300 LOC) - Monitor external state files
|
||||||
|
|
||||||
|
**Recommended split**:
|
||||||
|
```
|
||||||
|
ralph-tracker.ts (core output parsing, ~1200 LOC)
|
||||||
|
ralph-todo-manager.ts (todo parsing + management, ~700 LOC)
|
||||||
|
ralph-plan-tracker.ts (plan tasks + checkpoints, ~800 LOC)
|
||||||
|
ralph-circuit-breaker.ts (circuit breaker logic, ~400 LOC)
|
||||||
|
```
|
||||||
|
|
||||||
|
### respawn-controller.ts (3,611 LOC)
|
||||||
|
|
||||||
|
**6 responsibilities mixed**:
|
||||||
|
1. State Machine (~1,000 LOC) - 6+ states, transitions
|
||||||
|
2. Idle Detection (~800 LOC) - 5 layers + multi-signal combining
|
||||||
|
3. AI Checkers (~600 LOC) - Idle + plan checkers integration
|
||||||
|
4. Health Scoring (~500 LOC) - Metrics, circuit breaker, scoring
|
||||||
|
5. Action Logging (~300 LOC) - Timeline, detection status
|
||||||
|
6. Stuck-State Detection (~250 LOC) - Timeout tracking
|
||||||
|
|
||||||
|
**Recommended split**:
|
||||||
|
```
|
||||||
|
respawn-controller.ts (state machine core, ~1000 LOC)
|
||||||
|
respawn-idle-detection.ts (all 5 idle detection layers, ~800 LOC)
|
||||||
|
respawn-health-scorer.ts (metrics & health scoring, ~500 LOC)
|
||||||
|
```
|
||||||
|
|
||||||
|
### session.ts (2,418 LOC)
|
||||||
|
|
||||||
|
**8 responsibilities mixed**:
|
||||||
|
1. PTY Management (~600 LOC)
|
||||||
|
2. Terminal I/O (~400 LOC)
|
||||||
|
3. Token Tracking (~200 LOC)
|
||||||
|
4. Task Tracking (~250 LOC)
|
||||||
|
5. Ralph Integration (~200 LOC)
|
||||||
|
6. Auto-Clear/Compact (~300 LOC)
|
||||||
|
7. Image Watching (~100 LOC)
|
||||||
|
8. CLI Detection (~150 LOC)
|
||||||
|
|
||||||
|
**Recommended split**:
|
||||||
|
```
|
||||||
|
session.ts (PTY + terminal I/O core, ~1000 LOC)
|
||||||
|
session-tracking.ts (token + task + Ralph, ~500 LOC)
|
||||||
|
session-auto-ops.ts (auto-clear/compact + image, ~300 LOC)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. High: types.ts God File
|
||||||
|
|
||||||
|
**File**: `src/types.ts` (1,443 lines, 72 exported definitions)
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Impact**: Every file imports from types.ts. Hard to find relevant types.
|
||||||
|
|
||||||
|
### Current Contents
|
||||||
|
|
||||||
|
- 46 interfaces
|
||||||
|
- 25 types
|
||||||
|
- 1 enum (ApiErrorCode)
|
||||||
|
- 9 factory functions (createInitialState, etc.)
|
||||||
|
|
||||||
|
### Recommended Split
|
||||||
|
|
||||||
|
```
|
||||||
|
src/types/
|
||||||
|
├── index.ts (barrel export - transparent migration)
|
||||||
|
├── session.ts (SessionState, SessionConfig, SessionMode, SessionColor)
|
||||||
|
├── task.ts (TaskState, TaskDefinition, TaskStatus)
|
||||||
|
├── respawn.ts (RespawnConfig, RespawnState, CircuitBreakerStatus)
|
||||||
|
├── ralph.ts (RalphLoopState, RalphTrackerState, RalphTodoItem)
|
||||||
|
├── api.ts (ApiResponse, ApiErrorCode, HookEventType, all route types)
|
||||||
|
├── lifecycle.ts (LifecycleEventType, LifecycleEntry)
|
||||||
|
└── common.ts (Disposable, BufferConfig, CleanupResourceType)
|
||||||
|
```
|
||||||
|
|
||||||
|
The barrel export makes this a transparent refactor - existing `import from './types'` continues to work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. High: Zod Schemas Duplicate TypeScript Types
|
||||||
|
|
||||||
|
**File**: `src/web/schemas.ts` (508 lines)
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Impact**: When a type changes, the Zod schema must be manually updated too. Source of bugs.
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Zod schemas manually duplicate TypeScript interfaces. **Zero `z.infer` usage found.**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// types.ts (manual interface)
|
||||||
|
export interface CreateSessionRequest {
|
||||||
|
workingDir?: string;
|
||||||
|
mode?: SessionMode;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemas.ts (manual Zod schema - duplicated!)
|
||||||
|
export const CreateSessionSchema = z.object({
|
||||||
|
workingDir: safePathSchema.optional(),
|
||||||
|
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||||
|
name: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Use `z.infer` to derive TypeScript types from Zod schemas (single source of truth):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// schemas.ts
|
||||||
|
export const CreateSessionSchema = z.object({
|
||||||
|
workingDir: safePathSchema.optional(),
|
||||||
|
mode: z.enum(['claude', 'shell', 'opencode']).optional(),
|
||||||
|
name: z.string().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// types.ts (auto-derived)
|
||||||
|
export type CreateSessionRequest = z.infer<typeof CreateSessionSchema>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Affected schemas** (~10):
|
||||||
|
- CreateSessionSchema
|
||||||
|
- RunPromptSchema
|
||||||
|
- ResizeSchema
|
||||||
|
- CreateCaseSchema
|
||||||
|
- QuickStartSchema
|
||||||
|
- HookEventSchema
|
||||||
|
- RespawnConfigSchema
|
||||||
|
- ConfigUpdateSchema
|
||||||
|
- SettingsUpdateSchema
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. High: Test Coverage Gaps
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Impact**: Critical code paths untested. Regressions go unnoticed.
|
||||||
|
|
||||||
|
### Untested Source Files
|
||||||
|
|
||||||
|
| File | Lines | Risk |
|
||||||
|
|------|-------|------|
|
||||||
|
| `src/web/server.ts` | 6,736 | CRITICAL - Core REST API, 280+ routes |
|
||||||
|
| `src/plan-orchestrator.ts` | ~500 | HIGH - Multi-agent plan generation |
|
||||||
|
| `src/tunnel-manager.ts` | ~200 | MEDIUM - Cloudflare tunnel |
|
||||||
|
| `src/session-lifecycle-log.ts` | ~150 | MEDIUM - JSONL audit log |
|
||||||
|
| `src/ai-plan-checker.ts` | ~300 | MEDIUM - Plan completion detection |
|
||||||
|
| `src/templates/claude-md.ts` | ~200 | LOW - CLAUDE.md generation |
|
||||||
|
| `src/utils/claude-cli-resolver.ts` | ~100 | LOW - CLI path resolution |
|
||||||
|
| `src/utils/opencode-cli-resolver.ts` | ~100 | LOW - OpenCode CLI support |
|
||||||
|
| `src/utils/regex-patterns.ts` | ~100 | LOW - Used everywhere! |
|
||||||
|
| `src/utils/token-validation.ts` | ~50 | LOW - Token counting |
|
||||||
|
|
||||||
|
### Test Quality Issues
|
||||||
|
|
||||||
|
**10 "not.toThrow()" tests without behavior verification**:
|
||||||
|
```typescript
|
||||||
|
// BAD: Only checks it doesn't crash
|
||||||
|
expect(() => tracker.processMessage(null)).not.toThrow();
|
||||||
|
|
||||||
|
// GOOD: Also verify defensive behavior
|
||||||
|
expect(() => tracker.processMessage(null)).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
```
|
||||||
|
|
||||||
|
Locations:
|
||||||
|
- `task-tracker.test.ts` - 5 instances
|
||||||
|
- `image-watcher.test.ts` - 1 instance
|
||||||
|
- `task-queue.test.ts` - 1 instance
|
||||||
|
- Others scattered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. High: Duplicated Test Mocks
|
||||||
|
|
||||||
|
**Severity**: HIGH
|
||||||
|
**Impact**: Mock changes need updating in 4 places. Inconsistent mock behavior.
|
||||||
|
|
||||||
|
### MockSession Defined 4 Times
|
||||||
|
|
||||||
|
| File | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| `test/respawn-controller.test.ts` | Full mock with event emitter |
|
||||||
|
| `test/session-manager.test.ts` | Simpler mock |
|
||||||
|
| `test/respawn-team-awareness.test.ts` | Copy of respawn-controller mock |
|
||||||
|
| `test/respawn-test-utils.ts` | **Comprehensive mock - UNUSED!** |
|
||||||
|
|
||||||
|
### MockStateStore Defined 2 Times
|
||||||
|
|
||||||
|
| File | Usage |
|
||||||
|
|------|-------|
|
||||||
|
| `test/session-manager.test.ts` | Basic mock |
|
||||||
|
| `test/ralph-loop.test.ts` | Separate implementation |
|
||||||
|
|
||||||
|
### Unused Test Utilities
|
||||||
|
|
||||||
|
`test/respawn-test-utils.ts` exports these utilities that **no test file imports**:
|
||||||
|
- `createTimeController()` - Abstraction over vitest fake timers
|
||||||
|
- `MockAiIdleChecker` - Fully mocked AI idle checker
|
||||||
|
- `MockAiPlanChecker` - Fully mocked plan checker
|
||||||
|
- Factory functions for pre-configured controllers
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Create `test/mocks/` directory:
|
||||||
|
```
|
||||||
|
test/
|
||||||
|
├── mocks/
|
||||||
|
│ ├── mock-session.ts (single MockSession, used everywhere)
|
||||||
|
│ ├── mock-state-store.ts (single MockStateStore)
|
||||||
|
│ └── index.ts (barrel export)
|
||||||
|
├── utils/
|
||||||
|
│ └── time-controller.ts (from respawn-test-utils.ts)
|
||||||
|
└── ... test files
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Medium: Hardcoded Magic Values
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Impact**: Hard to tune, inconsistent when same value appears in multiple places.
|
||||||
|
|
||||||
|
### Already Centralized (Good)
|
||||||
|
|
||||||
|
- `src/config/buffer-limits.ts` - All buffer sizes
|
||||||
|
- `src/config/map-limits.ts` - All collection limits
|
||||||
|
|
||||||
|
### NOT Centralized (40+ values scattered)
|
||||||
|
|
||||||
|
**In server.ts** (lines 145-194):
|
||||||
|
```typescript
|
||||||
|
const TASK_UPDATE_BATCH_INTERVAL = 100;
|
||||||
|
const STATE_UPDATE_DEBOUNCE_INTERVAL = 500;
|
||||||
|
const SESSIONS_LIST_CACHE_TTL = 1000;
|
||||||
|
const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000;
|
||||||
|
const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000;
|
||||||
|
const MAX_TERMINAL_COLS = 500;
|
||||||
|
const MAX_TERMINAL_ROWS = 200;
|
||||||
|
const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const MAX_AUTH_SESSIONS = 100;
|
||||||
|
const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const STATS_COLLECTION_INTERVAL_MS = 2000;
|
||||||
|
const MAX_INPUT_LENGTH = 64 * 1024;
|
||||||
|
```
|
||||||
|
|
||||||
|
**In hooks-config.ts**: `timeout: 10000` hardcoded 6 times.
|
||||||
|
|
||||||
|
**In respawn-controller.ts** (lines 538-565): 10 timing constants.
|
||||||
|
|
||||||
|
**In utils**: `EXEC_TIMEOUT_MS = 5000` duplicated in both `claude-cli-resolver.ts` and `opencode-cli-resolver.ts`.
|
||||||
|
|
||||||
|
**In app.js**:
|
||||||
|
```javascript
|
||||||
|
// line 27: 600000 - stuck detection threshold
|
||||||
|
// line 24: 5000 - default scrollback
|
||||||
|
// lines 34-35: 128*1024, 256*1024 - chunk sizes
|
||||||
|
// lines 152-155: 150, 100 - keyboard detection thresholds
|
||||||
|
// lines 573-575: 80, 300, 100 - swipe detection params
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Create additional config files:
|
||||||
|
```
|
||||||
|
src/config/
|
||||||
|
├── buffer-limits.ts (existing)
|
||||||
|
├── map-limits.ts (existing)
|
||||||
|
├── server-config.ts (NEW - web server intervals, auth, caching)
|
||||||
|
├── timing-config.ts (NEW - debounce delays, check intervals)
|
||||||
|
└── terminal-config.ts (NEW - max cols/rows, batch intervals)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Medium: Frontend Global State Monolith
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Impact**: All state in single CodemanApp class. Tight coupling between unrelated systems.
|
||||||
|
|
||||||
|
### 60+ State Variables in CodemanApp Constructor (lines 2667-2805)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
this.sessions = new Map(); // Session data
|
||||||
|
this.subagents = new Map(); // Agent tracking
|
||||||
|
this.subagentActivity = new Map(); // Tool call tracking
|
||||||
|
this.subagentToolResults = new Map(); // Result caching
|
||||||
|
this.subagentParentMap = new Map(); // Agent-to-session mapping
|
||||||
|
this.teams = new Map(); // Team tracking
|
||||||
|
this.teamTasks = new Map(); // Team task state
|
||||||
|
this.planSubagents = new Map(); // Plan agent tracking
|
||||||
|
this.pendingWrites = []; // Terminal write queue
|
||||||
|
this.terminalBufferCache = new Map(); // Buffer caching (unbounded!)
|
||||||
|
this.projectInsights = new Map(); // Bash tool insights
|
||||||
|
// ... 40+ more
|
||||||
|
```
|
||||||
|
|
||||||
|
### Problems
|
||||||
|
|
||||||
|
1. **18 Map instances** with complex cross-references (no garbage collection strategy)
|
||||||
|
2. **No domain separation**: Session, subagent, notification, UI, and network state mixed
|
||||||
|
3. **Implicit dependencies**: `selectSession()` requires 5+ Maps to be in consistent state
|
||||||
|
4. **`terminalBufferCache`** has no max size - can grow unbounded with many sessions
|
||||||
|
|
||||||
|
### Recommended Domain Split
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Instead of 60+ flat properties:
|
||||||
|
class SessionState {
|
||||||
|
sessions = new Map();
|
||||||
|
sessionOrder = [];
|
||||||
|
terminalBuffers = new Map();
|
||||||
|
tabAlerts = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubagentState {
|
||||||
|
subagents = new Map();
|
||||||
|
activity = new Map();
|
||||||
|
parentMap = new Map();
|
||||||
|
windows = new Map();
|
||||||
|
minimized = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
class TeamState {
|
||||||
|
teams = new Map();
|
||||||
|
tasks = new Map();
|
||||||
|
teammates = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
class UIState {
|
||||||
|
activeSessionId = null;
|
||||||
|
draggedTabId = null;
|
||||||
|
isLoadingBuffer = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Medium: Frontend Code Duplication
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Impact**: Repeated patterns increase maintenance burden and inconsistency risk.
|
||||||
|
|
||||||
|
### Duplicated Patterns
|
||||||
|
|
||||||
|
**API fetch calls** (~50 instances):
|
||||||
|
```javascript
|
||||||
|
// Repeated everywhere:
|
||||||
|
fetch(`/api/sessions/${sessionId}/...`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({...})
|
||||||
|
}).catch(() => {})
|
||||||
|
```
|
||||||
|
**Fix**: Extract `ApiClient` class.
|
||||||
|
|
||||||
|
**`innerHTML` usage** (104 instances):
|
||||||
|
- Mix of template strings, createElement chains, and direct innerHTML
|
||||||
|
- Some with manual XSS escaping (`text.replace(/</g, '<')`), some without
|
||||||
|
- No consistent DOM creation pattern
|
||||||
|
|
||||||
|
**`typeof app !== 'undefined'` checks** (20+ instances):
|
||||||
|
- Lines 458, 467, 481, 614, 617, 1549, etc.
|
||||||
|
- **Fix**: Ensure `app` is always defined as global singleton.
|
||||||
|
|
||||||
|
**Element visibility toggling** (212+ occurrences):
|
||||||
|
```javascript
|
||||||
|
element.classList.add('active')
|
||||||
|
element.classList.remove('active')
|
||||||
|
```
|
||||||
|
**Fix**: Create `toggleClass(el, className, condition)` utility.
|
||||||
|
|
||||||
|
### Event Listener Issues
|
||||||
|
|
||||||
|
- **152 `addEventListener` calls** with fragile cleanup
|
||||||
|
- **Mix of inline (`onclick="app.method()"`) and addEventListener** - hard to track
|
||||||
|
- **Element cache (`_elemCache`) never invalidated** if DOM elements are recreated (line 2808)
|
||||||
|
- **Tab drag-and-drop listeners** may not clean up if user switches tabs mid-drag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Medium: Inconsistent Logging
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Impact**: Hard to debug in production. Can't filter by severity or component.
|
||||||
|
|
||||||
|
### Current State
|
||||||
|
|
||||||
|
- **345 console calls** across source files
|
||||||
|
- **No structured logging** - all `console.log/error` directly
|
||||||
|
- **No log levels** (DEBUG, INFO, WARN, ERROR)
|
||||||
|
|
||||||
|
### Inconsistent Prefixes
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Some files use brackets:
|
||||||
|
console.log('[Session] Starting interactive...');
|
||||||
|
console.log('[RalphLoop] Task assigned...');
|
||||||
|
console.log('[TunnelManager] Tunnel started');
|
||||||
|
|
||||||
|
// Others use no prefix:
|
||||||
|
console.error('Failed to spawn PTY:', err);
|
||||||
|
console.log('Server listening on port', port);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Positive: CleanupManager Has Debug Mode
|
||||||
|
|
||||||
|
`src/utils/cleanup-manager.ts` has a `debugMode` flag for conditional debug logging - good pattern not replicated elsewhere.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Either:
|
||||||
|
1. Enforce consistent `[ComponentName]` prefixes via lint rule
|
||||||
|
2. Create lightweight logger abstraction (not a heavy framework)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Medium: Utils Barrel Export Gaps
|
||||||
|
|
||||||
|
**File**: `src/utils/index.ts`
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Impact**: Forces deep imports, unclear public API.
|
||||||
|
|
||||||
|
### Missing Exports
|
||||||
|
|
||||||
|
These functions are defined but NOT exported from the barrel:
|
||||||
|
- `createAnsiPatternFull()` and `createAnsiPatternSimple()` (factory functions from `regex-patterns.ts`)
|
||||||
|
- `SAFE_PATH_PATTERN` (from `regex-patterns.ts`)
|
||||||
|
- `validateTokenCounts()` and `validateTokensAndCost()` (from `token-validation.ts`)
|
||||||
|
- `isSimilar()`, `isSimilarByDistance()`, `levenshteinDistance()`, `normalizePhrase()` (from `string-similarity.ts` - though some are dead code, see finding #16)
|
||||||
|
|
||||||
|
### Deep Import Anti-Pattern (16 instances)
|
||||||
|
|
||||||
|
Some files bypass the barrel unnecessarily:
|
||||||
|
```typescript
|
||||||
|
// Could use barrel:
|
||||||
|
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||||
|
import { LRUMap } from './utils/lru-map.js';
|
||||||
|
|
||||||
|
// Must deep import (not in barrel):
|
||||||
|
import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Add missing exports to `src/utils/index.ts` and update import sites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Medium: Non-Null Assertion Risks
|
||||||
|
|
||||||
|
**Severity**: MEDIUM
|
||||||
|
**Impact**: Runtime crashes if assumptions violated. 37 instances found.
|
||||||
|
|
||||||
|
### Distribution
|
||||||
|
|
||||||
|
| File | Count | Risk Level |
|
||||||
|
|------|-------|------------|
|
||||||
|
| `src/web/server.ts` | 10 | Low (auth flow verified) |
|
||||||
|
| `src/session.ts` | 6 | **High** (mux/terminal refs) |
|
||||||
|
| `src/respawn-controller.ts` | 4 | Low (config validated) |
|
||||||
|
| `src/lru-map.ts` | 3 | Low (checked lookups) |
|
||||||
|
| `src/subagent-watcher.ts` | 2 | Low (pending tool calls) |
|
||||||
|
| Others | 12 | Low |
|
||||||
|
|
||||||
|
### High-Risk Examples (session.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Line 915 - _mux could be null if startInteractive called during cleanup
|
||||||
|
`[Session] Starting interactive (with ${this._mux!.backend})`
|
||||||
|
|
||||||
|
// Line 954 - _muxSession could be null in race condition
|
||||||
|
this._muxSession!.muxName
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Add null guards before assertions, or document invariants:
|
||||||
|
```typescript
|
||||||
|
// Before:
|
||||||
|
this._mux!.backend
|
||||||
|
|
||||||
|
// After:
|
||||||
|
if (!this._mux) throw new Error('Invariant: _mux must be initialized before startInteractive');
|
||||||
|
this._mux.backend
|
||||||
|
```
|
||||||
|
|
||||||
|
### Positive Notes
|
||||||
|
|
||||||
|
- **0 instances of `as any`**
|
||||||
|
- **0 instances of `@ts-ignore` or `@ts-expect-error`**
|
||||||
|
- TypeScript overall score: 8.5/10
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Low: Dead Utility Functions
|
||||||
|
|
||||||
|
**File**: `src/utils/string-similarity.ts`
|
||||||
|
**Severity**: LOW
|
||||||
|
**Impact**: Code clutter, confusion about what's actually used.
|
||||||
|
|
||||||
|
### Unused Functions
|
||||||
|
|
||||||
|
These are defined and exported but **never imported anywhere**:
|
||||||
|
- `isSimilar(a, b, threshold)` - similarity check with threshold
|
||||||
|
- `isSimilarByDistance(a, b, maxDistance)` - Levenshtein-based check
|
||||||
|
- `levenshteinDistance(a, b)` - raw edit distance
|
||||||
|
- `normalizePhrase(phrase)` - phrase normalization
|
||||||
|
|
||||||
|
### Actually Used
|
||||||
|
|
||||||
|
Only these are imported from the barrel:
|
||||||
|
- `stringSimilarity()` - used in ralph-tracker.ts
|
||||||
|
- `fuzzyPhraseMatch()` - used in ralph-tracker.ts
|
||||||
|
- `todoContentHash()` - used in ralph-tracker.ts
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
Delete unused functions or mark as `@internal` if kept for future use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. Low: No Dependency Injection for File I/O
|
||||||
|
|
||||||
|
**Severity**: LOW (practical impact limited at current scale)
|
||||||
|
**Impact**: Can't mock filesystem for unit tests. 68+ hard-coded filesystem calls.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// state-store.ts - directly imports and uses fs
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
|
// push-store.ts - hard-coded paths
|
||||||
|
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||||
|
const SUBS_FILE = join(DATA_DIR, 'push-subscriptions.json');
|
||||||
|
|
||||||
|
// ai-checker-base.ts - direct execSync
|
||||||
|
execSync(`tmux kill-session -t "${this.checkMuxName}"`, { timeout: 3000 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why This Is Lower Priority
|
||||||
|
|
||||||
|
- The codebase uses integration tests (spawning real processes/tmux sessions) rather than unit tests
|
||||||
|
- Most filesystem operations are in infrastructure code, not business logic
|
||||||
|
- Adding DI would be a large refactor with limited near-term benefit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 18. Scorecard & Prioritized Roadmap
|
||||||
|
|
||||||
|
### Overall Scores
|
||||||
|
|
||||||
|
| Category | Score | Notes |
|
||||||
|
|----------|-------|-------|
|
||||||
|
| TypeScript Safety | 8.5/10 | 0 `any`, 0 `@ts-ignore`, 37 non-null assertions |
|
||||||
|
| Error Handling | 8/10 | Consistent helpers, proper type guards, no untyped catches |
|
||||||
|
| Async/Promise Safety | 9.5/10 | Zero unhandled rejections, all async properly guarded |
|
||||||
|
| Resource Cleanup | 7/10 | Good patterns but CleanupManager unused |
|
||||||
|
| Module Organization | 5/10 | 3 god files, types.ts too large |
|
||||||
|
| Test Coverage | 6/10 | Core logic tested, server.ts untested |
|
||||||
|
| Config Centralization | 6/10 | Partially done, 40+ scattered values |
|
||||||
|
| Frontend Architecture | 4/10 | 15K monolith, 60+ Maps, no modularity |
|
||||||
|
| Code Duplication | 5/10 | Debounce patterns, fetch calls, mocks |
|
||||||
|
|
||||||
|
### Implementation Phases
|
||||||
|
|
||||||
|
**Phase 1 - Quick Wins (1-2 days)** ✅ COMPLETE
|
||||||
|
1. ✅ Export missing functions from utils barrel (~30 min) — `createAnsiPatternFull`, `createAnsiPatternSimple`, `SAFE_PATH_PATTERN`, `validateTokenCounts`, `validateTokensAndCost` all now exported from `src/utils/index.ts`
|
||||||
|
2. ✅ Delete dead utility functions (~15 min) — `isSimilar()` removed from `string-similarity.ts`; `levenshteinDistance()`, `isSimilarByDistance()`, `normalizePhrase()` made private (used internally by `fuzzyPhraseMatch`/`stringSimilarity`)
|
||||||
|
3. ✅ Consolidate duplicated `EXEC_TIMEOUT_MS` constant (~15 min) — Created `src/config/exec-timeout.ts` as single source of truth; `claude-cli-resolver.ts`, `opencode-cli-resolver.ts`, and `tmux-manager.ts` all import from it
|
||||||
|
4. ✅ Add `z.infer` to Zod schemas (~2 hours) — `src/web/schemas.ts` now has 36 `z.infer` type exports (lines 512-547) covering all schemas
|
||||||
|
5. ✅ Fix 10 weak "not.toThrow()" tests (~1 hour) — All `not.toThrow()` calls now have behavior assertions: `task-tracker.test.ts` (6 instances all followed by state checks), `image-watcher.test.ts` (1 instance followed by length check), `session-manager.test.ts` (1 instance followed by count check)
|
||||||
|
|
||||||
|
**Phase 2 - CleanupManager & Debounce (2-3 days)** ✅ COMPLETE
|
||||||
|
1. ✅ Create `Debouncer` utility class (~1 hour) — Created `src/utils/debouncer.ts` with `Debouncer` and `KeyedDebouncer` classes; exported from `src/utils/index.ts`
|
||||||
|
2. ✅ Migrate 8 files from manual debounce to Debouncer (~3 hours) — `state-store.ts` (2 Debouncers), `push-store.ts` (1 Debouncer), `ralph-tracker.ts` (2 Debouncers), `bash-tool-parser.ts` (1 Debouncer), `image-watcher.ts` (1 KeyedDebouncer), `subagent-watcher.ts` (1 KeyedDebouncer), `server.ts` (1 KeyedDebouncer for persist timers)
|
||||||
|
3. ✅ Migrate respawn-controller to CleanupManager (~3 hours) — 10 timer fields replaced with `CleanupManager` instance; `clearTimers()` now uses `cleanup.dispose()` with reinitialization for reuse
|
||||||
|
4. ✅ Migrate server.ts timer cleanup to CleanupManager (~2 hours) — 5 standalone timers migrated to `CleanupManager`; terminal batch timers and pending respawn starts left as manual Maps (complex lifecycle)
|
||||||
|
5. ✅ Migrate remaining files (~2 hours) — `ralph-tracker.ts` (watcher + stall timer → CleanupManager), `bash-tool-parser.ts` (auto-remove timers → CleanupManager), `subagent-watcher.ts` (intervals + watchers → CleanupManager); ~30 manual timer fields eliminated, ~200+ lines of boilerplate removed
|
||||||
|
|
||||||
|
**Phase 3 - server.ts Route Extraction (3-4 days)**
|
||||||
|
1. Create `src/web/routes/` directory structure
|
||||||
|
2. Extract session routes (~2 hours)
|
||||||
|
3. Extract respawn routes (~1 hour)
|
||||||
|
4. Extract subagent routes (~1 hour)
|
||||||
|
5. Extract remaining route groups (~4 hours)
|
||||||
|
6. Create auth middleware (~1 hour)
|
||||||
|
7. Create error handler middleware (~1 hour)
|
||||||
|
8. Reduce server.ts to ~1,500 LOC
|
||||||
|
|
||||||
|
**Phase 4 - Domain File Splitting (2-3 days)**
|
||||||
|
1. Split types.ts into types/ directory (~2 hours)
|
||||||
|
2. Split ralph-tracker.ts into 4 files (~4 hours)
|
||||||
|
3. Split respawn-controller.ts into 3 files (~4 hours)
|
||||||
|
4. Split session.ts into 3 files (~3 hours)
|
||||||
|
|
||||||
|
**Phase 5 - Frontend Modularization (3-4 days)**
|
||||||
|
1. Extract mobile handlers to mobile.js (~2 hours)
|
||||||
|
2. Extract voice input to voice.js (~3 hours)
|
||||||
|
3. Extract NotificationManager to notifications.js (~2 hours)
|
||||||
|
4. Extract KeyboardAccessoryBar (~1 hour)
|
||||||
|
5. Create ApiClient wrapper (~2 hours)
|
||||||
|
6. Create config.js for magic numbers (~1 hour)
|
||||||
|
7. Remove inlined xterm-zerolag-input copy (~1 hour)
|
||||||
|
|
||||||
|
**Phase 6 - Config Consolidation (1 day)**
|
||||||
|
1. Create `src/config/server-config.ts` (~1 hour)
|
||||||
|
2. Create `src/config/timing-config.ts` (~1 hour)
|
||||||
|
3. Move scattered constants from 10+ files (~3 hours)
|
||||||
|
|
||||||
|
**Phase 7 - Test Infrastructure (2-3 days)**
|
||||||
|
1. Consolidate MockSession into single shared mock (~2 hours)
|
||||||
|
2. Create `test/mocks/` with barrel export (~1 hour)
|
||||||
|
3. Import unused respawn-test-utils where needed (~1 hour)
|
||||||
|
4. Add server.ts route tests (ongoing, ~8+ hours)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: File Size Inventory
|
||||||
|
|
||||||
|
| File | Lines | Category |
|
||||||
|
|------|-------|----------|
|
||||||
|
| `src/web/public/app.js` | 15,196 | Frontend |
|
||||||
|
| `src/web/server.ts` | 6,736 | Backend |
|
||||||
|
| `src/ralph-tracker.ts` | 3,905 | Domain |
|
||||||
|
| `src/respawn-controller.ts` | 3,611 | Domain |
|
||||||
|
| `src/session.ts` | 2,418 | Domain |
|
||||||
|
| `src/subagent-watcher.ts` | 1,689 | Domain |
|
||||||
|
| `src/types.ts` | 1,443 | Types |
|
||||||
|
| `src/state-store.ts` | 910 | Infrastructure |
|
||||||
|
| `src/ralph-loop.ts` | ~600 | Domain |
|
||||||
|
| `src/web/schemas.ts` | 508 | Validation |
|
||||||
|
| All other src/ files | <500 each | Various |
|
||||||
|
|
||||||
|
### What's Working Well
|
||||||
|
|
||||||
|
These patterns should be **preserved, not refactored**:
|
||||||
|
- Clean one-way dependency graph (no circular deps)
|
||||||
|
- EventEmitter-based decoupling between domain models
|
||||||
|
- Proper `import type` usage (19 files, consistent)
|
||||||
|
- Utility type adoption (101 instances of Record, Partial, Omit, etc.)
|
||||||
|
- `assertNever()` for exhaustive switch checking
|
||||||
|
- `StaleExpirationMap` and `LRUMap` for bounded collections
|
||||||
|
- State persistence circuit breaker pattern
|
||||||
|
- TypeScript strict mode with all safety flags enabled
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# Performance & Responsiveness Optimization Plan
|
# Performance & Responsiveness Optimization Plan
|
||||||
|
|
||||||
**Date**: 2026-02-28
|
**Date**: 2026-02-28
|
||||||
**Status**: In Progress
|
**Status**: Phases 1–4 Complete. Phase 5 optional/deferred.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ Three independent research passes analyzed the Codeman codebase for performance
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1: Quick Wins — ALREADY IMPLEMENTED
|
## Phase 1: Quick Wins — COMPLETE
|
||||||
|
|
||||||
All Phase 1 items were found to already exist in the codebase during verification:
|
All Phase 1 items were found to already exist in the codebase during verification:
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ All Phase 1 items were found to already exist in the codebase during verificatio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 2: Frontend Responsiveness — MOSTLY ALREADY IMPLEMENTED
|
## Phase 2: Frontend Responsiveness — COMPLETE
|
||||||
|
|
||||||
### 2.1 Batch `getBoundingClientRect()` in connection lines — DONE
|
### 2.1 Batch `getBoundingClientRect()` in connection lines — DONE
|
||||||
- **Files**: `src/web/public/app.js` (`_updateConnectionLinesImmediate()`)
|
- **Files**: `src/web/public/app.js` (`_updateConnectionLinesImmediate()`)
|
||||||
@@ -51,7 +51,7 @@ All Phase 1 items were found to already exist in the codebase during verificatio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 3: Backend Hot Paths
|
## Phase 3: Backend Hot Paths — COMPLETE
|
||||||
|
|
||||||
### 3.1 State diff broadcasts — ALREADY OPTIMIZED
|
### 3.1 State diff broadcasts — ALREADY OPTIMIZED
|
||||||
- `broadcastSessionStateDebounced()` already batches at 500ms intervals
|
- `broadcastSessionStateDebounced()` already batches at 500ms intervals
|
||||||
@@ -84,35 +84,37 @@ All Phase 1 items were found to already exist in the codebase during verificatio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 4: System-Level Improvements
|
## Phase 4: System-Level Improvements — COMPLETE
|
||||||
|
|
||||||
### 4.1 Incremental state persistence
|
### 4.1 Incremental state persistence — DONE
|
||||||
- **Files**: `src/state-store.ts` (~lines 145-160)
|
- **Files**: `src/state-store.ts` (`assembleStateJson()`, `setSession()`)
|
||||||
- **Problem**: Every 500ms debounce writes the entire `AppState` (all sessions, tasks, config) via `JSON.stringify()`. With 50 sessions, state can be tens of MB. Serialization alone costs 50-100ms.
|
- **Change**: Added `dirtySessions` Set and `cachedSessionJsons` Map. On persist, only dirty sessions are re-serialized; clean sessions reuse cached JSON fragments. `setSession()` marks sessions dirty; `assembleStateJson()` rebuilds only changed fragments.
|
||||||
- **Fix**: Track dirty sessions. On persist, only re-serialize dirty sessions; cache serialized JSON for clean sessions. Assemble final output from cached fragments.
|
- **Impact**: Serialization cost reduced from O(all sessions) to O(dirty sessions). Typical steady-state: 1-2 dirty sessions instead of 50.
|
||||||
- **Impact**: Reduces serialization cost from O(all sessions) to O(dirty sessions). Typical steady-state: 1-2 dirty sessions instead of 50.
|
|
||||||
|
|
||||||
### 4.2 Replace polling with fs watchers for team watcher
|
### 4.2 Replace polling with fs watchers for team watcher — DONE
|
||||||
- **Files**: `src/team-watcher.ts` (~lines 148-180)
|
- **Files**: `src/team-watcher.ts` (`setupFsWatchers()`)
|
||||||
- **Problem**: Polls `~/.claude/teams/` every 5s via `readdir()` + `stat()`. Blocks event loop for 100-200ms on large directories.
|
- **Change**: Added chokidar watchers on both `~/.claude/teams/` and `~/.claude/tasks/` directories for instant event-driven detection. Lock files ignored via chokidar config. Mtime-based dedup skips unchanged files. Polling interval relaxed from 5s to 30s as a fallback.
|
||||||
- **Fix**: Use `chokidar` (already a dependency) or `fs.watch()` to react to changes. Keep a 30s fallback poll for reliability.
|
- **Impact**: Near-instant team detection; polling overhead eliminated for normal operation.
|
||||||
- **Impact**: Eliminates 5s polling overhead; near-instant team detection.
|
|
||||||
|
|
||||||
### 4.3 Consolidate subagent file watchers
|
### 4.3 Consolidate subagent file watchers — DONE
|
||||||
- **Files**: `src/subagent-watcher.ts` (~line 229+)
|
- **Files**: `src/subagent-watcher.ts` (`setupDirectoryWatcher()`)
|
||||||
- **Problem**: One chokidar watcher per agent directory. With 500 agents, that's 500 inotify watchers consuming kernel resources.
|
- **Change**: Replaced per-agent chokidar watchers with one `fs.watch()` per session subagent directory. Events are routed to the correct agent via filename. Per-file debouncing (100ms) prevents hammering on bulk discovery.
|
||||||
- **Fix**: Watch at the session level (one watcher per session's subagent directory), not per-agent. Parse events to route to correct agent.
|
- **Impact**: Inotify watchers reduced from potentially 500 (one per agent) to ~50 (one per session directory).
|
||||||
- **Impact**: Reduces inotify watchers from 500 to ~50 (one per session).
|
|
||||||
|
|
||||||
### 4.4 Stream transcript files instead of full reads
|
### 4.4 Stream transcript files instead of full reads — DONE
|
||||||
- **Files**: `src/subagent-watcher.ts` (~lines 959-964)
|
- **Files**: `src/subagent-watcher.ts` (`tailFile()`, `findDescriptionInAgentFile()`, parent transcript lookup)
|
||||||
- **Problem**: `loadTranscript()` reads entire transcript file (can be >100KB). With 500 agents discovered at once, that's 50MB of file reads.
|
- **Change**: Multiple streaming strategies implemented:
|
||||||
- **Fix**: Only read last 10KB for display (tail). Full file on-demand only (e.g., when user opens transcript viewer).
|
- **Live monitoring**: Position-based `tailFile()` with `createReadStream({ start: fromPosition })` — only reads new content
|
||||||
- **Impact**: Reduces file I/O from 50MB to 5MB for bulk agent discovery.
|
- **Parent transcript lookup**: Streams only last 16KB (`createReadStream({ start: offset })`)
|
||||||
|
- **Description extraction**: Streams only first 8KB, exits early after 5 lines
|
||||||
|
- **Full read**: Only for on-demand transcript review panel (with optional `limit` parameter)
|
||||||
|
- **Impact**: File I/O for bulk agent discovery reduced from ~50MB to ~5MB.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 5: Long-Term Architectural (Optional)
|
## Phase 5: Long-Term Architectural (Optional) — NOT STARTED
|
||||||
|
|
||||||
|
These items are deferred until scaling demands justify the complexity.
|
||||||
|
|
||||||
### 5.1 Worker thread for PTY processing
|
### 5.1 Worker thread for PTY processing
|
||||||
- **Files**: `src/session.ts`
|
- **Files**: `src/session.ts`
|
||||||
@@ -134,42 +136,23 @@ All Phase 1 items were found to already exist in the codebase during verificatio
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Priority Matrix (Remaining Work)
|
## Completion Summary
|
||||||
|
|
||||||
| # | Item | Impact | Risk | Effort |
|
| Phase | Scope | Status | Items |
|
||||||
|---|------|--------|------|--------|
|
|-------|-------|--------|-------|
|
||||||
| 3.1 | State diff broadcasts | **Very High** | Medium | 3-4h |
|
| 1 | Quick Wins | **Complete** | 5/5 (all pre-existing) |
|
||||||
| 3.2 | Fix session cache invalidation | **High** | Low | 1h |
|
| 2 | Frontend Responsiveness | **Complete** | 3/3 actionable done, 2 skipped |
|
||||||
| 3.3 | Skip PTY processing for hidden sessions | **High** | Medium | 2-3h |
|
| 3 | Backend Hot Paths | **Complete** | 4/4 actionable done, 1 deferred |
|
||||||
| 3.5 | Throttle detection broadcasts | **Medium** | Low | 1h |
|
| 4 | System-Level | **Complete** | 4/4 done |
|
||||||
| 3.4 | Batch liveness checks | **Medium** | Low | 1-2h |
|
| 5 | Long-Term Architectural | **Not started** | 0/3 — deferred until needed |
|
||||||
| 4.1 | Incremental state persistence | **Medium** | Medium | 3-4h |
|
|
||||||
| 4.2 | Team watcher fs events | **Low-Med** | Medium | 2h |
|
|
||||||
| 4.3 | Consolidate file watchers | **Low-Med** | Medium | 2h |
|
|
||||||
| 4.4 | Stream transcripts | **Low-Med** | Low | 1h |
|
|
||||||
| 5.1 | Worker thread PTY | **Med** (at scale) | High | 8h |
|
|
||||||
| 5.2 | Per-session SSE subs | **Med** (at scale) | High | 4h |
|
|
||||||
| 5.3 | O(1) LRUMap | **Very Low** | Medium | 2h |
|
|
||||||
|
|
||||||
---
|
**Overall**: 16/16 actionable items complete. 3 optional items deferred.
|
||||||
|
|
||||||
## Recommended Execution Order
|
|
||||||
|
|
||||||
**Sprint 1** (Phase 3 — Backend Hot Paths): Items 3.1, 3.2, 3.3, 3.5
|
|
||||||
- Backend serialization and broadcast efficiency
|
|
||||||
- Highest remaining impact; requires careful testing with multiple active sessions
|
|
||||||
|
|
||||||
**Sprint 2** (Phase 4 — System Level): Items 4.1, 3.4, 4.3, 4.4
|
|
||||||
- State persistence, liveness checks, watcher consolidation
|
|
||||||
- Medium-complexity refactors
|
|
||||||
|
|
||||||
**Sprint 3** (Phase 5 — Architectural): Items 5.1, 5.2 — only if scaling demands it
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Measurement
|
## Measurement
|
||||||
|
|
||||||
Before starting implementation, establish baselines:
|
Before starting Phase 5, establish baselines:
|
||||||
|
|
||||||
1. **Frontend**: Record Chrome DevTools Performance trace with 10 sessions open. Measure:
|
1. **Frontend**: Record Chrome DevTools Performance trace with 10 sessions open. Measure:
|
||||||
- Frame rate during rapid terminal output
|
- Frame rate during rapid terminal output
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
# Phase 1 Implementation Plan: Quick Wins
|
||||||
|
|
||||||
|
**Source**: `docs/code-structure-findings.md` (Phase 1 - Quick Wins section)
|
||||||
|
**Estimated effort**: 1-2 days
|
||||||
|
**Tasks**: 5 independent tasks (can be done in parallel unless noted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety Constraints
|
||||||
|
|
||||||
|
Before starting ANY work, read and follow these rules:
|
||||||
|
|
||||||
|
1. **Never run `npx vitest run`** (full suite) -- it kills tmux sessions. You are running inside a Codeman-managed tmux session.
|
||||||
|
2. **Run individual tests only**: `npx vitest run test/<file>.test.ts`
|
||||||
|
3. **Never test on port 3000** -- the live dev server runs there. Tests use ports 3150+.
|
||||||
|
4. **After TypeScript changes**: Run `tsc --noEmit` to verify type checking passes.
|
||||||
|
5. **Before considering done**: Run `npm run lint` and `npm run format:check` to ensure CI passes.
|
||||||
|
6. **Never kill tmux sessions** -- check `echo $CODEMAN_MUX` first.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task Dependencies
|
||||||
|
|
||||||
|
All 5 tasks are independent and can be done in parallel. However:
|
||||||
|
- Task 1 (barrel exports) is a prerequisite if you want to update import sites to use the barrel after Task 3 (consolidate EXEC_TIMEOUT_MS). The EXEC_TIMEOUT_MS consolidation creates a new export that should be added to the barrel.
|
||||||
|
- Task 2 (delete dead functions) removes functions that Task 1 would otherwise need to add to the barrel. Do Task 2 first or simultaneously with Task 1 to avoid adding exports for dead code.
|
||||||
|
|
||||||
|
**Recommended order**: Task 2 -> Task 1 -> Task 3 -> Task 4 -> Task 5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Export Missing Functions from Utils Barrel
|
||||||
|
|
||||||
|
**File**: `src/utils/index.ts`
|
||||||
|
**Time**: ~30 minutes
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
The barrel file (`src/utils/index.ts`) is missing exports for several functions that are defined in util modules, forcing consumers to use deep imports or preventing usage entirely.
|
||||||
|
|
||||||
|
### Missing Exports
|
||||||
|
|
||||||
|
From `src/utils/regex-patterns.ts`:
|
||||||
|
- `createAnsiPatternFull()` -- factory for fresh ANSI regex (documented in CLAUDE.md)
|
||||||
|
- `createAnsiPatternSimple()` -- factory for fresh ANSI regex (documented in CLAUDE.md)
|
||||||
|
- `stripAnsi()` -- ANSI stripping utility
|
||||||
|
- `SAFE_PATH_PATTERN` -- regex for safe file paths (currently deep-imported by `schemas.ts` and `tmux-manager.ts`)
|
||||||
|
|
||||||
|
From `src/utils/token-validation.ts`:
|
||||||
|
- `validateTokenCounts()` -- token count validation (documented in CLAUDE.md)
|
||||||
|
- `validateTokensAndCost()` -- token + cost validation (documented in CLAUDE.md)
|
||||||
|
|
||||||
|
**Note**: Do NOT export `isSimilar`, `isSimilarByDistance`, `levenshteinDistance`, or `normalizePhrase` from `string-similarity.ts` -- these are dead code (see Task 2).
|
||||||
|
|
||||||
|
### Edit 1: Add missing regex-patterns exports
|
||||||
|
|
||||||
|
**File**: `src/utils/index.ts`
|
||||||
|
|
||||||
|
**Old code** (lines 13-18):
|
||||||
|
```typescript
|
||||||
|
export {
|
||||||
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
|
TOKEN_PATTERN,
|
||||||
|
SPINNER_PATTERN,
|
||||||
|
} from './regex-patterns.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
export {
|
||||||
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
|
TOKEN_PATTERN,
|
||||||
|
SPINNER_PATTERN,
|
||||||
|
createAnsiPatternFull,
|
||||||
|
createAnsiPatternSimple,
|
||||||
|
stripAnsi,
|
||||||
|
SAFE_PATH_PATTERN,
|
||||||
|
} from './regex-patterns.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 2: Add missing token-validation exports
|
||||||
|
|
||||||
|
**File**: `src/utils/index.ts`
|
||||||
|
|
||||||
|
**Old code** (line 19):
|
||||||
|
```typescript
|
||||||
|
export { MAX_SESSION_TOKENS } from './token-validation.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional follow-up: Update deep imports to use barrel
|
||||||
|
|
||||||
|
These files currently deep-import `SAFE_PATH_PATTERN` and could be updated to use the barrel instead:
|
||||||
|
|
||||||
|
- `src/web/schemas.ts` line 11: `import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js';` could become `import { SAFE_PATH_PATTERN } from '../utils/index.js';`
|
||||||
|
- `src/tmux-manager.ts` line 44: `import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js';` could become part of existing barrel import
|
||||||
|
|
||||||
|
This is a low-priority cosmetic change. The barrel export itself is the important fix.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tsc --noEmit
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Delete Dead Utility Functions
|
||||||
|
|
||||||
|
**File**: `src/utils/string-similarity.ts`
|
||||||
|
**Time**: ~15 minutes
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
Four exported functions in `string-similarity.ts` are never imported anywhere in the codebase:
|
||||||
|
- `levenshteinDistance()` (lines 27-69)
|
||||||
|
- `isSimilar()` (lines 106-108)
|
||||||
|
- `isSimilarByDistance()` (lines 123-125)
|
||||||
|
- `normalizePhrase()` (lines 139-144)
|
||||||
|
|
||||||
|
Only three functions are actually used (all by `ralph-tracker.ts` via the barrel):
|
||||||
|
- `stringSimilarity()` -- uses `levenshteinDistance()` internally
|
||||||
|
- `fuzzyPhraseMatch()` -- uses `normalizePhrase()` and `isSimilarByDistance()` internally
|
||||||
|
- `todoContentHash()`
|
||||||
|
|
||||||
|
### Strategy
|
||||||
|
|
||||||
|
`levenshteinDistance()` is called by `stringSimilarity()`, and `normalizePhrase()` and `isSimilarByDistance()` are called by `fuzzyPhraseMatch()`. So they cannot be deleted -- they just need to be un-exported (made private to the module).
|
||||||
|
|
||||||
|
`isSimilar()` is truly dead -- not called by anything. Delete it entirely.
|
||||||
|
|
||||||
|
### Edit 1: Remove `export` from `levenshteinDistance`
|
||||||
|
|
||||||
|
**File**: `src/utils/string-similarity.ts`
|
||||||
|
|
||||||
|
**Old code** (line 27):
|
||||||
|
```typescript
|
||||||
|
export function levenshteinDistance(a: string, b: string): number {
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
function levenshteinDistance(a: string, b: string): number {
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 2: Delete `isSimilar` function entirely
|
||||||
|
|
||||||
|
**File**: `src/utils/string-similarity.ts`
|
||||||
|
|
||||||
|
**Old code** (lines 94-108):
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**: (delete entirely -- replace with empty string)
|
||||||
|
|
||||||
|
### Edit 3: Remove `export` from `isSimilarByDistance`
|
||||||
|
|
||||||
|
**File**: `src/utils/string-similarity.ts`
|
||||||
|
|
||||||
|
**Old code** (line 123):
|
||||||
|
```typescript
|
||||||
|
export function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
function isSimilarByDistance(a: string, b: string, maxDistance = 2): boolean {
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 4: Remove `export` from `normalizePhrase`
|
||||||
|
|
||||||
|
**File**: `src/utils/string-similarity.ts`
|
||||||
|
|
||||||
|
**Old code** (line 139):
|
||||||
|
```typescript
|
||||||
|
export function normalizePhrase(phrase: string): string {
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
function normalizePhrase(phrase: string): string {
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tsc --noEmit
|
||||||
|
npx vitest run test/string-utilities.test.ts
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: If `test/string-utilities.test.ts` imports any of the now-unexported functions, those test imports will fail. Check the test file and remove tests for `isSimilar` (deleted) and update any direct tests for `levenshteinDistance`, `isSimilarByDistance`, `normalizePhrase` to test them indirectly through the public API (`stringSimilarity`, `fuzzyPhraseMatch`), or remove those tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Consolidate Duplicated `EXEC_TIMEOUT_MS` Constant
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/utils/claude-cli-resolver.ts` (line 17)
|
||||||
|
- `src/utils/opencode-cli-resolver.ts` (line 16)
|
||||||
|
- `src/tmux-manager.ts` (line 63) -- also has its own copy
|
||||||
|
|
||||||
|
**Time**: ~15 minutes
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
`EXEC_TIMEOUT_MS = 5000` is defined identically in three files. Changes need to happen in all three places.
|
||||||
|
|
||||||
|
### Strategy
|
||||||
|
|
||||||
|
Create a shared constant and export it. The natural home is a new config file since the existing config files (`buffer-limits.ts`, `map-limits.ts`) follow this pattern. However, to keep it minimal, we can add it to an existing config file or create a small one.
|
||||||
|
|
||||||
|
**Recommended approach**: Add to `src/config/timing-config.ts` (new file) as a single constant. This file can grow later in Phase 6 to hold other timing constants.
|
||||||
|
|
||||||
|
Alternatively, the simplest approach: export from one of the existing utils and import in the others. Since both CLI resolvers are in `src/utils/`, the cleanest approach is to put it in a shared location.
|
||||||
|
|
||||||
|
### Option A: Add to existing config (simpler)
|
||||||
|
|
||||||
|
Create `src/config/exec-timeout.ts`:
|
||||||
|
|
||||||
|
**New file**: `src/config/exec-timeout.ts`
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* Timeout for child process exec commands (e.g., `which claude`, `which opencode`, tmux commands).
|
||||||
|
* Used across CLI resolvers and tmux manager.
|
||||||
|
*/
|
||||||
|
export const EXEC_TIMEOUT_MS = 5000;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 1: Update `claude-cli-resolver.ts`
|
||||||
|
|
||||||
|
**File**: `src/utils/claude-cli-resolver.ts`
|
||||||
|
|
||||||
|
**Old code** (lines 11-17):
|
||||||
|
```typescript
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { delimiter, dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 2: Update `opencode-cli-resolver.ts`
|
||||||
|
|
||||||
|
**File**: `src/utils/opencode-cli-resolver.ts`
|
||||||
|
|
||||||
|
**Old code** (lines 10-16):
|
||||||
|
```typescript
|
||||||
|
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;
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 3: Update `tmux-manager.ts`
|
||||||
|
|
||||||
|
**File**: `src/tmux-manager.ts`
|
||||||
|
|
||||||
|
**Old code** (line 63):
|
||||||
|
```typescript
|
||||||
|
const EXEC_TIMEOUT_MS = 5000;
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `tmux-manager.ts` already has many imports at the top of the file. Add this import near the other local imports (around lines 43-56). The `const EXEC_TIMEOUT_MS = 5000;` on line 63 should be deleted entirely (replaced with the import).
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tsc --noEmit
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Add `z.infer` to Zod Schemas
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/web/schemas.ts` (add type exports)
|
||||||
|
- `src/types.ts` (replace manual interfaces with `z.infer` re-exports where applicable)
|
||||||
|
|
||||||
|
**Time**: ~2 hours
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
All 30+ Zod schemas in `schemas.ts` define validation rules, but zero use `z.infer` to derive TypeScript types. Instead, `types.ts` manually duplicates interfaces that match the schemas. When a schema changes, the type must be manually updated too.
|
||||||
|
|
||||||
|
### Strategy
|
||||||
|
|
||||||
|
Add `z.infer` type exports to `schemas.ts` for each exported schema. This creates derived types as the single source of truth. For schemas that have corresponding manual interfaces in `types.ts`, the manual interface can be replaced with a re-export of the inferred type.
|
||||||
|
|
||||||
|
**Important**: Not all schemas have matching interfaces in `types.ts`. The `RespawnConfig` interface in `types.ts` (line 395) has all required fields, while `RespawnConfigSchema` has all optional fields (it's for partial updates). These are NOT the same type and should NOT be unified.
|
||||||
|
|
||||||
|
### Edit 1: Add inferred type exports to `schemas.ts`
|
||||||
|
|
||||||
|
**File**: `src/web/schemas.ts`
|
||||||
|
|
||||||
|
After each schema definition, add a corresponding type export. Add the following lines at the **end of the file** (after line 509):
|
||||||
|
|
||||||
|
**Old code** (end of file, lines 506-509):
|
||||||
|
```typescript
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait -- the end of file is actually at line 509 after the `RalphLoopStartSchema`. Add the type exports after the last schema:
|
||||||
|
|
||||||
|
**Append to end of file** `src/web/schemas.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
|
||||||
|
// ========== Inferred Types ==========
|
||||||
|
// Derive TypeScript types from Zod schemas (single source of truth)
|
||||||
|
|
||||||
|
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 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>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### What NOT to do
|
||||||
|
|
||||||
|
Do NOT replace the `RespawnConfig` interface in `types.ts` with `z.infer<typeof RespawnConfigSchema>`. The schema has all optional fields (for partial config updates), but the interface has required fields (for the full config object). These are intentionally different shapes.
|
||||||
|
|
||||||
|
Similarly, do NOT try to unify every interface in `types.ts` with a schema -- most interfaces in `types.ts` represent internal domain objects (SessionState, TaskState, etc.) that have no corresponding Zod schema. The schemas only exist for API request validation.
|
||||||
|
|
||||||
|
### Future opportunity
|
||||||
|
|
||||||
|
In a future phase, route handlers in `server.ts` can use these inferred types for request body typing:
|
||||||
|
```typescript
|
||||||
|
const body = CreateSessionSchema.parse(request.body) as CreateSessionInput;
|
||||||
|
```
|
||||||
|
This task only adds the type exports. Migrating route handlers to use them is out of scope.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tsc --noEmit
|
||||||
|
npm run lint
|
||||||
|
npm run format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Fix Weak `not.toThrow()` Tests with Behavioral Assertions
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `test/task-tracker.test.ts` -- 6 instances
|
||||||
|
- `test/image-watcher.test.ts` -- 1 instance
|
||||||
|
- `test/task-queue.test.ts` -- 1 instance
|
||||||
|
- `test/hooks-config.test.ts` -- 1 instance
|
||||||
|
- `test/session-manager.test.ts` -- 1 instance
|
||||||
|
|
||||||
|
**Time**: ~1 hour
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
|
||||||
|
10 tests only assert `not.toThrow()` without verifying the actual defensive behavior. These tests prove the code doesn't crash but don't verify it does the right thing.
|
||||||
|
|
||||||
|
### Fix Strategy
|
||||||
|
|
||||||
|
After each `not.toThrow()`, add a behavioral assertion that verifies the state is correct (e.g., no tasks were created, no side effects occurred).
|
||||||
|
|
||||||
|
### Edit 1: `task-tracker.test.ts` -- null message (line 566)
|
||||||
|
|
||||||
|
**File**: `test/task-tracker.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle null message', () => {
|
||||||
|
expect(() => tracker.processMessage(null)).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle null message', () => {
|
||||||
|
expect(() => tracker.processMessage(null)).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
expect(tracker.getRunningCount()).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 2: `task-tracker.test.ts` -- message without content (line 569-571)
|
||||||
|
|
||||||
|
**File**: `test/task-tracker.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle message without content', () => {
|
||||||
|
expect(() => tracker.processMessage({ message: {} })).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle message without content', () => {
|
||||||
|
expect(() => tracker.processMessage({ message: {} })).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 3: `task-tracker.test.ts` -- empty content array (line 573-575)
|
||||||
|
|
||||||
|
**File**: `test/task-tracker.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle empty content array', () => {
|
||||||
|
expect(() => tracker.processMessage({ message: { content: [] } })).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle empty content array', () => {
|
||||||
|
expect(() => tracker.processMessage({ message: { content: [] } })).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 4: `task-tracker.test.ts` -- tool_result for unknown task (lines 577-590)
|
||||||
|
|
||||||
|
**File**: `test/task-tracker.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle tool_result for unknown task', () => {
|
||||||
|
expect(() => {
|
||||||
|
tracker.processMessage({
|
||||||
|
message: {
|
||||||
|
content: [{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'unknown-task',
|
||||||
|
is_error: false,
|
||||||
|
content: 'Done',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle tool_result for unknown task', () => {
|
||||||
|
expect(() => {
|
||||||
|
tracker.processMessage({
|
||||||
|
message: {
|
||||||
|
content: [{
|
||||||
|
type: 'tool_result',
|
||||||
|
tool_use_id: 'unknown-task',
|
||||||
|
is_error: false,
|
||||||
|
content: 'Done',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(tracker.getTask('unknown-task')).toBeUndefined();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 5: `task-tracker.test.ts` -- empty terminal output (lines 592-595)
|
||||||
|
|
||||||
|
**File**: `test/task-tracker.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle empty terminal output', () => {
|
||||||
|
expect(() => tracker.processTerminalOutput('')).not.toThrow();
|
||||||
|
expect(() => tracker.processTerminalOutput(' ')).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle empty terminal output', () => {
|
||||||
|
expect(() => tracker.processTerminalOutput('')).not.toThrow();
|
||||||
|
expect(() => tracker.processTerminalOutput(' ')).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
expect(tracker.getRunningCount()).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 6: `image-watcher.test.ts` -- unwatchSession for non-watched session (line 123)
|
||||||
|
|
||||||
|
**File**: `test/image-watcher.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should be safe to call for non-watched session', () => {
|
||||||
|
expect(() => watcher.unwatchSession('nonexistent')).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should be safe to call for non-watched session', () => {
|
||||||
|
expect(() => watcher.unwatchSession('nonexistent')).not.toThrow();
|
||||||
|
expect(watcher.getWatchedSessions()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 7: `task-queue.test.ts` -- dependencies on non-existent tasks (lines 538-542)
|
||||||
|
|
||||||
|
**File**: `test/task-queue.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should allow dependencies on non-existent tasks (just unsatisfied, not a cycle)', () => {
|
||||||
|
// Dependencies on non-existent tasks are valid - they just won't be satisfied
|
||||||
|
expect(() => {
|
||||||
|
queue.addTask({ prompt: 'Task D', dependencies: ['non-existent-id'] });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should allow dependencies on non-existent tasks (just unsatisfied, not a cycle)', () => {
|
||||||
|
// Dependencies on non-existent tasks are valid - they just won't be satisfied
|
||||||
|
let task: ReturnType<typeof queue.addTask> | undefined;
|
||||||
|
expect(() => {
|
||||||
|
task = queue.addTask({ prompt: 'Task D', dependencies: ['non-existent-id'] });
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(task).toBeDefined();
|
||||||
|
expect(task!.dependencies).toEqual(['non-existent-id']);
|
||||||
|
// Task should be pending but blocked (dependency unsatisfied)
|
||||||
|
expect(queue.next()?.prompt).toBeUndefined();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Wait -- `queue.next()` returns `null` when no next task is available (all blocked). Let me adjust:
|
||||||
|
|
||||||
|
**New code** (corrected):
|
||||||
|
```typescript
|
||||||
|
it('should allow dependencies on non-existent tasks (just unsatisfied, not a cycle)', () => {
|
||||||
|
// Dependencies on non-existent tasks are valid - they just won't be satisfied
|
||||||
|
let task: ReturnType<typeof queue.addTask> | undefined;
|
||||||
|
expect(() => {
|
||||||
|
task = queue.addTask({ prompt: 'Task D', dependencies: ['non-existent-id'] });
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(task).toBeDefined();
|
||||||
|
expect(task!.dependencies).toEqual(['non-existent-id']);
|
||||||
|
// Task exists but is blocked (dependency unsatisfied), so next() skips it
|
||||||
|
expect(queue.getAllTasks()).toHaveLength(1);
|
||||||
|
expect(queue.next()).toBeNull();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 8: `hooks-config.test.ts` -- valid JSON check (line 129)
|
||||||
|
|
||||||
|
**File**: `test/hooks-config.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should write valid JSON', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
|
const content = readFileSync(settingsPath, 'utf-8');
|
||||||
|
expect(() => JSON.parse(content)).not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should write valid JSON', () => {
|
||||||
|
writeHooksConfig(testDir);
|
||||||
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
|
const content = readFileSync(settingsPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
expect(parsed).toBeDefined();
|
||||||
|
expect(typeof parsed).toBe('object');
|
||||||
|
expect(parsed.hooks).toBeDefined();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Edit 9: `session-manager.test.ts` -- stopSession for non-existent (line 216)
|
||||||
|
|
||||||
|
**File**: `test/session-manager.test.ts`
|
||||||
|
|
||||||
|
**Old code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle non-existent session gracefully', async () => {
|
||||||
|
await expect(manager.stopSession('non-existent')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**New code**:
|
||||||
|
```typescript
|
||||||
|
it('should handle non-existent session gracefully', async () => {
|
||||||
|
await expect(manager.stopSession('non-existent')).resolves.not.toThrow();
|
||||||
|
expect(manager.getSessionCount()).toBe(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
Run each test file individually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx vitest run test/task-tracker.test.ts
|
||||||
|
npx vitest run test/image-watcher.test.ts
|
||||||
|
npx vitest run test/task-queue.test.ts
|
||||||
|
npx vitest run test/hooks-config.test.ts
|
||||||
|
npx vitest run test/session-manager.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: `hooks-config.test.ts` and `session-manager.test.ts` spawn real servers on ports 3130-3131. Only run them if you are NOT running other tests that use those ports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final Verification Checklist
|
||||||
|
|
||||||
|
After all 5 tasks are complete, run the following in order:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. TypeScript type checking
|
||||||
|
tsc --noEmit
|
||||||
|
|
||||||
|
# 2. Linting
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# 3. Formatting
|
||||||
|
npm run format:check
|
||||||
|
|
||||||
|
# 4. Run affected test files individually (NOT the full suite)
|
||||||
|
npx vitest run test/string-utilities.test.ts
|
||||||
|
npx vitest run test/task-tracker.test.ts
|
||||||
|
npx vitest run test/image-watcher.test.ts
|
||||||
|
npx vitest run test/task-queue.test.ts
|
||||||
|
npx vitest run test/session-manager.test.ts
|
||||||
|
npx vitest run test/hooks-config.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
If any formatting issues arise, fix with:
|
||||||
|
```bash
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
If any lint issues arise, fix with:
|
||||||
|
```bash
|
||||||
|
npm run lint:fix
|
||||||
|
```
|
||||||
|
|
||||||
|
### Summary of Changes
|
||||||
|
|
||||||
|
| Task | Files Modified | Files Created |
|
||||||
|
|------|---------------|---------------|
|
||||||
|
| 1. Barrel exports | `src/utils/index.ts` | -- |
|
||||||
|
| 2. Dead functions | `src/utils/string-similarity.ts` | -- |
|
||||||
|
| 3. EXEC_TIMEOUT_MS | `src/utils/claude-cli-resolver.ts`, `src/utils/opencode-cli-resolver.ts`, `src/tmux-manager.ts` | `src/config/exec-timeout.ts` |
|
||||||
|
| 4. z.infer types | `src/web/schemas.ts` | -- |
|
||||||
|
| 5. Weak tests | `test/task-tracker.test.ts`, `test/image-watcher.test.ts`, `test/task-queue.test.ts`, `test/hooks-config.test.ts`, `test/session-manager.test.ts` | -- |
|
||||||
|
|
||||||
|
**Total files modified**: 10
|
||||||
|
**Total files created**: 1
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+35
-40
@@ -15,6 +15,7 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
import { EventEmitter } from 'node:events';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { ActiveBashTool } from './types.js';
|
import { ActiveBashTool } from './types.js';
|
||||||
|
import { CleanupManager, Debouncer } from './utils/index.js';
|
||||||
|
|
||||||
// ========== Configuration Constants ==========
|
// ========== Configuration Constants ==========
|
||||||
|
|
||||||
@@ -145,15 +146,14 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
private _workingDir: string;
|
private _workingDir: string;
|
||||||
private _homeDir: string;
|
private _homeDir: string;
|
||||||
|
|
||||||
// Track auto-remove timers for cleanup
|
// Centralized resource cleanup for auto-remove timers
|
||||||
private _autoRemoveTimers: Set<ReturnType<typeof setTimeout>> = new Set();
|
private cleanup = new CleanupManager();
|
||||||
|
|
||||||
// Flag to prevent operations after destroy
|
// Flag to prevent operations after destroy
|
||||||
private _destroyed: boolean = false;
|
private _destroyed: boolean = false;
|
||||||
|
|
||||||
// Debouncing
|
// Debouncing
|
||||||
private _pendingUpdate: boolean = false;
|
private _updateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||||
private _updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
constructor(config: BashToolParserConfig) {
|
constructor(config: BashToolParserConfig) {
|
||||||
super();
|
super();
|
||||||
@@ -524,13 +524,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
this.scheduleUpdate();
|
this.scheduleUpdate();
|
||||||
|
|
||||||
// Remove completed tool after a short delay to allow UI to show completion
|
// Remove completed tool after a short delay to allow UI to show completion
|
||||||
const timer = setTimeout(() => {
|
this.cleanup.setTimeout(
|
||||||
this._autoRemoveTimers.delete(timer);
|
() => {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
this._activeTools.delete(tool.id);
|
this._activeTools.delete(tool.id);
|
||||||
this.scheduleUpdate();
|
this.scheduleUpdate();
|
||||||
}, 2000);
|
},
|
||||||
this._autoRemoveTimers.add(timer);
|
2000,
|
||||||
|
{ description: 'auto-remove completed tool' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this._lastToolId = null;
|
this._lastToolId = null;
|
||||||
return;
|
return;
|
||||||
@@ -562,13 +564,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
this.scheduleUpdate();
|
this.scheduleUpdate();
|
||||||
|
|
||||||
// Auto-remove suggestions after 30 seconds
|
// Auto-remove suggestions after 30 seconds
|
||||||
const timer = setTimeout(() => {
|
this.cleanup.setTimeout(
|
||||||
this._autoRemoveTimers.delete(timer);
|
() => {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
this._activeTools.delete(tool.id);
|
this._activeTools.delete(tool.id);
|
||||||
this.scheduleUpdate();
|
this.scheduleUpdate();
|
||||||
}, 30000);
|
},
|
||||||
this._autoRemoveTimers.add(timer);
|
30000,
|
||||||
|
{ description: 'auto-remove suggestion tool' }
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,13 +603,15 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
this.scheduleUpdate();
|
this.scheduleUpdate();
|
||||||
|
|
||||||
// Auto-remove after 60 seconds
|
// Auto-remove after 60 seconds
|
||||||
const timer = setTimeout(() => {
|
this.cleanup.setTimeout(
|
||||||
this._autoRemoveTimers.delete(timer);
|
() => {
|
||||||
if (this._destroyed) return;
|
if (this._destroyed) return;
|
||||||
this._activeTools.delete(tool.id);
|
this._activeTools.delete(tool.id);
|
||||||
this.scheduleUpdate();
|
this.scheduleUpdate();
|
||||||
}, 60000);
|
},
|
||||||
this._autoRemoveTimers.add(timer);
|
60000,
|
||||||
|
{ description: 'auto-remove log file tool' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,13 +681,9 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
* Schedule a debounced update emission.
|
* Schedule a debounced update emission.
|
||||||
*/
|
*/
|
||||||
private scheduleUpdate(): void {
|
private scheduleUpdate(): void {
|
||||||
if (this._pendingUpdate) return;
|
this._updateDeb.schedule(() => {
|
||||||
|
|
||||||
this._pendingUpdate = true;
|
|
||||||
this._updateTimer = setTimeout(() => {
|
|
||||||
this._pendingUpdate = false;
|
|
||||||
this.emitUpdate();
|
this.emitUpdate();
|
||||||
}, EVENT_DEBOUNCE_MS);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -696,15 +698,8 @@ export class BashToolParser extends EventEmitter<BashToolParserEvents> {
|
|||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
if (this._updateTimer) {
|
this._updateDeb.dispose();
|
||||||
clearTimeout(this._updateTimer);
|
this.cleanup.dispose();
|
||||||
this._updateTimer = null;
|
|
||||||
}
|
|
||||||
// Clear all auto-remove timers to prevent orphaned callbacks
|
|
||||||
for (const timer of this._autoRemoveTimers) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
this._autoRemoveTimers.clear();
|
|
||||||
this._activeTools.clear();
|
this._activeTools.clear();
|
||||||
this.removeAllListeners();
|
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 { basename, extname, relative } from 'node:path';
|
||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
import type { ImageDetectedEvent } from './types.js';
|
import type { ImageDetectedEvent } from './types.js';
|
||||||
|
import { KeyedDebouncer } from './utils/index.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -65,11 +66,11 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
/** Map of sessionId -> working directory path */
|
/** Map of sessionId -> working directory path */
|
||||||
private sessionDirs = new Map<string, string>();
|
private sessionDirs = new Map<string, string>();
|
||||||
|
|
||||||
/** Debounce timers for rapid image creation (keyed by filePath) */
|
/** Per-file debouncer for rapid image creation */
|
||||||
private debounceTimers = new Map<string, NodeJS.Timeout>();
|
private fileDeb = new KeyedDebouncer(DEBOUNCE_DELAY_MS);
|
||||||
|
|
||||||
/** Track which session owns each debounce timer (for cleanup) */
|
/** Track which session owns each debounced file (for cleanup) */
|
||||||
private timerToSession = new Map<string, string>();
|
private fileToSession = new Map<string, string>();
|
||||||
|
|
||||||
/** Per-session burst tracking: sessionId -> { count, windowStart } */
|
/** Per-session burst tracking: sessionId -> { count, windowStart } */
|
||||||
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
|
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
|
||||||
@@ -118,11 +119,8 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
this.sessionDirs.clear();
|
this.sessionDirs.clear();
|
||||||
|
|
||||||
// Clear all debounce timers
|
// Clear all debounce timers
|
||||||
for (const timer of this.debounceTimers.values()) {
|
this.fileDeb.dispose();
|
||||||
clearTimeout(timer);
|
this.fileToSession.clear();
|
||||||
}
|
|
||||||
this.debounceTimers.clear();
|
|
||||||
this.timerToSession.clear();
|
|
||||||
this.burstTrackers.clear();
|
this.burstTrackers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,20 +210,15 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
this.sessionDirs.delete(sessionId);
|
this.sessionDirs.delete(sessionId);
|
||||||
|
|
||||||
// Clear any pending debounce timers for this session
|
// Clear any pending debounce timers for this session
|
||||||
// Collect keys first to avoid iterator invalidation during deletion
|
const toCancel: string[] = [];
|
||||||
const toDelete: string[] = [];
|
for (const [filePath, ownerId] of this.fileToSession) {
|
||||||
for (const [filePath, ownerId] of this.timerToSession) {
|
|
||||||
if (ownerId === sessionId) {
|
if (ownerId === sessionId) {
|
||||||
toDelete.push(filePath);
|
toCancel.push(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const filePath of toDelete) {
|
for (const filePath of toCancel) {
|
||||||
const timer = this.debounceTimers.get(filePath);
|
this.fileDeb.cancelKey(filePath);
|
||||||
if (timer) {
|
this.fileToSession.delete(filePath);
|
||||||
clearTimeout(timer);
|
|
||||||
this.debounceTimers.delete(filePath);
|
|
||||||
}
|
|
||||||
this.timerToSession.delete(filePath);
|
|
||||||
}
|
}
|
||||||
this.burstTrackers.delete(sessionId);
|
this.burstTrackers.delete(sessionId);
|
||||||
}
|
}
|
||||||
@@ -269,22 +262,15 @@ export class ImageWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||||
const existingTimer = this.debounceTimers.get(filePath);
|
this.fileDeb.schedule(filePath, () => {
|
||||||
if (existingTimer) {
|
this.fileToSession.delete(filePath);
|
||||||
clearTimeout(existingTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.debounceTimers.delete(filePath);
|
|
||||||
this.timerToSession.delete(filePath);
|
|
||||||
this.emitImageDetected(sessionId, filePath);
|
this.emitImageDetected(sessionId, filePath);
|
||||||
// Increment burst count on actual emission (not on detection)
|
// Increment burst count on actual emission (not on detection)
|
||||||
const b = this.burstTrackers.get(sessionId);
|
const b = this.burstTrackers.get(sessionId);
|
||||||
if (b) b.count++;
|
if (b) b.count++;
|
||||||
}, DEBOUNCE_DELAY_MS);
|
});
|
||||||
|
|
||||||
this.debounceTimers.set(filePath, timer);
|
this.fileToSession.set(filePath, sessionId);
|
||||||
this.timerToSession.set(filePath, sessionId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+4
-11
@@ -11,6 +11,7 @@ import { join } from 'node:path';
|
|||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
import type { VapidKeys, PushSubscriptionRecord } from './types.js';
|
||||||
|
import { Debouncer } from './utils/index.js';
|
||||||
|
|
||||||
const DATA_DIR = join(homedir(), '.codeman');
|
const DATA_DIR = join(homedir(), '.codeman');
|
||||||
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
const KEYS_FILE = join(DATA_DIR, 'push-keys.json');
|
||||||
@@ -20,7 +21,7 @@ const SAVE_DEBOUNCE_MS = 500;
|
|||||||
export class PushSubscriptionStore {
|
export class PushSubscriptionStore {
|
||||||
private vapidKeys: VapidKeys | null = null;
|
private vapidKeys: VapidKeys | null = null;
|
||||||
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
|
private subscriptions: Map<string, PushSubscriptionRecord> = new Map();
|
||||||
private saveTimer: NodeJS.Timeout | null = null;
|
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||||
private _disposed = false;
|
private _disposed = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -149,10 +150,7 @@ export class PushSubscriptionStore {
|
|||||||
/** Schedule a debounced save */
|
/** Schedule a debounced save */
|
||||||
private scheduleSave(): void {
|
private scheduleSave(): void {
|
||||||
if (this._disposed) return;
|
if (this._disposed) return;
|
||||||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
this.saveDeb.schedule(() => this.flushSave());
|
||||||
this.saveTimer = setTimeout(() => {
|
|
||||||
this.flushSave();
|
|
||||||
}, SAVE_DEBOUNCE_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Immediately persist subscriptions to disk */
|
/** Immediately persist subscriptions to disk */
|
||||||
@@ -169,11 +167,6 @@ export class PushSubscriptionStore {
|
|||||||
dispose(): void {
|
dispose(): void {
|
||||||
if (this._disposed) return;
|
if (this._disposed) return;
|
||||||
this._disposed = true;
|
this._disposed = true;
|
||||||
if (this.saveTimer) {
|
this.saveDeb.flush(() => this.flushSave());
|
||||||
clearTimeout(this.saveTimer);
|
|
||||||
this.saveTimer = null;
|
|
||||||
}
|
|
||||||
// Final flush
|
|
||||||
this.flushSave();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-82
@@ -34,7 +34,14 @@ import {
|
|||||||
PlanTaskStatus,
|
PlanTaskStatus,
|
||||||
TddPhase,
|
TddPhase,
|
||||||
} from './types.js';
|
} 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_LINE_BUFFER_SIZE } from './config/buffer-limits.js';
|
||||||
import { MAX_TODOS_PER_SESSION } from './config/map-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 */
|
/** Timestamp of last cleanup check for throttling */
|
||||||
private _lastCleanupTime: number = 0;
|
private _lastCleanupTime: number = 0;
|
||||||
|
|
||||||
/** Debounce timer for todoUpdate events */
|
/** Centralized resource cleanup for timers/intervals */
|
||||||
private _todoUpdateTimer: NodeJS.Timeout | null = null;
|
private cleanup = new CleanupManager();
|
||||||
|
|
||||||
/** Debounce timer for loopUpdate events */
|
/** Debouncer for todoUpdate events */
|
||||||
private _loopUpdateTimer: NodeJS.Timeout | null = null;
|
private _todoUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||||
|
|
||||||
/** Flag indicating pending todoUpdate emission */
|
/** Debouncer for loopUpdate events */
|
||||||
private _todoUpdatePending: boolean = false;
|
private _loopUpdateDeb = new Debouncer(EVENT_DEBOUNCE_MS);
|
||||||
|
|
||||||
/** Flag indicating pending loopUpdate emission */
|
|
||||||
private _loopUpdatePending: boolean = false;
|
|
||||||
|
|
||||||
/** When true, prevents auto-enable on pattern detection */
|
/** When true, prevents auto-enable on pattern detection */
|
||||||
private _autoEnableDisabled: boolean = true;
|
private _autoEnableDisabled: boolean = true;
|
||||||
@@ -621,8 +625,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
|
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
|
||||||
private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null;
|
private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null;
|
||||||
|
|
||||||
/** Debounce timer for file change events */
|
/** Debouncer for file change events */
|
||||||
private _fixPlanReloadTimer: NodeJS.Timeout | null = null;
|
private _fixPlanReloadDeb = new Debouncer(500);
|
||||||
|
|
||||||
/** Path to the @fix_plan.md file being watched */
|
/** Path to the @fix_plan.md file being watched */
|
||||||
private _fixPlanPath: string | null = null;
|
private _fixPlanPath: string | null = null;
|
||||||
@@ -665,8 +669,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
/** Last observed iteration count for stall detection */
|
/** Last observed iteration count for stall detection */
|
||||||
private _lastObservedIteration: number = 0;
|
private _lastObservedIteration: number = 0;
|
||||||
|
|
||||||
/** Timer for iteration stall detection */
|
/** CleanupManager registration ID for iteration stall detection interval */
|
||||||
private _iterationStallTimer: NodeJS.Timeout | null = null;
|
private _iterationStallTimerId: string | null = null;
|
||||||
|
|
||||||
/** Iteration stall warning threshold (ms) - default 10 minutes */
|
/** Iteration stall warning threshold (ms) - default 10 minutes */
|
||||||
private _iterationStallWarningMs: number = 10 * 60 * 1000;
|
private _iterationStallWarningMs: number = 10 * 60 * 1000;
|
||||||
@@ -869,14 +873,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private handleFixPlanChange(): void {
|
private handleFixPlanChange(): void {
|
||||||
// Debounce rapid changes (e.g., multiple writes)
|
// Debounce rapid changes (e.g., multiple writes)
|
||||||
if (this._fixPlanReloadTimer) {
|
this._fixPlanReloadDeb.schedule(() => {
|
||||||
clearTimeout(this._fixPlanReloadTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._fixPlanReloadTimer = setTimeout(() => {
|
|
||||||
this._fixPlanReloadTimer = null;
|
|
||||||
this.loadFixPlanFromDisk();
|
this.loadFixPlanFromDisk();
|
||||||
}, 500); // 500ms debounce
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -892,10 +891,7 @@ export class RalphTracker extends EventEmitter {
|
|||||||
this._fixPlanWatcher.close();
|
this._fixPlanWatcher.close();
|
||||||
this._fixPlanWatcher = null;
|
this._fixPlanWatcher = null;
|
||||||
}
|
}
|
||||||
if (this._fixPlanReloadTimer) {
|
this._fixPlanReloadDeb.cancel();
|
||||||
clearTimeout(this._fixPlanReloadTimer);
|
|
||||||
this._fixPlanReloadTimer = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1023,16 +1019,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* Called during reset/fullReset to prevent stale emissions.
|
* Called during reset/fullReset to prevent stale emissions.
|
||||||
*/
|
*/
|
||||||
private clearDebounceTimers(): void {
|
private clearDebounceTimers(): void {
|
||||||
if (this._todoUpdateTimer) {
|
this._todoUpdateDeb.cancel();
|
||||||
clearTimeout(this._todoUpdateTimer);
|
this._loopUpdateDeb.cancel();
|
||||||
this._todoUpdateTimer = null;
|
|
||||||
}
|
|
||||||
if (this._loopUpdateTimer) {
|
|
||||||
clearTimeout(this._loopUpdateTimer);
|
|
||||||
this._loopUpdateTimer = null;
|
|
||||||
}
|
|
||||||
this._todoUpdatePending = false;
|
|
||||||
this._loopUpdatePending = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1041,19 +1029,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
||||||
*/
|
*/
|
||||||
private emitTodoUpdateDebounced(): void {
|
private emitTodoUpdateDebounced(): void {
|
||||||
this._todoUpdatePending = true;
|
this._todoUpdateDeb.schedule(() => {
|
||||||
|
this.emit('todoUpdate', this.todos);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1062,19 +1040,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
* The event fires after EVENT_DEBOUNCE_MS of inactivity.
|
||||||
*/
|
*/
|
||||||
private emitLoopUpdateDebounced(): void {
|
private emitLoopUpdateDebounced(): void {
|
||||||
this._loopUpdatePending = true;
|
this._loopUpdateDeb.schedule(() => {
|
||||||
|
this.emit('loopUpdate', this.loopState);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1082,21 +1050,11 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* Useful for testing or when immediate state sync is needed.
|
* Useful for testing or when immediate state sync is needed.
|
||||||
*/
|
*/
|
||||||
flushPendingEvents(): void {
|
flushPendingEvents(): void {
|
||||||
if (this._todoUpdatePending) {
|
if (this._todoUpdateDeb.isPending) {
|
||||||
this._todoUpdatePending = false;
|
this._todoUpdateDeb.flush(() => this.emit('todoUpdate', this.todos));
|
||||||
if (this._todoUpdateTimer) {
|
|
||||||
clearTimeout(this._todoUpdateTimer);
|
|
||||||
this._todoUpdateTimer = null;
|
|
||||||
}
|
|
||||||
this.emit('todoUpdate', this.todos);
|
|
||||||
}
|
}
|
||||||
if (this._loopUpdatePending) {
|
if (this._loopUpdateDeb.isPending) {
|
||||||
this._loopUpdatePending = false;
|
this._loopUpdateDeb.flush(() => this.emit('loopUpdate', this.loopState));
|
||||||
if (this._loopUpdateTimer) {
|
|
||||||
clearTimeout(this._loopUpdateTimer);
|
|
||||||
this._loopUpdateTimer = null;
|
|
||||||
}
|
|
||||||
this.emit('loopUpdate', this.loopState);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1116,18 +1074,22 @@ export class RalphTracker extends EventEmitter {
|
|||||||
this._iterationStallWarned = false;
|
this._iterationStallWarned = false;
|
||||||
|
|
||||||
// Check every minute
|
// Check every minute
|
||||||
this._iterationStallTimer = setInterval(() => {
|
this._iterationStallTimerId = this.cleanup.setInterval(
|
||||||
this.checkIterationStall();
|
() => {
|
||||||
}, 60 * 1000);
|
this.checkIterationStall();
|
||||||
|
},
|
||||||
|
60 * 1000,
|
||||||
|
{ description: 'iteration stall detection' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop iteration stall detection timer.
|
* Stop iteration stall detection timer.
|
||||||
*/
|
*/
|
||||||
stopIterationStallDetection(): void {
|
stopIterationStallDetection(): void {
|
||||||
if (this._iterationStallTimer) {
|
if (this._iterationStallTimerId) {
|
||||||
clearInterval(this._iterationStallTimer);
|
this.cleanup.unregister(this._iterationStallTimerId);
|
||||||
this._iterationStallTimer = null;
|
this._iterationStallTimerId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3888,7 +3850,8 @@ export class RalphTracker extends EventEmitter {
|
|||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.clearDebounceTimers();
|
this.clearDebounceTimers();
|
||||||
this.stopWatchingFixPlan();
|
this.stopWatchingFixPlan();
|
||||||
this.stopIterationStallDetection();
|
this._fixPlanReloadDeb.dispose();
|
||||||
|
this.cleanup.dispose();
|
||||||
this._todos.clear();
|
this._todos.clear();
|
||||||
this._taskNumberToContent.clear();
|
this._taskNumberToContent.clear();
|
||||||
this._todoStartTimes.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 { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||||
import type { TeamWatcher } from './team-watcher.js';
|
import type { TeamWatcher } from './team-watcher.js';
|
||||||
import { BufferAccumulator } from './utils/buffer-accumulator.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 { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js';
|
||||||
import type {
|
import type {
|
||||||
RespawnCycleMetrics,
|
RespawnCycleMetrics,
|
||||||
@@ -641,26 +641,29 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** Current state machine state */
|
/** Current state machine state */
|
||||||
private _state: RespawnState = 'stopped';
|
private _state: RespawnState = 'stopped';
|
||||||
|
|
||||||
/** Timer for step delays */
|
/** Centralized resource cleanup manager for timers */
|
||||||
private stepTimer: NodeJS.Timeout | null = null;
|
private cleanup = new CleanupManager();
|
||||||
|
|
||||||
/** Timer for completion confirmation (Layer 2) */
|
/** Timer ID for step delays */
|
||||||
private completionConfirmTimer: NodeJS.Timeout | null = null;
|
private stepTimerId: string | null = null;
|
||||||
|
|
||||||
/** Timer for no-output fallback (Layer 5) */
|
/** Timer ID for completion confirmation (Layer 2) */
|
||||||
private noOutputTimer: NodeJS.Timeout | null = null;
|
private completionConfirmTimerId: string | null = null;
|
||||||
|
|
||||||
/** Timer for periodic detection status updates */
|
/** Timer ID for no-output fallback (Layer 5) */
|
||||||
private detectionUpdateTimer: NodeJS.Timeout | null = null;
|
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) */
|
/** Cached key fields from last emitted detection status (for dedup) */
|
||||||
private lastEmittedDetectionKey: string = '';
|
private lastEmittedDetectionKey: string = '';
|
||||||
|
|
||||||
/** Timer for auto-accepting plan mode prompts */
|
/** Timer ID for auto-accepting plan mode prompts */
|
||||||
private autoAcceptTimer: NodeJS.Timeout | null = null;
|
private autoAcceptTimerId: string | null = null;
|
||||||
|
|
||||||
/** Timer for pre-filter silence detection (triggers AI check) */
|
/** Timer ID for pre-filter silence detection (triggers AI check) */
|
||||||
private preFilterTimer: NodeJS.Timeout | null = null;
|
private preFilterTimerId: string | null = null;
|
||||||
|
|
||||||
/** Whether any terminal output has been received since start/last-auto-accept */
|
/** Whether any terminal output has been received since start/last-auto-accept */
|
||||||
private hasReceivedOutput: boolean = false;
|
private hasReceivedOutput: boolean = false;
|
||||||
@@ -682,8 +685,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** Timestamp when idle_prompt was received */
|
/** Timestamp when idle_prompt was received */
|
||||||
private idlePromptTime: number | null = null;
|
private idlePromptTime: number | null = null;
|
||||||
|
|
||||||
/** Timer for short confirmation after hook signal (handles race conditions) */
|
/** Timer ID for short confirmation after hook signal (handles race conditions) */
|
||||||
private hookConfirmTimer: NodeJS.Timeout | null = null;
|
private hookConfirmTimerId: string | null = null;
|
||||||
|
|
||||||
/** Confirmation delay after hook signal before confirming idle (ms) */
|
/** Confirmation delay after hook signal before confirming idle (ms) */
|
||||||
private static readonly HOOK_CONFIRM_DELAY_MS = 3000;
|
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) */
|
/** Unique ID for current AI check request (to detect stale results) */
|
||||||
private _currentAiCheckId: string | null = null;
|
private _currentAiCheckId: string | null = null;
|
||||||
|
|
||||||
/** Timer for /clear step fallback (sends /init if no prompt detected) */
|
/** Timer ID for /clear step fallback (sends /init if no prompt detected) */
|
||||||
private clearFallbackTimer: NodeJS.Timeout | null = null;
|
private clearFallbackTimerId: string | null = null;
|
||||||
|
|
||||||
/** Timer for step completion confirmation (waits for silence after completion) */
|
/** Timer ID for step completion confirmation (waits for silence after completion) */
|
||||||
private stepConfirmTimer: NodeJS.Timeout | null = null;
|
private stepConfirmTimerId: string | null = null;
|
||||||
|
|
||||||
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
/** Fallback timeout for /clear step (ms) - sends /init without waiting for prompt */
|
||||||
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
private static readonly CLEAR_FALLBACK_TIMEOUT_MS = 10000;
|
||||||
@@ -741,8 +744,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** Timestamp when the current state was entered */
|
/** Timestamp when the current state was entered */
|
||||||
private stateEnteredAt: number = 0;
|
private stateEnteredAt: number = 0;
|
||||||
|
|
||||||
/** Timer for stuck-state detection */
|
/** Timer ID for stuck-state detection */
|
||||||
private stuckStateTimer: NodeJS.Timeout | null = null;
|
private stuckStateTimerId: string | null = null;
|
||||||
|
|
||||||
/** Whether a stuck-state warning has been emitted for current state */
|
/** Whether a stuck-state warning has been emitted for current state */
|
||||||
private stuckStateWarned: boolean = false;
|
private stuckStateWarned: boolean = false;
|
||||||
@@ -1200,31 +1203,35 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.stopDetectionUpdates();
|
this.stopDetectionUpdates();
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.lastEmittedDetectionKey = '';
|
this.lastEmittedDetectionKey = '';
|
||||||
this.detectionUpdateTimer = setInterval(() => {
|
this.detectionUpdateTimerId = this.cleanup.setInterval(
|
||||||
try {
|
() => {
|
||||||
if (this._state !== 'stopped') {
|
try {
|
||||||
const status = this.getDetectionStatus();
|
if (this._state !== 'stopped') {
|
||||||
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
const status = this.getDetectionStatus();
|
||||||
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
||||||
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
||||||
if (key !== this.lastEmittedDetectionKey) {
|
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
||||||
this.lastEmittedDetectionKey = key;
|
if (key !== this.lastEmittedDetectionKey) {
|
||||||
this.emit('detectionUpdate', status);
|
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,
|
||||||
}
|
{ description: 'detection status updates' }
|
||||||
}, 2000);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stop periodic detection status updates.
|
* Stop periodic detection status updates.
|
||||||
*/
|
*/
|
||||||
private stopDetectionUpdates(): void {
|
private stopDetectionUpdates(): void {
|
||||||
if (this.detectionUpdateTimer) {
|
if (this.detectionUpdateTimerId) {
|
||||||
clearInterval(this.detectionUpdateTimer);
|
this.cleanup.unregister(this.detectionUpdateTimerId);
|
||||||
this.detectionUpdateTimer = null;
|
this.detectionUpdateTimerId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1515,8 +1522,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.lastWorkingPatternTime = now;
|
this.lastWorkingPatternTime = now;
|
||||||
|
|
||||||
// Cancel hook confirmation timer if running
|
// Cancel hook confirmation timer if running
|
||||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'working patterns detected');
|
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimerId, 'working patterns detected');
|
||||||
this.hookConfirmTimer = null;
|
this.hookConfirmTimerId = null;
|
||||||
|
|
||||||
// Cancel any pending completion confirmation
|
// Cancel any pending completion confirmation
|
||||||
this.cancelCompletionConfirm();
|
this.cancelCompletionConfirm();
|
||||||
@@ -1650,8 +1657,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
private checkClearComplete(): void {
|
private checkClearComplete(): void {
|
||||||
// Clear the fallback timer since we got prompt detection
|
// Clear the fallback timer since we got prompt detection
|
||||||
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimer, 'prompt detected');
|
this.cancelTrackedTimer('clear-fallback', this.clearFallbackTimerId, 'prompt detected');
|
||||||
this.clearFallbackTimer = null;
|
this.clearFallbackTimerId = null;
|
||||||
this.logAction('step', '/clear completed');
|
this.logAction('step', '/clear completed');
|
||||||
this.emit('stepCompleted', 'clear');
|
this.emit('stepCompleted', 'clear');
|
||||||
|
|
||||||
@@ -1699,11 +1706,11 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.logAction('step', 'Monitoring if /init triggered work...');
|
this.logAction('step', 'Monitoring if /init triggered work...');
|
||||||
|
|
||||||
// Give Claude a moment to start working before checking for idle
|
// Give Claude a moment to start working before checking for idle
|
||||||
this.stepTimer = this.startTrackedTimer(
|
this.stepTimerId = this.startTrackedTimer(
|
||||||
'init-monitor',
|
'init-monitor',
|
||||||
3000,
|
3000,
|
||||||
() => {
|
() => {
|
||||||
this.stepTimer = null;
|
this.stepTimerId = null;
|
||||||
// If still in monitoring state and no work detected, consider it idle
|
// If still in monitoring state and no work detected, consider it idle
|
||||||
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
if (this._state === 'monitoring_init' && !this.workingDetected) {
|
||||||
this.checkMonitoringInitIdle();
|
this.checkMonitoringInitIdle();
|
||||||
@@ -1719,9 +1726,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
* @fires stepCompleted - With step 'init'
|
* @fires stepCompleted - With step 'init'
|
||||||
*/
|
*/
|
||||||
private checkMonitoringInitIdle(): void {
|
private checkMonitoringInitIdle(): void {
|
||||||
if (this.stepTimer) {
|
if (this.stepTimerId) {
|
||||||
clearTimeout(this.stepTimer);
|
this.cleanup.unregister(this.stepTimerId);
|
||||||
this.stepTimer = null;
|
this.stepTimerId = null;
|
||||||
}
|
}
|
||||||
this.log('/init did not trigger work, sending kickstart prompt');
|
this.log('/init did not trigger work, sending kickstart prompt');
|
||||||
this.emit('stepCompleted', 'init');
|
this.emit('stepCompleted', 'init');
|
||||||
@@ -1737,11 +1744,11 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
this.clearWorkingPatternWindow();
|
this.clearWorkingPatternWindow();
|
||||||
|
|
||||||
this.stepTimer = this.startTrackedTimer(
|
this.stepTimerId = this.startTrackedTimer(
|
||||||
'step-delay',
|
'step-delay',
|
||||||
this.config.interStepDelayMs,
|
this.config.interStepDelayMs,
|
||||||
async () => {
|
async () => {
|
||||||
this.stepTimer = null;
|
this.stepTimerId = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
const prompt = this.config.kickstartPrompt!;
|
const prompt = this.config.kickstartPrompt!;
|
||||||
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
|
||||||
@@ -1773,46 +1780,20 @@ export class RespawnController extends EventEmitter {
|
|||||||
private clearTimers(): void {
|
private clearTimers(): void {
|
||||||
// Clear tracked timers map first to avoid stale entries during individual cleanup
|
// Clear tracked timers map first to avoid stale entries during individual cleanup
|
||||||
this.activeTimers.clear();
|
this.activeTimers.clear();
|
||||||
if (this.stepTimer) {
|
this.cleanup.dispose();
|
||||||
clearTimeout(this.stepTimer);
|
// Reinitialize for reuse (controller can be stopped and restarted)
|
||||||
this.stepTimer = null;
|
this.cleanup = new CleanupManager();
|
||||||
}
|
// Null out IDs
|
||||||
if (this.clearFallbackTimer) {
|
this.stepTimerId = null;
|
||||||
clearTimeout(this.clearFallbackTimer);
|
this.completionConfirmTimerId = null;
|
||||||
this.clearFallbackTimer = null;
|
this.noOutputTimerId = null;
|
||||||
}
|
this.detectionUpdateTimerId = null;
|
||||||
if (this.completionConfirmTimer) {
|
this.autoAcceptTimerId = null;
|
||||||
clearTimeout(this.completionConfirmTimer);
|
this.preFilterTimerId = null;
|
||||||
this.completionConfirmTimer = null;
|
this.hookConfirmTimerId = null;
|
||||||
}
|
this.clearFallbackTimerId = null;
|
||||||
if (this.stepConfirmTimer) {
|
this.stepConfirmTimerId = null;
|
||||||
clearTimeout(this.stepConfirmTimer);
|
this.stuckStateTimerId = null;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Stuck-State Detection Methods ==========
|
// ========== Stuck-State Detection Methods ==========
|
||||||
@@ -1826,21 +1807,25 @@ export class RespawnController extends EventEmitter {
|
|||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (this.stuckStateTimer) {
|
if (this.stuckStateTimerId) {
|
||||||
clearInterval(this.stuckStateTimer);
|
this.cleanup.unregister(this.stuckStateTimerId);
|
||||||
this.stuckStateTimer = null;
|
this.stuckStateTimerId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check interval for stuck state
|
// Check interval for stuck state
|
||||||
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
|
const checkIntervalMs = Math.min(this.config.stuckStateWarningMs, 60000); // Check every minute max
|
||||||
|
|
||||||
this.stuckStateTimer = setInterval(() => {
|
this.stuckStateTimerId = this.cleanup.setInterval(
|
||||||
try {
|
() => {
|
||||||
this.checkStuckState();
|
try {
|
||||||
} catch (err) {
|
this.checkStuckState();
|
||||||
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
|
} catch (err) {
|
||||||
}
|
console.error(`[RespawnController] Error in stuckStateTimer:`, err);
|
||||||
}, checkIntervalMs);
|
}
|
||||||
|
},
|
||||||
|
checkIntervalMs,
|
||||||
|
{ description: 'stuck-state detection' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1981,7 +1966,7 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Start a tracked timer with UI countdown support.
|
* Start a tracked timer with UI countdown support.
|
||||||
* Emits timerStarted event and tracks the timer for UI display.
|
* 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 now = Date.now();
|
||||||
const endsAt = now + durationMs;
|
const endsAt = now + durationMs;
|
||||||
|
|
||||||
@@ -1989,19 +1974,23 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.emit('timerStarted', { name, durationMs, endsAt, reason });
|
this.emit('timerStarted', { name, durationMs, endsAt, reason });
|
||||||
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
|
this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`);
|
||||||
|
|
||||||
return setTimeout(() => {
|
return this.cleanup.setTimeout(
|
||||||
this.activeTimers.delete(name);
|
() => {
|
||||||
this.emit('timerCompleted', name);
|
this.activeTimers.delete(name);
|
||||||
callback();
|
this.emit('timerCompleted', name);
|
||||||
}, durationMs);
|
callback();
|
||||||
|
},
|
||||||
|
durationMs,
|
||||||
|
{ description: name }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel a tracked timer and emit cancellation event.
|
* Cancel a tracked timer and emit cancellation event.
|
||||||
*/
|
*/
|
||||||
private cancelTrackedTimer(name: string, timerRef: NodeJS.Timeout | null, reason?: string): void {
|
private cancelTrackedTimer(name: string, timerId: string | null, reason?: string): void {
|
||||||
if (timerRef) {
|
if (timerId) {
|
||||||
clearTimeout(timerRef);
|
this.cleanup.unregister(timerId);
|
||||||
if (this.activeTimers.has(name)) {
|
if (this.activeTimers.has(name)) {
|
||||||
this.activeTimers.delete(name);
|
this.activeTimers.delete(name);
|
||||||
this.emit('timerCancelled', name, reason);
|
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).
|
* (used when AI check is disabled or has too many errors).
|
||||||
*/
|
*/
|
||||||
private startNoOutputTimer(): void {
|
private startNoOutputTimer(): void {
|
||||||
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'restarting');
|
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'restarting');
|
||||||
this.noOutputTimer = null;
|
this.noOutputTimerId = null;
|
||||||
|
|
||||||
this.noOutputTimer = this.startTrackedTimer(
|
this.noOutputTimerId = this.startTrackedTimer(
|
||||||
'no-output-fallback',
|
'no-output-fallback',
|
||||||
this.config.noOutputTimeoutMs,
|
this.config.noOutputTimeoutMs,
|
||||||
() => {
|
() => {
|
||||||
this.noOutputTimer = null;
|
this.noOutputTimerId = null;
|
||||||
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
if (this._state === 'watching' || this._state === 'confirming_idle') {
|
||||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||||
this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`);
|
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.
|
* This provides an additional path to AI check even without a completion message.
|
||||||
*/
|
*/
|
||||||
private startPreFilterTimer(): void {
|
private startPreFilterTimer(): void {
|
||||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'restarting');
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'restarting');
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimerId = null;
|
||||||
|
|
||||||
// Only set up pre-filter when AI check is enabled
|
// Only set up pre-filter when AI check is enabled
|
||||||
if (!this.config.aiIdleCheckEnabled) return;
|
if (!this.config.aiIdleCheckEnabled) return;
|
||||||
|
|
||||||
this.preFilterTimer = this.startTrackedTimer(
|
this.preFilterTimerId = this.startTrackedTimer(
|
||||||
'pre-filter',
|
'pre-filter',
|
||||||
this.config.completionConfirmMs,
|
this.config.completionConfirmMs,
|
||||||
() => {
|
() => {
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimerId = null;
|
||||||
if (this._state === 'watching') {
|
if (this._state === 'watching') {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const msSinceOutput = now - this.lastOutputTime;
|
const msSinceOutput = now - this.lastOutputTime;
|
||||||
@@ -2252,18 +2241,18 @@ export class RespawnController extends EventEmitter {
|
|||||||
|
|
||||||
if (result.verdict === 'IDLE') {
|
if (result.verdict === 'IDLE') {
|
||||||
// Cancel any pending confirmation timers - AI has spoken
|
// Cancel any pending confirmation timers - AI has spoken
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'AI verdict: IDLE');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE');
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'AI verdict: IDLE');
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimerId = null;
|
||||||
|
|
||||||
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`);
|
||||||
this.emit('aiCheckCompleted', result);
|
this.emit('aiCheckCompleted', result);
|
||||||
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`);
|
||||||
} else if (result.verdict === 'WORKING') {
|
} else if (result.verdict === 'WORKING') {
|
||||||
// Cancel timers and go to cooldown
|
// Cancel timers and go to cooldown
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'AI verdict: WORKING');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
|
|
||||||
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`);
|
||||||
this.emit('aiCheckCompleted', result);
|
this.emit('aiCheckCompleted', result);
|
||||||
@@ -2319,14 +2308,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
* and no elicitation dialog was detected. Only handles plan mode approvals.
|
||||||
*/
|
*/
|
||||||
private startAutoAcceptTimer(): void {
|
private startAutoAcceptTimer(): void {
|
||||||
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'restarting');
|
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimerId, 'restarting');
|
||||||
this.autoAcceptTimer = null;
|
this.autoAcceptTimerId = null;
|
||||||
|
|
||||||
this.autoAcceptTimer = this.startTrackedTimer(
|
this.autoAcceptTimerId = this.startTrackedTimer(
|
||||||
'auto-accept',
|
'auto-accept',
|
||||||
this.config.autoAcceptDelayMs,
|
this.config.autoAcceptDelayMs,
|
||||||
() => {
|
() => {
|
||||||
this.autoAcceptTimer = null;
|
this.autoAcceptTimerId = null;
|
||||||
this.tryAutoAccept();
|
this.tryAutoAccept();
|
||||||
},
|
},
|
||||||
'plan mode detection'
|
'plan mode detection'
|
||||||
@@ -2338,8 +2327,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Called when a completion message is detected (normal idle flow handles it).
|
* Called when a completion message is detected (normal idle flow handles it).
|
||||||
*/
|
*/
|
||||||
private cancelAutoAcceptTimer(): void {
|
private cancelAutoAcceptTimer(): void {
|
||||||
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimer, 'cancelled');
|
this.cancelTrackedTimer('auto-accept', this.autoAcceptTimerId, 'cancelled');
|
||||||
this.autoAcceptTimer = null;
|
this.autoAcceptTimerId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2500,8 +2489,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cancel completion confirmation - auto-accept takes precedence
|
// Cancel completion confirmation - auto-accept takes precedence
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'auto-accept');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'auto-accept');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
this.completionMessageTime = null;
|
this.completionMessageTime = null;
|
||||||
|
|
||||||
// Ensure we're in watching state (not confirming_idle or ai_checking)
|
// 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
|
// Cancel completion confirm timer - hook takes precedence
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'Stop hook received');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
|
|
||||||
// Cancel pre-filter timer - hook takes precedence
|
// Cancel pre-filter timer - hook takes precedence
|
||||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'Stop hook received');
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'Stop hook received');
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimerId = null;
|
||||||
|
|
||||||
// Start short confirmation timer to handle race conditions
|
// Start short confirmation timer to handle race conditions
|
||||||
// (e.g., Stop hook arrives but Claude immediately starts new work)
|
// (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
|
// Cancel all other detection timers - this is definitive
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'idle_prompt received');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received');
|
this.cancelTrackedTimer('pre-filter', this.preFilterTimerId, 'idle_prompt received');
|
||||||
this.preFilterTimer = null;
|
this.preFilterTimerId = null;
|
||||||
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimer, 'idle_prompt received');
|
this.cancelTrackedTimer('no-output-fallback', this.noOutputTimerId, 'idle_prompt received');
|
||||||
this.noOutputTimer = null;
|
this.noOutputTimerId = null;
|
||||||
|
|
||||||
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
|
// idle_prompt is an even stronger signal than Stop hook (60s+ idle)
|
||||||
// Skip confirmation and go directly to 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')
|
* @param hookType - Which hook triggered this ('stop' or 'idle_prompt')
|
||||||
*/
|
*/
|
||||||
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
|
private startHookConfirmTimer(hookType: 'stop' | 'idle_prompt'): void {
|
||||||
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimer, 'restarting');
|
this.cancelTrackedTimer('hook-confirm', this.hookConfirmTimerId, 'restarting');
|
||||||
this.hookConfirmTimer = null;
|
this.hookConfirmTimerId = null;
|
||||||
|
|
||||||
this.hookConfirmTimer = this.startTrackedTimer(
|
this.hookConfirmTimerId = this.startTrackedTimer(
|
||||||
'hook-confirm',
|
'hook-confirm',
|
||||||
RespawnController.HOOK_CONFIRM_DELAY_MS,
|
RespawnController.HOOK_CONFIRM_DELAY_MS,
|
||||||
() => {
|
() => {
|
||||||
this.hookConfirmTimer = null;
|
this.hookConfirmTimerId = null;
|
||||||
|
|
||||||
// Verify we haven't received new output since the hook arrived
|
// Verify we haven't received new output since the hook arrived
|
||||||
const hookTime = hookType === 'stop' ? this.stopHookTime : this.idlePromptTime;
|
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.
|
* After completion message, waits for output silence then triggers AI check.
|
||||||
*/
|
*/
|
||||||
private startCompletionConfirmTimer(): void {
|
private startCompletionConfirmTimer(): void {
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'restarting');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'restarting');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
|
|
||||||
this.setState('confirming_idle');
|
this.setState('confirming_idle');
|
||||||
this.logAction('detection', 'Completion message found in output');
|
this.logAction('detection', 'Completion message found in output');
|
||||||
|
|
||||||
this.completionConfirmTimer = this.startTrackedTimer(
|
this.completionConfirmTimerId = this.startTrackedTimer(
|
||||||
'completion-confirm',
|
'completion-confirm',
|
||||||
this.config.completionConfirmMs,
|
this.config.completionConfirmMs,
|
||||||
() => {
|
() => {
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||||
if (msSinceOutput >= this.config.completionConfirmMs) {
|
if (msSinceOutput >= this.config.completionConfirmMs) {
|
||||||
@@ -2725,8 +2714,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Cancel completion confirmation if new activity detected.
|
* Cancel completion confirmation if new activity detected.
|
||||||
*/
|
*/
|
||||||
private cancelCompletionConfirm(): void {
|
private cancelCompletionConfirm(): void {
|
||||||
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'activity detected');
|
this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimerId, 'activity detected');
|
||||||
this.completionConfirmTimer = null;
|
this.completionConfirmTimerId = null;
|
||||||
if (this._state === 'confirming_idle') {
|
if (this._state === 'confirming_idle') {
|
||||||
this.setState('watching');
|
this.setState('watching');
|
||||||
this.completionMessageTime = null;
|
this.completionMessageTime = null;
|
||||||
@@ -2739,14 +2728,14 @@ export class RespawnController extends EventEmitter {
|
|||||||
* This ensures Claude has finished processing before we send the next command.
|
* This ensures Claude has finished processing before we send the next command.
|
||||||
*/
|
*/
|
||||||
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
|
private startStepConfirmTimer(step: 'update' | 'init' | 'kickstart'): void {
|
||||||
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'restarting');
|
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimerId, 'restarting');
|
||||||
this.stepConfirmTimer = null;
|
this.stepConfirmTimerId = null;
|
||||||
|
|
||||||
this.stepConfirmTimer = this.startTrackedTimer(
|
this.stepConfirmTimerId = this.startTrackedTimer(
|
||||||
'step-confirm',
|
'step-confirm',
|
||||||
this.config.completionConfirmMs,
|
this.config.completionConfirmMs,
|
||||||
() => {
|
() => {
|
||||||
this.stepConfirmTimer = null;
|
this.stepConfirmTimerId = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
const msSinceOutput = Date.now() - this.lastOutputTime;
|
const msSinceOutput = Date.now() - this.lastOutputTime;
|
||||||
|
|
||||||
@@ -2779,8 +2768,8 @@ export class RespawnController extends EventEmitter {
|
|||||||
* Cancel step confirmation if working patterns detected.
|
* Cancel step confirmation if working patterns detected.
|
||||||
*/
|
*/
|
||||||
private cancelStepConfirm(): void {
|
private cancelStepConfirm(): void {
|
||||||
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimer, 'working detected');
|
this.cancelTrackedTimer('step-confirm', this.stepConfirmTimerId, 'working detected');
|
||||||
this.stepConfirmTimer = null;
|
this.stepConfirmTimerId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2942,11 +2931,11 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
this.terminalBuffer.clear(); // Clear buffer for fresh detection
|
||||||
this.clearWorkingPatternWindow(); // Clear rolling window
|
this.clearWorkingPatternWindow(); // Clear rolling window
|
||||||
|
|
||||||
this.stepTimer = this.startTrackedTimer(
|
this.stepTimerId = this.startTrackedTimer(
|
||||||
'step-delay',
|
'step-delay',
|
||||||
this.config.interStepDelayMs,
|
this.config.interStepDelayMs,
|
||||||
async () => {
|
async () => {
|
||||||
this.stepTimer = null;
|
this.stepTimerId = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
|
|
||||||
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
|
||||||
@@ -2983,11 +2972,11 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
this.clearWorkingPatternWindow();
|
this.clearWorkingPatternWindow();
|
||||||
|
|
||||||
this.stepTimer = this.startTrackedTimer(
|
this.stepTimerId = this.startTrackedTimer(
|
||||||
'step-delay',
|
'step-delay',
|
||||||
this.config.interStepDelayMs,
|
this.config.interStepDelayMs,
|
||||||
async () => {
|
async () => {
|
||||||
this.stepTimer = null;
|
this.stepTimerId = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.logAction('command', 'Sending: /clear');
|
this.logAction('command', 'Sending: /clear');
|
||||||
await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI
|
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;
|
this.promptDetected = false;
|
||||||
|
|
||||||
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
// Start fallback timer - if no prompt detected after 10s, proceed to /init anyway
|
||||||
this.clearFallbackTimer = this.startTrackedTimer(
|
this.clearFallbackTimerId = this.startTrackedTimer(
|
||||||
'clear-fallback',
|
'clear-fallback',
|
||||||
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
|
RespawnController.CLEAR_FALLBACK_TIMEOUT_MS,
|
||||||
() => {
|
() => {
|
||||||
this.clearFallbackTimer = null;
|
this.clearFallbackTimerId = null;
|
||||||
if (this._state === 'waiting_clear') {
|
if (this._state === 'waiting_clear') {
|
||||||
this.logAction('step', '/clear fallback: proceeding to /init');
|
this.logAction('step', '/clear fallback: proceeding to /init');
|
||||||
this.emit('stepCompleted', 'clear');
|
this.emit('stepCompleted', 'clear');
|
||||||
@@ -3027,11 +3016,11 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.terminalBuffer.clear();
|
this.terminalBuffer.clear();
|
||||||
this.clearWorkingPatternWindow();
|
this.clearWorkingPatternWindow();
|
||||||
|
|
||||||
this.stepTimer = this.startTrackedTimer(
|
this.stepTimerId = this.startTrackedTimer(
|
||||||
'step-delay',
|
'step-delay',
|
||||||
this.config.interStepDelayMs,
|
this.config.interStepDelayMs,
|
||||||
async () => {
|
async () => {
|
||||||
this.stepTimer = null;
|
this.stepTimerId = null;
|
||||||
if (this._state === 'stopped') return;
|
if (this._state === 'stopped') return;
|
||||||
this.logAction('command', 'Sending: /init');
|
this.logAction('command', 'Sending: /init');
|
||||||
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI
|
||||||
|
|||||||
+12
-25
@@ -28,7 +28,7 @@ import {
|
|||||||
TokenStats,
|
TokenStats,
|
||||||
TokenUsageEntry,
|
TokenUsageEntry,
|
||||||
} from './types.js';
|
} 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) */
|
/** Debounce delay for batching state writes (ms) */
|
||||||
const SAVE_DEBOUNCE_MS = 500;
|
const SAVE_DEBOUNCE_MS = 500;
|
||||||
@@ -60,7 +60,7 @@ const MAX_CONSECUTIVE_FAILURES = 3;
|
|||||||
export class StateStore {
|
export class StateStore {
|
||||||
private state: AppState;
|
private state: AppState;
|
||||||
private filePath: string;
|
private filePath: string;
|
||||||
private saveTimeout: NodeJS.Timeout | null = null;
|
private saveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||||
private dirty: boolean = false;
|
private dirty: boolean = false;
|
||||||
private dirtySessions = new Set<string>();
|
private dirtySessions = new Set<string>();
|
||||||
private cachedSessionJsons = new Map<string, 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)
|
// Inner state storage (separate from main state to reduce write frequency)
|
||||||
private ralphStates: Map<string, RalphSessionState> = new Map();
|
private ralphStates: Map<string, RalphSessionState> = new Map();
|
||||||
private ralphStatePath: string;
|
private ralphStatePath: string;
|
||||||
private ralphStateSaveTimeout: NodeJS.Timeout | null = null;
|
private ralphStateSaveDeb = new Debouncer(SAVE_DEBOUNCE_MS);
|
||||||
private ralphStateDirty: boolean = false;
|
private ralphStateDirty: boolean = false;
|
||||||
|
|
||||||
// Circuit breaker for save failures (prevents hammering disk on persistent errors)
|
// Circuit breaker for save failures (prevents hammering disk on persistent errors)
|
||||||
@@ -150,14 +150,12 @@ export class StateStore {
|
|||||||
*/
|
*/
|
||||||
save(): void {
|
save(): void {
|
||||||
this.dirty = true;
|
this.dirty = true;
|
||||||
if (this.saveTimeout) {
|
if (this.saveDeb.isPending) return; // Already scheduled
|
||||||
return; // Already scheduled
|
this.saveDeb.schedule(() => {
|
||||||
}
|
|
||||||
this.saveTimeout = setTimeout(() => {
|
|
||||||
this.saveNowAsync().catch((err) => {
|
this.saveNowAsync().catch((err) => {
|
||||||
console.error('[StateStore] Async save failed:', err);
|
console.error('[StateStore] Async save failed:', err);
|
||||||
});
|
});
|
||||||
}, SAVE_DEBOUNCE_MS);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -242,10 +240,7 @@ export class StateStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _doSaveAsync(): Promise<void> {
|
private async _doSaveAsync(): Promise<void> {
|
||||||
if (this.saveTimeout) {
|
this.saveDeb.cancel();
|
||||||
clearTimeout(this.saveTimeout);
|
|
||||||
this.saveTimeout = null;
|
|
||||||
}
|
|
||||||
if (!this.dirty) {
|
if (!this.dirty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -332,10 +327,7 @@ export class StateStore {
|
|||||||
* Prefer saveNowAsync() for normal operation.
|
* Prefer saveNowAsync() for normal operation.
|
||||||
*/
|
*/
|
||||||
saveNow(): void {
|
saveNow(): void {
|
||||||
if (this.saveTimeout) {
|
this.saveDeb.cancel();
|
||||||
clearTimeout(this.saveTimeout);
|
|
||||||
this.saveTimeout = null;
|
|
||||||
}
|
|
||||||
if (!this.dirty) {
|
if (!this.dirty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -775,12 +767,10 @@ export class StateStore {
|
|||||||
// Debounced save for inner states
|
// Debounced save for inner states
|
||||||
private saveRalphStates(): void {
|
private saveRalphStates(): void {
|
||||||
this.ralphStateDirty = true;
|
this.ralphStateDirty = true;
|
||||||
if (this.ralphStateSaveTimeout) {
|
if (this.ralphStateSaveDeb.isPending) return; // Already scheduled
|
||||||
return; // Already scheduled
|
this.ralphStateSaveDeb.schedule(() => {
|
||||||
}
|
|
||||||
this.ralphStateSaveTimeout = setTimeout(() => {
|
|
||||||
this.saveRalphStatesNow();
|
this.saveRalphStatesNow();
|
||||||
}, SAVE_DEBOUNCE_MS);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -788,10 +778,7 @@ export class StateStore {
|
|||||||
* Writes to temp file first, then renames to prevent corruption on crash.
|
* Writes to temp file first, then renames to prevent corruption on crash.
|
||||||
*/
|
*/
|
||||||
private saveRalphStatesNow(): void {
|
private saveRalphStatesNow(): void {
|
||||||
if (this.ralphStateSaveTimeout) {
|
this.ralphStateSaveDeb.cancel();
|
||||||
clearTimeout(this.ralphStateSaveTimeout);
|
|
||||||
this.ralphStateSaveTimeout = null;
|
|
||||||
}
|
|
||||||
if (!this.ralphStateDirty) {
|
if (!this.ralphStateDirty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-105
@@ -14,6 +14,7 @@ import { join, basename } from 'node:path';
|
|||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { readFile, readdir, stat as statAsync } from 'node:fs/promises';
|
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 { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js';
|
||||||
|
import { CleanupManager, KeyedDebouncer } from './utils/index.js';
|
||||||
|
|
||||||
// ========== Types ==========
|
// ========== Types ==========
|
||||||
|
|
||||||
@@ -161,12 +162,11 @@ const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates
|
|||||||
export class SubagentWatcher extends EventEmitter {
|
export class SubagentWatcher extends EventEmitter {
|
||||||
private filePositions = new Map<string, number>();
|
private filePositions = new Map<string, number>();
|
||||||
private dirWatchers = new Map<string, FSWatcher>();
|
private dirWatchers = new Map<string, FSWatcher>();
|
||||||
// Per-file debounce timers for directory watcher (replaces per-file FSWatchers)
|
// Per-file debouncer for directory watcher (replaces per-file FSWatchers)
|
||||||
private fileDebouncers = new Map<string, NodeJS.Timeout>();
|
private fileDeb = new KeyedDebouncer(FILE_CONTENT_DEBOUNCE_MS);
|
||||||
private agentInfo = new Map<string, SubagentInfo>();
|
private agentInfo = new Map<string, SubagentInfo>();
|
||||||
private idleTimers = new Map<string, NodeJS.Timeout>();
|
private idleDeb = new KeyedDebouncer(IDLE_TIMEOUT_MS);
|
||||||
private pollInterval: NodeJS.Timeout | null = null;
|
private cleanup = new CleanupManager();
|
||||||
private livenessInterval: NodeJS.Timeout | null = null;
|
|
||||||
private _isRunning = false;
|
private _isRunning = false;
|
||||||
private knownSubagentDirs = new Set<string>();
|
private knownSubagentDirs = new Set<string>();
|
||||||
// Map of agentId -> Map of toolUseId -> { toolName, timestamp } (for linking tool_result to tool_call)
|
// 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
|
// Periodic scan for new subagent directories
|
||||||
// Full directory traversal only every FULL_SCAN_EVERY_N_POLLS polls (~5s)
|
// Full directory traversal only every FULL_SCAN_EVERY_N_POLLS polls (~5s)
|
||||||
// FSWatchers handle known directories between full scans
|
// FSWatchers handle known directories between full scans
|
||||||
this.pollInterval = setInterval(() => {
|
this.cleanup.setInterval(
|
||||||
this._pollCount++;
|
() => {
|
||||||
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
|
this._pollCount++;
|
||||||
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
|
||||||
}
|
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
|
||||||
}, POLL_INTERVAL_MS);
|
}
|
||||||
|
},
|
||||||
|
POLL_INTERVAL_MS,
|
||||||
|
{ description: 'subagent directory poll' }
|
||||||
|
);
|
||||||
|
|
||||||
// Periodic liveness check for active subagents
|
// Periodic liveness check for active subagents
|
||||||
this.startLivenessChecker();
|
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
|
* 3. Full pgrep scan (expensive, ~500ms) — only for agents that fail tiers 1+2
|
||||||
*/
|
*/
|
||||||
private startLivenessChecker(): void {
|
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 () => {
|
try {
|
||||||
// Guard: prevent concurrent liveness checks (avoids duplicate completed events)
|
// Collect agents that need the expensive pgrep scan
|
||||||
if (this._isCheckingLiveness) return;
|
const needsFullScan: SubagentInfo[] = [];
|
||||||
this._isCheckingLiveness = true;
|
|
||||||
|
|
||||||
try {
|
for (const [_agentId, info] of this.agentInfo) {
|
||||||
// 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
|
|
||||||
if (info.status !== 'active' && info.status !== 'idle') continue;
|
if (info.status !== 'active' && info.status !== 'idle') continue;
|
||||||
|
|
||||||
const alive = this.checkSubagentAliveFromPidMap(info, pidMap);
|
// Tier 1: File mtime check (~0.3ms per agent)
|
||||||
if (!alive) {
|
if (await this.checkSubagentFileAlive(info)) continue;
|
||||||
info.pid = undefined;
|
|
||||||
info.status = 'completed';
|
// Tier 2: Cached PID check (~0.1ms per agent)
|
||||||
this.pendingToolCalls.delete(info.agentId);
|
if (info.pid && (await this.checkPidAlive(info.pid))) continue;
|
||||||
this.emit('subagent:completed', info);
|
|
||||||
|
// 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)
|
// Periodically clean up stale completed agents (older than 24 hours)
|
||||||
this.cleanupStaleAgents();
|
this.cleanupStaleAgents();
|
||||||
} finally {
|
} finally {
|
||||||
this._isCheckingLiveness = false;
|
this._isCheckingLiveness = false;
|
||||||
}
|
}
|
||||||
}, LIVENESS_CHECK_MS);
|
},
|
||||||
|
LIVENESS_CHECK_MS,
|
||||||
|
{ description: 'subagent liveness check' }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -418,21 +424,12 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
stop(): void {
|
stop(): void {
|
||||||
this._isRunning = false;
|
this._isRunning = false;
|
||||||
|
|
||||||
if (this.pollInterval) {
|
// Dispose poll and liveness intervals, then re-create for potential restart
|
||||||
clearInterval(this.pollInterval);
|
this.cleanup.dispose();
|
||||||
this.pollInterval = null;
|
this.cleanup = new CleanupManager();
|
||||||
}
|
|
||||||
|
|
||||||
if (this.livenessInterval) {
|
|
||||||
clearInterval(this.livenessInterval);
|
|
||||||
this.livenessInterval = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear file debouncers
|
// Clear file debouncers
|
||||||
for (const timer of this.fileDebouncers.values()) {
|
this.fileDeb.dispose();
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
this.fileDebouncers.clear();
|
|
||||||
this.fileAgentContext.clear();
|
this.fileAgentContext.clear();
|
||||||
|
|
||||||
// Remove error handlers before closing watchers to prevent memory leak
|
// Remove error handlers before closing watchers to prevent memory leak
|
||||||
@@ -447,10 +444,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.dirWatchers.clear();
|
this.dirWatchers.clear();
|
||||||
|
|
||||||
for (const timer of this.idleTimers.values()) {
|
this.idleDeb.dispose();
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
this.idleTimers.clear();
|
|
||||||
|
|
||||||
// Clear all state for clean restart
|
// Clear all state for clean restart
|
||||||
this.filePositions.clear();
|
this.filePositions.clear();
|
||||||
@@ -532,16 +526,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
this.pendingToolCalls.delete(agentId);
|
this.pendingToolCalls.delete(agentId);
|
||||||
this.filePositions.delete(info.filePath);
|
this.filePositions.delete(info.filePath);
|
||||||
this.fileAgentContext.delete(info.filePath);
|
this.fileAgentContext.delete(info.filePath);
|
||||||
const debounceTimer = this.fileDebouncers.get(info.filePath);
|
this.fileDeb.cancelKey(info.filePath);
|
||||||
if (debounceTimer) {
|
this.idleDeb.cancelKey(agentId);
|
||||||
clearTimeout(debounceTimer);
|
|
||||||
this.fileDebouncers.delete(info.filePath);
|
|
||||||
}
|
|
||||||
const timer = this.idleTimers.get(agentId);
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
this.idleTimers.delete(agentId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,9 +620,9 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
agentCount: this.agentInfo.size,
|
agentCount: this.agentInfo.size,
|
||||||
fileDebouncerCount: this.fileDebouncers.size,
|
fileDebouncerCount: this.fileDeb.size,
|
||||||
dirWatcherCount: this.dirWatchers.size,
|
dirWatcherCount: this.dirWatchers.size,
|
||||||
idleTimerCount: this.idleTimers.size,
|
idleTimerCount: this.idleDeb.size,
|
||||||
pendingToolCallsCount,
|
pendingToolCallsCount,
|
||||||
knownDirsCount: this.knownSubagentDirs.size,
|
knownDirsCount: this.knownSubagentDirs.size,
|
||||||
filePositionsCount: this.filePositions.size,
|
filePositionsCount: this.filePositions.size,
|
||||||
@@ -1129,13 +1115,8 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (!filename?.endsWith('.jsonl')) return;
|
if (!filename?.endsWith('.jsonl')) return;
|
||||||
const filePath = join(dir, filename);
|
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
|
// Debounce 100ms to batch rapid writes
|
||||||
const timer = setTimeout(() => {
|
this.fileDeb.schedule(filePath, () => {
|
||||||
this.fileDebouncers.delete(filePath);
|
|
||||||
if (!existsSync(filePath)) return;
|
if (!existsSync(filePath)) return;
|
||||||
|
|
||||||
if (this.fileAgentContext.has(filePath)) {
|
if (this.fileAgentContext.has(filePath)) {
|
||||||
@@ -1145,9 +1126,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// New file — register it
|
// New file — register it
|
||||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
||||||
}
|
}
|
||||||
}, FILE_CONTENT_DEBOUNCE_MS);
|
});
|
||||||
|
|
||||||
this.fileDebouncers.set(filePath, timer);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle watcher errors to prevent unhandled exceptions
|
// Handle watcher errors to prevent unhandled exceptions
|
||||||
@@ -1602,25 +1581,13 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* Reset idle timer for an agent
|
* Reset idle timer for an agent
|
||||||
*/
|
*/
|
||||||
private resetIdleTimer(agentId: string): void {
|
private resetIdleTimer(agentId: string): void {
|
||||||
const existing = this.idleTimers.get(agentId);
|
this.idleDeb.schedule(agentId, () => {
|
||||||
if (existing) {
|
|
||||||
clearTimeout(existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
// Guard against race condition: agent may have been deleted before timer fires
|
|
||||||
const info = this.agentInfo.get(agentId);
|
const info = this.agentInfo.get(agentId);
|
||||||
if (!info) {
|
if (!info) return;
|
||||||
// Agent was deleted - clean up timer reference
|
|
||||||
this.idleTimers.delete(agentId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (info.status === 'active') {
|
if (info.status === 'active') {
|
||||||
info.status = 'idle';
|
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
|
// Timing Constants
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/** Timeout for exec commands (5 seconds) */
|
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
||||||
const EXEC_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
/** Delay after tmux session creation — enough for detached tmux to be queryable */
|
||||||
const TMUX_CREATION_WAIT_MS = 100;
|
const TMUX_CREATION_WAIT_MS = 100;
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import { execSync } from 'node:child_process';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { delimiter, dirname, join } from 'node:path';
|
import { delimiter, dirname, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
/** Timeout for exec commands (5 seconds) */
|
|
||||||
const EXEC_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
/** Common directories where the Claude CLI binary may be installed */
|
/** Common directories where the Claude CLI binary may be installed */
|
||||||
const CLAUDE_SEARCH_DIRS = [
|
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 { BufferAccumulator } from './buffer-accumulator.js';
|
||||||
export { LRUMap, type LRUMapOptions } from './lru-map.js';
|
export { LRUMap, type LRUMapOptions } from './lru-map.js';
|
||||||
export { CleanupManager, type TimerOptions } from './cleanup-manager.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 { StaleExpirationMap, type StaleExpirationMapOptions } from './stale-expiration-map.js';
|
||||||
export {
|
export {
|
||||||
ANSI_ESCAPE_PATTERN_FULL,
|
ANSI_ESCAPE_PATTERN_FULL,
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
TOKEN_PATTERN,
|
TOKEN_PATTERN,
|
||||||
SPINNER_PATTERN,
|
SPINNER_PATTERN,
|
||||||
|
createAnsiPatternFull,
|
||||||
|
createAnsiPatternSimple,
|
||||||
|
stripAnsi,
|
||||||
|
SAFE_PATH_PATTERN,
|
||||||
} from './regex-patterns.js';
|
} 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 { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js';
|
||||||
export { assertNever } from './type-safety.js';
|
export { assertNever } from './type-safety.js';
|
||||||
export { wrapWithNice } from './nice-wrapper.js';
|
export { wrapWithNice } from './nice-wrapper.js';
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import { execSync } from 'node:child_process';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
|
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
|
||||||
/** Timeout for exec commands (5 seconds) */
|
|
||||||
const EXEC_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
/** Common directories where the OpenCode CLI binary may be installed */
|
/** Common directories where the OpenCode CLI binary may be installed */
|
||||||
const OPENCODE_SEARCH_DIRS = [
|
const OPENCODE_SEARCH_DIRS = [
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
* levenshteinDistance('hello', 'helo') // 1 (one deletion)
|
* levenshteinDistance('hello', 'helo') // 1 (one deletion)
|
||||||
* levenshteinDistance('COMPLETE', 'COMPLET') // 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
|
// Ensure a is the shorter string for space efficiency
|
||||||
if (a.length > b.length) {
|
if (a.length > b.length) {
|
||||||
[a, b] = [b, a];
|
[a, b] = [b, a];
|
||||||
@@ -91,22 +91,6 @@ export function stringSimilarity(a: string, b: string): number {
|
|||||||
return 1 - distance / maxLength;
|
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.
|
* Check if two strings are similar with edit distance tolerance.
|
||||||
* More intuitive for short strings than percentage-based threshold.
|
* 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', 'COMPLET', 2) // true (distance 1)
|
||||||
* isSimilarByDistance('COMPLETE', 'COMP', 2) // false (distance 4)
|
* 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;
|
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'
|
||||||
* normalizePhrase('Task Done') // 'TASKDONE'
|
* normalizePhrase('Task Done') // 'TASKDONE'
|
||||||
*/
|
*/
|
||||||
export function normalizePhrase(phrase: string): string {
|
function normalizePhrase(phrase: string): string {
|
||||||
return phrase
|
return phrase
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
.replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
|
.replace(/[\s_\-.]+/g, '') // Remove whitespace, underscores, hyphens, dots
|
||||||
|
|||||||
@@ -506,3 +506,42 @@ export const RalphLoopStartSchema = z.object({
|
|||||||
)
|
)
|
||||||
.optional(),
|
.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,
|
RalphLoopStartSchema,
|
||||||
isValidWorkingDir,
|
isValidWorkingDir,
|
||||||
} from './schemas.js';
|
} 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';
|
import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
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
|
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
|
||||||
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
|
||||||
});
|
});
|
||||||
// Scheduled runs cleanup timer
|
// Centralized cleanup for standalone timers (intervals + resettable timeouts)
|
||||||
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
|
private cleanup = new CleanupManager();
|
||||||
// SSE event batching
|
// SSE event batching
|
||||||
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
|
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)
|
// State update batching (reduce expensive toDetailedState() serialization)
|
||||||
private stateUpdatePending: Set<string> = new Set();
|
private stateUpdatePending: Set<string> = new Set();
|
||||||
private stateUpdateTimer: NodeJS.Timeout | null = null;
|
private stateUpdateTimerId: string | null = null;
|
||||||
// SSE client health check timer
|
|
||||||
private sseHealthCheckTimer: NodeJS.Timeout | null = null;
|
|
||||||
// Flag to prevent new timers during shutdown
|
// Flag to prevent new timers during shutdown
|
||||||
private _isStopping: boolean = false;
|
private _isStopping: boolean = false;
|
||||||
// Cached light state for SSE init (avoids rebuilding on every reconnect)
|
// 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;
|
private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null;
|
||||||
// Token recording for daily stats (track what's been recorded to avoid double-counting)
|
// 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 lastRecordedTokens: Map<string, { input: number; output: number }> = new Map();
|
||||||
private tokenRecordingTimer: NodeJS.Timeout | null = null;
|
|
||||||
// Server startup time for respawn grace period calculation
|
// Server startup time for respawn grace period calculation
|
||||||
private readonly serverStartTime: number = Date.now();
|
private readonly serverStartTime: number = Date.now();
|
||||||
// Pending respawn start timers (for cleanup on shutdown)
|
// Pending respawn start timers (for cleanup on shutdown)
|
||||||
private pendingRespawnStarts: Map<string, NodeJS.Timeout> = new Map();
|
private pendingRespawnStarts: Map<string, NodeJS.Timeout> = new Map();
|
||||||
// Active plan orchestrators (for cancellation via API)
|
// Active plan orchestrators (for cancellation via API)
|
||||||
private activePlanOrchestrators: Map<string, PlanOrchestrator> = new Map();
|
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)
|
// Grace period before starting restored respawn controllers (2 minutes)
|
||||||
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
|
private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000;
|
||||||
// Stored listener handlers for cleanup
|
// 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 */
|
/** Debounced wrapper — coalesces rapid persistSessionState calls per session */
|
||||||
private persistSessionState(session: Session): void {
|
private persistSessionState(session: Session): void {
|
||||||
const existing = this.persistDebounceTimers.get(session.id);
|
this.persistDeb.schedule(session.id, () => {
|
||||||
if (existing) clearTimeout(existing);
|
// Session may have been removed during debounce
|
||||||
this.persistDebounceTimers.set(
|
if (this.sessions.has(session.id)) {
|
||||||
session.id,
|
this._persistSessionStateNow(session);
|
||||||
setTimeout(() => {
|
}
|
||||||
this.persistDebounceTimers.delete(session.id);
|
});
|
||||||
// Session may have been removed during debounce
|
|
||||||
if (this.sessions.has(session.id)) {
|
|
||||||
this._persistSessionStateNow(session);
|
|
||||||
}
|
|
||||||
}, 100)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Persists full session state including respawn config to state.json */
|
/** 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)
|
// Clear pending persist-debounce timer (prevents stale closure holding session ref)
|
||||||
const pendingPersist = this.persistDebounceTimers.get(sessionId);
|
this.persistDeb.cancelKey(sessionId);
|
||||||
if (pendingPersist) {
|
|
||||||
clearTimeout(pendingPersist);
|
|
||||||
this.persistDebounceTimers.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear batches, per-session timers, and pending state updates
|
// Clear batches, per-session timers, and pending state updates
|
||||||
this.terminalBatches.delete(sessionId);
|
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);
|
this.lastTerminalEventTime.delete(session.id);
|
||||||
|
|
||||||
// Clear pending persist-debounce timer
|
// Clear pending persist-debounce timer
|
||||||
const pendingPersist = this.persistDebounceTimers.get(session.id);
|
this.persistDeb.cancelKey(session.id);
|
||||||
if (pendingPersist) {
|
|
||||||
clearTimeout(pendingPersist);
|
|
||||||
this.persistDebounceTimers.delete(session.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close any active file streams
|
// Close any active file streams
|
||||||
fileStreamManager.closeSessionStreams(session.id);
|
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}`;
|
const key = `${sessionId}:${task.id}`;
|
||||||
this.taskUpdateBatches.set(key, { sessionId, task });
|
this.taskUpdateBatches.set(key, { sessionId, task });
|
||||||
|
|
||||||
if (!this.taskUpdateBatchTimer) {
|
if (!this.taskUpdateBatchTimerId) {
|
||||||
this.taskUpdateBatchTimer = setTimeout(() => {
|
this.taskUpdateBatchTimerId = this.cleanup.setTimeout(
|
||||||
this.flushTaskUpdateBatches();
|
() => {
|
||||||
this.taskUpdateBatchTimer = null;
|
this.taskUpdateBatchTimerId = null;
|
||||||
}, TASK_UPDATE_BATCH_INTERVAL);
|
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);
|
this.stateUpdatePending.add(sessionId);
|
||||||
|
|
||||||
if (!this.stateUpdateTimer) {
|
if (!this.stateUpdateTimerId) {
|
||||||
this.stateUpdateTimer = setTimeout(() => {
|
this.stateUpdateTimerId = this.cleanup.setTimeout(
|
||||||
this.flushStateUpdates();
|
() => {
|
||||||
this.stateUpdateTimer = null;
|
this.stateUpdateTimerId = null;
|
||||||
}, STATE_UPDATE_DEBOUNCE_INTERVAL);
|
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}`;
|
process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`;
|
||||||
|
|
||||||
// Start scheduled runs cleanup timer
|
// Start scheduled runs cleanup timer
|
||||||
this.scheduledCleanupTimer = setInterval(() => {
|
this.cleanup.setInterval(
|
||||||
this.cleanupScheduledRuns();
|
() => {
|
||||||
}, SCHEDULED_CLEANUP_INTERVAL);
|
this.cleanupScheduledRuns();
|
||||||
|
},
|
||||||
|
SCHEDULED_CLEANUP_INTERVAL,
|
||||||
|
{ description: 'scheduled runs cleanup' }
|
||||||
|
);
|
||||||
|
|
||||||
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
// Start SSE client health check timer (prevents memory leaks from dead connections)
|
||||||
this.sseHealthCheckTimer = setInterval(() => {
|
this.cleanup.setInterval(
|
||||||
this.cleanupDeadSSEClients();
|
() => {
|
||||||
}, SSE_HEALTH_CHECK_INTERVAL);
|
this.cleanupDeadSSEClients();
|
||||||
|
},
|
||||||
|
SSE_HEALTH_CHECK_INTERVAL,
|
||||||
|
{ description: 'SSE client health check' }
|
||||||
|
);
|
||||||
|
|
||||||
// Start token recording timer (every 5 minutes for long-running sessions)
|
// Start token recording timer (every 5 minutes for long-running sessions)
|
||||||
this.tokenRecordingTimer = setInterval(
|
this.cleanup.setInterval(
|
||||||
() => {
|
() => {
|
||||||
this.recordPeriodicTokenUsage();
|
this.recordPeriodicTokenUsage();
|
||||||
},
|
},
|
||||||
5 * 60 * 1000
|
5 * 60 * 1000,
|
||||||
|
{ description: 'periodic token recording' }
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start subagent watcher for Claude Code background agent visibility (if enabled)
|
// 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
|
// Set stopping flag to prevent new timer creation during shutdown
|
||||||
this._isStopping = true;
|
this._isStopping = true;
|
||||||
|
|
||||||
// Clear SSE health check timer
|
// Dispose all managed timers (intervals + resettable timeouts)
|
||||||
if (this.sseHealthCheckTimer) {
|
this.cleanup.dispose();
|
||||||
clearInterval(this.sseHealthCheckTimer);
|
|
||||||
this.sseHealthCheckTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gracefully close all SSE connections before clearing
|
// Gracefully close all SSE connections before clearing
|
||||||
for (const client of this.sseClients) {
|
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.terminalBatches.clear();
|
||||||
this.terminalBatchSizes.clear();
|
this.terminalBatchSizes.clear();
|
||||||
|
|
||||||
if (this.taskUpdateBatchTimer) {
|
|
||||||
clearTimeout(this.taskUpdateBatchTimer);
|
|
||||||
this.taskUpdateBatchTimer = null;
|
|
||||||
}
|
|
||||||
this.taskUpdateBatches.clear();
|
this.taskUpdateBatches.clear();
|
||||||
|
|
||||||
if (this.stateUpdateTimer) {
|
|
||||||
clearTimeout(this.stateUpdateTimer);
|
|
||||||
this.stateUpdateTimer = null;
|
|
||||||
}
|
|
||||||
this.stateUpdatePending.clear();
|
this.stateUpdatePending.clear();
|
||||||
|
|
||||||
// Clear token recording timer
|
|
||||||
if (this.tokenRecordingTimer) {
|
|
||||||
clearInterval(this.tokenRecordingTimer);
|
|
||||||
this.tokenRecordingTimer = null;
|
|
||||||
}
|
|
||||||
this.lastRecordedTokens.clear();
|
this.lastRecordedTokens.clear();
|
||||||
|
|
||||||
// Clear scheduled cleanup timer
|
|
||||||
if (this.scheduledCleanupTimer) {
|
|
||||||
clearInterval(this.scheduledCleanupTimer);
|
|
||||||
this.scheduledCleanupTimer = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop multiplexer and flush pending saves
|
// Stop multiplexer and flush pending saves
|
||||||
this.mux.destroy();
|
this.mux.destroy();
|
||||||
|
|
||||||
// Flush any pending persist-debounce timers and persist dirty sessions
|
// Flush any pending persist-debounce timers and persist dirty sessions
|
||||||
for (const [sessionId, timer] of this.persistDebounceTimers) {
|
this.persistDeb.flushAll((sessionId) => {
|
||||||
clearTimeout(timer);
|
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
this._persistSessionStateNow(session);
|
this._persistSessionStateNow(session);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
this.persistDebounceTimers.clear();
|
|
||||||
|
|
||||||
// Clear cached state
|
// Clear cached state
|
||||||
this.cachedLightState = null;
|
this.cachedLightState = null;
|
||||||
|
|||||||
@@ -126,7 +126,10 @@ describe('writeHooksConfig', () => {
|
|||||||
writeHooksConfig(testDir);
|
writeHooksConfig(testDir);
|
||||||
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
const settingsPath = join(testDir, '.claude', 'settings.local.json');
|
||||||
const content = readFileSync(settingsPath, 'utf-8');
|
const content = readFileSync(settingsPath, 'utf-8');
|
||||||
expect(() => JSON.parse(content)).not.toThrow();
|
const parsed = JSON.parse(content);
|
||||||
|
expect(parsed).toHaveProperty('hooks');
|
||||||
|
expect(parsed.hooks).toHaveProperty('Notification');
|
||||||
|
expect(parsed.hooks).toHaveProperty('Stop');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include hooks config in output', () => {
|
it('should include hooks config in output', () => {
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ describe('ImageWatcher', () => {
|
|||||||
|
|
||||||
it('should be safe to call for non-watched session', () => {
|
it('should be safe to call for non-watched session', () => {
|
||||||
expect(() => watcher.unwatchSession('nonexistent')).not.toThrow();
|
expect(() => watcher.unwatchSession('nonexistent')).not.toThrow();
|
||||||
|
expect(watcher.getWatchedSessions()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should clear pending debounce timers for the session', () => {
|
it('should clear pending debounce timers for the session', () => {
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ describe('SessionManager', () => {
|
|||||||
|
|
||||||
it('should handle non-existent session gracefully', async () => {
|
it('should handle non-existent session gracefully', async () => {
|
||||||
await expect(manager.stopSession('non-existent')).resolves.not.toThrow();
|
await expect(manager.stopSession('non-existent')).resolves.not.toThrow();
|
||||||
|
expect(manager.getSessionCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update stored session to stopped', async () => {
|
it('should update stored session to stopped', async () => {
|
||||||
|
|||||||
@@ -536,9 +536,11 @@ describe('circular dependency detection', () => {
|
|||||||
|
|
||||||
it('should allow dependencies on non-existent tasks (just unsatisfied, not a cycle)', () => {
|
it('should allow dependencies on non-existent tasks (just unsatisfied, not a cycle)', () => {
|
||||||
// Dependencies on non-existent tasks are valid - they just won't be satisfied
|
// Dependencies on non-existent tasks are valid - they just won't be satisfied
|
||||||
expect(() => {
|
const task = queue.addTask({ prompt: 'Task D', dependencies: ['non-existent-id'] });
|
||||||
queue.addTask({ prompt: 'Task D', dependencies: ['non-existent-id'] });
|
expect(queue.getAllTasks()).toHaveLength(1);
|
||||||
}).not.toThrow();
|
expect(task.dependencies).toEqual(['non-existent-id']);
|
||||||
|
// Task is blocked by unsatisfied dependency, so next() should return null
|
||||||
|
expect(queue.next()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should detect self-dependency when task references itself', () => {
|
it('should detect self-dependency when task references itself', () => {
|
||||||
|
|||||||
@@ -564,14 +564,18 @@ describe('TaskTracker', () => {
|
|||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle null message', () => {
|
it('should handle null message', () => {
|
||||||
expect(() => tracker.processMessage(null)).not.toThrow();
|
expect(() => tracker.processMessage(null)).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
expect(tracker.getRunningCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle message without content', () => {
|
it('should handle message without content', () => {
|
||||||
expect(() => tracker.processMessage({ message: {} })).not.toThrow();
|
expect(() => tracker.processMessage({ message: {} })).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty content array', () => {
|
it('should handle empty content array', () => {
|
||||||
expect(() => tracker.processMessage({ message: { content: [] } })).not.toThrow();
|
expect(() => tracker.processMessage({ message: { content: [] } })).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle tool_result for unknown task', () => {
|
it('should handle tool_result for unknown task', () => {
|
||||||
@@ -587,11 +591,15 @@ describe('TaskTracker', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
|
expect(tracker.getTask('unknown-task')).toBeUndefined();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty terminal output', () => {
|
it('should handle empty terminal output', () => {
|
||||||
expect(() => tracker.processTerminalOutput('')).not.toThrow();
|
expect(() => tracker.processTerminalOutput('')).not.toThrow();
|
||||||
expect(() => tracker.processTerminalOutput(' ')).not.toThrow();
|
expect(() => tracker.processTerminalOutput(' ')).not.toThrow();
|
||||||
|
expect(tracker.getAllTasks().size).toBe(0);
|
||||||
|
expect(tracker.getRunningCount()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user