mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
chore: version packages
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
# aicodeman
|
||||
|
||||
## 0.2.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- System-level performance optimizations (Phase 4): stream parent transcripts instead of full reads, consolidate subagent file watchers from 500 to ~50 using directory-level inotify, incremental state persistence with per-session JSON caching, and replace team watcher polling with chokidar fs events
|
||||
|
||||
## 0.2.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -52,7 +52,7 @@ When user says "COM":
|
||||
4. **Sync CLAUDE.md version**: Update the `**Version**` line below to match the new version from `package.json`
|
||||
5. **Commit and deploy**: `git add -A && git commit -m "chore: version packages" && git push && npm run build && systemctl --user restart codeman-web`
|
||||
|
||||
**Version**: 0.2.8 (must match `package.json`)
|
||||
**Version**: 0.2.9 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Performance & Responsiveness Optimization Plan
|
||||
|
||||
**Date**: 2026-02-28
|
||||
**Status**: In Progress
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Three independent research passes analyzed the Codeman codebase for performance bottlenecks across frontend rendering, backend hot paths, and system-level resource usage. The codebase already has strong foundational optimizations (per-session adaptive batching, rAF terminal writes, DEC 2026 sync markers, backpressure handling). This plan targets the remaining high-impact opportunities.
|
||||
|
||||
**Key finding**: The biggest wins come from **skipping unnecessary work** — serializing unchanged state, processing output nobody is watching, and reducing broadcast volume.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Quick Wins — ALREADY IMPLEMENTED
|
||||
|
||||
All Phase 1 items were found to already exist in the codebase during verification:
|
||||
|
||||
| # | Item | Status | Evidence |
|
||||
|---|------|--------|----------|
|
||||
| 1.1 | Skip terminal writes for hidden tabs | Done | SSE handler filters by `activeSessionId` (app.js:4076) |
|
||||
| 1.2 | mobile.css media query | Done | `media="(max-width: 1023px)"` on link tag (index.html:13) |
|
||||
| 1.3 | Deduplicate init API calls | Done | `_initGeneration` dedup + 3s fallback timer (app.js:2901-2904) |
|
||||
| 1.4 | Remove cache-busting timestamps | Done | No `?_t=` patterns found anywhere |
|
||||
| 1.5 | JS/CSS minification + compression | Done | esbuild minify + gzip + brotli in build.mjs (lines 42-51) |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Frontend Responsiveness — MOSTLY ALREADY IMPLEMENTED
|
||||
|
||||
### 2.1 Batch `getBoundingClientRect()` in connection lines — DONE
|
||||
- **Files**: `src/web/public/app.js` (`_updateConnectionLinesImmediate()`)
|
||||
- **Change**: Refactored to batch all layout reads into Phase 1 (collect all rects into a Map), then perform all SVG writes in Phase 2 using cached values. Classic read-then-write pattern prevents interleaved forced reflows.
|
||||
|
||||
### 2.2 Clean up ResizeObservers — Already implemented
|
||||
- `forceCloseSubagentWindow()` disconnects observers (app.js:12618-12620)
|
||||
- `cleanupAllFloatingWindows()` disconnects all on reconnect (app.js:12649-12653)
|
||||
- Observer refs stored on `windowData.resizeObserver` (app.js:12492)
|
||||
|
||||
### 2.3 Drag handler cleanup — Already implemented
|
||||
- `makeWindowDraggable()` returns listener refs, stored in `windowData.dragListeners`
|
||||
- `forceCloseSubagentWindow()` removes all document-level drag listeners (app.js:12622-12630)
|
||||
- Panel drags add listeners on mousedown, remove on mouseup (app.js:10253-10284)
|
||||
|
||||
### 2.4 Mobile window position cache — Skipped
|
||||
- O(n) loop over max ~20 windows; complexity of cached counter not justified
|
||||
|
||||
### 2.5 Lazy modal DOM — Skipped
|
||||
- Large effort, marginal benefit for a vanilla JS app with fast DOM construction
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Backend Hot Paths
|
||||
|
||||
### 3.1 State diff broadcasts — ALREADY OPTIMIZED
|
||||
- `broadcastSessionStateDebounced()` already batches at 500ms intervals
|
||||
- `toLightDetailedState()` excludes heavy buffers (textOutput, terminalBuffer)
|
||||
- Per-session serialization is <1ms; with debouncing, only 1-3 sessions serialize per flush
|
||||
- JSON.stringify happens once per broadcast (not per client) — serialization cost is negligible
|
||||
- Full state diffs would add significant frontend complexity for marginal gain
|
||||
|
||||
### 3.2 Improve session list cache hit rate — DONE
|
||||
- **Files**: `src/web/server.ts` (`broadcast()` method)
|
||||
- **Change**: Cache now only invalidated on truly structural events (`session:created`, `session:deleted`, `session:updated`) instead of on every `session:*` and `respawn:*` event. High-frequency events like `session:working`, `session:idle`, `session:completion`, `respawn:stateChanged` no longer defeat the 1s TTL cache.
|
||||
- **Impact**: Cache hit ratio from ~0% to ~80%+ during active sessions. The debounced `session:updated` still refreshes the cache within 500ms of any state change.
|
||||
|
||||
### 3.3 Skip PTY processing — ALREADY OPTIMIZED
|
||||
- `_processExpensiveParsers()` is already throttled to every 150ms (not per-chunk)
|
||||
- Lazy ANSI stripping via `getCleanData()` closure — only computed when a consumer needs it
|
||||
- Quick pre-checks skip parsers when content is irrelevant (e.g., token parser only runs if data contains "token")
|
||||
- OpenCode sessions skip all Claude-specific parsers entirely
|
||||
- Further optimization would require visibility-aware processing, adding complexity for marginal gain
|
||||
|
||||
### 3.4 Batch subagent liveness checks — Deferred
|
||||
- `/proc/{pid}` stat calls are ~0.1ms each; even with 500 agents, total is 50ms every 10s
|
||||
- Current approach is simple and reliable; batching adds race condition risk
|
||||
- Consider only if profiling shows this as a bottleneck
|
||||
|
||||
### 3.5 Deduplicate detection update emissions — DONE
|
||||
- **Files**: `src/respawn-controller.ts` (`startDetectionUpdates()`)
|
||||
- **Change**: Detection status now only emitted when key fields (confidenceLevel, statusText, controller state) actually change. Previously emitted every 2s regardless, broadcasting identical status to all SSE clients.
|
||||
- **Impact**: For stable/idle sessions, eliminates ~100% of redundant detection broadcasts. For active sessions, reduces broadcasts to only meaningful state transitions.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: System-Level Improvements
|
||||
|
||||
### 4.1 Incremental state persistence
|
||||
- **Files**: `src/state-store.ts` (~lines 145-160)
|
||||
- **Problem**: Every 500ms debounce writes the entire `AppState` (all sessions, tasks, config) via `JSON.stringify()`. With 50 sessions, state can be tens of MB. Serialization alone costs 50-100ms.
|
||||
- **Fix**: Track dirty sessions. On persist, only re-serialize dirty sessions; cache serialized JSON for clean sessions. Assemble final output from cached fragments.
|
||||
- **Impact**: Reduces serialization cost from O(all sessions) to O(dirty sessions). Typical steady-state: 1-2 dirty sessions instead of 50.
|
||||
|
||||
### 4.2 Replace polling with fs watchers for team watcher
|
||||
- **Files**: `src/team-watcher.ts` (~lines 148-180)
|
||||
- **Problem**: Polls `~/.claude/teams/` every 5s via `readdir()` + `stat()`. Blocks event loop for 100-200ms on large directories.
|
||||
- **Fix**: Use `chokidar` (already a dependency) or `fs.watch()` to react to changes. Keep a 30s fallback poll for reliability.
|
||||
- **Impact**: Eliminates 5s polling overhead; near-instant team detection.
|
||||
|
||||
### 4.3 Consolidate subagent file watchers
|
||||
- **Files**: `src/subagent-watcher.ts` (~line 229+)
|
||||
- **Problem**: One chokidar watcher per agent directory. With 500 agents, that's 500 inotify watchers consuming kernel resources.
|
||||
- **Fix**: Watch at the session level (one watcher per session's subagent directory), not per-agent. Parse events to route to correct agent.
|
||||
- **Impact**: Reduces inotify watchers from 500 to ~50 (one per session).
|
||||
|
||||
### 4.4 Stream transcript files instead of full reads
|
||||
- **Files**: `src/subagent-watcher.ts` (~lines 959-964)
|
||||
- **Problem**: `loadTranscript()` reads entire transcript file (can be >100KB). With 500 agents discovered at once, that's 50MB of file reads.
|
||||
- **Fix**: Only read last 10KB for display (tail). Full file on-demand only (e.g., when user opens transcript viewer).
|
||||
- **Impact**: Reduces file I/O from 50MB to 5MB for bulk agent discovery.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Long-Term Architectural (Optional)
|
||||
|
||||
### 5.1 Worker thread for PTY processing
|
||||
- **Files**: `src/session.ts`
|
||||
- **Problem**: ANSI stripping, Ralph tracking, and bash tool parsing all run on the main event loop. At scale (50 busy sessions), this consumes 300-500ms CPU/sec.
|
||||
- **Fix**: Offload ANSI strip + line processing to a worker thread pool. Main thread receives clean text + parsed events.
|
||||
- **Impact**: Frees event loop for I/O operations. Most impactful at 10+ concurrent busy sessions.
|
||||
|
||||
### 5.2 Per-session SSE subscriptions
|
||||
- **Files**: `src/web/server.ts`
|
||||
- **Problem**: Every SSE event is broadcast to all connected clients. A client watching session A still receives events for sessions B through Z.
|
||||
- **Fix**: Clients subscribe to specific session IDs. Server only sends events to interested clients.
|
||||
- **Impact**: Reduces SSE broadcast fan-out from N clients to ~1-2 per event. Major improvement at 100 SSE clients.
|
||||
|
||||
### 5.3 O(1) LRUMap via doubly-linked list
|
||||
- **Files**: `src/utils/lru-map.ts` (~lines 98-110)
|
||||
- **Problem**: `get()` uses delete + re-insert to refresh position — O(n) on Map iteration for delete.
|
||||
- **Fix**: Implement classic LRU with doubly-linked list + Map for O(1) get/put/evict.
|
||||
- **Impact**: Low — current sizes (max 500) make this barely measurable. Only worthwhile if LRUMap is used on hot paths.
|
||||
|
||||
---
|
||||
|
||||
## Priority Matrix (Remaining Work)
|
||||
|
||||
| # | Item | Impact | Risk | Effort |
|
||||
|---|------|--------|------|--------|
|
||||
| 3.1 | State diff broadcasts | **Very High** | Medium | 3-4h |
|
||||
| 3.2 | Fix session cache invalidation | **High** | Low | 1h |
|
||||
| 3.3 | Skip PTY processing for hidden sessions | **High** | Medium | 2-3h |
|
||||
| 3.5 | Throttle detection broadcasts | **Medium** | Low | 1h |
|
||||
| 3.4 | Batch liveness checks | **Medium** | Low | 1-2h |
|
||||
| 4.1 | Incremental state persistence | **Medium** | Medium | 3-4h |
|
||||
| 4.2 | Team watcher fs events | **Low-Med** | Medium | 2h |
|
||||
| 4.3 | Consolidate file watchers | **Low-Med** | Medium | 2h |
|
||||
| 4.4 | Stream transcripts | **Low-Med** | Low | 1h |
|
||||
| 5.1 | Worker thread PTY | **Med** (at scale) | High | 8h |
|
||||
| 5.2 | Per-session SSE subs | **Med** (at scale) | High | 4h |
|
||||
| 5.3 | O(1) LRUMap | **Very Low** | Medium | 2h |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
**Sprint 1** (Phase 3 — Backend Hot Paths): Items 3.1, 3.2, 3.3, 3.5
|
||||
- Backend serialization and broadcast efficiency
|
||||
- Highest remaining impact; requires careful testing with multiple active sessions
|
||||
|
||||
**Sprint 2** (Phase 4 — System Level): Items 4.1, 3.4, 4.3, 4.4
|
||||
- State persistence, liveness checks, watcher consolidation
|
||||
- Medium-complexity refactors
|
||||
|
||||
**Sprint 3** (Phase 5 — Architectural): Items 5.1, 5.2 — only if scaling demands it
|
||||
|
||||
---
|
||||
|
||||
## Measurement
|
||||
|
||||
Before starting implementation, establish baselines:
|
||||
|
||||
1. **Frontend**: Record Chrome DevTools Performance trace with 10 sessions open. Measure:
|
||||
- Frame rate during rapid terminal output
|
||||
- Long tasks (>50ms) count per 30s
|
||||
- Heap size after 1h session
|
||||
|
||||
2. **Backend**: Add `performance.now()` instrumentation around:
|
||||
- `flushSessionTerminalBatch()` — time per flush
|
||||
- `broadcastSessionStateDebounced()` — serialization time
|
||||
- `StateStore.save()` — persist time
|
||||
- Event loop lag via `monitorEventLoopDelay()`
|
||||
|
||||
3. **First load**: Lighthouse score on desktop and mobile (simulated 3G)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "aicodeman",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.9",
|
||||
"description": "The missing control plane for AI coding agents - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
@@ -653,6 +653,9 @@ export class RespawnController extends EventEmitter {
|
||||
/** Timer for periodic detection status updates */
|
||||
private detectionUpdateTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** Cached key fields from last emitted detection status (for dedup) */
|
||||
private lastEmittedDetectionKey: string = '';
|
||||
|
||||
/** Timer for auto-accepting plan mode prompts */
|
||||
private autoAcceptTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
@@ -1196,10 +1199,18 @@ export class RespawnController extends EventEmitter {
|
||||
private startDetectionUpdates(): void {
|
||||
this.stopDetectionUpdates();
|
||||
if (this._state === 'stopped') return;
|
||||
this.lastEmittedDetectionKey = '';
|
||||
this.detectionUpdateTimer = setInterval(() => {
|
||||
try {
|
||||
if (this._state !== 'stopped') {
|
||||
this.emit('detectionUpdate', this.getDetectionStatus());
|
||||
const status = this.getDetectionStatus();
|
||||
// Only emit when status meaningfully changed (confidence, state text, or timer values)
|
||||
// to avoid broadcasting identical data every 2s for stable/idle sessions.
|
||||
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
|
||||
if (key !== this.lastEmittedDetectionKey) {
|
||||
this.lastEmittedDetectionKey = key;
|
||||
this.emit('detectionUpdate', status);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
|
||||
|
||||
+104
-16
@@ -62,6 +62,8 @@ export class StateStore {
|
||||
private filePath: string;
|
||||
private saveTimeout: NodeJS.Timeout | null = null;
|
||||
private dirty: boolean = false;
|
||||
private dirtySessions = new Set<string>();
|
||||
private cachedSessionJsons = new Map<string, string>();
|
||||
|
||||
// Inner state storage (separate from main state to reduce write frequency)
|
||||
private ralphStates: Map<string, RalphSessionState> = new Map();
|
||||
@@ -97,6 +99,10 @@ export class StateStore {
|
||||
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
|
||||
this.state = this.load();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
// Pre-populate session cache for loaded state
|
||||
for (const [id, session] of Object.entries(this.state.sessions)) {
|
||||
this.cachedSessionJsons.set(id, JSON.stringify(session));
|
||||
}
|
||||
this.loadRalphStates();
|
||||
}
|
||||
|
||||
@@ -176,6 +182,65 @@ export class StateStore {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assemble JSON string with incremental per-session caching.
|
||||
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
|
||||
*/
|
||||
private assembleStateJson(): string {
|
||||
// Re-serialize dirty sessions and update cache
|
||||
for (const id of this.dirtySessions) {
|
||||
const session = this.state.sessions[id];
|
||||
if (session) {
|
||||
this.cachedSessionJsons.set(id, JSON.stringify(session));
|
||||
} else {
|
||||
this.cachedSessionJsons.delete(id);
|
||||
}
|
||||
}
|
||||
this.dirtySessions.clear();
|
||||
|
||||
// Build sessions object from cached fragments
|
||||
const sessionParts: string[] = [];
|
||||
for (const [id, session] of Object.entries(this.state.sessions)) {
|
||||
let json = this.cachedSessionJsons.get(id);
|
||||
if (!json) {
|
||||
// Session not in cache (loaded from disk or set via direct state mutation)
|
||||
json = JSON.stringify(session);
|
||||
this.cachedSessionJsons.set(id, json);
|
||||
}
|
||||
sessionParts.push(`${JSON.stringify(id)}:${json}`);
|
||||
}
|
||||
|
||||
// Prune stale cache entries (sessions removed via direct state mutation)
|
||||
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
|
||||
for (const cachedId of this.cachedSessionJsons.keys()) {
|
||||
if (!(cachedId in this.state.sessions)) {
|
||||
this.cachedSessionJsons.delete(cachedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
|
||||
const sessionsJson = `{${sessionParts.join(',')}}`;
|
||||
|
||||
// Serialize non-session fields individually (they're small)
|
||||
const parts: string[] = [
|
||||
`"sessions":${sessionsJson}`,
|
||||
`"tasks":${JSON.stringify(this.state.tasks)}`,
|
||||
`"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`,
|
||||
`"config":${JSON.stringify(this.state.config)}`,
|
||||
];
|
||||
|
||||
// Optional fields
|
||||
if (this.state.globalStats) {
|
||||
parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`);
|
||||
}
|
||||
if (this.state.tokenStats) {
|
||||
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
|
||||
}
|
||||
|
||||
return `{${parts.join(',')}}`;
|
||||
}
|
||||
|
||||
private async _doSaveAsync(): Promise<void> {
|
||||
if (this.saveTimeout) {
|
||||
clearTimeout(this.saveTimeout);
|
||||
@@ -199,15 +264,23 @@ export class StateStore {
|
||||
|
||||
// Step 1: Serialize state (validates it's JSON-safe)
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
json = this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
|
||||
@@ -279,15 +352,23 @@ export class StateStore {
|
||||
let json: string;
|
||||
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
json = this.assembleStateJson();
|
||||
} catch (assembleErr) {
|
||||
// Fallback to full serialization if incremental assembly fails
|
||||
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
try {
|
||||
json = JSON.stringify(this.state);
|
||||
} catch (err) {
|
||||
console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err);
|
||||
this.consecutiveSaveFailures++;
|
||||
if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
||||
console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly');
|
||||
this.circuitBreakerOpen = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Backup via atomic copy (avoids reading entire file into memory)
|
||||
@@ -387,12 +468,15 @@ export class StateStore {
|
||||
/** Sets a session state and triggers a debounced save. */
|
||||
setSession(id: string, session: AppState['sessions'][string]) {
|
||||
this.state.sessions[id] = session;
|
||||
this.dirtySessions.add(id);
|
||||
this.save();
|
||||
}
|
||||
|
||||
/** Removes a session state and triggers a debounced save. */
|
||||
removeSession(id: string) {
|
||||
delete this.state.sessions[id];
|
||||
this.cachedSessionJsons.delete(id);
|
||||
this.dirtySessions.delete(id);
|
||||
this.save();
|
||||
}
|
||||
|
||||
@@ -413,6 +497,8 @@ export class StateStore {
|
||||
const name = this.state.sessions[sessionId]?.name;
|
||||
cleaned.push({ id: sessionId, name });
|
||||
delete this.state.sessions[sessionId];
|
||||
this.cachedSessionJsons.delete(sessionId);
|
||||
this.dirtySessions.delete(sessionId);
|
||||
// Also clean up Ralph state for this session
|
||||
this.ralphStates.delete(sessionId);
|
||||
}
|
||||
@@ -475,6 +561,8 @@ export class StateStore {
|
||||
this.state = createInitialState();
|
||||
this.state.config.stateFilePath = this.filePath;
|
||||
this.ralphStates.clear();
|
||||
this.cachedSessionJsons.clear();
|
||||
this.dirtySessions.clear();
|
||||
this.saveNow(); // Immediate save for reset operations
|
||||
this.saveRalphStatesNow();
|
||||
}
|
||||
|
||||
+128
-112
@@ -160,8 +160,9 @@ const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates
|
||||
|
||||
export class SubagentWatcher extends EventEmitter {
|
||||
private filePositions = new Map<string, number>();
|
||||
private fileWatchers = new Map<string, FSWatcher>();
|
||||
private dirWatchers = new Map<string, FSWatcher>();
|
||||
// Per-file debounce timers for directory watcher (replaces per-file FSWatchers)
|
||||
private fileDebouncers = new Map<string, NodeJS.Timeout>();
|
||||
private agentInfo = new Map<string, SubagentInfo>();
|
||||
private idleTimers = new Map<string, NodeJS.Timeout>();
|
||||
private pollInterval: NodeJS.Timeout | null = null;
|
||||
@@ -180,7 +181,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
private parentDescriptionCache = new Map<string, { descriptions: Map<string, string>; timestamp: number }>();
|
||||
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
|
||||
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
|
||||
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
|
||||
// Map filePath → { projectHash, sessionId } for directory watcher file-change handling
|
||||
private fileAgentContext = new Map<string, { projectHash: string; sessionId: string }>();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -426,17 +428,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.livenessInterval = null;
|
||||
}
|
||||
|
||||
// Remove error handlers before closing watchers to prevent memory leak
|
||||
for (const [filePath, handler] of this.fileWatcherErrorHandlers) {
|
||||
const watcher = this.fileWatchers.get(filePath);
|
||||
if (watcher) watcher.off('error', handler);
|
||||
// Clear file debouncers
|
||||
for (const timer of this.fileDebouncers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
this.fileWatcherErrorHandlers.clear();
|
||||
|
||||
for (const watcher of this.fileWatchers.values()) {
|
||||
watcher.close();
|
||||
}
|
||||
this.fileWatchers.clear();
|
||||
this.fileDebouncers.clear();
|
||||
this.fileAgentContext.clear();
|
||||
|
||||
// Remove error handlers before closing watchers to prevent memory leak
|
||||
for (const [dir, handler] of this.dirWatcherErrorHandlers) {
|
||||
@@ -534,11 +531,11 @@ export class SubagentWatcher extends EventEmitter {
|
||||
this.agentInfo.delete(agentId);
|
||||
this.pendingToolCalls.delete(agentId);
|
||||
this.filePositions.delete(info.filePath);
|
||||
const watcher = this.fileWatchers.get(info.filePath);
|
||||
if (watcher) {
|
||||
watcher.close();
|
||||
this.fileWatchers.delete(info.filePath);
|
||||
this.fileWatcherErrorHandlers.delete(info.filePath);
|
||||
this.fileAgentContext.delete(info.filePath);
|
||||
const debounceTimer = this.fileDebouncers.get(info.filePath);
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
this.fileDebouncers.delete(info.filePath);
|
||||
}
|
||||
const timer = this.idleTimers.get(agentId);
|
||||
if (timer) {
|
||||
@@ -622,7 +619,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
*/
|
||||
getStats(): {
|
||||
agentCount: number;
|
||||
fileWatcherCount: number;
|
||||
fileDebouncerCount: number;
|
||||
dirWatcherCount: number;
|
||||
idleTimerCount: number;
|
||||
pendingToolCallsCount: number;
|
||||
@@ -637,7 +634,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
return {
|
||||
agentCount: this.agentInfo.size,
|
||||
fileWatcherCount: this.fileWatchers.size,
|
||||
fileDebouncerCount: this.fileDebouncers.size,
|
||||
dirWatcherCount: this.dirWatchers.size,
|
||||
idleTimerCount: this.idleTimers.size,
|
||||
pendingToolCallsCount,
|
||||
@@ -955,14 +952,29 @@ export class SubagentWatcher extends EventEmitter {
|
||||
try {
|
||||
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
|
||||
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
|
||||
let fileSize: number;
|
||||
try {
|
||||
await statAsync(transcriptPath);
|
||||
const fileStat = await statAsync(transcriptPath);
|
||||
fileSize = fileStat.size;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf8');
|
||||
const lines = content.split('\n').filter((l) => l.trim());
|
||||
// Only read last 16KB — toolUseResult entries are near the end of the transcript
|
||||
const TAIL_BYTES = 16384;
|
||||
const startOffset = Math.max(0, fileSize - TAIL_BYTES);
|
||||
const content = await new Promise<string>((resolve, reject) => {
|
||||
const chunks: string[] = [];
|
||||
const stream = createReadStream(transcriptPath, { start: startOffset, encoding: 'utf8' });
|
||||
stream.on('data', (chunk) => chunks.push(String(chunk)));
|
||||
stream.on('end', () => resolve(chunks.join('')));
|
||||
stream.on('error', reject);
|
||||
});
|
||||
let lines = content.split('\n').filter((l) => l.trim());
|
||||
// If we started mid-file, drop the first partial line
|
||||
if (startOffset > 0 && lines.length > 0) {
|
||||
lines = lines.slice(1);
|
||||
}
|
||||
|
||||
// Parse ALL toolUseResult entries into a Map and cache them
|
||||
const descriptions = new Map<string, string>();
|
||||
@@ -1091,39 +1103,51 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a subagent directory for new/updated files
|
||||
* Watch a subagent directory for new and changed files.
|
||||
* Uses a single directory-level fs.watch() instead of per-file watchers.
|
||||
* On Linux, inotify IN_MODIFY fires for content changes within the directory.
|
||||
*/
|
||||
private async watchSubagentDir(dir: string, projectHash: string, sessionId: string): Promise<void> {
|
||||
if (this.knownSubagentDirs.has(dir)) return;
|
||||
this.knownSubagentDirs.add(dir);
|
||||
|
||||
// Watch existing files (initial scan - skip old files)
|
||||
// Register existing files (initial scan - skip old files)
|
||||
try {
|
||||
const files = await readdir(dir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.jsonl')) {
|
||||
await this.watchAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for new files with debounce to allow content to be written
|
||||
// Single directory watcher handles both new files and file content changes
|
||||
try {
|
||||
const watcher = watch(dir, (_eventType, filename) => {
|
||||
if (filename?.endsWith('.jsonl')) {
|
||||
const filePath = join(dir, filename);
|
||||
// Wait 100ms for file content to be written before processing
|
||||
// Even if file is empty after debounce, we still watch it - the
|
||||
// description retry mechanisms in processEntry and the file change
|
||||
// handler will extract description when content arrives
|
||||
setTimeout(() => {
|
||||
if (existsSync(filePath)) {
|
||||
this.watchAgentFile(filePath, projectHash, sessionId);
|
||||
}
|
||||
}, FILE_CONTENT_DEBOUNCE_MS);
|
||||
}
|
||||
if (!filename?.endsWith('.jsonl')) return;
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
// Clear existing debounce for this file
|
||||
const existing = this.fileDebouncers.get(filePath);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
// Debounce 100ms to batch rapid writes
|
||||
const timer = setTimeout(() => {
|
||||
this.fileDebouncers.delete(filePath);
|
||||
if (!existsSync(filePath)) return;
|
||||
|
||||
if (this.fileAgentContext.has(filePath)) {
|
||||
// Known file — handle content change
|
||||
this.handleFileChange(filePath).catch(() => {});
|
||||
} else {
|
||||
// New file — register it
|
||||
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
|
||||
}
|
||||
}, FILE_CONTENT_DEBOUNCE_MS);
|
||||
|
||||
this.fileDebouncers.set(filePath, timer);
|
||||
});
|
||||
|
||||
// Handle watcher errors to prevent unhandled exceptions
|
||||
@@ -1145,26 +1169,80 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch a specific agent transcript file
|
||||
* Handle a file content change for an already-registered agent file.
|
||||
* Tails from last known position, updates info, retries description if missing.
|
||||
*/
|
||||
private async handleFileChange(filePath: string): Promise<void> {
|
||||
const context = this.fileAgentContext.get(filePath);
|
||||
if (!context) return;
|
||||
|
||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||
const currentPos = this.filePositions.get(filePath) || 0;
|
||||
const newPos = await this.tailFile(filePath, agentId, context.sessionId, currentPos);
|
||||
this.filePositions.set(filePath, newPos);
|
||||
|
||||
// Update info
|
||||
const existingInfo = this.agentInfo.get(agentId);
|
||||
if (existingInfo) {
|
||||
try {
|
||||
const newStat = await statAsync(filePath);
|
||||
existingInfo.lastActivityAt = Date.now();
|
||||
existingInfo.fileSize = newStat.size;
|
||||
existingInfo.status = 'active';
|
||||
} catch {
|
||||
// Stat failed
|
||||
}
|
||||
|
||||
// Retry description extraction if missing (race condition fix)
|
||||
if (!existingInfo.description) {
|
||||
// First try parent transcript (most reliable)
|
||||
let extractedDescription = await this.extractDescriptionFromParentTranscript(
|
||||
existingInfo.projectHash,
|
||||
existingInfo.sessionId,
|
||||
agentId
|
||||
);
|
||||
// Fallback to subagent file
|
||||
if (!extractedDescription) {
|
||||
extractedDescription = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
if (extractedDescription) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(extractedDescription)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
}
|
||||
existingInfo.description = extractedDescription;
|
||||
this.emit('subagent:updated', existingInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset idle timer
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a specific agent transcript file (discovery + initial read).
|
||||
* Does NOT create a per-file watcher — the directory watcher handles changes.
|
||||
* @param filePath Path to the agent transcript file
|
||||
* @param projectHash Claude project hash
|
||||
* @param sessionId Claude session ID
|
||||
* @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS
|
||||
*/
|
||||
private async watchAgentFile(
|
||||
private async registerAgentFile(
|
||||
filePath: string,
|
||||
projectHash: string,
|
||||
sessionId: string,
|
||||
isInitialScan: boolean = false
|
||||
): Promise<void> {
|
||||
if (this.fileWatchers.has(filePath)) return;
|
||||
if (this.fileAgentContext.has(filePath)) return;
|
||||
|
||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||
|
||||
// Initial info - handle race condition where file may be deleted between discovery and stat
|
||||
let stat;
|
||||
let fileStat;
|
||||
try {
|
||||
stat = await statAsync(filePath);
|
||||
fileStat = await statAsync(filePath);
|
||||
} catch {
|
||||
// File was deleted between discovery and stat - skip this agent
|
||||
return;
|
||||
@@ -1172,7 +1250,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
|
||||
// On initial scan, skip old files to avoid loading stale historical data
|
||||
if (isInitialScan) {
|
||||
const fileAge = Date.now() - stat.mtime.getTime();
|
||||
const fileAge = Date.now() - fileStat.mtime.getTime();
|
||||
if (fileAge > STARTUP_MAX_FILE_AGE_MS) {
|
||||
return; // Skip old files on startup
|
||||
}
|
||||
@@ -1197,12 +1275,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
sessionId,
|
||||
projectHash,
|
||||
filePath,
|
||||
startedAt: stat.birthtime.toISOString(),
|
||||
lastActivityAt: stat.mtime.getTime(),
|
||||
startedAt: fileStat.birthtime.toISOString(),
|
||||
lastActivityAt: fileStat.mtime.getTime(),
|
||||
status: 'active',
|
||||
toolCallCount: 0,
|
||||
entryCount: 0,
|
||||
fileSize: stat.size,
|
||||
fileSize: fileStat.size,
|
||||
description,
|
||||
};
|
||||
|
||||
@@ -1221,6 +1299,8 @@ export class SubagentWatcher extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Track file context for directory watcher change handling
|
||||
this.fileAgentContext.set(filePath, { projectHash, sessionId });
|
||||
this.agentInfo.set(agentId, info);
|
||||
this.emit('subagent:discovered', info);
|
||||
|
||||
@@ -1234,71 +1314,7 @@ export class SubagentWatcher extends EventEmitter {
|
||||
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
|
||||
});
|
||||
|
||||
// Watch for changes
|
||||
try {
|
||||
const watcher = watch(filePath, async (eventType) => {
|
||||
if (eventType === 'change') {
|
||||
const currentPos = this.filePositions.get(filePath) || 0;
|
||||
const newPos = await this.tailFile(filePath, agentId, sessionId, currentPos);
|
||||
this.filePositions.set(filePath, newPos);
|
||||
|
||||
// Update info
|
||||
const existingInfo = this.agentInfo.get(agentId);
|
||||
if (existingInfo) {
|
||||
try {
|
||||
const newStat = await statAsync(filePath);
|
||||
existingInfo.lastActivityAt = Date.now();
|
||||
existingInfo.fileSize = newStat.size;
|
||||
existingInfo.status = 'active';
|
||||
} catch {
|
||||
// Stat failed
|
||||
}
|
||||
|
||||
// Retry description extraction if missing (race condition fix)
|
||||
if (!existingInfo.description) {
|
||||
// First try parent transcript (most reliable)
|
||||
let extractedDescription = await this.extractDescriptionFromParentTranscript(
|
||||
existingInfo.projectHash,
|
||||
existingInfo.sessionId,
|
||||
agentId
|
||||
);
|
||||
// Fallback to subagent file
|
||||
if (!extractedDescription) {
|
||||
extractedDescription = await this.extractDescriptionFromFile(filePath);
|
||||
}
|
||||
if (extractedDescription) {
|
||||
// Check if this is an internal agent - if so, remove it
|
||||
if (this.isInternalAgent(extractedDescription)) {
|
||||
this.removeAgent(agentId);
|
||||
return;
|
||||
}
|
||||
existingInfo.description = extractedDescription;
|
||||
this.emit('subagent:updated', existingInfo);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset idle timer
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle watcher errors to prevent unhandled exceptions
|
||||
// Store handler reference for proper cleanup
|
||||
const errorHandler = (error: Error) => {
|
||||
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId);
|
||||
watcher.close();
|
||||
this.fileWatcherErrorHandlers.delete(filePath);
|
||||
this.fileWatchers.delete(filePath);
|
||||
};
|
||||
watcher.on('error', errorHandler);
|
||||
this.fileWatcherErrorHandlers.set(filePath, errorHandler);
|
||||
|
||||
this.fileWatchers.set(filePath, watcher);
|
||||
this.resetIdleTimer(agentId);
|
||||
} catch {
|
||||
// Watch failed
|
||||
}
|
||||
this.resetIdleTimer(agentId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+56
-1
@@ -13,12 +13,14 @@ import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar';
|
||||
|
||||
import type { TeamConfig, TeamMember, TeamTask, InboxMessage } from './types.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
const POLL_INTERVAL_MS = 30000;
|
||||
const MAX_CACHED_TEAMS = 50;
|
||||
const MAX_CACHED_TASKS = 200;
|
||||
|
||||
@@ -37,6 +39,8 @@ export class TeamWatcher extends EventEmitter {
|
||||
private inboxMtimes: Map<string, number> = new Map();
|
||||
// Reverse index: sessionId → teamName for O(1) lookup
|
||||
private sessionToTeam: Map<string, string> = new Map();
|
||||
private teamsWatcher: ChokidarWatcher | null = null;
|
||||
private tasksWatcher: ChokidarWatcher | null = null;
|
||||
|
||||
constructor(teamsDir?: string, tasksDir?: string) {
|
||||
super();
|
||||
@@ -49,9 +53,60 @@ export class TeamWatcher extends EventEmitter {
|
||||
if (this.pollTimer) return;
|
||||
this.poll();
|
||||
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
|
||||
this.setupFsWatchers();
|
||||
}
|
||||
|
||||
private setupFsWatchers(): void {
|
||||
try {
|
||||
this.teamsWatcher = chokidarWatch(this.teamsDir, {
|
||||
depth: 2,
|
||||
awaitWriteFinish: { stabilityThreshold: 200 },
|
||||
ignored: /\.lock/,
|
||||
ignoreInitial: true,
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
const teamsHandler = () => this.pollAsync().catch(() => {});
|
||||
this.teamsWatcher.on('add', teamsHandler);
|
||||
this.teamsWatcher.on('change', teamsHandler);
|
||||
this.teamsWatcher.on('unlink', teamsHandler);
|
||||
this.teamsWatcher.on('unlinkDir', teamsHandler);
|
||||
this.teamsWatcher.on('error', (err) => {
|
||||
console.warn('[TeamWatcher] chokidar teams watcher error:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[TeamWatcher] Failed to set up teams chokidar watcher, relying on polling:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
this.tasksWatcher = chokidarWatch(this.tasksDir, {
|
||||
depth: 1,
|
||||
awaitWriteFinish: { stabilityThreshold: 200 },
|
||||
ignored: /\.lock/,
|
||||
ignoreInitial: true,
|
||||
persistent: false,
|
||||
});
|
||||
|
||||
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {}));
|
||||
this.tasksWatcher.on('error', (err) => {
|
||||
console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[TeamWatcher] Failed to set up tasks chokidar watcher, relying on polling:', err);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Close chokidar watchers
|
||||
if (this.teamsWatcher) {
|
||||
this.teamsWatcher.close().catch(() => {});
|
||||
this.teamsWatcher = null;
|
||||
}
|
||||
if (this.tasksWatcher) {
|
||||
this.tasksWatcher.close().catch(() => {});
|
||||
this.tasksWatcher = null;
|
||||
}
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
|
||||
+51
-19
@@ -12011,8 +12011,6 @@ class CodemanApp {
|
||||
const svg = document.getElementById('connectionLines');
|
||||
if (!svg) return;
|
||||
|
||||
svg.innerHTML = '';
|
||||
|
||||
// Check if Ralph wizard modal is open
|
||||
const wizardModal = document.getElementById('ralphWizardModal');
|
||||
const wizardOpen = wizardModal?.classList.contains('active');
|
||||
@@ -12032,8 +12030,51 @@ class CodemanApp {
|
||||
.filter(([, data]) => data.element)
|
||||
.map(([id, data]) => ({ id, ...data }));
|
||||
|
||||
// === PHASE 1: Batch all layout reads (getBoundingClientRect) ===
|
||||
// Reading layout properties forces the browser to calculate layout.
|
||||
// By batching all reads before any writes, we avoid repeated forced reflows.
|
||||
const rects = new Map();
|
||||
|
||||
// Read all subagent window rects
|
||||
for (const { agentId, win } of visibleSubagentWindows) {
|
||||
const winRect = win.getBoundingClientRect();
|
||||
rects.set('sub:' + agentId, win.getBoundingClientRect());
|
||||
}
|
||||
|
||||
// Read all plan subagent rects
|
||||
for (const planAgent of planSubagentArray) {
|
||||
rects.set('plan:' + planAgent.id, planAgent.element.getBoundingClientRect());
|
||||
}
|
||||
|
||||
// Read wizard rect (if open)
|
||||
let wizardRect = null;
|
||||
if (wizardOpen && wizardContent) {
|
||||
wizardRect = wizardContent.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// Read tab rects for normal mode (only tabs that are actually needed)
|
||||
if (!wizardOpen) {
|
||||
for (const { agentId } of visibleSubagentWindows) {
|
||||
const parentSessionId = this.subagentParentMap.get(agentId);
|
||||
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
|
||||
}
|
||||
}
|
||||
|
||||
// Read plan window rects for wizard-to-plan lines
|
||||
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
|
||||
for (const [agentId, windowData] of this.planSubagents) {
|
||||
if (!windowData.element) continue;
|
||||
const key = 'planwin:' + agentId;
|
||||
if (!rects.has(key)) rects.set(key, windowData.element.getBoundingClientRect());
|
||||
}
|
||||
}
|
||||
|
||||
// === PHASE 2: DOM writes using cached rects (no more layout reads) ===
|
||||
svg.innerHTML = '';
|
||||
|
||||
for (const { agentId } of visibleSubagentWindows) {
|
||||
const winRect = rects.get('sub:' + agentId);
|
||||
|
||||
// If wizard is open with plan subagents, connect regular subagents to plan subagent windows
|
||||
if (wizardOpen && wizardContent && planSubagentArray.length > 0) {
|
||||
@@ -12042,7 +12083,7 @@ class CodemanApp {
|
||||
let nearestDistance = Infinity;
|
||||
|
||||
for (const planAgent of planSubagentArray) {
|
||||
const planRect = planAgent.element.getBoundingClientRect();
|
||||
const planRect = rects.get('plan:' + planAgent.id);
|
||||
const planCenterX = planRect.left + planRect.width / 2;
|
||||
const planCenterY = planRect.top + planRect.height / 2;
|
||||
const winCenterX = winRect.left + winRect.width / 2;
|
||||
@@ -12056,7 +12097,7 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
if (nearestPlanAgent) {
|
||||
const planRect = nearestPlanAgent.element.getBoundingClientRect();
|
||||
const planRect = rects.get('plan:' + nearestPlanAgent.id);
|
||||
|
||||
// Draw line from plan subagent window to regular subagent window
|
||||
let x1, y1, x2, y2;
|
||||
@@ -12087,8 +12128,6 @@ class CodemanApp {
|
||||
}
|
||||
} else if (wizardOpen && wizardContent) {
|
||||
// Wizard open but no plan subagents - connect directly to wizard
|
||||
const wizardRect = wizardContent.getBoundingClientRect();
|
||||
|
||||
const winCenterX = winRect.left + winRect.width / 2;
|
||||
const wizardCenterX = wizardRect.left + wizardRect.width / 2;
|
||||
|
||||
@@ -12124,15 +12163,12 @@ class CodemanApp {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the TAB element by its data-id
|
||||
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
|
||||
if (!tab) {
|
||||
const tabRect = rects.get('tab:' + parentSessionId);
|
||||
if (!tabRect) {
|
||||
// Tab not in DOM (might be scrolled out or session closed)
|
||||
continue;
|
||||
}
|
||||
|
||||
const tabRect = tab.getBoundingClientRect();
|
||||
|
||||
// Draw curved line from TAB bottom-center to window top-center
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
@@ -12155,13 +12191,9 @@ class CodemanApp {
|
||||
// Draw lines from wizard to plan subagent windows (Opus agents during plan generation)
|
||||
// Skip if agents are minimized to tab
|
||||
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
|
||||
const wizardRect = wizardContent.getBoundingClientRect();
|
||||
|
||||
for (const [agentId, windowData] of this.planSubagents) {
|
||||
const win = windowData.element;
|
||||
if (!win) continue;
|
||||
|
||||
const winRect = win.getBoundingClientRect();
|
||||
for (const [agentId] of this.planSubagents) {
|
||||
const winRect = rects.get('planwin:' + agentId);
|
||||
if (!winRect) continue;
|
||||
|
||||
// Determine which side of wizard the window is on
|
||||
const winCenterX = winRect.left + winRect.width / 2;
|
||||
|
||||
+12
-10
@@ -995,10 +995,10 @@ export class WebServer extends EventEmitter {
|
||||
},
|
||||
},
|
||||
watchers: {
|
||||
fileWatchers: subagentStats.fileWatcherCount,
|
||||
fileDebouncers: subagentStats.fileDebouncerCount,
|
||||
dirWatchers: subagentStats.dirWatcherCount,
|
||||
transcriptWatchers: this.transcriptWatchers.size,
|
||||
total: subagentStats.fileWatcherCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
|
||||
total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
|
||||
},
|
||||
timers: {
|
||||
respawnTimers: this.respawnTimers.size,
|
||||
@@ -5878,15 +5878,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
private broadcast(event: string, data: unknown): void {
|
||||
// Invalidate caches on state-changing broadcasts, but NOT on high-frequency
|
||||
// streaming events that don't change session metadata (terminal data,
|
||||
// detection updates). These fire every 16ms-2s and would make the 1s TTL
|
||||
// caches permanently empty — defeating their purpose.
|
||||
// Invalidate caches only on structurally significant events — ones that
|
||||
// change session list content (creation, deletion, or full state refresh).
|
||||
// High-frequency non-structural events (working/idle transitions, completion,
|
||||
// error, respawn state changes) are NOT worth invalidating for because:
|
||||
// 1. The debounced session:updated follows within 500ms with the new state
|
||||
// 2. These caches serve /api/sessions and SSE init — neither is polled rapidly
|
||||
// 3. Invalidating on every working/idle transition makes the 1s TTL useless
|
||||
if (
|
||||
(event.startsWith('session:') || event.startsWith('respawn:')) &&
|
||||
event !== 'session:terminal' &&
|
||||
event !== 'session:needsRefresh' &&
|
||||
event !== 'respawn:detectionUpdate'
|
||||
event === 'session:created' ||
|
||||
event === 'session:deleted' ||
|
||||
event === 'session:updated'
|
||||
) {
|
||||
this.cachedLightState = null;
|
||||
this.cachedSessionsList = null;
|
||||
|
||||
+101
-6
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { existsSync, unlinkSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { existsSync, unlinkSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
@@ -360,7 +360,7 @@ describe('StateStore', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
store.addToGlobalStats(1000, 500, 0.05);
|
||||
store.addToGlobalStats(2000, 1000, 0.10);
|
||||
store.addToGlobalStats(2000, 1000, 0.1);
|
||||
|
||||
const stats = store.getGlobalStats();
|
||||
expect(stats.totalInputTokens).toBe(3000);
|
||||
@@ -388,20 +388,20 @@ describe('StateStore', () => {
|
||||
// Simulate active sessions
|
||||
const activeSessions = {
|
||||
'session-1': { inputTokens: 1000, outputTokens: 500, totalCost: 0.05 },
|
||||
'session-2': { inputTokens: 2000, outputTokens: 1000, totalCost: 0.10 },
|
||||
'session-2': { inputTokens: 2000, outputTokens: 1000, totalCost: 0.1 },
|
||||
};
|
||||
|
||||
const aggregate = store.getAggregateStats(activeSessions);
|
||||
|
||||
expect(aggregate.totalInputTokens).toBe(8000); // 5000 + 1000 + 2000
|
||||
expect(aggregate.totalOutputTokens).toBe(4000); // 2500 + 500 + 1000
|
||||
expect(aggregate.totalCost).toBeCloseTo(0.40, 10); // 0.25 + 0.05 + 0.10
|
||||
expect(aggregate.totalCost).toBeCloseTo(0.4, 10); // 0.25 + 0.05 + 0.10
|
||||
expect(aggregate.activeSessionsCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should persist global stats across instances', () => {
|
||||
const store1 = new StateStore(testFilePath);
|
||||
store1.addToGlobalStats(10000, 5000, 0.50);
|
||||
store1.addToGlobalStats(10000, 5000, 0.5);
|
||||
store1.incrementSessionsCreated();
|
||||
store1.flushAll();
|
||||
|
||||
@@ -410,10 +410,105 @@ describe('StateStore', () => {
|
||||
|
||||
expect(stats.totalInputTokens).toBe(10000);
|
||||
expect(stats.totalOutputTokens).toBe(5000);
|
||||
expect(stats.totalCost).toBe(0.50);
|
||||
expect(stats.totalCost).toBe(0.5);
|
||||
expect(stats.totalSessionsCreated).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assembleStateJson', () => {
|
||||
it('should produce output identical to JSON.stringify(state)', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
// Add sessions, tasks, config changes
|
||||
store.setSession('s1', createMockSessionState('s1'));
|
||||
store.setSession('s2', createMockSessionState('s2'));
|
||||
store.setTask('t1', createMockTaskState('t1'));
|
||||
store.setConfig({ maxConcurrentSessions: 5 });
|
||||
store.addToGlobalStats(1000, 500, 0.05);
|
||||
|
||||
// Force a save so assembleStateJson is called
|
||||
store.saveNow();
|
||||
|
||||
// Read persisted file and compare with full JSON.stringify
|
||||
const persisted = JSON.parse(readFileSync(testFilePath, 'utf-8'));
|
||||
const fullStringify = JSON.parse(JSON.stringify(store.getState()));
|
||||
|
||||
expect(persisted).toEqual(fullStringify);
|
||||
});
|
||||
|
||||
it('should correctly persist all sessions after partial update', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
// Create 10 sessions
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.setSession(`s${i}`, createMockSessionState(`s${i}`));
|
||||
}
|
||||
store.saveNow();
|
||||
|
||||
// Modify only 1 session
|
||||
const updated = createMockSessionState('s3');
|
||||
updated.status = 'idle';
|
||||
updated.pid = 99999;
|
||||
store.setSession('s3', updated);
|
||||
store.saveNow();
|
||||
|
||||
// Read from disk and verify all 10 sessions are present and correct
|
||||
const persisted = JSON.parse(readFileSync(testFilePath, 'utf-8'));
|
||||
expect(Object.keys(persisted.sessions)).toHaveLength(10);
|
||||
|
||||
// The updated session should have the new values
|
||||
expect(persisted.sessions['s3'].status).toBe('idle');
|
||||
expect(persisted.sessions['s3'].pid).toBe(99999);
|
||||
|
||||
// Other sessions should be unchanged
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (i === 3) continue;
|
||||
expect(persisted.sessions[`s${i}`].id).toBe(`s${i}`);
|
||||
expect(persisted.sessions[`s${i}`].status).toBe('running');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle session removal and prune cache correctly', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
store.setSession('s1', createMockSessionState('s1'));
|
||||
store.setSession('s2', createMockSessionState('s2'));
|
||||
store.setSession('s3', createMockSessionState('s3'));
|
||||
store.saveNow();
|
||||
|
||||
// Remove s2
|
||||
store.removeSession('s2');
|
||||
store.saveNow();
|
||||
|
||||
const persisted = JSON.parse(readFileSync(testFilePath, 'utf-8'));
|
||||
expect(Object.keys(persisted.sessions)).toHaveLength(2);
|
||||
expect(persisted.sessions['s1']).toBeDefined();
|
||||
expect(persisted.sessions['s2']).toBeUndefined();
|
||||
expect(persisted.sessions['s3']).toBeDefined();
|
||||
|
||||
// Verify round-trip: state in memory matches what's on disk
|
||||
const fullStringify = JSON.parse(JSON.stringify(store.getState()));
|
||||
expect(persisted).toEqual(fullStringify);
|
||||
});
|
||||
|
||||
it('should persist the last value after rapid updates to the same session', () => {
|
||||
const store = new StateStore(testFilePath);
|
||||
|
||||
// Rapidly update the same session 100 times
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const session = createMockSessionState('rapid');
|
||||
session.pid = i;
|
||||
store.setSession('rapid', session);
|
||||
}
|
||||
|
||||
// Only one save at the end
|
||||
store.saveNow();
|
||||
|
||||
const persisted = JSON.parse(readFileSync(testFilePath, 'utf-8'));
|
||||
expect(persisted.sessions['rapid']).toBeDefined();
|
||||
expect(persisted.sessions['rapid'].pid).toBe(99); // Last value wins
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions to create mock state objects
|
||||
|
||||
@@ -1236,7 +1236,24 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
mockCreateReadStream.mockReturnValue({});
|
||||
|
||||
// createReadStream now used for parent transcript reading (stream tail)
|
||||
// Return a stream-like EventEmitter that emits the transcript content
|
||||
mockCreateReadStream.mockImplementation((filepath: string) => {
|
||||
const stream = new EventEmitter() as EventEmitter & { destroy: () => void };
|
||||
stream.destroy = vi.fn();
|
||||
if (typeof filepath === 'string' && filepath.includes('session1.jsonl')) {
|
||||
// Emit parent transcript content on next tick
|
||||
process.nextTick(() => {
|
||||
stream.emit('data', parentTranscript + '\n');
|
||||
stream.emit('end');
|
||||
});
|
||||
} else {
|
||||
// For agent files, emit end immediately (empty content)
|
||||
process.nextTick(() => stream.emit('end'));
|
||||
}
|
||||
return stream;
|
||||
});
|
||||
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
mockReaddirSync.mockImplementation((path: string) => {
|
||||
@@ -1251,14 +1268,6 @@ describe('SubagentWatcher', () => {
|
||||
mtime: new Date(),
|
||||
size: 100,
|
||||
});
|
||||
// Return parent transcript when reading the session transcript
|
||||
// Return empty for subagent file (will fall back, but we want to test parent extraction)
|
||||
mockReadFileSync.mockImplementation((filepath: string) => {
|
||||
if (filepath.includes('session1.jsonl')) {
|
||||
return parentTranscript;
|
||||
}
|
||||
return ''; // Empty subagent file
|
||||
});
|
||||
|
||||
const discoveredHandler = vi.fn();
|
||||
watcher.on('subagent:discovered', discoveredHandler);
|
||||
@@ -1509,14 +1518,11 @@ describe('SubagentWatcher', () => {
|
||||
});
|
||||
|
||||
describe('File Watcher Management', () => {
|
||||
it('should close file watchers on stop', async () => {
|
||||
const mockFileWatcher = { close: vi.fn(), on: vi.fn(), off: vi.fn() };
|
||||
it('should close directory watchers on stop', async () => {
|
||||
const mockDirWatcher = { close: vi.fn(), on: vi.fn(), off: vi.fn() };
|
||||
|
||||
mockWatch.mockImplementation((path: string) => {
|
||||
if (path.endsWith('.jsonl')) return mockFileWatcher;
|
||||
return mockDirWatcher;
|
||||
});
|
||||
// Only directory watchers are created (no per-file watchers)
|
||||
mockWatch.mockReturnValue(mockDirWatcher);
|
||||
|
||||
const mockRl = new EventEmitter();
|
||||
mockCreateInterface.mockReturnValue(mockRl);
|
||||
@@ -1545,8 +1551,8 @@ describe('SubagentWatcher', () => {
|
||||
|
||||
watcher.stop();
|
||||
|
||||
// Watchers should be closed
|
||||
expect(mockFileWatcher.close).toHaveBeenCalled();
|
||||
// Directory watchers should be closed (per-file watchers no longer exist)
|
||||
expect(mockDirWatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should clear idle timers on stop', async () => {
|
||||
|
||||
Reference in New Issue
Block a user