mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
refactor: split god files into focused modules (phase 4, steps 2-4)
Split 3 large files into 11 focused sub-modules via composition: ralph-tracker.ts (3,868 → ~2,400 LOC): - ralph-plan-tracker.ts: plan task tracking, checkpoints, history - ralph-fix-plan-watcher.ts: @fix_plan.md file watching - ralph-stall-detector.ts: iteration stall detection - ralph-status-parser.ts: RALPH_STATUS block parsing, circuit breaker respawn-controller.ts (3,611 → ~3,200 LOC): - respawn-patterns.ts: pure pattern detection functions - respawn-adaptive-timing.ts: adaptive timing with percentile calc - respawn-metrics.ts: cycle metrics tracking + aggregation - respawn-health.ts: pure health scoring functions session.ts (2,418 → ~1,800 LOC): - session-cli-builder.ts: CLI argument construction - session-auto-ops.ts: auto-compact/clear automation - session-task-cache.ts: task description LRU cache All external APIs preserved via delegation. Events forwarded through parent classes. Zero behavioral changes — all 436 tests pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -110,7 +110,7 @@ journalctl --user -u codeman-web -f
|
||||
## Import Conventions
|
||||
|
||||
- **Utilities**: Import from `./utils` (re-exports all): `import { LRUMap, stripAnsi } from './utils'`
|
||||
- **Types**: Use type imports: `import type { SessionState } from './types'`
|
||||
- **Types**: Use type imports from barrel: `import type { SessionState } from './types'` (re-exports from `src/types/` domain files)
|
||||
- **Config**: Import from specific files: `import { MAX_TERMINAL_BUFFER_SIZE } from './config/buffer-limits'`
|
||||
|
||||
## Architecture
|
||||
@@ -146,16 +146,18 @@ journalctl --user -u codeman-web -f
|
||||
| `src/session-lifecycle-log.ts` | Append-only JSONL audit log at `~/.codeman/session-lifecycle.jsonl` |
|
||||
| `src/image-watcher.ts` | Watches for image file creation (screenshots, etc.) |
|
||||
| `src/file-stream-manager.ts` | Manages `tail -f` processes for live log viewing |
|
||||
| `src/plan-orchestrator.ts` | Multi-agent plan generation with research and planning phases |
|
||||
| `src/plan-orchestrator.ts` | 2-agent plan generation: optional research agent → planner agent |
|
||||
| `src/prompts/index.ts` | Barrel export for all agent prompts |
|
||||
| `src/prompts/*.ts` | Agent prompts (research-agent, planner) |
|
||||
| `src/templates/claude-md.ts` | CLAUDE.md generation for new cases |
|
||||
| `src/tunnel-manager.ts` | Manages cloudflared child process for Cloudflare tunnel remote access |
|
||||
| `src/cli.ts` | Command-line interface handlers |
|
||||
| `src/web/server.ts` | Fastify REST API + SSE at `/api/events` (~280 routes) |
|
||||
| `src/web/server.ts` | Fastify server setup, SSE at `/api/events`, delegates to route modules |
|
||||
| `src/web/routes/*.ts` | 13 domain route modules (session, respawn, ralph, plan, etc.) — each exports `register*Routes()` |
|
||||
| `src/web/route-helpers.ts` | Shared helper utilities for route modules |
|
||||
| `src/web/schemas.ts` | Zod v4 validation schemas with path/env security allowlists |
|
||||
| `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows, mobile support (~15K lines) |
|
||||
| `src/types.ts` | All TypeScript interfaces (~70 type/interface/enum defs, ~1450 lines) |
|
||||
| `src/types.ts` | Barrel re-export from `src/types/` — 14 domain files (session, task, respawn, ralph, api, etc.) |
|
||||
|
||||
**Large files** (>50KB): `app.js`, `ralph-tracker.ts`, `respawn-controller.ts`, `session.ts`, `subagent-watcher.ts` — these contain complex state machines; read `docs/respawn-state-machine.md` before modifying.
|
||||
|
||||
@@ -284,7 +286,7 @@ The frontend is a single ~15K-line vanilla JS file with these key systems:
|
||||
|
||||
### API Route Categories
|
||||
|
||||
~280 route handlers in `server.ts:buildServer()`. Key groups:
|
||||
~280 route handlers split across `src/web/routes/` domain modules. Key groups:
|
||||
|
||||
| Group | Prefix | Count | Key endpoints |
|
||||
|-------|--------|-------|---------------|
|
||||
@@ -302,7 +304,7 @@ The frontend is a single ~15K-line vanilla JS file with these key systems:
|
||||
|
||||
## Adding Features
|
||||
|
||||
- **API endpoint**: Types in `types.ts`, route in `server.ts:buildServer()`, use `createErrorResponse()`. Validate request bodies with Zod schemas in `schemas.ts`.
|
||||
- **API endpoint**: Types in `src/types/` (domain file), route in the appropriate `src/web/routes/*-routes.ts` module, use `createErrorResponse()`. Validate request bodies with Zod schemas in `schemas.ts`.
|
||||
- **SSE event**: Emit via `broadcast()`, handle in `app.js` SSE listener section (search `addListener(`)
|
||||
- **Session setting**: Add to `SessionState` in `types.ts`, include in `session.toState()`, call `persistSessionState()`
|
||||
- **Hook event**: Add to `HookEventType` in `types.ts`, add hook command in `hooks-config.ts:generateHooksConfig()`, update `HookEventSchema` in `schemas.ts`
|
||||
@@ -442,10 +444,11 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base
|
||||
| **Claude Code hooks** | `docs/claude-code-hooks-reference.md` |
|
||||
| **Terminal anti-flicker** | `docs/terminal-anti-flicker.md` |
|
||||
| **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` |
|
||||
| **API routes** | `src/web/server.ts:buildServer()` or README.md |
|
||||
| **SSE events** | Search `broadcast(` in `server.ts` |
|
||||
| **Session statuses** | `SessionStatus` type in `src/types.ts` |
|
||||
| **Error codes** | `createErrorResponse()` in `src/types.ts` |
|
||||
| **API routes** | `src/web/routes/` domain modules, or README.md |
|
||||
| **SSE events** | Search `broadcast(` in `server.ts` and route modules |
|
||||
| **Session statuses** | `SessionStatus` in `src/types/session.ts` |
|
||||
| **Error codes** | `createErrorResponse()` in `src/types/api.ts` |
|
||||
| **Refactoring phases** | `docs/phase1-implementation-plan.md` through `docs/phase-4-domain-splitting-plan.md` |
|
||||
| **Test utilities** | `test/respawn-test-utils.ts` |
|
||||
| **Mobile test suite** | `mobile-test/README.md` |
|
||||
| **OpenCode integration** | `docs/opencode-integration.md` |
|
||||
@@ -498,7 +501,7 @@ Run `npx vitest run test/memory-leak-prevention.test.ts` to verify patterns.
|
||||
|
||||
**Investigating a bug**: Start dev server (`npx tsx src/index.ts web`), reproduce in browser, check terminal output and `~/.codeman/state.json` for clues.
|
||||
|
||||
**Adding a new API endpoint**: Define types in `types.ts`, add route in `server.ts:buildServer()`, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`.
|
||||
**Adding a new API endpoint**: Define types in the appropriate `src/types/*.ts` domain file, add route in the matching `src/web/routes/*-routes.ts` module, broadcast SSE events if needed, handle in `app.js:handleSSEEvent()`.
|
||||
|
||||
**Modifying respawn behavior**: Study `docs/respawn-state-machine.md` first. The state machine is in `respawn-controller.ts`. Use MockSession from `test/respawn-test-utils.ts` for testing.
|
||||
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
# Phase 4: Domain File Splitting — Implementation Plan
|
||||
|
||||
**Date**: 2026-03-01
|
||||
**Prerequisites**: Phase 1-3 complete (utils cleanup, CleanupManager/Debouncer migration, route extraction)
|
||||
**Goal**: Split 4 god files into focused modules with barrel exports for transparent migration.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Split types.ts into types/ directory](#1-split-typests-into-types-directory)
|
||||
2. [Split ralph-tracker.ts into focused modules](#2-split-ralph-trackerts-into-focused-modules)
|
||||
3. [Split respawn-controller.ts into focused modules](#3-split-respawn-controllerts-into-focused-modules)
|
||||
4. [Split session.ts into focused modules](#4-split-sessionts-into-focused-modules)
|
||||
5. [Execution Order & Dependencies](#5-execution-order--dependencies)
|
||||
6. [Validation Checklist](#6-validation-checklist)
|
||||
|
||||
---
|
||||
|
||||
## 1. Split types.ts into types/ directory
|
||||
|
||||
**Current**: 1,443 lines, 71 exports, imported by 36 files.
|
||||
**Risk**: LOW — pure type refactor, no runtime behavior change.
|
||||
|
||||
### Target Structure
|
||||
|
||||
```
|
||||
src/types/
|
||||
├── index.ts (barrel re-export — transparent migration)
|
||||
├── common.ts (Disposable, BufferConfig, CleanupResourceType, CleanupRegistration)
|
||||
├── session.ts (SessionStatus, SessionMode, ClaudeMode, SessionConfig, SessionColor,
|
||||
│ SessionState, OpenCodeConfig, SessionOutput)
|
||||
├── task.ts (TaskStatus, TaskDefinition, TaskState)
|
||||
├── app-state.ts (AppState, AppConfig, GlobalStats, TokenUsageEntry, TokenStats,
|
||||
│ DEFAULT_CONFIG, createInitialState, createInitialGlobalStats)
|
||||
├── respawn.ts (RespawnConfig, PersistedRespawnConfig, CycleOutcome,
|
||||
│ RespawnCycleMetrics, RespawnAggregateMetrics, HealthStatus,
|
||||
│ RalphLoopHealthScore, TimingHistory, RespawnPreset)
|
||||
├── ralph.ts (RalphLoopStatus, RalphLoopState, RalphTodoStatus, RalphTodoPriority,
|
||||
│ RalphTodoItem, RalphTodoProgress, RalphSessionState,
|
||||
│ RalphStatusValue, RalphTestsStatus, RalphWorkType, RalphStatusBlock,
|
||||
│ CompletionConfidence, RalphTrackerState,
|
||||
│ CircuitBreakerState, CircuitBreakerReason, CircuitBreakerStatus,
|
||||
│ createInitialCircuitBreakerStatus, createInitialRalphTrackerState,
|
||||
│ createInitialRalphSessionState)
|
||||
├── api.ts (ApiErrorCode, ApiResponse, HookEventType, QuickStartResponse,
|
||||
│ CaseInfo, createErrorResponse, isError, getErrorMessage)
|
||||
├── lifecycle.ts (LifecycleEventType, LifecycleEntry)
|
||||
├── run-summary.ts (RunSummaryEventType, RunSummaryEventSeverity, RunSummaryEvent,
|
||||
│ RunSummaryStats, RunSummary, createInitialRunSummaryStats)
|
||||
├── tools.ts (ActiveBashToolStatus, ActiveBashTool, ImageDetectedEvent)
|
||||
├── teams.ts (TeamConfig, TeamMember, TeamTask, InboxMessage, PaneInfo)
|
||||
├── push.ts (PushSubscriptionRecord, VapidKeys)
|
||||
└── plan.ts (PlanTaskStatus, TddPhase, PlanItem re-export, NiceConfig,
|
||||
DEFAULT_NICE_CONFIG, ProcessStats)
|
||||
```
|
||||
|
||||
### Steps
|
||||
|
||||
1. **Create `src/types/` directory** and each domain file above.
|
||||
|
||||
2. **Move types** from `src/types.ts` into their domain files. Preserve all JSDoc comments. Each file should import from siblings as needed (e.g., `ralph.ts` imports `CircuitBreakerState` within itself — no cross-file deps needed since they're in the same file).
|
||||
|
||||
3. **Create barrel `src/types/index.ts`** that re-exports everything:
|
||||
```typescript
|
||||
export * from './common.js';
|
||||
export * from './session.js';
|
||||
export * from './task.js';
|
||||
export * from './app-state.js';
|
||||
export * from './respawn.js';
|
||||
export * from './ralph.js';
|
||||
export * from './api.js';
|
||||
export * from './lifecycle.js';
|
||||
export * from './run-summary.js';
|
||||
export * from './tools.js';
|
||||
export * from './teams.js';
|
||||
export * from './push.js';
|
||||
export * from './plan.js';
|
||||
```
|
||||
|
||||
4. **Delete old `src/types.ts`** and replace with a single-line re-export barrel:
|
||||
```typescript
|
||||
export * from './types/index.js';
|
||||
```
|
||||
This ensures `import { ... } from './types.js'` continues to work everywhere — zero changes to 36 import sites.
|
||||
|
||||
5. **Verify**: `tsc --noEmit` and `npm run lint` must pass. No runtime changes.
|
||||
|
||||
### Internal Dependencies Between Domain Files
|
||||
|
||||
Some types reference others across domains. Handle with imports:
|
||||
|
||||
| File | Imports From |
|
||||
|------|-------------|
|
||||
| `app-state.ts` | `session.ts` (SessionState), `task.ts` (TaskState), `ralph.ts` (RalphLoopState, RalphSessionState) |
|
||||
| `respawn.ts` | None (self-contained) |
|
||||
| `ralph.ts` | None (self-contained) |
|
||||
| `run-summary.ts` | None (self-contained) |
|
||||
| `api.ts` | None (self-contained) |
|
||||
| `session.ts` | `respawn.ts` (RespawnConfig), `ralph.ts` (RalphTrackerState, RalphTodoItem, CircuitBreakerStatus, RalphSessionState, RunSummaryEvent) |
|
||||
|
||||
Wait — `SessionState` references `RespawnConfig`, `RalphTrackerState`, `CircuitBreakerStatus`, and `RunSummaryEvent`. This creates imports from `session.ts` → `respawn.ts`, `ralph.ts`, `run-summary.ts`. This is fine (one-way deps, no cycles).
|
||||
|
||||
---
|
||||
|
||||
## 2. Split ralph-tracker.ts into focused modules
|
||||
|
||||
**Current**: 3,868 lines, single `RalphTracker` class with 5 responsibilities.
|
||||
**Risk**: MEDIUM — class has shared mutable state, but extractable modules are well-isolated.
|
||||
|
||||
### Coupling Analysis Summary
|
||||
|
||||
| Module | Coupling | Extractability |
|
||||
|--------|----------|----------------|
|
||||
| Plan task tracking | LOW | HIGH — only reads `cycleCount` |
|
||||
| Fix-plan file watching | LOW | HIGH — callback-based todo replacement |
|
||||
| Iteration stall detection | LOW | HIGH — notification-based |
|
||||
| RALPH_STATUS block parsing + circuit breaker | MEDIUM | MEDIUM — callback for circuit breaker updates |
|
||||
| Todo parsing, loop detection, completion | HIGH | LOW — deeply entangled shared state |
|
||||
|
||||
### Target Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── ralph-tracker.ts (~1,800 LOC — core: output parsing, loop state,
|
||||
│ todo management, completion detection)
|
||||
├── ralph-plan-tracker.ts (~600 LOC — plan tasks, checkpoints, history, rollback)
|
||||
├── ralph-status-parser.ts (~300 LOC — RALPH_STATUS block parsing, circuit breaker)
|
||||
├── ralph-fix-plan-watcher.ts (~150 LOC — @fix_plan.md file watching)
|
||||
└── ralph-stall-detector.ts (~80 LOC — iteration stall detection)
|
||||
```
|
||||
|
||||
### Step 2a: Extract `RalphPlanTracker` (~600 LOC)
|
||||
|
||||
**Why first**: Lowest coupling. Only dependency is `cycleCount` for checkpoint detection.
|
||||
|
||||
**Extract these from `RalphTracker`**:
|
||||
|
||||
Types to export:
|
||||
- `EnhancedPlanTask` (interface, currently lines 56-87)
|
||||
- `CheckpointReview` (interface, currently lines 90-139)
|
||||
|
||||
Properties to move:
|
||||
- `_planVersion: number`
|
||||
- `_planHistory: Array<{version, timestamp, tasks, summary}>`
|
||||
- `_planTasks: Map<string, EnhancedPlanTask>`
|
||||
- `_checkpointIterations: number[]`
|
||||
- `_lastCheckpointIteration: number`
|
||||
|
||||
Methods to move:
|
||||
- `initializePlanTasks(items)`
|
||||
- `updatePlanTask(taskId, update)`
|
||||
- `addPlanTask(params)`
|
||||
- `getPlanTasks()`
|
||||
- `generateCheckpointReview()`
|
||||
- `getPlanHistory()`
|
||||
- `rollbackToVersion(version)`
|
||||
- `isCheckpointDue()`
|
||||
- `planVersion` getter
|
||||
- `_savePlanToHistory()` (private)
|
||||
- `_unblockDependentTasks()` (private)
|
||||
- `_checkForCheckpoint()` (private)
|
||||
|
||||
Events emitted (define in new class):
|
||||
- `planInitialized`
|
||||
- `planTaskUpdate`
|
||||
- `taskBlocked`
|
||||
- `taskUnblocked`
|
||||
- `planCheckpoint`
|
||||
|
||||
**Interface with parent**:
|
||||
```typescript
|
||||
export class RalphPlanTracker extends EventEmitter {
|
||||
constructor() { ... }
|
||||
|
||||
// Parent calls this when iteration changes (for checkpoint detection)
|
||||
notifyCycleCount(cycleCount: number): void { ... }
|
||||
|
||||
// Full public API moves here unchanged
|
||||
initializePlanTasks(items: PlanItem[]): void { ... }
|
||||
updatePlanTask(taskId: string, update: { ... }): { ... } | null { ... }
|
||||
// ...etc
|
||||
}
|
||||
```
|
||||
|
||||
**In `RalphTracker`**: Replace plan methods with delegation:
|
||||
```typescript
|
||||
readonly planTracker = new RalphPlanTracker();
|
||||
|
||||
// Forward plan events
|
||||
this.planTracker.on('planInitialized', (...args) => this.emit('planInitialized', ...args));
|
||||
// ...etc
|
||||
|
||||
// In detectLoopStatus(), when cycleCount changes:
|
||||
this.planTracker.notifyCycleCount(this._loopState.cycleCount);
|
||||
```
|
||||
|
||||
### Step 2b: Extract `RalphFixPlanWatcher` (~150 LOC)
|
||||
|
||||
**Extract these**:
|
||||
|
||||
Properties:
|
||||
- `_workingDir: string | null`
|
||||
- `_fixPlanPath: string | null`
|
||||
- `_fixPlanWatcher: FSWatcher | null`
|
||||
- `_fixPlanWatcherErrorHandler`
|
||||
- `_fixPlanReloadDeb`
|
||||
|
||||
Methods:
|
||||
- `setWorkingDir(workingDir)`
|
||||
- `loadFixPlanFromDisk()`
|
||||
- `startWatchingFixPlan()`
|
||||
- `stopWatchingFixPlan()`
|
||||
- `handleFixPlanChange()`
|
||||
- `isFileAuthoritative` getter
|
||||
|
||||
**Interface with parent**:
|
||||
```typescript
|
||||
export class RalphFixPlanWatcher extends EventEmitter {
|
||||
get isFileAuthoritative(): boolean { ... }
|
||||
|
||||
setWorkingDir(workingDir: string): void { ... }
|
||||
stop(): void { ... }
|
||||
}
|
||||
|
||||
// Events:
|
||||
// 'todosLoaded' → (todos: Array<{id, content, status, priority}>) — parent replaces _todos
|
||||
```
|
||||
|
||||
**In `RalphTracker`**:
|
||||
```typescript
|
||||
readonly fixPlanWatcher = new RalphFixPlanWatcher();
|
||||
|
||||
constructor() {
|
||||
this.fixPlanWatcher.on('todosLoaded', (items) => {
|
||||
// Replace _todos with file-based items
|
||||
this._todos.clear();
|
||||
for (const item of items) {
|
||||
this.addOrUpdateTodo(item.id, item.content, item.status, item.priority);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delegate isFileAuthoritative
|
||||
get isFileAuthoritative(): boolean {
|
||||
return this.fixPlanWatcher.isFileAuthoritative;
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2c: Extract `RalphStallDetector` (~80 LOC)
|
||||
|
||||
**Extract these**:
|
||||
|
||||
Properties:
|
||||
- `_lastIterationChangeTime`
|
||||
- `_lastObservedIteration`
|
||||
- `_iterationStallTimerId`
|
||||
- `_iterationStallWarningMs`
|
||||
- `_iterationStallCriticalMs`
|
||||
- `_iterationStallWarned`
|
||||
|
||||
Methods:
|
||||
- `startIterationStallDetection()`
|
||||
- `stopIterationStallDetection()`
|
||||
- `checkIterationStall()`
|
||||
- `getIterationStallMetrics()`
|
||||
- `configureIterationStallThresholds(warningMs, criticalMs)`
|
||||
|
||||
**Interface with parent**:
|
||||
```typescript
|
||||
export class RalphStallDetector extends EventEmitter {
|
||||
constructor(private cleanup: CleanupManager) { ... }
|
||||
|
||||
start(): void { ... }
|
||||
stop(): void { ... }
|
||||
|
||||
// Parent calls when iteration changes
|
||||
notifyIterationChanged(iteration: number): void {
|
||||
this._lastIterationChangeTime = Date.now();
|
||||
this._lastObservedIteration = iteration;
|
||||
this._iterationStallWarned = false;
|
||||
}
|
||||
|
||||
// Parent calls to check if loop is active
|
||||
setLoopActive(active: boolean): void { ... }
|
||||
|
||||
getIterationStallMetrics(): { ... } { ... }
|
||||
}
|
||||
|
||||
// Events: 'iterationStallWarning', 'iterationStallCritical'
|
||||
```
|
||||
|
||||
### Step 2d: Extract `RalphStatusParser` (~300 LOC)
|
||||
|
||||
**Extract these**:
|
||||
|
||||
Properties:
|
||||
- `_circuitBreaker: CircuitBreakerStatus`
|
||||
- `_statusBlockBuffer: string[]`
|
||||
- `_inStatusBlock: boolean`
|
||||
- `_lastStatusBlock: RalphStatusBlock | null`
|
||||
- `_completionIndicators: number`
|
||||
- `_exitGateMet: boolean`
|
||||
- `_totalFilesModified: number`
|
||||
- `_totalTasksCompleted: number`
|
||||
|
||||
Methods:
|
||||
- `processStatusBlockLine(line)`
|
||||
- `parseStatusBlock(lines)`
|
||||
- `detectCompletionIndicators(line)`
|
||||
- `updateCircuitBreaker(hasProgress, testsStatus, status)`
|
||||
- `resetCircuitBreaker()`
|
||||
- `circuitBreakerStatus` getter
|
||||
- `lastStatusBlock` getter
|
||||
- `cumulativeStats` getter
|
||||
- `exitGateMet` getter
|
||||
|
||||
Regex patterns to move:
|
||||
- `RALPH_STATUS_START_PATTERN` through `RALPH_RECOMMENDATION_PATTERN`
|
||||
- `COMPLETION_INDICATOR_PATTERNS`
|
||||
|
||||
**Interface with parent**:
|
||||
```typescript
|
||||
export class RalphStatusParser extends EventEmitter {
|
||||
processLine(line: string): void { ... } // calls processStatusBlockLine + detectCompletionIndicators
|
||||
|
||||
get circuitBreakerStatus(): CircuitBreakerStatus { ... }
|
||||
get lastStatusBlock(): RalphStatusBlock | null { ... }
|
||||
get exitGateMet(): boolean { ... }
|
||||
get cumulativeStats(): { ... } { ... }
|
||||
|
||||
resetCircuitBreaker(): void { ... }
|
||||
reset(): void { ... }
|
||||
}
|
||||
|
||||
// Events: 'statusBlockDetected', 'circuitBreakerUpdate', 'exitGateMet'
|
||||
```
|
||||
|
||||
**In `RalphTracker.processLine()`**:
|
||||
```typescript
|
||||
// Replace inline status block handling with delegation
|
||||
this.statusParser.processLine(line);
|
||||
```
|
||||
|
||||
### Step 2e: Keep in `ralph-tracker.ts` (~1,800 LOC)
|
||||
|
||||
The core remains tightly coupled and stays together:
|
||||
- Output parsing pipeline (`processTerminalData`, `processCleanData`, `processLine`)
|
||||
- Loop state management (`_loopState`, `detectLoopStatus`, `enable/disable/startLoop/stopLoop`)
|
||||
- Todo management (`_todos`, `detectTodoItems`, `addOrUpdateTodo`, `updateTodoStatus`, `getTodoStats`)
|
||||
- Completion detection (`detectCompletionPhrase`, `handleCompletionPhrase`, `calculateCompletionConfidence`)
|
||||
- All-tasks-complete detection (`detectAllTasksComplete`)
|
||||
- Auto-enable logic (`shouldAutoEnable`)
|
||||
- Lifecycle (`reset`, `fullReset`, `clear`, `restoreState`, `destroy`)
|
||||
- Event debouncing and buffering
|
||||
|
||||
The class coordinates the extracted modules via composition:
|
||||
```typescript
|
||||
export class RalphTracker extends EventEmitter {
|
||||
readonly planTracker = new RalphPlanTracker();
|
||||
readonly fixPlanWatcher = new RalphFixPlanWatcher();
|
||||
readonly stallDetector: RalphStallDetector;
|
||||
readonly statusParser = new RalphStatusParser();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.stallDetector = new RalphStallDetector(this.cleanup);
|
||||
this._wireSubModuleEvents();
|
||||
}
|
||||
|
||||
private _wireSubModuleEvents(): void {
|
||||
// Forward all sub-module events through RalphTracker
|
||||
// so external consumers don't need to know about the split
|
||||
for (const event of ['planInitialized', 'planTaskUpdate', ...]) {
|
||||
this.planTracker.on(event, (...args) => this.emit(event, ...args));
|
||||
}
|
||||
// ...same for statusParser, stallDetector, fixPlanWatcher
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migration Safety
|
||||
|
||||
- All events continue to be emitted from `RalphTracker` (forwarded from sub-modules)
|
||||
- All public methods stay on `RalphTracker` (delegated to sub-modules)
|
||||
- External consumers (`session.ts`, `case-routes.ts`) see zero API changes
|
||||
- New sub-modules are exposed as `readonly` properties for direct access where needed
|
||||
|
||||
---
|
||||
|
||||
## 3. Split respawn-controller.ts into focused modules
|
||||
|
||||
**Current**: 3,611 lines, single `RespawnController` class with 6 responsibilities.
|
||||
**Risk**: MEDIUM — health scoring and metrics are cleanly decoupled; detection is tightly coupled.
|
||||
|
||||
### Coupling Analysis Summary
|
||||
|
||||
| Module | Coupling | Extractability |
|
||||
|--------|----------|----------------|
|
||||
| Health scoring | NONE | HIGH — pure calculations from metrics |
|
||||
| Cycle metrics | LOW | HIGH — standalone tracking |
|
||||
| Adaptive timing | LOW | HIGH — standalone timing adjustments |
|
||||
| Stuck-state detection | LOW | MEDIUM — needs state + config refs |
|
||||
| Pattern detection utilities | NONE | HIGH — pure functions |
|
||||
| State machine + idle detection + AI checkers | HIGH | LOW — deeply entangled |
|
||||
|
||||
### Target Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── respawn-controller.ts (~2,200 LOC — state machine, idle detection,
|
||||
│ AI checkers, terminal handling, hook signals,
|
||||
│ auto-accept, step execution)
|
||||
├── respawn-health.ts (~250 LOC — health scoring + recommendations)
|
||||
├── respawn-metrics.ts (~200 LOC — cycle metrics + aggregate stats)
|
||||
├── respawn-adaptive-timing.ts (~100 LOC — adaptive timing with percentile calc)
|
||||
└── respawn-patterns.ts (~50 LOC — terminal pattern detection utilities)
|
||||
```
|
||||
|
||||
### Step 3a: Extract `RespawnPatterns` (~50 LOC)
|
||||
|
||||
**Pure utility functions, zero coupling**.
|
||||
|
||||
Move:
|
||||
- `isCompletionMessage(data): boolean`
|
||||
- `hasWorkingPattern(data, window): boolean`
|
||||
- `extractTokenCount(data): number | null`
|
||||
- `PROMPT_PATTERNS` array
|
||||
- `WORKING_PATTERNS` array
|
||||
|
||||
```typescript
|
||||
// src/respawn-patterns.ts
|
||||
import { TOKEN_PATTERN, SPINNER_PATTERN } from './utils/index.js';
|
||||
|
||||
export const PROMPT_PATTERNS = ['❯', '>', '$', '%', '#'];
|
||||
|
||||
export const WORKING_PATTERNS = [/* 70+ patterns */];
|
||||
|
||||
export function isCompletionMessage(data: string): boolean { ... }
|
||||
export function hasWorkingPattern(data: string, window: string): boolean { ... }
|
||||
export function extractTokenCount(data: string): number | null { ... }
|
||||
```
|
||||
|
||||
**In `RespawnController`**: Import and call:
|
||||
```typescript
|
||||
import { isCompletionMessage, hasWorkingPattern, extractTokenCount } from './respawn-patterns.js';
|
||||
```
|
||||
|
||||
### Step 3b: Extract `RespawnAdaptiveTiming` (~100 LOC)
|
||||
|
||||
**Self-contained timing controller**.
|
||||
|
||||
Move properties:
|
||||
- `timingHistory: TimingHistory`
|
||||
|
||||
Move methods:
|
||||
- `recordTimingData(idleDetectionMs, cycleDurationMs)`
|
||||
- `updateAdaptiveTiming()`
|
||||
- `getTimingHistory()`
|
||||
- `getAdaptiveCompletionConfirmMs()`
|
||||
|
||||
```typescript
|
||||
export class RespawnAdaptiveTiming {
|
||||
private timingHistory: TimingHistory;
|
||||
|
||||
constructor(private config: { adaptiveMinConfirmMs: number; adaptiveMaxConfirmMs: number }) {
|
||||
this.timingHistory = { recentIdleDetectionMs: [], recentCycleDurationMs: [], ... };
|
||||
}
|
||||
|
||||
recordTimingData(idleDetectionMs: number, cycleDurationMs: number): void { ... }
|
||||
getAdaptiveCompletionConfirmMs(): number { ... }
|
||||
getTimingHistory(): TimingHistory { ... }
|
||||
reset(): void { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3c: Extract `RespawnCycleMetrics` (~200 LOC)
|
||||
|
||||
**Standalone metrics tracker**.
|
||||
|
||||
Move properties:
|
||||
- `currentCycleMetrics`
|
||||
- `recentCycleMetrics[]`
|
||||
- `aggregateMetrics`
|
||||
- `MAX_CYCLE_METRICS_IN_MEMORY`
|
||||
|
||||
Move methods:
|
||||
- `startCycleMetrics(idleReason)`
|
||||
- `recordCycleStep(step)`
|
||||
- `completeCycleMetrics(outcome, errorMessage?)`
|
||||
- `updateAggregateMetrics(metrics)`
|
||||
- `getAggregateMetrics()`
|
||||
- `getRecentCycleMetrics(limit?)`
|
||||
|
||||
```typescript
|
||||
export class RespawnCycleMetricsTracker {
|
||||
private currentCycleMetrics: Partial<RespawnCycleMetrics> | null = null;
|
||||
private recentCycleMetrics: RespawnCycleMetrics[] = [];
|
||||
private aggregateMetrics: RespawnAggregateMetrics;
|
||||
|
||||
startCycle(sessionId: string, cycleNumber: number, idleReason: string): void { ... }
|
||||
recordStep(step: string): void { ... }
|
||||
completeCycle(outcome: CycleOutcome, errorMessage?: string): RespawnCycleMetrics | null { ... }
|
||||
getAggregate(): RespawnAggregateMetrics { ... }
|
||||
getRecent(limit?: number): RespawnCycleMetrics[] { ... }
|
||||
reset(): void { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Callback**: `completeCycle()` returns the completed metrics so the controller can pass them to `adaptiveTiming.recordTimingData()`.
|
||||
|
||||
### Step 3d: Extract `RespawnHealthCalculator` (~250 LOC)
|
||||
|
||||
**Pure calculation — no state of its own**.
|
||||
|
||||
Move methods:
|
||||
- `calculateHealthScore()`
|
||||
- `calculateCycleSuccessScore()`
|
||||
- `calculateCircuitBreakerScore()`
|
||||
- `calculateIterationProgressScore()`
|
||||
- `calculateAiCheckerScore()`
|
||||
- `calculateStuckRecoveryScore()`
|
||||
- `generateHealthRecommendations(components)`
|
||||
- `generateHealthSummary(score, status, components)`
|
||||
- `shouldSkipClear()` (belongs here since it's a pure calculation on token/config)
|
||||
|
||||
```typescript
|
||||
export interface HealthInputs {
|
||||
aggregateMetrics: RespawnAggregateMetrics;
|
||||
circuitBreakerStatus: CircuitBreakerStatus;
|
||||
iterationStallMetrics: { stallDurationMs: number; warningMs: number; criticalMs: number } | null;
|
||||
aiCheckerState: { disabled: boolean; inCooldown: boolean; hasErrors: boolean };
|
||||
stuckRecoveryCount: number;
|
||||
maxStuckRecoveries: number;
|
||||
}
|
||||
|
||||
export function calculateHealthScore(inputs: HealthInputs): RalphLoopHealthScore { ... }
|
||||
|
||||
export function shouldSkipClear(
|
||||
lastTokenCount: number,
|
||||
skipClearThresholdPercent: number,
|
||||
maxContextTokens: number
|
||||
): boolean { ... }
|
||||
```
|
||||
|
||||
**Made as pure functions** (not a class) since they hold no state.
|
||||
|
||||
### Step 3e: Keep in `respawn-controller.ts` (~2,200 LOC)
|
||||
|
||||
The core state machine, idle detection, and AI checker integration stays:
|
||||
- State machine transitions (`setState`, `start`, `stop`, `pause`, `resume`)
|
||||
- Terminal data handling (`handleTerminalData`)
|
||||
- All 5 idle detection layers + hook signals
|
||||
- AI checker integration (`tryStartAiCheck`, `startAiCheck`, `startPlanCheck`)
|
||||
- Auto-accept logic
|
||||
- Step execution (`sendUpdateDocs`, `sendClear`, `sendInit`, `sendKickstart`)
|
||||
- Timer management (`startTrackedTimer`, `cancelTrackedTimer`)
|
||||
- Stuck-state detection and recovery
|
||||
- Action logging
|
||||
|
||||
The class composes extracted modules:
|
||||
```typescript
|
||||
import { RespawnAdaptiveTiming } from './respawn-adaptive-timing.js';
|
||||
import { RespawnCycleMetricsTracker } from './respawn-metrics.js';
|
||||
import { calculateHealthScore, shouldSkipClear } from './respawn-health.js';
|
||||
import { isCompletionMessage, hasWorkingPattern, extractTokenCount } from './respawn-patterns.js';
|
||||
|
||||
export class RespawnController extends EventEmitter {
|
||||
private adaptiveTiming: RespawnAdaptiveTiming;
|
||||
private cycleMetrics: RespawnCycleMetricsTracker;
|
||||
|
||||
calculateHealthScore(): RalphLoopHealthScore {
|
||||
return calculateHealthScore({
|
||||
aggregateMetrics: this.cycleMetrics.getAggregate(),
|
||||
circuitBreakerStatus: this.session.ralphTracker.circuitBreakerStatus,
|
||||
iterationStallMetrics: this.session.ralphTracker.getIterationStallMetrics(),
|
||||
aiCheckerState: { ... },
|
||||
stuckRecoveryCount: this.stuckRecoveryCount,
|
||||
maxStuckRecoveries: this.config.maxStuckRecoveries ?? 3,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Split session.ts into focused modules
|
||||
|
||||
**Current**: 2,418 lines, single `Session` class.
|
||||
**Risk**: LOW-MEDIUM — extractable pieces are utility-like with clear boundaries.
|
||||
|
||||
### Coupling Analysis Summary
|
||||
|
||||
| Module | Coupling | Extractability |
|
||||
|--------|----------|----------------|
|
||||
| CLI arg builder | NONE | HIGH — pure functions used at spawn time |
|
||||
| Auto-compact/clear | LOW | HIGH — self-contained automation with config |
|
||||
| Token tracking | LOW | MEDIUM — reads PTY output, writes state |
|
||||
| Task description cache | LOW | HIGH — separate LRU cache |
|
||||
| PTY + mux lifecycle | HIGH | KEEP — core of the class |
|
||||
| Tracker integration | HIGH | KEEP — event forwarding plumbing |
|
||||
|
||||
### Target Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── session.ts (~1,600 LOC — PTY lifecycle, terminal I/O,
|
||||
│ tracker integration, output processing,
|
||||
│ token tracking, state management)
|
||||
├── session-cli-builder.ts (~250 LOC — Claude/OpenCode CLI arg construction)
|
||||
├── session-auto-ops.ts (~300 LOC — auto-compact, auto-clear automation)
|
||||
└── session-task-cache.ts (~100 LOC — task description LRU cache)
|
||||
```
|
||||
|
||||
### Step 4a: Extract `SessionCliBuilder` (~250 LOC)
|
||||
|
||||
**Pure functions — zero coupling to Session instance**.
|
||||
|
||||
Move:
|
||||
- `buildClaudeArgs()` logic (currently inlined in `startInteractive` and `runPrompt`)
|
||||
- `buildOpenCodeArgs()` logic
|
||||
- Model mapping constants
|
||||
- Claude mode to flag mapping
|
||||
- Environment variable construction
|
||||
|
||||
```typescript
|
||||
// src/session-cli-builder.ts
|
||||
export interface CliBuilderConfig {
|
||||
claudeMode: ClaudeMode;
|
||||
model?: string;
|
||||
workingDir: string;
|
||||
sessionId: string;
|
||||
niceConfig?: NiceConfig;
|
||||
isOpenCode?: boolean;
|
||||
openCodeConfig?: OpenCodeConfig;
|
||||
}
|
||||
|
||||
export function buildInteractiveArgs(config: CliBuilderConfig): string[] { ... }
|
||||
export function buildPromptArgs(config: CliBuilderConfig, prompt: string): string[] { ... }
|
||||
export function buildShellArgs(shell?: string): string[] { ... }
|
||||
export function buildClaudeEnv(config: CliBuilderConfig): Record<string, string> { ... }
|
||||
```
|
||||
|
||||
### Step 4b: Extract `SessionAutoOps` (~300 LOC)
|
||||
|
||||
**Self-contained automation with config-based thresholds**.
|
||||
|
||||
Move properties:
|
||||
- `_autoCompactThreshold`
|
||||
- `_autoClearThreshold`
|
||||
- `_isAutoCompacting`
|
||||
- `_isAutoClearing`
|
||||
- `_autoCompactCount`
|
||||
- `_autoClearCount`
|
||||
- `_lastAutoCompactTime`
|
||||
- `_lastAutoClearTime`
|
||||
|
||||
Move methods:
|
||||
- `checkAutoCompact(tokenCount)`
|
||||
- `performAutoCompact()`
|
||||
- `checkAutoClear(tokenCount)`
|
||||
- `performAutoClear()`
|
||||
- Auto-compact/clear threshold configuration
|
||||
|
||||
```typescript
|
||||
export class SessionAutoOps extends EventEmitter {
|
||||
constructor(
|
||||
private writeCommand: (command: string) => Promise<void>,
|
||||
private getTokenCount: () => number,
|
||||
config: { compactThreshold: number; clearThreshold: number }
|
||||
) { ... }
|
||||
|
||||
/** Called after token count updates. Checks thresholds and triggers if needed. */
|
||||
checkThresholds(tokenCount: number): void { ... }
|
||||
|
||||
updateConfig(config: { compactThreshold?: number; clearThreshold?: number }): void { ... }
|
||||
getStats(): { autoCompactCount: number; autoClearCount: number; ... } { ... }
|
||||
}
|
||||
|
||||
// Events: 'autoCompact', 'autoClear'
|
||||
```
|
||||
|
||||
**In `Session`**: Compose and wire:
|
||||
```typescript
|
||||
private autoOps = new SessionAutoOps(
|
||||
(cmd) => this.writeViaMux(cmd),
|
||||
() => this._state.tokenCount,
|
||||
{ compactThreshold: 110_000, clearThreshold: 140_000 }
|
||||
);
|
||||
```
|
||||
|
||||
### Step 4c: Extract `SessionTaskCache` (~100 LOC)
|
||||
|
||||
**Isolated LRU cache for task descriptions**.
|
||||
|
||||
Move:
|
||||
- `_taskDescriptionCache: LRUMap<number, { description: string; timestamp: number }>`
|
||||
- `_taskDescriptionMaxAge`
|
||||
- `findTaskDescriptionNear(lineNumber)`
|
||||
- `cacheTaskDescription(lineNumber, description)`
|
||||
|
||||
```typescript
|
||||
export class SessionTaskCache {
|
||||
private cache: LRUMap<number, { description: string; timestamp: number }>;
|
||||
private maxAgeMs: number;
|
||||
|
||||
constructor(maxSize: number = 50, maxAgeMs: number = 30_000) { ... }
|
||||
|
||||
find(lineNumber: number, searchRadius: number = 50): string | null { ... }
|
||||
add(lineNumber: number, description: string): void { ... }
|
||||
clear(): void { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4d: Keep in `session.ts` (~1,600 LOC)
|
||||
|
||||
The core stays together:
|
||||
- PTY process management (`spawn`, `kill`, `resize`, `writeViaMux`)
|
||||
- Data streaming pipeline (PTY → buffer → ANSI strip → JSON parse → events)
|
||||
- Tracker initialization and event forwarding (RalphTracker, BashToolParser, TaskTracker)
|
||||
- Output processing (message extraction, completion detection)
|
||||
- Token tracking (status line parsing)
|
||||
- State management (`toState()`, `updateState()`)
|
||||
- Session lifecycle (`startInteractive`, `startShell`, `runPrompt`)
|
||||
- CLI info detection (version, model, account)
|
||||
|
||||
---
|
||||
|
||||
## 5. Execution Order & Dependencies
|
||||
|
||||
Execute in this order to minimize risk. Each step is independently deployable.
|
||||
|
||||
```
|
||||
Step 1: types.ts split
|
||||
↓ (no runtime change, just file reorganization)
|
||||
Step 2a: RalphPlanTracker extraction
|
||||
↓ (independent of types split)
|
||||
Step 2b: RalphFixPlanWatcher extraction
|
||||
Step 2c: RalphStallDetector extraction
|
||||
Step 2d: RalphStatusParser extraction
|
||||
↓ (ralph-tracker.ts now ~1,800 LOC)
|
||||
Step 3a: RespawnPatterns extraction
|
||||
Step 3b: RespawnAdaptiveTiming extraction
|
||||
Step 3c: RespawnCycleMetrics extraction
|
||||
Step 3d: RespawnHealthCalculator extraction
|
||||
↓ (respawn-controller.ts now ~2,200 LOC)
|
||||
Step 4a: SessionCliBuilder extraction
|
||||
Step 4b: SessionAutoOps extraction
|
||||
Step 4c: SessionTaskCache extraction
|
||||
↓ (session.ts now ~1,600 LOC)
|
||||
```
|
||||
|
||||
**Parallelization**: Steps 1, 2a-2d, 3a-3d, and 4a-4c can be done by separate agents in parallel since they touch different files. However, within each group, sequential execution is safer.
|
||||
|
||||
### Risk Mitigation
|
||||
|
||||
- **Barrel exports**: Every split uses delegation + barrel re-export so external consumers see zero API changes
|
||||
- **Event forwarding**: Sub-modules emit events, parent class forwards them — no event contract changes
|
||||
- **Incremental**: Each step can be verified independently with `tsc --noEmit` + `npm run lint`
|
||||
- **No test changes needed**: External API stays identical; existing tests continue to pass
|
||||
|
||||
---
|
||||
|
||||
## 6. Validation Checklist
|
||||
|
||||
After each step, verify:
|
||||
|
||||
- [ ] `tsc --noEmit` passes (no type errors)
|
||||
- [ ] `npm run lint` passes (no unused imports, etc.)
|
||||
- [ ] `npm run format:check` passes
|
||||
- [ ] `npx vitest run test/respawn-controller.test.ts` passes (for respawn splits)
|
||||
- [ ] `npx vitest run test/ralph-tracker.test.ts` passes (for ralph splits)
|
||||
- [ ] `npx vitest run test/session-manager.test.ts` passes (for session splits)
|
||||
- [ ] Dev server starts: `npx tsx src/index.ts web`
|
||||
- [ ] Existing sessions work (create, interact, delete)
|
||||
- [ ] Respawn cycle works (enable respawn, verify idle detection fires)
|
||||
- [ ] No new circular dependencies: `npx madge --circular src/`
|
||||
|
||||
### Size Targets
|
||||
|
||||
| File | Before | After |
|
||||
|------|--------|-------|
|
||||
| `src/types.ts` | 1,443 LOC | 1 LOC (re-export barrel) |
|
||||
| `src/ralph-tracker.ts` | 3,868 LOC | ~1,800 LOC |
|
||||
| `src/respawn-controller.ts` | 3,611 LOC | ~2,200 LOC |
|
||||
| `src/session.ts` | 2,418 LOC | ~1,600 LOC |
|
||||
| **Total new files** | — | 12 files |
|
||||
| **Net LOC change** | — | ~0 (refactor only) |
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* @fileoverview RalphFixPlanWatcher - Watches @fix_plan.md for changes
|
||||
*
|
||||
* Monitors the @fix_plan.md file in the session's working directory
|
||||
* for changes, parsing todo items from the markdown format.
|
||||
*
|
||||
* Extracted from ralph-tracker.ts as part of domain splitting.
|
||||
*
|
||||
* @module ralph-fix-plan-watcher
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { existsSync, FSWatcher, watch as fsWatch } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { RalphTodoStatus, RalphTodoPriority, RalphTodoItem } from './types.js';
|
||||
|
||||
// ========== @fix_plan.md Generation & Import Utility Functions ==========
|
||||
|
||||
/**
|
||||
* Generate @fix_plan.md content from todo items.
|
||||
* Groups todos by priority and status.
|
||||
*
|
||||
* @param todos - Array of todo items
|
||||
* @returns Markdown content for @fix_plan.md
|
||||
*/
|
||||
export function generateFixPlanMarkdown(todos: RalphTodoItem[]): string {
|
||||
const lines: string[] = ['# Fix Plan', ''];
|
||||
|
||||
// Group by priority
|
||||
const p0: RalphTodoItem[] = [];
|
||||
const p1: RalphTodoItem[] = [];
|
||||
const p2: RalphTodoItem[] = [];
|
||||
const noPriority: RalphTodoItem[] = [];
|
||||
const completed: RalphTodoItem[] = [];
|
||||
|
||||
for (const todo of todos) {
|
||||
if (todo.status === 'completed') {
|
||||
completed.push(todo);
|
||||
} else if (todo.priority === 'P0') {
|
||||
p0.push(todo);
|
||||
} else if (todo.priority === 'P1') {
|
||||
p1.push(todo);
|
||||
} else if (todo.priority === 'P2') {
|
||||
p2.push(todo);
|
||||
} else {
|
||||
noPriority.push(todo);
|
||||
}
|
||||
}
|
||||
|
||||
// High Priority (P0)
|
||||
if (p0.length > 0) {
|
||||
lines.push('## High Priority (P0)');
|
||||
for (const todo of p0) {
|
||||
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
||||
lines.push(`- ${checkbox} ${todo.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Standard (P1)
|
||||
if (p1.length > 0) {
|
||||
lines.push('## Standard (P1)');
|
||||
for (const todo of p1) {
|
||||
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
||||
lines.push(`- ${checkbox} ${todo.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Nice to Have (P2)
|
||||
if (p2.length > 0) {
|
||||
lines.push('## Nice to Have (P2)');
|
||||
for (const todo of p2) {
|
||||
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
||||
lines.push(`- ${checkbox} ${todo.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Tasks (no priority)
|
||||
if (noPriority.length > 0) {
|
||||
lines.push('## Tasks');
|
||||
for (const todo of noPriority) {
|
||||
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
|
||||
lines.push(`- ${checkbox} ${todo.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Completed
|
||||
if (completed.length > 0) {
|
||||
lines.push('## Completed');
|
||||
for (const todo of completed) {
|
||||
lines.push(`- [x] ${todo.content}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse @fix_plan.md content and return parsed todo items.
|
||||
*
|
||||
* @param content - Markdown content from @fix_plan.md
|
||||
* @param parsePriority - Function to parse priority from content text
|
||||
* @param generateTodoId - Function to generate stable todo ID from content
|
||||
* @returns Array of parsed todo items
|
||||
*/
|
||||
export function importFixPlanMarkdown(
|
||||
content: string,
|
||||
parsePriority: (content: string) => RalphTodoPriority,
|
||||
generateTodoId: (content: string) => string
|
||||
): RalphTodoItem[] {
|
||||
const lines = content.split('\n');
|
||||
const newTodos: RalphTodoItem[] = [];
|
||||
let currentPriority: RalphTodoPriority = null;
|
||||
|
||||
// Patterns for section headers
|
||||
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0)/i;
|
||||
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
|
||||
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
|
||||
const completedHeaderPattern = /^##\s*Completed/i;
|
||||
const tasksHeaderPattern = /^##\s*Tasks/i;
|
||||
|
||||
// Pattern for todo items
|
||||
const todoPattern = /^-\s*\[([ x-])\]\s*(.+)$/;
|
||||
|
||||
let inCompletedSection = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check for section headers
|
||||
if (p0HeaderPattern.test(trimmed)) {
|
||||
currentPriority = 'P0';
|
||||
inCompletedSection = false;
|
||||
continue;
|
||||
}
|
||||
if (p1HeaderPattern.test(trimmed)) {
|
||||
currentPriority = 'P1';
|
||||
inCompletedSection = false;
|
||||
continue;
|
||||
}
|
||||
if (p2HeaderPattern.test(trimmed)) {
|
||||
currentPriority = 'P2';
|
||||
inCompletedSection = false;
|
||||
continue;
|
||||
}
|
||||
if (completedHeaderPattern.test(trimmed)) {
|
||||
inCompletedSection = true;
|
||||
continue;
|
||||
}
|
||||
if (tasksHeaderPattern.test(trimmed)) {
|
||||
currentPriority = null;
|
||||
inCompletedSection = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse todo item
|
||||
const match = trimmed.match(todoPattern);
|
||||
if (match) {
|
||||
const [, checkboxState, todoContent] = match;
|
||||
let status: RalphTodoStatus;
|
||||
|
||||
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
|
||||
status = 'completed';
|
||||
} else if (checkboxState === '-') {
|
||||
status = 'in_progress';
|
||||
} else {
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
// Parse priority from content if not in a priority section
|
||||
const parsedPriority = inCompletedSection ? null : currentPriority || parsePriority(todoContent);
|
||||
|
||||
const id = generateTodoId(todoContent);
|
||||
newTodos.push({
|
||||
id,
|
||||
content: todoContent.trim(),
|
||||
status,
|
||||
detectedAt: Date.now(),
|
||||
priority: parsedPriority,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newTodos;
|
||||
}
|
||||
|
||||
/**
|
||||
* RalphFixPlanWatcher - Watches @fix_plan.md for changes.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `todosLoaded` - Emits parsed todo items when @fix_plan.md is loaded/changed
|
||||
* - `enabled` - Emits when tracker should be auto-enabled (todos loaded from file)
|
||||
*/
|
||||
export class RalphFixPlanWatcher extends EventEmitter {
|
||||
/** Working directory for @fix_plan.md watching */
|
||||
private _workingDir: string | null = null;
|
||||
|
||||
/** Path to the @fix_plan.md file being watched */
|
||||
private _fixPlanPath: string | null = null;
|
||||
|
||||
/** File watcher for @fix_plan.md */
|
||||
private _fixPlanWatcher: FSWatcher | null = null;
|
||||
|
||||
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
|
||||
private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null;
|
||||
|
||||
/** Debounce timer for file change events */
|
||||
private _fixPlanReloadTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Priority parser injected from parent (for importFixPlanMarkdown) */
|
||||
private _parsePriority: (content: string) => RalphTodoPriority;
|
||||
|
||||
/** Todo ID generator injected from parent */
|
||||
private _generateTodoId: (content: string) => string;
|
||||
|
||||
constructor(parsePriority: (content: string) => RalphTodoPriority, generateTodoId: (content: string) => string) {
|
||||
super();
|
||||
this._parsePriority = parsePriority;
|
||||
this._generateTodoId = generateTodoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* When @fix_plan.md is active, treat it as the source of truth for todo status.
|
||||
* This prevents output-based detection from overriding file-based status.
|
||||
*/
|
||||
get isFileAuthoritative(): boolean {
|
||||
return this._fixPlanPath !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the working directory and start watching @fix_plan.md.
|
||||
* Automatically loads existing @fix_plan.md if present.
|
||||
* @param workingDir - The session's working directory
|
||||
*/
|
||||
setWorkingDir(workingDir: string): void {
|
||||
this._workingDir = workingDir;
|
||||
this._fixPlanPath = join(workingDir, '@fix_plan.md');
|
||||
|
||||
// Try to load existing @fix_plan.md
|
||||
this.loadFixPlanFromDisk();
|
||||
|
||||
// Start watching for changes
|
||||
this.startWatchingFixPlan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load @fix_plan.md from disk if it exists.
|
||||
* Called on initialization and when file changes are detected.
|
||||
*/
|
||||
async loadFixPlanFromDisk(): Promise<number> {
|
||||
if (!this._fixPlanPath) return 0;
|
||||
|
||||
try {
|
||||
if (!existsSync(this._fixPlanPath)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const content = await readFile(this._fixPlanPath, 'utf-8');
|
||||
const todos = importFixPlanMarkdown(content, this._parsePriority, this._generateTodoId);
|
||||
|
||||
if (todos.length > 0) {
|
||||
this.emit('todosLoaded', todos);
|
||||
console.log(`[RalphFixPlanWatcher] Loaded ${todos.length} todos from @fix_plan.md`);
|
||||
}
|
||||
|
||||
return todos.length;
|
||||
} catch (err) {
|
||||
// File doesn't exist or can't be read - that's OK
|
||||
console.log(`[RalphFixPlanWatcher] Could not load @fix_plan.md: ${err}`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching @fix_plan.md for changes.
|
||||
* Reloads todos when the file is modified.
|
||||
*/
|
||||
private startWatchingFixPlan(): void {
|
||||
if (!this._fixPlanPath || !this._workingDir) return;
|
||||
|
||||
// Stop existing watcher if any
|
||||
this.stopWatchingFixPlan();
|
||||
|
||||
try {
|
||||
// Only watch if the file exists
|
||||
if (!existsSync(this._fixPlanPath)) {
|
||||
// Watch the directory instead for file creation
|
||||
this._fixPlanWatcher = fsWatch(this._workingDir, (_eventType, filename) => {
|
||||
if (filename === '@fix_plan.md') {
|
||||
this.handleFixPlanChange();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Watch the file directly
|
||||
this._fixPlanWatcher = fsWatch(this._fixPlanPath, () => {
|
||||
this.handleFixPlanChange();
|
||||
});
|
||||
}
|
||||
// Add error handler to prevent unhandled errors and clean up on failure
|
||||
// Store handler reference for proper cleanup in stopWatchingFixPlan()
|
||||
if (this._fixPlanWatcher) {
|
||||
this._fixPlanWatcherErrorHandler = (err: Error) => {
|
||||
console.log(`[RalphFixPlanWatcher] FSWatcher error for @fix_plan.md: ${err.message}`);
|
||||
this.stopWatchingFixPlan();
|
||||
};
|
||||
this._fixPlanWatcher.on('error', this._fixPlanWatcherErrorHandler);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`[RalphFixPlanWatcher] Could not watch @fix_plan.md: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle @fix_plan.md file change with debouncing.
|
||||
*/
|
||||
private handleFixPlanChange(): void {
|
||||
// Debounce rapid changes (e.g., multiple writes)
|
||||
if (this._fixPlanReloadTimer) {
|
||||
clearTimeout(this._fixPlanReloadTimer);
|
||||
}
|
||||
|
||||
this._fixPlanReloadTimer = setTimeout(() => {
|
||||
this._fixPlanReloadTimer = null;
|
||||
this.loadFixPlanFromDisk();
|
||||
}, 500); // 500ms debounce
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching @fix_plan.md.
|
||||
*/
|
||||
stopWatchingFixPlan(): void {
|
||||
if (this._fixPlanWatcher) {
|
||||
// Remove error handler before closing to prevent memory leak
|
||||
if (this._fixPlanWatcherErrorHandler) {
|
||||
this._fixPlanWatcher.off('error', this._fixPlanWatcherErrorHandler);
|
||||
this._fixPlanWatcherErrorHandler = null;
|
||||
}
|
||||
this._fixPlanWatcher.close();
|
||||
this._fixPlanWatcher = null;
|
||||
}
|
||||
if (this._fixPlanReloadTimer) {
|
||||
clearTimeout(this._fixPlanReloadTimer);
|
||||
this._fixPlanReloadTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching and clean up all resources.
|
||||
*/
|
||||
stop(): void {
|
||||
this.stopWatchingFixPlan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stop();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* @fileoverview RalphPlanTracker - Enhanced plan task management
|
||||
*
|
||||
* Manages plan tasks with verification criteria, dependencies,
|
||||
* execution tracking, TDD workflow support, and plan versioning.
|
||||
*
|
||||
* Extracted from ralph-tracker.ts as part of domain splitting.
|
||||
*
|
||||
* @module ralph-plan-tracker
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type { PlanTaskStatus, TddPhase } from './types.js';
|
||||
|
||||
// ========== Enhanced Plan Task Interface ==========
|
||||
|
||||
/**
|
||||
* Enhanced plan task with verification criteria, dependencies, and execution tracking.
|
||||
* Supports TDD workflow, failure tracking, and plan versioning.
|
||||
*/
|
||||
export interface EnhancedPlanTask {
|
||||
/** Unique identifier (e.g., "P0-001") */
|
||||
id: string;
|
||||
/** Task description */
|
||||
content: string;
|
||||
/** Criticality level */
|
||||
priority: 'P0' | 'P1' | 'P2' | null;
|
||||
/** How to verify completion */
|
||||
verificationCriteria?: string;
|
||||
/** Command to run for verification */
|
||||
testCommand?: string;
|
||||
/** IDs of tasks that must complete first */
|
||||
dependencies: string[];
|
||||
/** Current execution status */
|
||||
status: PlanTaskStatus;
|
||||
/** How many times attempted */
|
||||
attempts: number;
|
||||
/** Most recent failure reason */
|
||||
lastError?: string;
|
||||
/** Timestamp of completion */
|
||||
completedAt?: number;
|
||||
/** Plan version this belongs to */
|
||||
version: number;
|
||||
/** TDD phase category */
|
||||
tddPhase?: TddPhase;
|
||||
/** ID of paired test/impl task */
|
||||
pairedWith?: string;
|
||||
/** Estimated complexity */
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
/** Checklist items for review tasks (tddPhase: 'review') */
|
||||
reviewChecklist?: string[];
|
||||
}
|
||||
|
||||
/** Checkpoint review data */
|
||||
export interface CheckpointReview {
|
||||
iteration: number;
|
||||
timestamp: number;
|
||||
summary: {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
blocked: number;
|
||||
pending: number;
|
||||
inProgress: number;
|
||||
};
|
||||
stuckTasks: Array<{
|
||||
id: string;
|
||||
content: string;
|
||||
attempts: number;
|
||||
lastError?: string;
|
||||
}>;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
const MAX_PLAN_HISTORY = 10;
|
||||
|
||||
/**
|
||||
* RalphPlanTracker - Manages enhanced plan tasks with versioning and checkpoints.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `planInitialized` - When a new plan is initialized
|
||||
* - `planTaskUpdate` - When a plan task is updated
|
||||
* - `taskBlocked` - When a task becomes blocked after too many failures
|
||||
* - `taskUnblocked` - When a task's dependencies are all met
|
||||
* - `planCheckpoint` - When a checkpoint review is triggered
|
||||
* - `planTaskAdded` - When a new task is added to the plan
|
||||
* - `planRollback` - When the plan is rolled back to a previous version
|
||||
*/
|
||||
export class RalphPlanTracker extends EventEmitter {
|
||||
/** Current version of the plan (incremented on changes) */
|
||||
private _planVersion: number = 1;
|
||||
|
||||
/** History of plan versions for rollback support */
|
||||
private _planHistory: Array<{
|
||||
version: number;
|
||||
timestamp: number;
|
||||
tasks: Map<string, EnhancedPlanTask>;
|
||||
summary: string;
|
||||
}> = [];
|
||||
|
||||
/** Enhanced plan tasks with execution tracking */
|
||||
private _planTasks: Map<string, EnhancedPlanTask> = new Map();
|
||||
|
||||
/** Checkpoint intervals (iterations at which to trigger review) */
|
||||
private _checkpointIterations: number[] = [5, 10, 20, 30, 50, 75, 100];
|
||||
|
||||
/** Last checkpoint iteration */
|
||||
private _lastCheckpointIteration: number = 0;
|
||||
|
||||
/** Current cycle count (fed by parent via notifyCycleCount) */
|
||||
private _cycleCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the plan tracker of the current cycle count.
|
||||
* Called by parent when iteration changes (for checkpoint detection).
|
||||
*/
|
||||
notifyCycleCount(cycleCount: number): void {
|
||||
this._cycleCount = cycleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plan tasks from generated plan items.
|
||||
* Called when wizard generates a new plan.
|
||||
*/
|
||||
initializePlanTasks(
|
||||
items: Array<{
|
||||
id?: string;
|
||||
content: string;
|
||||
priority?: 'P0' | 'P1' | 'P2' | null;
|
||||
verificationCriteria?: string;
|
||||
testCommand?: string;
|
||||
dependencies?: string[];
|
||||
tddPhase?: TddPhase;
|
||||
pairedWith?: string;
|
||||
complexity?: 'low' | 'medium' | 'high';
|
||||
}>
|
||||
): void {
|
||||
// Save current plan to history before replacing
|
||||
if (this._planTasks.size > 0) {
|
||||
this._savePlanToHistory('Plan replaced with new generation');
|
||||
}
|
||||
|
||||
// Clear and rebuild
|
||||
this._planTasks.clear();
|
||||
this._planVersion++;
|
||||
|
||||
items.forEach((item, idx) => {
|
||||
const id = item.id || `task-${idx}`;
|
||||
const task: EnhancedPlanTask = {
|
||||
id,
|
||||
content: item.content,
|
||||
priority: item.priority || null,
|
||||
verificationCriteria: item.verificationCriteria,
|
||||
testCommand: item.testCommand,
|
||||
dependencies: item.dependencies || [],
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
version: this._planVersion,
|
||||
tddPhase: item.tddPhase,
|
||||
pairedWith: item.pairedWith,
|
||||
complexity: item.complexity,
|
||||
};
|
||||
this._planTasks.set(id, task);
|
||||
});
|
||||
|
||||
this.emit('planInitialized', { version: this._planVersion, taskCount: this._planTasks.size });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific plan task's status, attempts, or error.
|
||||
*/
|
||||
updatePlanTask(
|
||||
taskId: string,
|
||||
update: {
|
||||
status?: PlanTaskStatus;
|
||||
error?: string;
|
||||
incrementAttempts?: boolean;
|
||||
}
|
||||
): { success: boolean; task?: EnhancedPlanTask; error?: string } {
|
||||
const task = this._planTasks.get(taskId);
|
||||
if (!task) {
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
if (update.status) {
|
||||
task.status = update.status;
|
||||
if (update.status === 'completed') {
|
||||
task.completedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
if (update.error) {
|
||||
task.lastError = update.error;
|
||||
}
|
||||
|
||||
if (update.incrementAttempts) {
|
||||
task.attempts++;
|
||||
|
||||
// After 3 failed attempts, mark as blocked and emit warning
|
||||
if (task.attempts >= 3 && task.status === 'failed') {
|
||||
task.status = 'blocked';
|
||||
this.emit('taskBlocked', {
|
||||
taskId,
|
||||
content: task.content,
|
||||
attempts: task.attempts,
|
||||
lastError: task.lastError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update blocked tasks when a dependency completes
|
||||
if (update.status === 'completed') {
|
||||
this._unblockDependentTasks(taskId);
|
||||
}
|
||||
|
||||
// Check for checkpoint
|
||||
this._checkForCheckpoint();
|
||||
|
||||
this.emit('planTaskUpdate', { taskId, task });
|
||||
return { success: true, task };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a new task to the plan (for runtime adaptation).
|
||||
*/
|
||||
addPlanTask(task: {
|
||||
content: string;
|
||||
priority?: 'P0' | 'P1' | 'P2';
|
||||
verificationCriteria?: string;
|
||||
dependencies?: string[];
|
||||
insertAfter?: string;
|
||||
}): { task: EnhancedPlanTask } {
|
||||
// Generate unique ID
|
||||
const existingIds = Array.from(this._planTasks.keys());
|
||||
const prefix = task.priority || 'P1';
|
||||
let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1;
|
||||
let id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
||||
while (this._planTasks.has(id)) {
|
||||
counter++;
|
||||
id = `${prefix}-${String(counter).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const newTask: EnhancedPlanTask = {
|
||||
id,
|
||||
content: task.content,
|
||||
priority: task.priority || null,
|
||||
verificationCriteria: task.verificationCriteria || 'Task completed successfully',
|
||||
dependencies: task.dependencies || [],
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
version: this._planVersion,
|
||||
};
|
||||
|
||||
this._planTasks.set(id, newTask);
|
||||
this.emit('planTaskAdded', { task: newTask });
|
||||
|
||||
return { task: newTask };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all plan tasks.
|
||||
*/
|
||||
getPlanTasks(): EnhancedPlanTask[] {
|
||||
return Array.from(this._planTasks.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a checkpoint review summarizing plan progress and stuck tasks.
|
||||
*/
|
||||
generateCheckpointReview(): CheckpointReview {
|
||||
const tasks = Array.from(this._planTasks.values());
|
||||
|
||||
const summary = {
|
||||
total: tasks.length,
|
||||
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||
blocked: tasks.filter((t) => t.status === 'blocked').length,
|
||||
pending: tasks.filter((t) => t.status === 'pending').length,
|
||||
inProgress: tasks.filter((t) => t.status === 'in_progress').length,
|
||||
};
|
||||
|
||||
// Find stuck tasks (3+ attempts or blocked)
|
||||
const stuckTasks = tasks
|
||||
.filter((t) => t.attempts >= 3 || t.status === 'blocked')
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
content: t.content,
|
||||
attempts: t.attempts,
|
||||
lastError: t.lastError,
|
||||
}));
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (stuckTasks.length > 0) {
|
||||
recommendations.push(`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`);
|
||||
}
|
||||
|
||||
if (summary.failed > summary.completed && summary.total > 5) {
|
||||
recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.');
|
||||
}
|
||||
|
||||
const progressPercent = summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0;
|
||||
if (progressPercent < 20 && this._cycleCount > 10) {
|
||||
recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.');
|
||||
}
|
||||
|
||||
if (summary.total > 0 && summary.blocked > summary.total / 3) {
|
||||
recommendations.push('Many tasks are blocked. Review dependency chain for bottlenecks.');
|
||||
}
|
||||
|
||||
return {
|
||||
iteration: this._cycleCount,
|
||||
timestamp: Date.now(),
|
||||
summary,
|
||||
stuckTasks,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan version history.
|
||||
*/
|
||||
getPlanHistory(): Array<{
|
||||
version: number;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
stats: { total: number; completed: number; failed: number };
|
||||
}> {
|
||||
return this._planHistory.map((h) => {
|
||||
const tasks = Array.from(h.tasks.values());
|
||||
return {
|
||||
version: h.version,
|
||||
timestamp: h.timestamp,
|
||||
summary: h.summary,
|
||||
stats: {
|
||||
total: tasks.length,
|
||||
completed: tasks.filter((t) => t.status === 'completed').length,
|
||||
failed: tasks.filter((t) => t.status === 'failed').length,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback to a previous plan version.
|
||||
*/
|
||||
rollbackToVersion(version: number): {
|
||||
success: boolean;
|
||||
plan?: EnhancedPlanTask[];
|
||||
error?: string;
|
||||
} {
|
||||
const historyEntry = this._planHistory.find((h) => h.version === version);
|
||||
if (!historyEntry) {
|
||||
return { success: false, error: `Version ${version} not found in history` };
|
||||
}
|
||||
|
||||
// Save current state first
|
||||
this._savePlanToHistory(`Rolled back from v${this._planVersion} to v${version}`);
|
||||
|
||||
// Restore the historical version
|
||||
this._planTasks.clear();
|
||||
for (const [id, task] of historyEntry.tasks) {
|
||||
// Reset execution state for retry
|
||||
this._planTasks.set(id, {
|
||||
...task,
|
||||
status: task.status === 'completed' ? 'completed' : 'pending',
|
||||
attempts: task.status === 'completed' ? task.attempts : 0,
|
||||
lastError: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
this._planVersion++;
|
||||
this.emit('planRollback', { version, newVersion: this._planVersion });
|
||||
|
||||
return { success: true, plan: Array.from(this._planTasks.values()) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if checkpoint review is due for current iteration.
|
||||
*/
|
||||
isCheckpointDue(): boolean {
|
||||
return this._checkpointIterations.includes(this._cycleCount) && this._cycleCount > this._lastCheckpointIteration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current plan version.
|
||||
*/
|
||||
get planVersion(): number {
|
||||
return this._planVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset plan state (soft reset - keeps version history).
|
||||
*/
|
||||
reset(): void {
|
||||
// Don't clear history or version on soft reset
|
||||
}
|
||||
|
||||
/**
|
||||
* Full reset - clears all plan state.
|
||||
*/
|
||||
fullReset(): void {
|
||||
this._planTasks.clear();
|
||||
this._planHistory.length = 0;
|
||||
this._planVersion = 1;
|
||||
this._lastCheckpointIteration = 0;
|
||||
this._cycleCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources.
|
||||
*/
|
||||
destroy(): void {
|
||||
this._planTasks.clear();
|
||||
this._planHistory.length = 0;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock tasks that were waiting on a completed dependency.
|
||||
*/
|
||||
private _unblockDependentTasks(completedTaskId: string): void {
|
||||
for (const [_, task] of this._planTasks) {
|
||||
if (task.dependencies.includes(completedTaskId)) {
|
||||
// Check if all dependencies are now complete
|
||||
const allDepsComplete = task.dependencies.every((depId) => {
|
||||
const dep = this._planTasks.get(depId);
|
||||
return dep && dep.status === 'completed';
|
||||
});
|
||||
|
||||
if (allDepsComplete && task.status === 'blocked') {
|
||||
task.status = 'pending';
|
||||
this.emit('taskUnblocked', { taskId: task.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current iteration is a checkpoint and emit review if so.
|
||||
*/
|
||||
private _checkForCheckpoint(): void {
|
||||
if (this._checkpointIterations.includes(this._cycleCount) && this._cycleCount > this._lastCheckpointIteration) {
|
||||
this._lastCheckpointIteration = this._cycleCount;
|
||||
const checkpoint = this.generateCheckpointReview();
|
||||
this.emit('planCheckpoint', checkpoint);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current plan state to history.
|
||||
*/
|
||||
private _savePlanToHistory(summary: string): void {
|
||||
// Clone current tasks
|
||||
const tasksCopy = new Map<string, EnhancedPlanTask>();
|
||||
for (const [id, task] of this._planTasks) {
|
||||
tasksCopy.set(id, { ...task });
|
||||
}
|
||||
|
||||
this._planHistory.push({
|
||||
version: this._planVersion,
|
||||
timestamp: Date.now(),
|
||||
tasks: tasksCopy,
|
||||
summary,
|
||||
});
|
||||
|
||||
// Limit history size
|
||||
if (this._planHistory.length > MAX_PLAN_HISTORY) {
|
||||
this._planHistory.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* @fileoverview RalphStallDetector - Iteration stall detection
|
||||
*
|
||||
* Monitors iteration progress and emits warnings when the loop
|
||||
* appears to be stalled (no iteration changes for extended periods).
|
||||
*
|
||||
* Extracted from ralph-tracker.ts as part of domain splitting.
|
||||
*
|
||||
* @module ralph-stall-detector
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
/**
|
||||
* RalphStallDetector - Detects iteration stalls in the Ralph loop.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `iterationStallWarning` - When iteration hasn't changed for warning threshold
|
||||
* - `iterationStallCritical` - When iteration hasn't changed for critical threshold
|
||||
*/
|
||||
export class RalphStallDetector extends EventEmitter {
|
||||
/** Timestamp when iteration count last changed */
|
||||
private _lastIterationChangeTime: number = 0;
|
||||
|
||||
/** Last observed iteration count for stall detection */
|
||||
private _lastObservedIteration: number = 0;
|
||||
|
||||
/** Timer for iteration stall detection */
|
||||
private _iterationStallTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Iteration stall warning threshold (ms) - default 10 minutes */
|
||||
private _iterationStallWarningMs: number = 10 * 60 * 1000;
|
||||
|
||||
/** Iteration stall critical threshold (ms) - default 20 minutes */
|
||||
private _iterationStallCriticalMs: number = 20 * 60 * 1000;
|
||||
|
||||
/** Whether stall warning has been emitted */
|
||||
private _iterationStallWarned: boolean = false;
|
||||
|
||||
/** Whether the loop is currently active */
|
||||
private _loopActive: boolean = false;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._lastIterationChangeTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start iteration stall detection timer.
|
||||
* Should be called when the loop becomes active.
|
||||
*/
|
||||
startIterationStallDetection(): void {
|
||||
this.stopIterationStallDetection();
|
||||
this._lastIterationChangeTime = Date.now();
|
||||
this._iterationStallWarned = false;
|
||||
|
||||
// Check every minute
|
||||
this._iterationStallTimer = setInterval(() => {
|
||||
this.checkIterationStall();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop iteration stall detection timer.
|
||||
*/
|
||||
stopIterationStallDetection(): void {
|
||||
if (this._iterationStallTimer) {
|
||||
clearInterval(this._iterationStallTimer);
|
||||
this._iterationStallTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the detector that the iteration has changed.
|
||||
* Resets stall tracking state.
|
||||
*/
|
||||
notifyIterationChanged(iteration: number): void {
|
||||
this._lastIterationChangeTime = Date.now();
|
||||
this._lastObservedIteration = iteration;
|
||||
this._iterationStallWarned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set whether the loop is currently active.
|
||||
* Stall detection only fires when loop is active.
|
||||
*/
|
||||
setLoopActive(active: boolean): void {
|
||||
this._loopActive = active;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for iteration stall and emit appropriate events.
|
||||
*/
|
||||
private checkIterationStall(): void {
|
||||
if (!this._loopActive) return;
|
||||
|
||||
const stallDurationMs = Date.now() - this._lastIterationChangeTime;
|
||||
|
||||
// Critical stall (longer duration)
|
||||
if (stallDurationMs >= this._iterationStallCriticalMs) {
|
||||
this.emit('iterationStallCritical', {
|
||||
iteration: this._lastObservedIteration,
|
||||
stallDurationMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Warning stall
|
||||
if (stallDurationMs >= this._iterationStallWarningMs && !this._iterationStallWarned) {
|
||||
this._iterationStallWarned = true;
|
||||
this.emit('iterationStallWarning', {
|
||||
iteration: this._lastObservedIteration,
|
||||
stallDurationMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get iteration stall metrics for monitoring.
|
||||
*/
|
||||
getIterationStallMetrics(): {
|
||||
lastIterationChangeTime: number;
|
||||
stallDurationMs: number;
|
||||
warningThresholdMs: number;
|
||||
criticalThresholdMs: number;
|
||||
isWarned: boolean;
|
||||
currentIteration: number;
|
||||
} {
|
||||
return {
|
||||
lastIterationChangeTime: this._lastIterationChangeTime,
|
||||
stallDurationMs: Date.now() - this._lastIterationChangeTime,
|
||||
warningThresholdMs: this._iterationStallWarningMs,
|
||||
criticalThresholdMs: this._iterationStallCriticalMs,
|
||||
isWarned: this._iterationStallWarned,
|
||||
currentIteration: this._lastObservedIteration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure iteration stall thresholds.
|
||||
* @param warningMs - Warning threshold in milliseconds
|
||||
* @param criticalMs - Critical threshold in milliseconds
|
||||
*/
|
||||
configureIterationStallThresholds(warningMs: number, criticalMs: number): void {
|
||||
this._iterationStallWarningMs = warningMs;
|
||||
this._iterationStallCriticalMs = criticalMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset stall detector state.
|
||||
*/
|
||||
reset(): void {
|
||||
this._lastIterationChangeTime = Date.now();
|
||||
this._lastObservedIteration = 0;
|
||||
this._iterationStallWarned = false;
|
||||
this._loopActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources.
|
||||
*/
|
||||
destroy(): void {
|
||||
this.stopIterationStallDetection();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* @fileoverview RalphStatusParser - RALPH_STATUS block parsing and circuit breaker
|
||||
*
|
||||
* Parses structured RALPH_STATUS blocks from Claude Code output
|
||||
* and manages the circuit breaker state machine.
|
||||
*
|
||||
* Extracted from ralph-tracker.ts as part of domain splitting.
|
||||
*
|
||||
* @module ralph-status-parser
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import type {
|
||||
RalphStatusBlock,
|
||||
RalphStatusValue,
|
||||
RalphTestsStatus,
|
||||
RalphWorkType,
|
||||
CircuitBreakerStatus,
|
||||
} from './types.js';
|
||||
import { createInitialCircuitBreakerStatus } from './types.js';
|
||||
|
||||
// ---------- RALPH_STATUS Block Patterns ----------
|
||||
// Based on Ralph Claude Code structured status reporting
|
||||
|
||||
/**
|
||||
* Matches the start of a RALPH_STATUS block
|
||||
* Pattern: ---RALPH_STATUS---
|
||||
*/
|
||||
const RALPH_STATUS_START_PATTERN = /^---RALPH_STATUS---\s*$/;
|
||||
|
||||
/**
|
||||
* Matches the end of a RALPH_STATUS block
|
||||
* Pattern: ---END_RALPH_STATUS---
|
||||
*/
|
||||
const RALPH_STATUS_END_PATTERN = /^---END_RALPH_STATUS---\s*$/;
|
||||
|
||||
/**
|
||||
* Matches STATUS field in RALPH_STATUS block
|
||||
* Captures: IN_PROGRESS | COMPLETE | BLOCKED
|
||||
*/
|
||||
const RALPH_STATUS_FIELD_PATTERN = /^STATUS:\s*(IN_PROGRESS|COMPLETE|BLOCKED)\s*$/i;
|
||||
|
||||
/**
|
||||
* Matches TASKS_COMPLETED_THIS_LOOP field
|
||||
* Captures: number
|
||||
*/
|
||||
const RALPH_TASKS_COMPLETED_PATTERN = /^TASKS_COMPLETED_THIS_LOOP:\s*(\d+)\s*$/i;
|
||||
|
||||
/**
|
||||
* Matches FILES_MODIFIED field
|
||||
* Captures: number
|
||||
*/
|
||||
const RALPH_FILES_MODIFIED_PATTERN = /^FILES_MODIFIED:\s*(\d+)\s*$/i;
|
||||
|
||||
/**
|
||||
* Matches TESTS_STATUS field
|
||||
* Captures: PASSING | FAILING | NOT_RUN
|
||||
*/
|
||||
const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s*$/i;
|
||||
|
||||
/**
|
||||
* Matches WORK_TYPE field
|
||||
* Captures: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING
|
||||
*/
|
||||
const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i;
|
||||
|
||||
/**
|
||||
* Matches EXIT_SIGNAL field
|
||||
* Captures: true | false
|
||||
*/
|
||||
const RALPH_EXIT_SIGNAL_PATTERN = /^EXIT_SIGNAL:\s*(true|false)\s*$/i;
|
||||
|
||||
/**
|
||||
* Matches RECOMMENDATION field
|
||||
* Captures: any text
|
||||
*/
|
||||
const RALPH_RECOMMENDATION_PATTERN = /^RECOMMENDATION:\s*(.+)$/i;
|
||||
|
||||
// ---------- Completion Indicator Patterns (for dual-condition exit) ----------
|
||||
|
||||
/**
|
||||
* Patterns that indicate potential completion (natural language)
|
||||
* Count >= 2 along with EXIT_SIGNAL: true triggers exit
|
||||
*/
|
||||
const COMPLETION_INDICATOR_PATTERNS = [
|
||||
/all\s+(?:tasks?|items?|work)\s+(?:are\s+)?(?:completed?|done|finished)/i,
|
||||
/(?:completed?|finished)\s+all\s+(?:tasks?|items?|work)/i,
|
||||
/nothing\s+(?:left|remaining)\s+to\s+do/i,
|
||||
/no\s+more\s+(?:tasks?|items?|work)/i,
|
||||
/everything\s+(?:is\s+)?(?:completed?|done)/i,
|
||||
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* RalphStatusParser - Parses RALPH_STATUS blocks and manages circuit breaker.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `statusBlockDetected` - When a complete RALPH_STATUS block is parsed
|
||||
* - `circuitBreakerUpdate` - When circuit breaker state changes
|
||||
* - `exitGateMet` - When dual-condition exit gate is met
|
||||
*/
|
||||
export class RalphStatusParser extends EventEmitter {
|
||||
/** Circuit breaker state tracking */
|
||||
private _circuitBreaker: CircuitBreakerStatus;
|
||||
|
||||
/** Buffer for RALPH_STATUS block lines */
|
||||
private _statusBlockBuffer: string[] = [];
|
||||
|
||||
/** Flag indicating we're inside a RALPH_STATUS block */
|
||||
private _inStatusBlock: boolean = false;
|
||||
|
||||
/** Last parsed RALPH_STATUS block */
|
||||
private _lastStatusBlock: RalphStatusBlock | null = null;
|
||||
|
||||
/** Count of completion indicators detected (for dual-condition exit) */
|
||||
private _completionIndicators: number = 0;
|
||||
|
||||
/** Whether dual-condition exit gate has been met */
|
||||
private _exitGateMet: boolean = false;
|
||||
|
||||
/** Cumulative files modified across all iterations */
|
||||
private _totalFilesModified: number = 0;
|
||||
|
||||
/** Cumulative tasks completed across all iterations */
|
||||
private _totalTasksCompleted: number = 0;
|
||||
|
||||
/** Current cycle count (fed by parent) */
|
||||
private _cycleCount: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a line for status block detection and completion indicators.
|
||||
* Main entry point - call this for each trimmed line.
|
||||
*/
|
||||
processLine(line: string): void {
|
||||
this.processStatusBlockLine(line);
|
||||
this.detectCompletionIndicators(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current cycle count (fed by parent for circuit breaker tracking).
|
||||
*/
|
||||
setCycleCount(cycleCount: number): void {
|
||||
this._cycleCount = cycleCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify of iteration progress (for circuit breaker reset on progress).
|
||||
* Called by parent when iteration count changes.
|
||||
*/
|
||||
notifyIterationProgress(currentIteration: number): void {
|
||||
if (
|
||||
this._circuitBreaker.state === 'HALF_OPEN' ||
|
||||
this._circuitBreaker.consecutiveNoProgress > 0 ||
|
||||
this._circuitBreaker.consecutiveSameError > 0 ||
|
||||
this._circuitBreaker.consecutiveTestsFailure > 0
|
||||
) {
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = currentIteration;
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
this._circuitBreaker.state = 'CLOSED';
|
||||
this._circuitBreaker.reason = 'Iteration progress detected';
|
||||
this._circuitBreaker.reasonCode = 'progress_detected';
|
||||
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current circuit breaker status.
|
||||
*/
|
||||
get circuitBreakerStatus(): CircuitBreakerStatus {
|
||||
return { ...this._circuitBreaker };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last parsed RALPH_STATUS block.
|
||||
*/
|
||||
get lastStatusBlock(): RalphStatusBlock | null {
|
||||
return this._lastStatusBlock ? { ...this._lastStatusBlock } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cumulative stats from status blocks.
|
||||
*/
|
||||
get cumulativeStats(): {
|
||||
filesModified: number;
|
||||
tasksCompleted: number;
|
||||
completionIndicators: number;
|
||||
} {
|
||||
return {
|
||||
filesModified: this._totalFilesModified,
|
||||
tasksCompleted: this._totalTasksCompleted,
|
||||
completionIndicators: this._completionIndicators,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether dual-condition exit gate has been met.
|
||||
*/
|
||||
get exitGateMet(): boolean {
|
||||
return this._exitGateMet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually reset circuit breaker to CLOSED state.
|
||||
* Use when user acknowledges the issue is resolved.
|
||||
*
|
||||
* @fires circuitBreakerUpdate
|
||||
*/
|
||||
resetCircuitBreaker(): void {
|
||||
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
||||
this._circuitBreaker.reason = 'Manual reset';
|
||||
this._circuitBreaker.reasonCode = 'manual_reset';
|
||||
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset status parser state (soft reset).
|
||||
* Clears status block buffer and completion indicators.
|
||||
* Keeps circuit breaker state (it tracks across iterations).
|
||||
*/
|
||||
reset(): void {
|
||||
this._statusBlockBuffer = [];
|
||||
this._inStatusBlock = false;
|
||||
this._lastStatusBlock = null;
|
||||
this._completionIndicators = 0;
|
||||
this._exitGateMet = false;
|
||||
this._totalFilesModified = 0;
|
||||
this._totalTasksCompleted = 0;
|
||||
// Keep circuit breaker state on soft reset (it tracks across iterations)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full reset - clears all state including circuit breaker.
|
||||
*/
|
||||
fullReset(): void {
|
||||
this.reset();
|
||||
this._circuitBreaker = createInitialCircuitBreakerStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all resources.
|
||||
*/
|
||||
destroy(): void {
|
||||
this._statusBlockBuffer.length = 0;
|
||||
this.removeAllListeners();
|
||||
}
|
||||
|
||||
// ========== Private Methods ==========
|
||||
|
||||
/**
|
||||
* Process a line for RALPH_STATUS block detection.
|
||||
* Buffers lines between ---RALPH_STATUS--- and ---END_RALPH_STATUS---
|
||||
* then parses the complete block.
|
||||
*
|
||||
* @param line - Single line to process (already trimmed)
|
||||
* @fires statusBlockDetected - When a complete block is parsed
|
||||
*/
|
||||
private processStatusBlockLine(line: string): void {
|
||||
// Check for block start
|
||||
if (RALPH_STATUS_START_PATTERN.test(line)) {
|
||||
this._inStatusBlock = true;
|
||||
this._statusBlockBuffer = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for block end
|
||||
if (this._inStatusBlock && RALPH_STATUS_END_PATTERN.test(line)) {
|
||||
this._inStatusBlock = false;
|
||||
this.parseStatusBlock(this._statusBlockBuffer);
|
||||
this._statusBlockBuffer = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Buffer lines while in block
|
||||
if (this._inStatusBlock) {
|
||||
this._statusBlockBuffer.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse buffered RALPH_STATUS block lines into structured data.
|
||||
*
|
||||
* P1-004: Enhanced with schema validation and error recovery
|
||||
*
|
||||
* @param lines - Array of lines between block markers
|
||||
* @fires statusBlockDetected - When parsing succeeds
|
||||
*/
|
||||
private parseStatusBlock(lines: string[]): void {
|
||||
const block: Partial<RalphStatusBlock> = {
|
||||
parsedAt: Date.now(),
|
||||
};
|
||||
const parseErrors: string[] = [];
|
||||
const unknownFields: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim();
|
||||
if (!trimmedLine) continue;
|
||||
|
||||
// Track whether this line matched any known field
|
||||
let matched = false;
|
||||
|
||||
// STATUS field (required)
|
||||
const statusMatch = trimmedLine.match(RALPH_STATUS_FIELD_PATTERN);
|
||||
if (statusMatch) {
|
||||
const value = statusMatch[1].toUpperCase();
|
||||
if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) {
|
||||
block.status = value as RalphStatusValue;
|
||||
} else {
|
||||
parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// TASKS_COMPLETED_THIS_LOOP field
|
||||
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
|
||||
if (tasksMatch) {
|
||||
const value = parseInt(tasksMatch[1], 10);
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.tasksCompletedThisLoop = value;
|
||||
} else {
|
||||
parseErrors.push(
|
||||
`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// FILES_MODIFIED field
|
||||
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
|
||||
if (filesMatch) {
|
||||
const value = parseInt(filesMatch[1], 10);
|
||||
if (!Number.isNaN(value) && value >= 0) {
|
||||
block.filesModified = value;
|
||||
} else {
|
||||
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// TESTS_STATUS field
|
||||
const testsMatch = trimmedLine.match(RALPH_TESTS_STATUS_PATTERN);
|
||||
if (testsMatch) {
|
||||
const value = testsMatch[1].toUpperCase();
|
||||
if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) {
|
||||
block.testsStatus = value as RalphTestsStatus;
|
||||
} else {
|
||||
parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// WORK_TYPE field
|
||||
const workMatch = trimmedLine.match(RALPH_WORK_TYPE_PATTERN);
|
||||
if (workMatch) {
|
||||
const value = workMatch[1].toUpperCase();
|
||||
if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) {
|
||||
block.workType = value as RalphWorkType;
|
||||
} else {
|
||||
parseErrors.push(
|
||||
`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`
|
||||
);
|
||||
}
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// EXIT_SIGNAL field
|
||||
const exitMatch = trimmedLine.match(RALPH_EXIT_SIGNAL_PATTERN);
|
||||
if (exitMatch) {
|
||||
block.exitSignal = exitMatch[1].toLowerCase() === 'true';
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// RECOMMENDATION field
|
||||
const recMatch = trimmedLine.match(RALPH_RECOMMENDATION_PATTERN);
|
||||
if (recMatch) {
|
||||
block.recommendation = recMatch[1].trim();
|
||||
matched = true;
|
||||
}
|
||||
|
||||
// Track unknown fields for debugging (only if looks like a field)
|
||||
if (!matched && trimmedLine.includes(':')) {
|
||||
const fieldName = trimmedLine.split(':')[0].trim().toUpperCase();
|
||||
if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) {
|
||||
unknownFields.push(fieldName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log parse errors if any
|
||||
if (parseErrors.length > 0) {
|
||||
console.warn(`[RalphStatusParser] RALPH_STATUS parse errors:\n - ${parseErrors.join('\n - ')}`);
|
||||
}
|
||||
|
||||
// Log unknown fields if any
|
||||
if (unknownFields.length > 0) {
|
||||
console.warn(`[RalphStatusParser] RALPH_STATUS unknown fields: ${unknownFields.join(', ')}`);
|
||||
}
|
||||
|
||||
// Validate required field: STATUS
|
||||
if (block.status === undefined) {
|
||||
console.warn('[RalphStatusParser] RALPH_STATUS block missing required STATUS field, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fill in defaults for missing optional fields
|
||||
const fullBlock: RalphStatusBlock = {
|
||||
status: block.status,
|
||||
tasksCompletedThisLoop: block.tasksCompletedThisLoop ?? 0,
|
||||
filesModified: block.filesModified ?? 0,
|
||||
testsStatus: block.testsStatus ?? 'NOT_RUN',
|
||||
workType: block.workType ?? 'IMPLEMENTATION',
|
||||
exitSignal: block.exitSignal ?? false,
|
||||
recommendation: block.recommendation ?? '',
|
||||
parsedAt: block.parsedAt!,
|
||||
};
|
||||
|
||||
this._lastStatusBlock = fullBlock;
|
||||
this.handleStatusBlock(fullBlock);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a parsed RALPH_STATUS block.
|
||||
* Updates circuit breaker, checks exit conditions.
|
||||
*
|
||||
* @param block - Parsed status block
|
||||
* @fires statusBlockDetected - With the block data
|
||||
* @fires circuitBreakerUpdate - If state changes
|
||||
* @fires exitGateMet - If dual-condition exit triggered
|
||||
*/
|
||||
private handleStatusBlock(block: RalphStatusBlock): void {
|
||||
// Update cumulative counts
|
||||
this._totalFilesModified += block.filesModified;
|
||||
this._totalTasksCompleted += block.tasksCompletedThisLoop;
|
||||
|
||||
// Check for progress (for circuit breaker)
|
||||
const hasProgress = block.filesModified > 0 || block.tasksCompletedThisLoop > 0;
|
||||
|
||||
// Update circuit breaker
|
||||
this.updateCircuitBreaker(hasProgress, block.testsStatus, block.status);
|
||||
|
||||
// Check completion indicators
|
||||
if (block.status === 'COMPLETE') {
|
||||
this._completionIndicators++;
|
||||
}
|
||||
|
||||
// Check dual-condition exit gate
|
||||
if (block.exitSignal && this._completionIndicators >= 2 && !this._exitGateMet) {
|
||||
this._exitGateMet = true;
|
||||
this.emit('exitGateMet', {
|
||||
completionIndicators: this._completionIndicators,
|
||||
exitSignal: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit the status block
|
||||
this.emit('statusBlockDetected', block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update circuit breaker state based on iteration results.
|
||||
*
|
||||
* @param hasProgress - Whether this iteration made progress
|
||||
* @param testsStatus - Current test status
|
||||
* @param status - Overall status from RALPH_STATUS
|
||||
* @fires circuitBreakerUpdate - If state changes
|
||||
*/
|
||||
private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void {
|
||||
const prevState = this._circuitBreaker.state;
|
||||
|
||||
if (hasProgress) {
|
||||
// Progress detected - reset counters, possibly close circuit
|
||||
this._circuitBreaker.consecutiveNoProgress = 0;
|
||||
this._circuitBreaker.consecutiveSameError = 0;
|
||||
this._circuitBreaker.lastProgressIteration = this._cycleCount;
|
||||
|
||||
if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
this._circuitBreaker.state = 'CLOSED';
|
||||
this._circuitBreaker.reason = 'Progress detected, circuit closed';
|
||||
this._circuitBreaker.reasonCode = 'progress_detected';
|
||||
}
|
||||
} else {
|
||||
// No progress
|
||||
this._circuitBreaker.consecutiveNoProgress++;
|
||||
|
||||
// State transitions based on consecutive no-progress
|
||||
if (this._circuitBreaker.state === 'CLOSED') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
} else if (this._circuitBreaker.consecutiveNoProgress >= 2) {
|
||||
this._circuitBreaker.state = 'HALF_OPEN';
|
||||
this._circuitBreaker.reason = 'Warning: no progress detected';
|
||||
this._circuitBreaker.reasonCode = 'no_progress_warning';
|
||||
}
|
||||
} else if (this._circuitBreaker.state === 'HALF_OPEN') {
|
||||
if (this._circuitBreaker.consecutiveNoProgress >= 3) {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `No progress for ${this._circuitBreaker.consecutiveNoProgress} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'no_progress_open';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track tests failure
|
||||
if (testsStatus === 'FAILING') {
|
||||
this._circuitBreaker.consecutiveTestsFailure++;
|
||||
if (this._circuitBreaker.consecutiveTestsFailure >= 5 && this._circuitBreaker.state !== 'OPEN') {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = `Tests failing for ${this._circuitBreaker.consecutiveTestsFailure} iterations`;
|
||||
this._circuitBreaker.reasonCode = 'tests_failing_too_long';
|
||||
}
|
||||
} else {
|
||||
this._circuitBreaker.consecutiveTestsFailure = 0;
|
||||
}
|
||||
|
||||
// Track blocked status
|
||||
if (status === 'BLOCKED' && this._circuitBreaker.state !== 'OPEN') {
|
||||
this._circuitBreaker.state = 'OPEN';
|
||||
this._circuitBreaker.reason = 'Claude reported BLOCKED status';
|
||||
this._circuitBreaker.reasonCode = 'same_error_repeated';
|
||||
}
|
||||
|
||||
// Emit if state changed
|
||||
if (prevState !== this._circuitBreaker.state) {
|
||||
this._circuitBreaker.lastTransitionAt = Date.now();
|
||||
this.emit('circuitBreakerUpdate', { ...this._circuitBreaker });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check line for completion indicators (natural language patterns).
|
||||
* Used for dual-condition exit gate.
|
||||
*
|
||||
* @param line - Line to check
|
||||
*/
|
||||
private detectCompletionIndicators(line: string): void {
|
||||
for (const pattern of COMPLETION_INDICATOR_PATTERNS) {
|
||||
if (pattern.test(line)) {
|
||||
this._completionIndicators++;
|
||||
break; // Only count once per line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+501
-1978
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* @fileoverview Adaptive timing controller for respawn idle detection.
|
||||
*
|
||||
* Extracted from respawn-controller.ts for modularity. Tracks historical timing
|
||||
* data and adjusts the completion confirm timeout dynamically based on the 75th
|
||||
* percentile of recent idle detection durations.
|
||||
*
|
||||
* @module respawn-adaptive-timing
|
||||
*/
|
||||
|
||||
import type { TimingHistory } from './types.js';
|
||||
|
||||
/**
|
||||
* Configuration for adaptive timing bounds.
|
||||
*/
|
||||
export interface AdaptiveTimingConfig {
|
||||
/** Minimum adaptive completion confirm timeout (ms) */
|
||||
adaptiveMinConfirmMs: number;
|
||||
/** Maximum adaptive completion confirm timeout (ms) */
|
||||
adaptiveMaxConfirmMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages adaptive timing for respawn idle detection.
|
||||
*
|
||||
* Uses historical idle detection durations to calculate an optimal completion
|
||||
* confirm timeout. The timeout is based on the 75th percentile of recent
|
||||
* durations with a 20% safety buffer, clamped to configured bounds.
|
||||
*/
|
||||
export class RespawnAdaptiveTiming {
|
||||
private timingHistory: TimingHistory;
|
||||
|
||||
constructor(private config: AdaptiveTimingConfig) {
|
||||
this.timingHistory = {
|
||||
recentIdleDetectionMs: [],
|
||||
recentCycleDurationMs: [],
|
||||
adaptiveCompletionConfirmMs: 10000, // Start with default
|
||||
sampleCount: 0,
|
||||
maxSamples: 20, // Keep last 20 samples for rolling average
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record timing data from a completed cycle for adaptive adjustments.
|
||||
*
|
||||
* @param idleDetectionMs - Time spent detecting idle
|
||||
* @param cycleDurationMs - Total cycle duration
|
||||
*/
|
||||
recordTimingData(idleDetectionMs: number, cycleDurationMs: number): void {
|
||||
const history = this.timingHistory;
|
||||
|
||||
// Add to rolling windows
|
||||
history.recentIdleDetectionMs.push(idleDetectionMs);
|
||||
history.recentCycleDurationMs.push(cycleDurationMs);
|
||||
|
||||
// Trim to max samples
|
||||
if (history.recentIdleDetectionMs.length > history.maxSamples) {
|
||||
history.recentIdleDetectionMs.shift();
|
||||
}
|
||||
if (history.recentCycleDurationMs.length > history.maxSamples) {
|
||||
history.recentCycleDurationMs.shift();
|
||||
}
|
||||
|
||||
history.sampleCount = history.recentIdleDetectionMs.length;
|
||||
history.lastUpdatedAt = Date.now();
|
||||
|
||||
// Recalculate adaptive timing
|
||||
this.updateAdaptiveTiming();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current adaptive completion confirm timeout.
|
||||
* Returns the calculated value, or the default if not enough samples.
|
||||
*
|
||||
* @returns Completion confirm timeout in milliseconds
|
||||
*/
|
||||
getAdaptiveCompletionConfirmMs(): number {
|
||||
// Need at least 5 samples before adjusting
|
||||
if (this.timingHistory.sampleCount < 5) {
|
||||
return this.timingHistory.adaptiveCompletionConfirmMs;
|
||||
}
|
||||
|
||||
return this.timingHistory.adaptiveCompletionConfirmMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current timing history for monitoring.
|
||||
* @returns Copy of timing history
|
||||
*/
|
||||
getTimingHistory(): TimingHistory {
|
||||
return { ...this.timingHistory };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all timing history.
|
||||
*/
|
||||
reset(): void {
|
||||
this.timingHistory = {
|
||||
recentIdleDetectionMs: [],
|
||||
recentCycleDurationMs: [],
|
||||
adaptiveCompletionConfirmMs: 10000,
|
||||
sampleCount: 0,
|
||||
maxSamples: 20,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate the adaptive completion confirm timeout based on historical data.
|
||||
* Uses the 75th percentile of recent idle detection times as the new timeout,
|
||||
* with a 20% buffer for safety.
|
||||
*/
|
||||
private updateAdaptiveTiming(): void {
|
||||
const history = this.timingHistory;
|
||||
const minMs = this.config.adaptiveMinConfirmMs;
|
||||
const maxMs = this.config.adaptiveMaxConfirmMs;
|
||||
|
||||
if (history.recentIdleDetectionMs.length < 5) return;
|
||||
|
||||
// Sort for percentile calculation
|
||||
const sorted = [...history.recentIdleDetectionMs].sort((a, b) => a - b);
|
||||
|
||||
// Use 75th percentile with 20% buffer
|
||||
const p75Index = Math.floor(sorted.length * 0.75);
|
||||
const p75Value = sorted[p75Index];
|
||||
const withBuffer = Math.round(p75Value * 1.2);
|
||||
|
||||
// Clamp to configured bounds
|
||||
const clamped = Math.max(minMs, Math.min(maxMs, withBuffer));
|
||||
|
||||
history.adaptiveCompletionConfirmMs = clamped;
|
||||
}
|
||||
}
|
||||
+259
-632
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @fileoverview Pure health scoring functions for respawn controller.
|
||||
*
|
||||
* Extracted from respawn-controller.ts for modularity. All functions are pure
|
||||
* (no side effects, no state) and take a HealthInputs interface that decouples
|
||||
* them from direct access to Session, RalphTracker, or AiChecker instances.
|
||||
*
|
||||
* @module respawn-health
|
||||
*/
|
||||
|
||||
import type { RespawnAggregateMetrics, RalphLoopHealthScore, HealthStatus, CircuitBreakerStatus } from './types.js';
|
||||
|
||||
/**
|
||||
* Input data for health score calculation.
|
||||
* Decouples the health calculation from direct access to controller internals.
|
||||
*/
|
||||
export interface HealthInputs {
|
||||
/** Aggregate cycle metrics */
|
||||
aggregateMetrics: RespawnAggregateMetrics;
|
||||
/** Current circuit breaker status */
|
||||
circuitBreakerStatus: CircuitBreakerStatus | null;
|
||||
/** Iteration stall metrics, or null if tracker unavailable */
|
||||
iterationStallMetrics: {
|
||||
stallDurationMs: number;
|
||||
warningThresholdMs: number;
|
||||
criticalThresholdMs: number;
|
||||
} | null;
|
||||
/** AI checker state summary */
|
||||
aiCheckerState: {
|
||||
status: string;
|
||||
consecutiveErrors: number;
|
||||
};
|
||||
/** Number of stuck-state recovery attempts */
|
||||
stuckRecoveryCount: number;
|
||||
/** Maximum allowed stuck recoveries */
|
||||
maxStuckRecoveries: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate a comprehensive health score for the Ralph Loop system.
|
||||
* Aggregates multiple health signals into a single score (0-100).
|
||||
*
|
||||
* @param inputs - Health calculation inputs
|
||||
* @returns Health score with component breakdown
|
||||
*/
|
||||
export function calculateHealthScore(inputs: HealthInputs): RalphLoopHealthScore {
|
||||
const now = Date.now();
|
||||
const components = {
|
||||
cycleSuccess: calculateCycleSuccessScore(inputs.aggregateMetrics),
|
||||
circuitBreaker: calculateCircuitBreakerScore(inputs.circuitBreakerStatus),
|
||||
iterationProgress: calculateIterationProgressScore(inputs.iterationStallMetrics),
|
||||
aiChecker: calculateAiCheckerScore(inputs.aiCheckerState),
|
||||
stuckRecovery: calculateStuckRecoveryScore(inputs.stuckRecoveryCount, inputs.maxStuckRecoveries),
|
||||
};
|
||||
|
||||
// Weighted average (cycle success is most important)
|
||||
const weights = {
|
||||
cycleSuccess: 0.35,
|
||||
circuitBreaker: 0.2,
|
||||
iterationProgress: 0.2,
|
||||
aiChecker: 0.15,
|
||||
stuckRecovery: 0.1,
|
||||
};
|
||||
|
||||
const score = Math.round(
|
||||
components.cycleSuccess * weights.cycleSuccess +
|
||||
components.circuitBreaker * weights.circuitBreaker +
|
||||
components.iterationProgress * weights.iterationProgress +
|
||||
components.aiChecker * weights.aiChecker +
|
||||
components.stuckRecovery * weights.stuckRecovery
|
||||
);
|
||||
|
||||
// Determine status
|
||||
let status: HealthStatus;
|
||||
if (score >= 90) status = 'excellent';
|
||||
else if (score >= 70) status = 'good';
|
||||
else if (score >= 50) status = 'degraded';
|
||||
else status = 'critical';
|
||||
|
||||
// Generate recommendations
|
||||
const recommendations = generateHealthRecommendations(components);
|
||||
|
||||
// Generate summary
|
||||
const summary = generateHealthSummary(score, status, components);
|
||||
|
||||
return {
|
||||
score,
|
||||
status,
|
||||
components,
|
||||
summary,
|
||||
recommendations,
|
||||
calculatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to skip the /clear step based on current context usage.
|
||||
* Skips if token count is below the configured threshold percentage.
|
||||
*
|
||||
* @param lastTokenCount - Current token count from the session
|
||||
* @param skipClearThresholdPercent - Threshold percentage below which to skip /clear
|
||||
* @param maxContextTokens - Approximate max context window size
|
||||
* @returns True if /clear should be skipped
|
||||
*/
|
||||
export function shouldSkipClear(
|
||||
lastTokenCount: number,
|
||||
skipClearThresholdPercent: number,
|
||||
maxContextTokens: number
|
||||
): boolean {
|
||||
if (lastTokenCount === 0) return false; // Can't determine, don't skip
|
||||
|
||||
const usagePercent = (lastTokenCount / maxContextTokens) * 100;
|
||||
return usagePercent < skipClearThresholdPercent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score based on recent cycle success rate.
|
||||
*/
|
||||
function calculateCycleSuccessScore(aggregateMetrics: RespawnAggregateMetrics): number {
|
||||
if (aggregateMetrics.totalCycles === 0) return 100; // No data = assume healthy
|
||||
return aggregateMetrics.successRate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score based on circuit breaker state.
|
||||
*/
|
||||
function calculateCircuitBreakerScore(circuitBreakerStatus: CircuitBreakerStatus | null): number {
|
||||
if (!circuitBreakerStatus) return 100;
|
||||
|
||||
switch (circuitBreakerStatus.state) {
|
||||
case 'CLOSED':
|
||||
return 100;
|
||||
case 'HALF_OPEN':
|
||||
return 50;
|
||||
case 'OPEN':
|
||||
return 0;
|
||||
default:
|
||||
return 100;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score based on iteration progress.
|
||||
*/
|
||||
function calculateIterationProgressScore(
|
||||
stallMetrics: { stallDurationMs: number; warningThresholdMs: number; criticalThresholdMs: number } | null
|
||||
): number {
|
||||
if (!stallMetrics) return 100;
|
||||
|
||||
const { stallDurationMs, warningThresholdMs, criticalThresholdMs } = stallMetrics;
|
||||
|
||||
if (stallDurationMs >= criticalThresholdMs) return 0;
|
||||
if (stallDurationMs >= warningThresholdMs) return 30;
|
||||
if (stallDurationMs >= warningThresholdMs / 2) return 70;
|
||||
return 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score based on AI checker health.
|
||||
*/
|
||||
function calculateAiCheckerScore(aiCheckerState: { status: string; consecutiveErrors: number }): number {
|
||||
if (aiCheckerState.status === 'disabled') return 30;
|
||||
if (aiCheckerState.status === 'cooldown') return 70;
|
||||
if (aiCheckerState.consecutiveErrors > 0) return 50;
|
||||
return 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate score based on stuck-state recovery count.
|
||||
*/
|
||||
function calculateStuckRecoveryScore(stuckRecoveryCount: number, maxStuckRecoveries: number): number {
|
||||
if (stuckRecoveryCount === 0) return 100;
|
||||
if (stuckRecoveryCount >= maxStuckRecoveries) return 0;
|
||||
return Math.round(100 - (stuckRecoveryCount / maxStuckRecoveries) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate health recommendations based on component scores.
|
||||
*/
|
||||
function generateHealthRecommendations(components: RalphLoopHealthScore['components']): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (components.cycleSuccess < 70) {
|
||||
recommendations.push('Cycle success rate is low. Check for recurring errors or stuck states.');
|
||||
}
|
||||
if (components.circuitBreaker < 50) {
|
||||
recommendations.push('Circuit breaker is open or half-open. Review recent errors and consider manual reset.');
|
||||
}
|
||||
if (components.iterationProgress < 50) {
|
||||
recommendations.push('Iteration progress has stalled. Check if Claude is stuck on a task.');
|
||||
}
|
||||
if (components.aiChecker < 50) {
|
||||
recommendations.push('AI idle checker has errors. May need to check Claude CLI availability.');
|
||||
}
|
||||
if (components.stuckRecovery < 50) {
|
||||
recommendations.push('Multiple stuck-state recoveries occurred. Consider increasing timeouts.');
|
||||
}
|
||||
|
||||
if (recommendations.length === 0) {
|
||||
recommendations.push('System is healthy. No action needed.');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable health summary.
|
||||
*/
|
||||
function generateHealthSummary(
|
||||
score: number,
|
||||
status: HealthStatus,
|
||||
components: RalphLoopHealthScore['components']
|
||||
): string {
|
||||
const lowest = Object.entries(components).reduce((min, [key, val]) => (val < min.val ? { key, val } : min), {
|
||||
key: '',
|
||||
val: 100,
|
||||
});
|
||||
|
||||
if (status === 'excellent') {
|
||||
return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`;
|
||||
}
|
||||
if (status === 'good') {
|
||||
return `Ralph Loop is operating well (${score}/100). Minor issues in ${lowest.key}.`;
|
||||
}
|
||||
if (status === 'degraded') {
|
||||
return `Ralph Loop is degraded (${score}/100). Primary issue: ${lowest.key} (${lowest.val}/100).`;
|
||||
}
|
||||
return `Ralph Loop is in critical state (${score}/100). Immediate attention needed: ${lowest.key}.`;
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* @fileoverview Cycle metrics tracker for respawn controller.
|
||||
*
|
||||
* Extracted from respawn-controller.ts for modularity. Tracks per-cycle metrics
|
||||
* and maintains aggregate statistics across all tracked cycles.
|
||||
*
|
||||
* @module respawn-metrics
|
||||
*/
|
||||
|
||||
import { assertNever } from './utils/index.js';
|
||||
import type { RespawnCycleMetrics, RespawnAggregateMetrics, CycleOutcome } from './types.js';
|
||||
|
||||
/**
|
||||
* Maximum number of cycle metrics to keep in memory.
|
||||
*/
|
||||
const MAX_CYCLE_METRICS_IN_MEMORY = 100;
|
||||
|
||||
/**
|
||||
* Tracks respawn cycle metrics and maintains aggregate statistics.
|
||||
*
|
||||
* Each respawn cycle is tracked from start to completion, recording timing,
|
||||
* steps completed, and outcome. Aggregate metrics provide a rolling view
|
||||
* of system health across recent cycles.
|
||||
*/
|
||||
export class RespawnCycleMetricsTracker {
|
||||
/** Current cycle being tracked */
|
||||
private currentCycleMetrics: Partial<RespawnCycleMetrics> | null = null;
|
||||
|
||||
/** Recent cycle metrics (rolling window for aggregate calculation) */
|
||||
private recentCycleMetrics: RespawnCycleMetrics[] = [];
|
||||
|
||||
/** Aggregate metrics across all tracked cycles */
|
||||
private aggregateMetrics: RespawnAggregateMetrics = {
|
||||
totalCycles: 0,
|
||||
successfulCycles: 0,
|
||||
stuckRecoveryCycles: 0,
|
||||
blockedCycles: 0,
|
||||
errorCycles: 0,
|
||||
avgCycleDurationMs: 0,
|
||||
avgIdleDetectionMs: 0,
|
||||
p90CycleDurationMs: 0,
|
||||
successRate: 100,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Start tracking metrics for a new cycle.
|
||||
* Called when a respawn cycle begins.
|
||||
*
|
||||
* @param sessionId - The session this cycle belongs to
|
||||
* @param cycleNumber - The cycle number within the session
|
||||
* @param idleReason - What triggered idle detection
|
||||
* @param idleDetectionStartTime - Timestamp when idle detection started
|
||||
* @param lastTokenCount - Token count at start of cycle
|
||||
* @param adaptiveCompletionConfirmMs - Completion confirm timeout used
|
||||
*/
|
||||
startCycle(
|
||||
sessionId: string,
|
||||
cycleNumber: number,
|
||||
idleReason: string,
|
||||
idleDetectionStartTime: number,
|
||||
lastTokenCount: number,
|
||||
adaptiveCompletionConfirmMs: number
|
||||
): void {
|
||||
const now = Date.now();
|
||||
this.currentCycleMetrics = {
|
||||
cycleId: `${sessionId}:${cycleNumber}`,
|
||||
sessionId,
|
||||
cycleNumber,
|
||||
startedAt: now,
|
||||
idleReason,
|
||||
idleDetectionMs: now - idleDetectionStartTime,
|
||||
stepsCompleted: [],
|
||||
clearSkipped: false,
|
||||
tokenCountAtStart: lastTokenCount,
|
||||
completionConfirmMsUsed: adaptiveCompletionConfirmMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a completed step in the current cycle.
|
||||
* @param step - Name of the step (e.g., 'update', 'clear', 'init')
|
||||
*/
|
||||
recordStep(step: string): void {
|
||||
if (!this.currentCycleMetrics) return;
|
||||
this.currentCycleMetrics.stepsCompleted?.push(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark that /clear was skipped in the current cycle.
|
||||
*/
|
||||
markClearSkipped(): void {
|
||||
if (this.currentCycleMetrics) {
|
||||
this.currentCycleMetrics.clearSkipped = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current in-progress cycle metrics (for external inspection).
|
||||
* @returns The current cycle metrics, or null if no cycle is in progress
|
||||
*/
|
||||
getCurrentCycle(): Partial<RespawnCycleMetrics> | null {
|
||||
return this.currentCycleMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete the current cycle metrics with outcome.
|
||||
* Adds to recent metrics and updates aggregates.
|
||||
*
|
||||
* @param outcome - Outcome of the cycle
|
||||
* @param lastTokenCount - Token count at end of cycle
|
||||
* @param errorMessage - Optional error message if outcome is 'error'
|
||||
* @returns The completed cycle metrics, or null if no cycle was in progress
|
||||
*/
|
||||
completeCycle(outcome: CycleOutcome, lastTokenCount: number, errorMessage?: string): RespawnCycleMetrics | null {
|
||||
if (!this.currentCycleMetrics) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const metrics: RespawnCycleMetrics = {
|
||||
...(this.currentCycleMetrics as RespawnCycleMetrics),
|
||||
completedAt: now,
|
||||
durationMs: now - (this.currentCycleMetrics.startedAt ?? now),
|
||||
outcome,
|
||||
errorMessage,
|
||||
tokenCountAtEnd: lastTokenCount,
|
||||
};
|
||||
|
||||
// Add to recent metrics
|
||||
this.recentCycleMetrics.push(metrics);
|
||||
if (this.recentCycleMetrics.length > MAX_CYCLE_METRICS_IN_MEMORY) {
|
||||
this.recentCycleMetrics.shift();
|
||||
}
|
||||
|
||||
// Update aggregate metrics
|
||||
this.updateAggregateMetrics(metrics);
|
||||
|
||||
// Clear current cycle
|
||||
this.currentCycleMetrics = null;
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aggregate metrics for monitoring.
|
||||
* @returns Copy of aggregate metrics
|
||||
*/
|
||||
getAggregate(): RespawnAggregateMetrics {
|
||||
return { ...this.aggregateMetrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent cycle metrics for analysis.
|
||||
* @param limit - Maximum number of metrics to return (default: 20)
|
||||
* @returns Recent cycle metrics, newest first
|
||||
*/
|
||||
getRecent(limit: number = 20): RespawnCycleMetrics[] {
|
||||
return this.recentCycleMetrics.slice(-limit).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics state.
|
||||
*/
|
||||
reset(): void {
|
||||
this.currentCycleMetrics = null;
|
||||
this.recentCycleMetrics = [];
|
||||
this.aggregateMetrics = {
|
||||
totalCycles: 0,
|
||||
successfulCycles: 0,
|
||||
stuckRecoveryCycles: 0,
|
||||
blockedCycles: 0,
|
||||
errorCycles: 0,
|
||||
avgCycleDurationMs: 0,
|
||||
avgIdleDetectionMs: 0,
|
||||
p90CycleDurationMs: 0,
|
||||
successRate: 100,
|
||||
lastUpdatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update aggregate metrics with a new cycle's data.
|
||||
* @param metrics - The completed cycle metrics
|
||||
*/
|
||||
private updateAggregateMetrics(metrics: RespawnCycleMetrics): void {
|
||||
const agg = this.aggregateMetrics;
|
||||
|
||||
agg.totalCycles++;
|
||||
|
||||
switch (metrics.outcome) {
|
||||
case 'success':
|
||||
agg.successfulCycles++;
|
||||
break;
|
||||
case 'stuck_recovery':
|
||||
agg.stuckRecoveryCycles++;
|
||||
break;
|
||||
case 'blocked':
|
||||
agg.blockedCycles++;
|
||||
break;
|
||||
case 'error':
|
||||
agg.errorCycles++;
|
||||
break;
|
||||
case 'cancelled':
|
||||
// Cancelled cycles don't count towards any specific category
|
||||
// but are still counted in totalCycles
|
||||
break;
|
||||
default:
|
||||
assertNever(metrics.outcome, `Unhandled CycleOutcome: ${metrics.outcome}`);
|
||||
}
|
||||
|
||||
// Recalculate averages using all recent metrics
|
||||
const durations = this.recentCycleMetrics.map((m) => m.durationMs);
|
||||
const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs);
|
||||
|
||||
if (durations.length > 0) {
|
||||
agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
||||
agg.avgIdleDetectionMs = Math.round(idleTimes.reduce((a, b) => a + b, 0) / idleTimes.length);
|
||||
|
||||
// Calculate P90
|
||||
const sortedDurations = [...durations].sort((a, b) => a - b);
|
||||
const p90Index = Math.floor(sortedDurations.length * 0.9);
|
||||
agg.p90CycleDurationMs = sortedDurations[p90Index];
|
||||
}
|
||||
|
||||
// Calculate success rate
|
||||
agg.successRate = agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100;
|
||||
|
||||
agg.lastUpdatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @fileoverview Pure utility functions for terminal pattern detection in respawn controller.
|
||||
*
|
||||
* Extracted from respawn-controller.ts for modularity. These are stateless functions
|
||||
* and constants used to detect completion messages, working patterns, and token counts
|
||||
* in terminal output.
|
||||
*
|
||||
* @module respawn-patterns
|
||||
*/
|
||||
|
||||
import { TOKEN_PATTERN } from './utils/index.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
/**
|
||||
* Pattern to detect completion messages from Claude Code.
|
||||
* Requires "Worked for" prefix to avoid false positives from bare time durations
|
||||
* in regular text (e.g., "wait for 5s", "run for 2m").
|
||||
*
|
||||
* Matches: "Worked for 2m 46s", "Worked for 46s", "Worked for 1h 2m 3s"
|
||||
* Does NOT match: "wait for 5s", "run for 2m", "for 3s the system..."
|
||||
*/
|
||||
const COMPLETION_TIME_PATTERN = /\bWorked\s+for\s+\d+[hms](\s*\d+[hms])*/i;
|
||||
|
||||
/**
|
||||
* Patterns indicating Claude is ready for input (legacy fallback).
|
||||
* Used as secondary signals, not primary detection.
|
||||
*/
|
||||
export const PROMPT_PATTERNS = [
|
||||
'❯', // Standard prompt
|
||||
'\u276f', // Unicode variant
|
||||
'⏵', // Claude Code prompt variant
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns indicating Claude is actively working.
|
||||
* When detected, resets all idle detection timers.
|
||||
* Note: ✻ and ✽ removed - they appear in completion messages too.
|
||||
*/
|
||||
export const WORKING_PATTERNS = [
|
||||
'Thinking',
|
||||
'Writing',
|
||||
'Reading',
|
||||
'Running',
|
||||
'Searching',
|
||||
'Editing',
|
||||
'Creating',
|
||||
'Deleting',
|
||||
'Analyzing',
|
||||
'Executing',
|
||||
'Synthesizing',
|
||||
'Brewing', // Claude's processing indicators
|
||||
'Compiling',
|
||||
'Building',
|
||||
'Installing',
|
||||
'Fetching',
|
||||
'Downloading',
|
||||
'Processing',
|
||||
'Generating',
|
||||
'Loading',
|
||||
'Starting',
|
||||
'Updating',
|
||||
'Checking',
|
||||
'Validating',
|
||||
'Testing',
|
||||
'Formatting',
|
||||
'Linting',
|
||||
'⠋',
|
||||
'⠙',
|
||||
'⠹',
|
||||
'⠸',
|
||||
'⠼',
|
||||
'⠴',
|
||||
'⠦',
|
||||
'⠧',
|
||||
'⠇',
|
||||
'⠏', // Spinner chars
|
||||
'◐',
|
||||
'◓',
|
||||
'◑',
|
||||
'◒', // Alternative spinners
|
||||
'⣾',
|
||||
'⣽',
|
||||
'⣻',
|
||||
'⢿',
|
||||
'⡿',
|
||||
'⣟',
|
||||
'⣯',
|
||||
'⣷', // Braille spinners
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if data contains a completion message pattern.
|
||||
* Matches "Worked for Xh Xm Xs" time duration patterns.
|
||||
*
|
||||
* @param data - Raw terminal output data
|
||||
* @returns True if completion message pattern is found
|
||||
*/
|
||||
export function isCompletionMessage(data: string): boolean {
|
||||
return COMPLETION_TIME_PATTERN.test(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a rolling window of terminal output contains working patterns.
|
||||
* The rolling window catches patterns split across chunks (e.g., "Thin" + "king").
|
||||
*
|
||||
* @param window - Rolling window of recent terminal output (already includes current data)
|
||||
* @returns True if any working pattern is found in the window
|
||||
*/
|
||||
export function hasWorkingPattern(window: string): boolean {
|
||||
return WORKING_PATTERNS.some((pattern) => window.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract token count from data if present.
|
||||
* Parses patterns like "123.4k tokens" or "1.5M tokens".
|
||||
*
|
||||
* @param data - Raw terminal output data
|
||||
* @returns Parsed token count, or null if no token pattern found
|
||||
*/
|
||||
export function extractTokenCount(data: string): number | null {
|
||||
const match = data.match(TOKEN_PATTERN);
|
||||
if (!match) return null;
|
||||
|
||||
let count = parseFloat(match[1]);
|
||||
const suffix = match[2]?.toLowerCase();
|
||||
if (suffix === 'k') count *= 1000;
|
||||
else if (suffix === 'm') count *= 1000000;
|
||||
|
||||
return Math.round(count);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* @fileoverview Auto-compact and auto-clear automation for Session.
|
||||
*
|
||||
* Monitors token counts and triggers /compact or /clear commands when
|
||||
* configurable thresholds are reached. Waits for Claude to be idle
|
||||
* before sending commands, with retry logic and mutual exclusion
|
||||
* (compact and clear never run simultaneously).
|
||||
*
|
||||
* @module session-auto-ops
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
// ============================================================================
|
||||
// Timing Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Delay for auto-compact/clear retry attempts (2 seconds) */
|
||||
const AUTO_RETRY_DELAY_MS = 2000;
|
||||
|
||||
/** Delay for auto-compact/clear initial check (1 second) */
|
||||
const AUTO_INITIAL_DELAY_MS = 1000;
|
||||
|
||||
/** Cooldown after compact completes before re-enabling (10 seconds) */
|
||||
const COMPACT_COOLDOWN_MS = 10000;
|
||||
|
||||
/** Cooldown after clear completes before re-enabling (5 seconds) */
|
||||
const CLEAR_COOLDOWN_MS = 5000;
|
||||
|
||||
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
||||
const MIN_AUTO_THRESHOLD = 1000;
|
||||
|
||||
/** Maximum valid threshold for auto-clear/compact (500k tokens) */
|
||||
const MAX_AUTO_THRESHOLD = 500_000;
|
||||
|
||||
/** Default auto-clear threshold when invalid value provided */
|
||||
const DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
|
||||
|
||||
/** Default auto-compact threshold when invalid value provided */
|
||||
const DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
|
||||
|
||||
/**
|
||||
* Callbacks required by SessionAutoOps to interact with the parent Session.
|
||||
*/
|
||||
export interface AutoOpsCallbacks {
|
||||
/** Send a command via the terminal multiplexer */
|
||||
writeCommand: (command: string) => Promise<boolean>;
|
||||
/** Check if Claude is currently working */
|
||||
isWorking: () => boolean;
|
||||
/** Check if the session has been stopped */
|
||||
isStopped: () => boolean;
|
||||
/** Get current total token count (input + output) */
|
||||
getTotalTokens: () => number;
|
||||
/** Get session ID for logging */
|
||||
getSessionId: () => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events emitted by SessionAutoOps.
|
||||
*/
|
||||
export interface SessionAutoOpsEvents {
|
||||
/** Auto-compact was triggered and the /compact command was sent */
|
||||
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
|
||||
/** Auto-clear was triggered and the /clear command was sent */
|
||||
autoClear: (data: { tokens: number; threshold: number }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages auto-compact and auto-clear automation for a Session.
|
||||
*
|
||||
* When enabled, monitors token counts after each update and triggers
|
||||
* /compact or /clear commands when thresholds are exceeded. Ensures
|
||||
* mutual exclusion between compact and clear operations.
|
||||
*/
|
||||
export class SessionAutoOps extends EventEmitter {
|
||||
// Auto-compact state
|
||||
private _autoCompactThreshold: number;
|
||||
private _autoCompactEnabled: boolean = false;
|
||||
private _autoCompactPrompt: string = '';
|
||||
private _isCompacting: boolean = false;
|
||||
private _autoCompactTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
// Auto-clear state
|
||||
private _autoClearThreshold: number;
|
||||
private _autoClearEnabled: boolean = false;
|
||||
private _isClearing: boolean = false;
|
||||
private _autoClearTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private readonly callbacks: AutoOpsCallbacks;
|
||||
|
||||
constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) {
|
||||
super();
|
||||
this.callbacks = callbacks;
|
||||
this._autoCompactThreshold = config?.compactThreshold ?? DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
this._autoClearThreshold = config?.clearThreshold ?? DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-compact getters/setters
|
||||
// ============================================================================
|
||||
|
||||
get autoCompactThreshold(): number {
|
||||
return this._autoCompactThreshold;
|
||||
}
|
||||
|
||||
get autoCompactEnabled(): boolean {
|
||||
return this._autoCompactEnabled;
|
||||
}
|
||||
|
||||
get autoCompactPrompt(): string {
|
||||
return this._autoCompactPrompt;
|
||||
}
|
||||
|
||||
get isCompacting(): boolean {
|
||||
return this._isCompacting;
|
||||
}
|
||||
|
||||
setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void {
|
||||
this._autoCompactEnabled = enabled;
|
||||
if (threshold !== undefined) {
|
||||
if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoCompact threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_COMPACT_THRESHOLD}.`
|
||||
);
|
||||
this._autoCompactThreshold = DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
} else {
|
||||
this._autoCompactThreshold = threshold;
|
||||
}
|
||||
}
|
||||
if (prompt !== undefined) {
|
||||
this._autoCompactPrompt = prompt;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-clear getters/setters
|
||||
// ============================================================================
|
||||
|
||||
get autoClearThreshold(): number {
|
||||
return this._autoClearThreshold;
|
||||
}
|
||||
|
||||
get autoClearEnabled(): boolean {
|
||||
return this._autoClearEnabled;
|
||||
}
|
||||
|
||||
get isClearing(): boolean {
|
||||
return this._isClearing;
|
||||
}
|
||||
|
||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||
this._autoClearEnabled = enabled;
|
||||
if (threshold !== undefined) {
|
||||
if (threshold < MIN_AUTO_THRESHOLD || threshold > MAX_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[SessionAutoOps ${this.callbacks.getSessionId()}] Invalid autoClear threshold ${threshold}, must be between ${MIN_AUTO_THRESHOLD} and ${MAX_AUTO_THRESHOLD}. Using default ${DEFAULT_AUTO_CLEAR_THRESHOLD}.`
|
||||
);
|
||||
this._autoClearThreshold = DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||
} else {
|
||||
this._autoClearThreshold = threshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Threshold checks
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Check if auto-compact should be triggered based on current token count.
|
||||
* Called after token count updates.
|
||||
*/
|
||||
checkAutoCompact(): void {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return;
|
||||
|
||||
const totalTokens = this.callbacks.getTotalTokens();
|
||||
if (totalTokens >= this._autoCompactThreshold) {
|
||||
this._isCompacting = true;
|
||||
console.log(
|
||||
`[SessionAutoOps] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`
|
||||
);
|
||||
|
||||
const checkAndCompact = async () => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._isCompacting) return;
|
||||
|
||||
if (!this.callbacks.isWorking()) {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
await this.callbacks.writeCommand(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
prompt: this._autoCompactPrompt || undefined,
|
||||
});
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoCompactTimer = setTimeout(() => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
this._autoCompactTimer = null;
|
||||
this._isCompacting = false;
|
||||
}, COMPACT_COOLDOWN_MS);
|
||||
}
|
||||
} else {
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auto-clear should be triggered based on current token count.
|
||||
* Called after token count updates.
|
||||
*/
|
||||
checkAutoClear(): void {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return;
|
||||
|
||||
const totalTokens = this.callbacks.getTotalTokens();
|
||||
if (totalTokens >= this._autoClearThreshold) {
|
||||
this._isClearing = true;
|
||||
console.log(
|
||||
`[SessionAutoOps] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`
|
||||
);
|
||||
|
||||
const checkAndClear = async () => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
if (!this._isClearing) return;
|
||||
|
||||
if (!this.callbacks.isWorking()) {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
|
||||
await this.callbacks.writeCommand('/clear\r');
|
||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoClearTimer = setTimeout(() => {
|
||||
if (this.callbacks.isStopped()) return;
|
||||
this._autoClearTimer = null;
|
||||
this._isClearing = false;
|
||||
}, CLEAR_COOLDOWN_MS);
|
||||
}
|
||||
} else {
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!this.callbacks.isStopped()) {
|
||||
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Cleanup
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Clear all timers and reset state. Called when the session stops.
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this._autoCompactTimer) {
|
||||
clearTimeout(this._autoCompactTimer);
|
||||
this._autoCompactTimer = null;
|
||||
}
|
||||
this._isCompacting = false;
|
||||
|
||||
if (this._autoClearTimer) {
|
||||
clearTimeout(this._autoClearTimer);
|
||||
this._autoClearTimer = null;
|
||||
}
|
||||
this._isClearing = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @fileoverview Pure functions for building CLI arguments and environment variables
|
||||
* for Claude and OpenCode CLI spawning.
|
||||
*
|
||||
* Extracted from Session to keep argument construction logic testable and
|
||||
* separate from PTY lifecycle management.
|
||||
*
|
||||
* @module session-cli-builder
|
||||
*/
|
||||
|
||||
import type { ClaudeMode } from './types.js';
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
* Returns an array of args to pass to the CLI.
|
||||
*/
|
||||
export function buildPermissionArgs(claudeMode: ClaudeMode, allowedTools?: string): string[] {
|
||||
switch (claudeMode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ['--dangerously-skip-permissions'];
|
||||
case 'allowedTools':
|
||||
if (allowedTools) {
|
||||
return ['--allowedTools', allowedTools];
|
||||
}
|
||||
// Fall back to normal mode if no tools specified
|
||||
return [];
|
||||
case 'normal':
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build args for an interactive Claude CLI session (direct PTY, non-mux fallback).
|
||||
*
|
||||
* @param sessionId - The Codeman session ID (passed as --session-id to Claude)
|
||||
* @param claudeMode - Permission mode for the CLI
|
||||
* @param model - Optional model override (e.g., 'opus', 'sonnet')
|
||||
* @param allowedTools - Optional comma-separated allowed tools list
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildInteractiveArgs(
|
||||
sessionId: string,
|
||||
claudeMode: ClaudeMode,
|
||||
model?: string,
|
||||
allowedTools?: string
|
||||
): string[] {
|
||||
const args = [...buildPermissionArgs(claudeMode, allowedTools), '--session-id', sessionId];
|
||||
if (model) args.push('--model', model);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build args for a one-shot Claude CLI prompt (runPrompt mode).
|
||||
*
|
||||
* @param prompt - The prompt text to send
|
||||
* @param model - Optional model override
|
||||
* @returns Array of CLI arguments
|
||||
*/
|
||||
export function buildPromptArgs(prompt: string, model?: string): string[] {
|
||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
args.push(prompt);
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables for Claude CLI processes (direct PTY, non-mux).
|
||||
*
|
||||
* Augments process.env with:
|
||||
* - UTF-8 locale settings
|
||||
* - Augmented PATH (includes Claude CLI directory)
|
||||
* - xterm-256color terminal type
|
||||
* - Codeman session identification vars
|
||||
*
|
||||
* @param sessionId - The Codeman session ID
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildClaudeEnv(sessionId: string): Record<string, string | undefined> {
|
||||
return {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables for mux-attached PTY sessions (tmux attach).
|
||||
* Lighter than buildClaudeEnv — no PATH augmentation or Codeman vars needed
|
||||
* since the mux session already has those set.
|
||||
*
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildMuxAttachEnv(): Record<string, string | undefined> {
|
||||
return {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variables for a direct shell session (non-mux fallback).
|
||||
*
|
||||
* @param sessionId - The Codeman session ID
|
||||
* @returns Environment variables object for pty.spawn
|
||||
*/
|
||||
export function buildShellEnv(sessionId: string): Record<string, string | undefined> {
|
||||
return {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: sessionId,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* @fileoverview LRU cache for task descriptions parsed from terminal output.
|
||||
*
|
||||
* Stores descriptions extracted from Claude Code's Task tool invocations
|
||||
* (e.g., "Explore(Check files)") keyed by timestamp. Used to correlate
|
||||
* with SubagentWatcher discoveries for better window titles.
|
||||
*
|
||||
* @module session-task-cache
|
||||
*/
|
||||
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
/** Default maximum number of task descriptions to keep */
|
||||
const DEFAULT_MAX_SIZE = 100;
|
||||
|
||||
/** Default maximum age for task descriptions (30 seconds) */
|
||||
const DEFAULT_MAX_AGE_MS = 30_000;
|
||||
|
||||
/**
|
||||
* LRU cache for task descriptions parsed from terminal output.
|
||||
*
|
||||
* Descriptions are keyed by the timestamp when they were parsed.
|
||||
* Old entries are automatically cleaned up based on maxAgeMs.
|
||||
* Size is bounded by the underlying LRUMap.
|
||||
*/
|
||||
export class SessionTaskCache {
|
||||
private readonly cache: LRUMap<number, string>;
|
||||
private readonly maxAgeMs: number;
|
||||
|
||||
constructor(maxSize: number = DEFAULT_MAX_SIZE, maxAgeMs: number = DEFAULT_MAX_AGE_MS) {
|
||||
this.cache = new LRUMap<number, string>({ maxSize });
|
||||
this.maxAgeMs = maxAgeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a task description at the given timestamp.
|
||||
*/
|
||||
add(timestamp: number, description: string): void {
|
||||
this.cache.set(timestamp, description);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove task descriptions older than maxAgeMs.
|
||||
* LRUMap maintains insertion order, so we can break early
|
||||
* once we find a non-expired entry.
|
||||
*/
|
||||
private cleanupOld(): void {
|
||||
const cutoff = Date.now() - this.maxAgeMs;
|
||||
for (const timestamp of this.cache.keysInOrder()) {
|
||||
if (timestamp < cutoff) {
|
||||
this.cache.delete(timestamp);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recent task descriptions sorted by timestamp (most recent first).
|
||||
*/
|
||||
getAll(): Array<{ timestamp: number; description: string }> {
|
||||
this.cleanupOld();
|
||||
const results: Array<{ timestamp: number; description: string }> = [];
|
||||
for (const [timestamp, description] of this.cache) {
|
||||
results.push({ timestamp, description });
|
||||
}
|
||||
return results.sort((a, b) => b.timestamp - a.timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a task description that was parsed close to a given timestamp.
|
||||
* Used to correlate with SubagentWatcher discoveries.
|
||||
*
|
||||
* @param subagentStartTime - The timestamp when the subagent was discovered
|
||||
* @param maxAgeMs - Maximum age difference to consider (default 10 seconds)
|
||||
* @returns The matching description or undefined
|
||||
*/
|
||||
findNear(subagentStartTime: number, maxAgeMs: number = 10000): string | undefined {
|
||||
this.cleanupOld();
|
||||
|
||||
let bestMatch: { timestamp: number; description: string } | undefined;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [timestamp, description] of this.cache) {
|
||||
const diff = Math.abs(subagentStartTime - timestamp);
|
||||
if (diff < maxAgeMs && diff < bestDiff) {
|
||||
bestMatch = { timestamp, description };
|
||||
bestDiff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached descriptions.
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
+66
-316
@@ -36,7 +36,6 @@ import { TaskTracker, type BackgroundTask } from './task-tracker.js';
|
||||
import { RalphTracker } from './ralph-tracker.js';
|
||||
import { BashToolParser } from './bash-tool-parser.js';
|
||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js';
|
||||
import {
|
||||
MAX_TERMINAL_BUFFER_SIZE,
|
||||
@@ -46,6 +45,15 @@ import {
|
||||
MAX_MESSAGES,
|
||||
MAX_LINE_BUFFER_SIZE,
|
||||
} from './config/buffer-limits.js';
|
||||
import {
|
||||
buildInteractiveArgs,
|
||||
buildPromptArgs,
|
||||
buildClaudeEnv,
|
||||
buildMuxAttachEnv,
|
||||
buildShellEnv,
|
||||
} from './session-cli-builder.js';
|
||||
import { SessionAutoOps } from './session-auto-ops.js';
|
||||
import { SessionTaskCache } from './session-task-cache.js';
|
||||
|
||||
export type { BackgroundTask } from './task-tracker.js';
|
||||
export type { RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js';
|
||||
@@ -63,11 +71,7 @@ const MUX_STARTUP_DELAY_MS = 300;
|
||||
/** Delay before declaring session idle after last output (2 seconds) */
|
||||
const IDLE_DETECTION_DELAY_MS = 2000;
|
||||
|
||||
/** Delay for auto-compact/clear retry attempts (2 seconds) */
|
||||
const AUTO_RETRY_DELAY_MS = 2000;
|
||||
|
||||
/** Delay for auto-compact/clear initial check (1 second) */
|
||||
const AUTO_INITIAL_DELAY_MS = 1000;
|
||||
// Note: Auto-compact/clear timing constants moved to session-auto-ops.ts
|
||||
|
||||
/** Graceful shutdown delay when stopping session (100ms) */
|
||||
const GRACEFUL_SHUTDOWN_DELAY_MS = 100;
|
||||
@@ -93,8 +97,7 @@ const CTRL_L_PATTERN = /\x0c/g;
|
||||
/** Pattern to split by newlines (CR or LF) */
|
||||
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
|
||||
|
||||
// Claude CLI PATH resolution — shared utility
|
||||
import { getAugmentedPath } from './utils/claude-cli-resolver.js';
|
||||
// Note: Claude CLI PATH resolution moved to session-cli-builder.ts (buildClaudeEnv)
|
||||
|
||||
/**
|
||||
* Represents a JSON message from Claude CLI's stream-json output format.
|
||||
@@ -224,9 +227,8 @@ export class Session extends EventEmitter {
|
||||
readonly createdAt: number;
|
||||
readonly mode: SessionMode;
|
||||
|
||||
/** Maximum number of task descriptions to keep (LRUMap handles size limit automatically) */
|
||||
private static readonly MAX_TASK_DESCRIPTIONS = 100;
|
||||
private static readonly TASK_DESCRIPTION_MAX_AGE_MS = 30000; // Keep descriptions for 30 seconds
|
||||
// Task description cache (extracted to SessionTaskCache)
|
||||
private _taskCache = new SessionTaskCache();
|
||||
|
||||
private _name: string;
|
||||
private ptyProcess: pty.IPty | null = null;
|
||||
@@ -255,15 +257,9 @@ export class Session extends EventEmitter {
|
||||
// Token tracking for auto-clear
|
||||
private _totalInputTokens: number = 0;
|
||||
private _totalOutputTokens: number = 0;
|
||||
private _autoClearThreshold: number = 140000; // Default 140k tokens
|
||||
private _autoClearEnabled: boolean = false;
|
||||
private _isClearing: boolean = false; // Prevent recursive clearing
|
||||
|
||||
// Auto-compact settings
|
||||
private _autoCompactThreshold: number = 110000; // Default 110k tokens (lower than clear)
|
||||
private _autoCompactEnabled: boolean = false;
|
||||
private _autoCompactPrompt: string = ''; // Optional prompt for compact
|
||||
private _isCompacting: boolean = false; // Prevent recursive compacting
|
||||
// Auto-compact/auto-clear automation (extracted to SessionAutoOps)
|
||||
private _autoOps!: SessionAutoOps;
|
||||
|
||||
// Image watcher setting (per-session toggle)
|
||||
private _imageWatcherEnabled: boolean = false;
|
||||
@@ -279,8 +275,6 @@ export class Session extends EventEmitter {
|
||||
private _cliInfoParsed: boolean = false; // Only parse once per session
|
||||
|
||||
// Timer tracking for cleanup (prevents memory leaks)
|
||||
private _autoCompactTimer: NodeJS.Timeout | null = null;
|
||||
private _autoClearTimer: NodeJS.Timeout | null = null;
|
||||
private _promptCheckInterval: NodeJS.Timeout | null = null;
|
||||
private _promptCheckTimeout: NodeJS.Timeout | null = null;
|
||||
private _shellIdleTimer: NodeJS.Timeout | null = null;
|
||||
@@ -340,12 +334,7 @@ export class Session extends EventEmitter {
|
||||
toolsUpdate: (tools: ActiveBashTool[]) => void;
|
||||
} | null = null;
|
||||
|
||||
// Task descriptions parsed from terminal output (e.g., "Explore(Description)")
|
||||
// Used to correlate with SubagentWatcher discoveries for better window titles
|
||||
// Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit
|
||||
private _recentTaskDescriptions: LRUMap<number, string> = new LRUMap({
|
||||
maxSize: Session.MAX_TASK_DESCRIPTIONS,
|
||||
});
|
||||
// Task descriptions parsed from terminal output — delegated to SessionTaskCache
|
||||
|
||||
// Throttle expensive PTY processing (Ralph, bash parser, task descriptions)
|
||||
// Accumulates clean data between processing windows to avoid running regex on every chunk
|
||||
@@ -463,6 +452,22 @@ export class Session extends EventEmitter {
|
||||
this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart);
|
||||
this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd);
|
||||
this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate);
|
||||
|
||||
// Initialize auto-compact/auto-clear automation and forward events
|
||||
this._autoOps = new SessionAutoOps({
|
||||
writeCommand: (cmd) => this.writeViaMux(cmd),
|
||||
isWorking: () => this._isWorking,
|
||||
isStopped: () => this._isStopped,
|
||||
getTotalTokens: () => this._totalInputTokens + this._totalOutputTokens,
|
||||
getSessionId: () => this.id,
|
||||
});
|
||||
this._autoOps.on('autoCompact', (data) => this.emit('autoCompact', data));
|
||||
this._autoOps.on('autoClear', (data) => {
|
||||
// Reset token counts on clear
|
||||
this._totalInputTokens = 0;
|
||||
this._totalOutputTokens = 0;
|
||||
this.emit('autoClear', data);
|
||||
});
|
||||
}
|
||||
|
||||
get status(): SessionStatus {
|
||||
@@ -597,25 +602,7 @@ export class Session extends EventEmitter {
|
||||
return this._allowedTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Claude CLI permission flags based on the configured mode.
|
||||
* Returns an array of args to pass to the CLI.
|
||||
*/
|
||||
private _buildPermissionArgs(): string[] {
|
||||
switch (this._claudeMode) {
|
||||
case 'dangerously-skip-permissions':
|
||||
return ['--dangerously-skip-permissions'];
|
||||
case 'allowedTools':
|
||||
if (this._allowedTools) {
|
||||
return ['--allowedTools', this._allowedTools];
|
||||
}
|
||||
// Fall back to normal mode if no tools specified
|
||||
return [];
|
||||
case 'normal':
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// Note: _buildPermissionArgs removed — now using buildInteractiveArgs from session-cli-builder.ts
|
||||
|
||||
/**
|
||||
* Set CPU priority configuration.
|
||||
@@ -689,11 +676,11 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
get autoClearThreshold(): number {
|
||||
return this._autoClearThreshold;
|
||||
return this._autoOps.autoClearThreshold;
|
||||
}
|
||||
|
||||
get autoClearEnabled(): boolean {
|
||||
return this._autoClearEnabled;
|
||||
return this._autoOps.autoClearEnabled;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
@@ -704,58 +691,24 @@ export class Session extends EventEmitter {
|
||||
this._name = value;
|
||||
}
|
||||
|
||||
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
|
||||
private static readonly MIN_AUTO_THRESHOLD = 1000;
|
||||
/** Maximum valid threshold for auto-clear/compact (500k tokens) */
|
||||
private static readonly MAX_AUTO_THRESHOLD = 500_000;
|
||||
/** Default auto-clear threshold when invalid value provided */
|
||||
private static readonly DEFAULT_AUTO_CLEAR_THRESHOLD = 140_000;
|
||||
/** Default auto-compact threshold when invalid value provided */
|
||||
private static readonly DEFAULT_AUTO_COMPACT_THRESHOLD = 110_000;
|
||||
|
||||
setAutoClear(enabled: boolean, threshold?: number): void {
|
||||
this._autoClearEnabled = enabled;
|
||||
if (threshold !== undefined) {
|
||||
// Validate threshold bounds
|
||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`
|
||||
);
|
||||
this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD;
|
||||
} else {
|
||||
this._autoClearThreshold = threshold;
|
||||
}
|
||||
}
|
||||
this._autoOps.setAutoClear(enabled, threshold);
|
||||
}
|
||||
|
||||
get autoCompactThreshold(): number {
|
||||
return this._autoCompactThreshold;
|
||||
return this._autoOps.autoCompactThreshold;
|
||||
}
|
||||
|
||||
get autoCompactEnabled(): boolean {
|
||||
return this._autoCompactEnabled;
|
||||
return this._autoOps.autoCompactEnabled;
|
||||
}
|
||||
|
||||
get autoCompactPrompt(): string {
|
||||
return this._autoCompactPrompt;
|
||||
return this._autoOps.autoCompactPrompt;
|
||||
}
|
||||
|
||||
setAutoCompact(enabled: boolean, threshold?: number, prompt?: string): void {
|
||||
this._autoCompactEnabled = enabled;
|
||||
if (threshold !== undefined) {
|
||||
// Validate threshold bounds
|
||||
if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) {
|
||||
console.warn(
|
||||
`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`
|
||||
);
|
||||
this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD;
|
||||
} else {
|
||||
this._autoCompactThreshold = threshold;
|
||||
}
|
||||
}
|
||||
if (prompt !== undefined) {
|
||||
this._autoCompactPrompt = prompt;
|
||||
}
|
||||
this._autoOps.setAutoCompact(enabled, threshold, prompt);
|
||||
}
|
||||
|
||||
get imageWatcherEnabled(): boolean {
|
||||
@@ -797,11 +750,11 @@ export class Session extends EventEmitter {
|
||||
lastActivityAt: this._lastActivityAt,
|
||||
name: this._name,
|
||||
mode: this.mode,
|
||||
autoClearEnabled: this._autoClearEnabled,
|
||||
autoClearThreshold: this._autoClearThreshold,
|
||||
autoCompactEnabled: this._autoCompactEnabled,
|
||||
autoCompactThreshold: this._autoCompactThreshold,
|
||||
autoCompactPrompt: this._autoCompactPrompt,
|
||||
autoClearEnabled: this._autoOps.autoClearEnabled,
|
||||
autoClearThreshold: this._autoOps.autoClearThreshold,
|
||||
autoCompactEnabled: this._autoOps.autoCompactEnabled,
|
||||
autoCompactThreshold: this._autoOps.autoCompactThreshold,
|
||||
autoCompactPrompt: this._autoOps.autoCompactPrompt,
|
||||
imageWatcherEnabled: this._imageWatcherEnabled,
|
||||
totalCost: this._totalCost,
|
||||
inputTokens: this._totalInputTokens,
|
||||
@@ -865,8 +818,8 @@ export class Session extends EventEmitter {
|
||||
total: this._totalInputTokens + this._totalOutputTokens,
|
||||
},
|
||||
autoClear: {
|
||||
enabled: this._autoClearEnabled,
|
||||
threshold: this._autoClearThreshold,
|
||||
enabled: this._autoOps.autoClearEnabled,
|
||||
threshold: this._autoOps.autoClearThreshold,
|
||||
},
|
||||
// CPU priority configuration
|
||||
nice: {
|
||||
@@ -979,14 +932,7 @@ export class Session extends EventEmitter {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
},
|
||||
env: buildMuxAttachEnv(),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1061,26 +1007,13 @@ export class Session extends EventEmitter {
|
||||
try {
|
||||
// Pass --session-id to use the SAME ID as the Codeman session
|
||||
// This ensures subagents can be directly matched to the correct tab
|
||||
const args = [...this._buildPermissionArgs(), '--session-id', this.id];
|
||||
if (this._model) args.push('--model', this._model);
|
||||
const args = buildInteractiveArgs(this.id, this._claudeMode, this._model, this._allowedTools);
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Codeman (helps prevent self-termination)
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: this.id,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
env: buildClaudeEnv(this.id),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY:', spawnErr);
|
||||
@@ -1372,14 +1305,7 @@ export class Session extends EventEmitter {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
},
|
||||
env: buildMuxAttachEnv(),
|
||||
}
|
||||
);
|
||||
} catch (spawnErr) {
|
||||
@@ -1413,15 +1339,7 @@ export class Session extends EventEmitter {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
TERM: 'xterm-256color',
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: this.id,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
env: buildShellEnv(this.id),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn shell PTY:', spawnErr);
|
||||
@@ -1530,11 +1448,7 @@ export class Session extends EventEmitter {
|
||||
model ? `(model: ${model})` : ''
|
||||
);
|
||||
|
||||
const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json'];
|
||||
if (model) {
|
||||
args.push('--model', model);
|
||||
}
|
||||
args.push(prompt);
|
||||
const args = buildPromptArgs(prompt, model);
|
||||
|
||||
try {
|
||||
this.ptyProcess = pty.spawn('claude', args, {
|
||||
@@ -1542,19 +1456,7 @@ export class Session extends EventEmitter {
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
cwd: this.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: 'en_US.UTF-8',
|
||||
LC_ALL: 'en_US.UTF-8',
|
||||
PATH: getAugmentedPath(),
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: undefined,
|
||||
CLAUDECODE: undefined,
|
||||
// Inform Claude it's running within Codeman
|
||||
CODEMAN_MUX: '1',
|
||||
CODEMAN_SESSION_ID: this.id,
|
||||
CODEMAN_API_URL: process.env.CODEMAN_API_URL || 'http://localhost:3000',
|
||||
},
|
||||
env: buildClaudeEnv(this.id),
|
||||
});
|
||||
} catch (spawnErr) {
|
||||
console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr);
|
||||
@@ -1725,8 +1627,8 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
// Check if we should auto-compact or auto-clear
|
||||
this.checkAutoCompact();
|
||||
this.checkAutoClear();
|
||||
this._autoOps.checkAutoCompact();
|
||||
this._autoOps.checkAutoClear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1784,30 +1686,7 @@ export class Session extends EventEmitter {
|
||||
while ((match = TASK_TOOL_PATTERN.exec(cleanLine)) !== null) {
|
||||
const description = match[2].trim();
|
||||
if (description && description.length > 0) {
|
||||
const now = Date.now();
|
||||
this._recentTaskDescriptions.set(now, description);
|
||||
|
||||
// Cleanup old entries
|
||||
this.cleanupOldTaskDescriptions();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove task descriptions older than TASK_DESCRIPTION_MAX_AGE_MS.
|
||||
* Size limit is handled automatically by LRUMap eviction on set().
|
||||
*/
|
||||
private cleanupOldTaskDescriptions(): void {
|
||||
const cutoff = Date.now() - Session.TASK_DESCRIPTION_MAX_AGE_MS;
|
||||
// Keys are timestamps - iterate and delete expired entries
|
||||
// LRUMap maintains insertion order, so we can break early once we find a non-expired entry
|
||||
for (const timestamp of this._recentTaskDescriptions.keysInOrder()) {
|
||||
if (timestamp < cutoff) {
|
||||
this._recentTaskDescriptions.delete(timestamp);
|
||||
} else {
|
||||
// Keys are ordered by insertion time (which is the timestamp)
|
||||
// Once we find a non-expired one, all subsequent are also non-expired
|
||||
break;
|
||||
this._taskCache.add(Date.now(), description);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1817,12 +1696,7 @@ export class Session extends EventEmitter {
|
||||
* Returns descriptions sorted by timestamp (most recent first).
|
||||
*/
|
||||
getRecentTaskDescriptions(): Array<{ timestamp: number; description: string }> {
|
||||
this.cleanupOldTaskDescriptions();
|
||||
const results: Array<{ timestamp: number; description: string }> = [];
|
||||
for (const [timestamp, description] of this._recentTaskDescriptions) {
|
||||
results.push({ timestamp, description });
|
||||
}
|
||||
return results.sort((a, b) => b.timestamp - a.timestamp);
|
||||
return this._taskCache.getAll();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1834,21 +1708,7 @@ export class Session extends EventEmitter {
|
||||
* @returns The matching description or undefined
|
||||
*/
|
||||
findTaskDescriptionNear(subagentStartTime: number, maxAgeMs: number = 10000): string | undefined {
|
||||
this.cleanupOldTaskDescriptions();
|
||||
|
||||
// Find the most recent description that was parsed before or around the subagent start time
|
||||
let bestMatch: { timestamp: number; description: string } | undefined;
|
||||
let bestDiff = Infinity;
|
||||
|
||||
for (const [timestamp, description] of this._recentTaskDescriptions) {
|
||||
const diff = Math.abs(subagentStartTime - timestamp);
|
||||
if (diff < maxAgeMs && diff < bestDiff) {
|
||||
bestMatch = { timestamp, description };
|
||||
bestDiff = diff;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.description;
|
||||
return this._taskCache.findNear(subagentStartTime, maxAgeMs);
|
||||
}
|
||||
|
||||
// Parse token count from Claude's status line in interactive mode
|
||||
@@ -1913,8 +1773,8 @@ export class Session extends EventEmitter {
|
||||
this._totalOutputTokens += Math.round(delta * 0.4);
|
||||
|
||||
// Check if we should auto-compact or auto-clear
|
||||
this.checkAutoCompact();
|
||||
this.checkAutoClear();
|
||||
this._autoOps.checkAutoCompact();
|
||||
this._autoOps.checkAutoClear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1997,107 +1857,7 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should auto-compact based on token threshold
|
||||
private checkAutoCompact(): void {
|
||||
if (this._isStopped) return; // Early exit check
|
||||
if (!this._autoCompactEnabled || this._isCompacting || this._isClearing) return;
|
||||
|
||||
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
|
||||
if (totalTokens >= this._autoCompactThreshold) {
|
||||
this._isCompacting = true;
|
||||
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
|
||||
|
||||
// Wait for Claude to be idle before compacting
|
||||
const checkAndCompact = async () => {
|
||||
// Check if session is still valid (not stopped) - must be first check
|
||||
if (this._isStopped) return;
|
||||
if (!this._isCompacting) return;
|
||||
|
||||
if (!this._isWorking) {
|
||||
// Re-check stopped state after async operation might have completed
|
||||
if (this._isStopped) return;
|
||||
|
||||
// Send /compact command with optional prompt
|
||||
const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r';
|
||||
await this.writeViaMux(compactCmd);
|
||||
this.emit('autoCompact', {
|
||||
tokens: totalTokens,
|
||||
threshold: this._autoCompactThreshold,
|
||||
prompt: this._autoCompactPrompt || undefined,
|
||||
});
|
||||
|
||||
// Wait a moment then re-enable (longer than clear since compact takes time)
|
||||
if (!this._isStopped) {
|
||||
this._autoCompactTimer = setTimeout(() => {
|
||||
if (this._isStopped) return; // Check at callback start
|
||||
this._autoCompactTimer = null;
|
||||
this._isCompacting = false;
|
||||
}, 10000);
|
||||
}
|
||||
} else {
|
||||
// Check again after delay
|
||||
if (!this._isStopped) {
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start checking after a short delay
|
||||
if (!this._isStopped) {
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should auto-clear based on token threshold
|
||||
private checkAutoClear(): void {
|
||||
if (this._isStopped) return; // Early exit check
|
||||
if (!this._autoClearEnabled || this._isClearing || this._isCompacting) return;
|
||||
|
||||
const totalTokens = this._totalInputTokens + this._totalOutputTokens;
|
||||
if (totalTokens >= this._autoClearThreshold) {
|
||||
this._isClearing = true;
|
||||
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
|
||||
|
||||
// Wait for Claude to be idle before clearing
|
||||
const checkAndClear = async () => {
|
||||
// Check if session is still valid (not stopped) - must be first check
|
||||
if (this._isStopped) return;
|
||||
if (!this._isClearing) return;
|
||||
|
||||
if (!this._isWorking) {
|
||||
// Re-check stopped state after async operation might have completed
|
||||
if (this._isStopped) return;
|
||||
|
||||
// Send /clear command
|
||||
await this.writeViaMux('/clear\r');
|
||||
// Reset token counts
|
||||
this._totalInputTokens = 0;
|
||||
this._totalOutputTokens = 0;
|
||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||
|
||||
// Wait a moment then re-enable
|
||||
if (!this._isStopped) {
|
||||
this._autoClearTimer = setTimeout(() => {
|
||||
if (this._isStopped) return; // Check at callback start
|
||||
this._autoClearTimer = null;
|
||||
this._isClearing = false;
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
// Check again after delay
|
||||
if (!this._isStopped) {
|
||||
this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Start checking after a short delay
|
||||
if (!this._isStopped) {
|
||||
this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: checkAutoCompact/checkAutoClear moved to SessionAutoOps (this._autoOps)
|
||||
|
||||
/**
|
||||
* Sends input directly to the PTY process.
|
||||
@@ -2265,18 +2025,8 @@ export class Session extends EventEmitter {
|
||||
this._lineBufferFlushTimer = null;
|
||||
}
|
||||
|
||||
// Clear auto-compact/auto-clear timers to prevent memory leaks
|
||||
if (this._autoCompactTimer) {
|
||||
clearTimeout(this._autoCompactTimer);
|
||||
this._autoCompactTimer = null;
|
||||
}
|
||||
this._isCompacting = false;
|
||||
|
||||
if (this._autoClearTimer) {
|
||||
clearTimeout(this._autoClearTimer);
|
||||
this._autoClearTimer = null;
|
||||
}
|
||||
this._isClearing = false;
|
||||
// Destroy auto-compact/auto-clear automation (clears its timers)
|
||||
this._autoOps.destroy();
|
||||
|
||||
// Clear prompt check timers
|
||||
if (this._promptCheckInterval) {
|
||||
@@ -2357,7 +2107,7 @@ export class Session extends EventEmitter {
|
||||
this._currentTaskId = null;
|
||||
|
||||
// Clear task description cache and agent tree to prevent memory leak
|
||||
this._recentTaskDescriptions.clear();
|
||||
this._taskCache.clear();
|
||||
this._childAgentIds = [];
|
||||
|
||||
// Kill the associated mux session if requested
|
||||
@@ -2413,6 +2163,6 @@ export class Session extends EventEmitter {
|
||||
this._messages = [];
|
||||
this._taskTracker.clear();
|
||||
this._ralphTracker.clear();
|
||||
this._recentTaskDescriptions.clear();
|
||||
this._taskCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user