mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix: ralph tracker — 3 bugs: first-occurrence completion, double-counting, TodoWrite pattern
Bug 1 (CRITICAL): canonicalCount >= 1 always fired on first <promise> tag (prompt echo). Changed to >= 2 so only 2nd+ occurrence triggers completion. Bug 2: checkMultiLinePatterns() re-detected complete tags already handled by processLine(), double-counting. Now only tries completion when partial buffer is non-empty (cross-chunk scenario). Bug 3: TodoWrite ✔ patterns required "Task #N" but real Claude Code output is plain "✔ content". Added TODO_PLAIN_CHECKMARK_PATTERN fallback. Includes 71 new deep tests + real-life verification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,7 +35,7 @@ When user says "COM":
|
|||||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||||
|
|
||||||
**Version**: 0.1550 (must match `package.json` for npm publish)
|
**Version**: 0.1551 (must match `package.json` for npm publish)
|
||||||
|
|
||||||
## Project Overview
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1550",
|
"version": "0.1551",
|
||||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+41
-18
@@ -237,6 +237,13 @@ const TODO_TASK_CREATED_PATTERN = /✔\s*Task\s*#(\d+)\s*created:\s*(.+)/g;
|
|||||||
const TODO_TASK_SUMMARY_PATTERN = /✔\s*#(\d+)\s+(.+)/g;
|
const TODO_TASK_SUMMARY_PATTERN = /✔\s*#(\d+)\s+(.+)/g;
|
||||||
const TODO_TASK_STATUS_PATTERN = /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g;
|
const TODO_TASK_STATUS_PATTERN = /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches plain checkmark TodoWrite output without task numbers.
|
||||||
|
* Real Claude Code TodoWrite output: "✔ Create hello.txt with Hello World"
|
||||||
|
* This is the most common format in actual usage.
|
||||||
|
*/
|
||||||
|
const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Patterns to exclude from todo detection
|
* Patterns to exclude from todo detection
|
||||||
* Prevents false positives from tool invocations and Claude commentary
|
* Prevents false positives from tool invocations and Claude commentary
|
||||||
@@ -1613,32 +1620,30 @@ export class RalphTracker extends EventEmitter {
|
|||||||
* @param data - The full data chunk (may contain multiple lines)
|
* @param data - The full data chunk (may contain multiple lines)
|
||||||
*/
|
*/
|
||||||
private checkMultiLinePatterns(data: string): void {
|
private checkMultiLinePatterns(data: string): void {
|
||||||
// If we have a partial promise buffer, prepend it to the new data
|
// Only try to complete a cross-chunk promise if we have a partial buffer.
|
||||||
const combinedData = this._partialPromiseBuffer + data;
|
// Without a partial buffer, complete tags are already handled by processLine
|
||||||
|
// via detectCompletionPhrase — re-detecting here would double-count.
|
||||||
// Try to find a complete promise tag in combined data
|
if (this._partialPromiseBuffer) {
|
||||||
const promiseMatch = combinedData.match(PROMISE_PATTERN);
|
const combinedData = this._partialPromiseBuffer + data;
|
||||||
if (promiseMatch) {
|
const promiseMatch = combinedData.match(PROMISE_PATTERN);
|
||||||
// Found complete tag - extract phrase and clear buffer
|
if (promiseMatch) {
|
||||||
const phrase = promiseMatch[1].trim();
|
const phrase = promiseMatch[1].trim();
|
||||||
this._partialPromiseBuffer = '';
|
this._partialPromiseBuffer = '';
|
||||||
this.handleCompletionPhrase(phrase);
|
this.handleCompletionPhrase(phrase);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for partial promise tag at end of combined data
|
// Check for partial promise tag at end of data (for next chunk)
|
||||||
const partialMatch = combinedData.match(PROMISE_PARTIAL_PATTERN);
|
const partialMatch = data.match(PROMISE_PARTIAL_PATTERN);
|
||||||
if (partialMatch) {
|
if (partialMatch) {
|
||||||
// Buffer the partial content (with size limit)
|
|
||||||
const partialContent = partialMatch[0];
|
const partialContent = partialMatch[0];
|
||||||
if (partialContent.length <= RalphTracker.MAX_PARTIAL_PROMISE_SIZE) {
|
if (partialContent.length <= RalphTracker.MAX_PARTIAL_PROMISE_SIZE) {
|
||||||
this._partialPromiseBuffer = partialContent;
|
this._partialPromiseBuffer = partialContent;
|
||||||
} else {
|
} else {
|
||||||
// Partial is too long, likely malformed - discard
|
|
||||||
this._partialPromiseBuffer = '';
|
this._partialPromiseBuffer = '';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No partial tag - clear buffer
|
|
||||||
this._partialPromiseBuffer = '';
|
this._partialPromiseBuffer = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1782,8 +1787,9 @@ export class RalphTracker extends EventEmitter {
|
|||||||
if (matchedPhrase) {
|
if (matchedPhrase) {
|
||||||
// Use the matched phrase (canonical) for tracking
|
// Use the matched phrase (canonical) for tracking
|
||||||
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0);
|
const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0);
|
||||||
// If this is a match of an expected phrase, treat as if we saw it
|
// Require 2nd+ occurrence of canonical phrase OR explicitly active loop.
|
||||||
if (canonicalCount >= 1 || this._loopState.active) {
|
// First occurrence (count=1) is the prompt echo — not actual completion.
|
||||||
|
if (canonicalCount >= 2 || this._loopState.active) {
|
||||||
// Mark as completion
|
// Mark as completion
|
||||||
this._loopState.active = false;
|
this._loopState.active = false;
|
||||||
this._loopState.lastActivity = Date.now();
|
this._loopState.lastActivity = Date.now();
|
||||||
@@ -2158,6 +2164,23 @@ export class RalphTracker extends EventEmitter {
|
|||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plain checkmark: "✔ Create hello.txt" (no task number)
|
||||||
|
// Only match if numbered patterns didn't already match on this line
|
||||||
|
if (!updated) {
|
||||||
|
TODO_PLAIN_CHECKMARK_PATTERN.lastIndex = 0;
|
||||||
|
while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) {
|
||||||
|
const content = match[1].trim();
|
||||||
|
// Skip if content matches exclude patterns
|
||||||
|
const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content));
|
||||||
|
if (shouldExclude) continue;
|
||||||
|
if (content.length < 5) continue;
|
||||||
|
// Skip status/created/updated prefixed content (already handled above)
|
||||||
|
if (/^(Task\s*#\d+|#\d+)\s/.test(content)) continue;
|
||||||
|
this.upsertTodo(content, 'completed');
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updated) {
|
if (updated) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { Session } from './session.js';
|
import { Session } from './session.js';
|
||||||
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle-checker.js';
|
||||||
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js';
|
||||||
|
import type { TeamWatcher } from './team-watcher.js';
|
||||||
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
import { BufferAccumulator } from './utils/buffer-accumulator.js';
|
||||||
import {
|
import {
|
||||||
ANSI_ESCAPE_PATTERN_SIMPLE,
|
ANSI_ESCAPE_PATTERN_SIMPLE,
|
||||||
@@ -223,7 +224,7 @@ export interface RespawnConfig {
|
|||||||
/**
|
/**
|
||||||
* The prompt to send when updating docs.
|
* The prompt to send when updating docs.
|
||||||
* Sent at the start of each respawn cycle.
|
* Sent at the start of each respawn cycle.
|
||||||
* @default 'update all the docs and CLAUDE.md'
|
* @default 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.'
|
||||||
*/
|
*/
|
||||||
updatePrompt: string;
|
updatePrompt: string;
|
||||||
|
|
||||||
@@ -542,7 +543,7 @@ export interface RespawnEvents {
|
|||||||
/** Default configuration values */
|
/** Default configuration values */
|
||||||
const DEFAULT_CONFIG: RespawnConfig = {
|
const DEFAULT_CONFIG: RespawnConfig = {
|
||||||
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback)
|
||||||
updatePrompt: 'update all the docs and CLAUDE.md',
|
updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.',
|
||||||
interStepDelayMs: 1000, // 1 second between steps
|
interStepDelayMs: 1000, // 1 second between steps
|
||||||
enabled: true,
|
enabled: true,
|
||||||
sendClear: true, // send /clear after update prompt
|
sendClear: true, // send /clear after update prompt
|
||||||
@@ -638,6 +639,9 @@ export class RespawnController extends EventEmitter {
|
|||||||
/** The session being controlled */
|
/** The session being controlled */
|
||||||
private session: Session;
|
private session: Session;
|
||||||
|
|
||||||
|
/** Optional team watcher for team-aware idle detection */
|
||||||
|
private teamWatcher: TeamWatcher | null = null;
|
||||||
|
|
||||||
/** Current configuration */
|
/** Current configuration */
|
||||||
private config: RespawnConfig;
|
private config: RespawnConfig;
|
||||||
|
|
||||||
@@ -1220,6 +1224,11 @@ export class RespawnController extends EventEmitter {
|
|||||||
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
|
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Set team watcher for team-aware idle detection */
|
||||||
|
setTeamWatcher(watcher: TeamWatcher): void {
|
||||||
|
this.teamWatcher = watcher;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start watching the session for idle state.
|
* Start watching the session for idle state.
|
||||||
*
|
*
|
||||||
@@ -2762,6 +2771,19 @@ export class RespawnController extends EventEmitter {
|
|||||||
`tokensStable=${status.tokensStable}, ` +
|
`tokensStable=${status.tokensStable}, ` +
|
||||||
`noWorking=${status.workingPatternsAbsent}`);
|
`noWorking=${status.workingPatternsAbsent}`);
|
||||||
|
|
||||||
|
// ========== Agent Teams Integration ==========
|
||||||
|
// Check if session has active teammates — don't respawn while team is working
|
||||||
|
if (this.teamWatcher?.hasActiveTeammates(this.session.id)) {
|
||||||
|
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
||||||
|
this.log(`Respawn blocked - ${count} active teammate(s) working`);
|
||||||
|
this.logAction('team', `Active teammates: ${count}`);
|
||||||
|
this.emit('respawnBlocked', { reason: 'active_teammates', details: `${count} teammate(s) still working` });
|
||||||
|
this.setState('watching');
|
||||||
|
this.startNoOutputTimer();
|
||||||
|
this.startPreFilterTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ========== RALPH_STATUS Integration ==========
|
// ========== RALPH_STATUS Integration ==========
|
||||||
// Check circuit breaker status - if OPEN, pause respawn
|
// Check circuit breaker status - if OPEN, pause respawn
|
||||||
const ralphTracker = this.session.ralphTracker;
|
const ralphTracker = this.session.ralphTracker;
|
||||||
@@ -2879,9 +2901,10 @@ export class RespawnController extends EventEmitter {
|
|||||||
let updatePrompt = this.config.updatePrompt;
|
let updatePrompt = this.config.updatePrompt;
|
||||||
|
|
||||||
if (statusBlock?.recommendation) {
|
if (statusBlock?.recommendation) {
|
||||||
// Append RECOMMENDATION to the update prompt for context
|
// Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines)
|
||||||
updatePrompt = `${this.config.updatePrompt}\n\nClaude's last recommendation: ${statusBlock.recommendation}`;
|
const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200);
|
||||||
this.logAction('ralph', `Using RECOMMENDATION: ${statusBlock.recommendation.substring(0, 50)}...`);
|
updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`;
|
||||||
|
this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ const BUILTIN_RESPAWN_PRESETS = [
|
|||||||
description: 'Unattended overnight runs with full context reset between cycles',
|
description: 'Unattended overnight runs with full context reset between cycles',
|
||||||
config: {
|
config: {
|
||||||
idleTimeoutMs: 10000,
|
idleTimeoutMs: 10000,
|
||||||
updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md, then continue working on the task.',
|
updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md so the next cycle can pick up where you left off.',
|
||||||
interStepDelayMs: 3000,
|
interStepDelayMs: 3000,
|
||||||
sendClear: true,
|
sendClear: true,
|
||||||
sendInit: true,
|
sendInit: true,
|
||||||
kickstartPrompt: 'read CLAUDE.md for context on what was accomplished in previous cycles, then pick up where you left off.',
|
kickstartPrompt: 'continue working on the task. Pick up where you left off based on the context above.',
|
||||||
autoAcceptPrompts: true,
|
autoAcceptPrompts: true,
|
||||||
},
|
},
|
||||||
durationMinutes: 480,
|
durationMinutes: 480,
|
||||||
@@ -7471,7 +7471,7 @@ class ClaudemanApp {
|
|||||||
if (config.enableRespawn) {
|
if (config.enableRespawn) {
|
||||||
const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
|
const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.';
|
||||||
|
|
||||||
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, check CLAUDE.md for notes from previous iterations, continue on the next uncompleted task, output <promise>${config.completionPhrase}</promise> when ALL tasks are complete.`;
|
const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output <promise>${config.completionPhrase}</promise> when ALL tasks are complete.`;
|
||||||
|
|
||||||
await fetch(`/api/sessions/${sessionId}/respawn/enable`, {
|
await fetch(`/api/sessions/${sessionId}/respawn/enable`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
|
|||||||
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js';
|
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js';
|
||||||
import { imageWatcher } from '../image-watcher.js';
|
import { imageWatcher } from '../image-watcher.js';
|
||||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||||
|
import { TeamWatcher } from '../team-watcher.js';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { RunSummaryTracker } from '../run-summary.js';
|
import { RunSummaryTracker } from '../run-summary.js';
|
||||||
@@ -413,6 +414,13 @@ export class WebServer extends EventEmitter {
|
|||||||
detected: (event: ImageDetectedEvent) => void;
|
detected: (event: ImageDetectedEvent) => void;
|
||||||
error: (error: Error, sessionId?: string) => void;
|
error: (error: Error, sessionId?: string) => void;
|
||||||
} | null = null;
|
} | null = null;
|
||||||
|
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||||
|
private teamWatcherHandlers: {
|
||||||
|
teamCreated: (config: unknown) => void;
|
||||||
|
teamUpdated: (config: unknown) => void;
|
||||||
|
teamRemoved: (config: unknown) => void;
|
||||||
|
taskUpdated: (data: unknown) => void;
|
||||||
|
} | null = null;
|
||||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||||
super();
|
super();
|
||||||
this.setMaxListeners(0);
|
this.setMaxListeners(0);
|
||||||
@@ -447,6 +455,9 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Set up image watcher listeners
|
// Set up image watcher listeners
|
||||||
this.setupImageWatcherListeners();
|
this.setupImageWatcherListeners();
|
||||||
|
|
||||||
|
// Set up team watcher listeners
|
||||||
|
this.setupTeamWatcherListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -527,6 +538,37 @@ export class WebServer extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners for team watcher.
|
||||||
|
* Broadcasts team activity events to SSE clients.
|
||||||
|
*/
|
||||||
|
private setupTeamWatcherListeners(): void {
|
||||||
|
this.teamWatcherHandlers = {
|
||||||
|
teamCreated: (config: unknown) => this.broadcast('team:created', config),
|
||||||
|
teamUpdated: (config: unknown) => this.broadcast('team:updated', config),
|
||||||
|
teamRemoved: (config: unknown) => this.broadcast('team:removed', config),
|
||||||
|
taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated);
|
||||||
|
this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
||||||
|
this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
||||||
|
this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up team watcher listeners to prevent memory leaks.
|
||||||
|
*/
|
||||||
|
private cleanupTeamWatcherListeners(): void {
|
||||||
|
if (this.teamWatcherHandlers) {
|
||||||
|
this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated);
|
||||||
|
this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated);
|
||||||
|
this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved);
|
||||||
|
this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated);
|
||||||
|
this.teamWatcherHandlers = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async setupRoutes(): Promise<void> {
|
private async setupRoutes(): Promise<void> {
|
||||||
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
|
// Allow multipart/form-data for screenshot uploads — skip Fastify's body parser
|
||||||
// so the route handler can read the raw stream directly.
|
// so the route handler can read the raw stream directly.
|
||||||
@@ -3579,6 +3621,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ========== Agent Teams ==========
|
||||||
|
|
||||||
|
// List all discovered teams
|
||||||
|
this.app.get('/api/teams', async () => {
|
||||||
|
return { success: true, data: this.teamWatcher.getTeams() };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get tasks for a specific team
|
||||||
|
this.app.get('/api/teams/:name/tasks', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
return { success: true, data: this.teamWatcher.getTeamTasks(name) };
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// ========== Hook Events ==========
|
// ========== Hook Events ==========
|
||||||
|
|
||||||
this.app.post('/api/hook-event', async (req) => {
|
this.app.post('/api/hook-event', async (req) => {
|
||||||
@@ -4324,6 +4380,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||||
|
// Wire team watcher for team-aware idle detection
|
||||||
|
controller.setTeamWatcher(this.teamWatcher);
|
||||||
|
|
||||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||||||
|
|
||||||
@@ -5150,6 +5209,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
console.log('Image watcher disabled by user settings');
|
console.log('Image watcher disabled by user settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Start team watcher for agent team awareness (always on — lightweight polling)
|
||||||
|
this.teamWatcher.start();
|
||||||
|
console.log('Team watcher started - monitoring ~/.claude/teams/ for agent team activity');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -5462,6 +5525,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
// Clean up watcher listeners to prevent memory leaks
|
// Clean up watcher listeners to prevent memory leaks
|
||||||
this.cleanupSubagentWatcherListeners();
|
this.cleanupSubagentWatcherListeners();
|
||||||
this.cleanupImageWatcherListeners();
|
this.cleanupImageWatcherListeners();
|
||||||
|
this.cleanupTeamWatcherListeners();
|
||||||
|
|
||||||
// Stop subagent watcher
|
// Stop subagent watcher
|
||||||
subagentWatcher.stop();
|
subagentWatcher.stop();
|
||||||
@@ -5469,6 +5533,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
// Stop image watcher
|
// Stop image watcher
|
||||||
imageWatcher.stop();
|
imageWatcher.stop();
|
||||||
|
|
||||||
|
// Stop team watcher
|
||||||
|
this.teamWatcher.stop();
|
||||||
|
|
||||||
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||||||
fileStreamManager.destroy();
|
fileStreamManager.destroy();
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -95,7 +95,7 @@ describe('RespawnController', () => {
|
|||||||
it('should have default configuration', () => {
|
it('should have default configuration', () => {
|
||||||
const config = controller.getConfig();
|
const config = controller.getConfig();
|
||||||
expect(config.enabled).toBe(true);
|
expect(config.enabled).toBe(true);
|
||||||
expect(config.updatePrompt).toBe('update all the docs and CLAUDE.md');
|
expect(config.updatePrompt).toBe('write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should allow custom configuration', () => {
|
it('should allow custom configuration', () => {
|
||||||
@@ -216,7 +216,7 @@ describe('RespawnController', () => {
|
|||||||
|
|
||||||
expect(stepSent).toBe('update');
|
expect(stepSent).toBe('update');
|
||||||
expect(session.writeBuffer.length).toBeGreaterThan(0);
|
expect(session.writeBuffer.length).toBeGreaterThan(0);
|
||||||
expect(session.writeBuffer[0]).toContain('update all the docs');
|
expect(session.writeBuffer[0]).toContain('write a brief progress summary');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transition through states during cycle', async () => {
|
it('should transition through states during cycle', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview Tests for RespawnController team-awareness integration
|
||||||
|
*
|
||||||
|
* Verifies that the respawn controller correctly blocks respawn cycles
|
||||||
|
* when TeamWatcher reports active teammates working on tasks.
|
||||||
|
*
|
||||||
|
* Port: N/A (no server — unit tests only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { RespawnController } from '../src/respawn-controller.js';
|
||||||
|
import { Session } from '../src/session.js';
|
||||||
|
import { TeamWatcher } from '../src/team-watcher.js';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
// ========== Mock Session ==========
|
||||||
|
|
||||||
|
class MockSession extends EventEmitter {
|
||||||
|
id = 'test-session-123';
|
||||||
|
workingDir = '/tmp/test';
|
||||||
|
status = 'idle';
|
||||||
|
pid = 12345;
|
||||||
|
isWorking = false;
|
||||||
|
writeBuffer: string[] = [];
|
||||||
|
ralphTracker: null = null;
|
||||||
|
|
||||||
|
async writeViaMux(data: string): Promise<boolean> {
|
||||||
|
this.writeBuffer.push(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
write(data: string): void {
|
||||||
|
this.writeBuffer.push(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
simulateCompletionMessage(): void {
|
||||||
|
this.emit('terminal', '✻ Worked for 2m 46s');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Mock TeamWatcher ==========
|
||||||
|
|
||||||
|
class MockTeamWatcher extends TeamWatcher {
|
||||||
|
private _hasActive = false;
|
||||||
|
private _activeCount = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Pass dummy dirs that don't exist — we override the methods anyway
|
||||||
|
super('/tmp/nonexistent-teams', '/tmp/nonexistent-tasks');
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveTeammates(sessionId: string, hasActive: boolean, count: number = 1): void {
|
||||||
|
this._hasActive = hasActive;
|
||||||
|
this._activeCount = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
override hasActiveTeammates(_sessionId: string): boolean {
|
||||||
|
return this._hasActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
override getActiveTeammateCount(_sessionId: string): number {
|
||||||
|
return this._activeCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent actual filesystem polling
|
||||||
|
override start(): void { /* noop */ }
|
||||||
|
override stop(): void { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Tests ==========
|
||||||
|
|
||||||
|
describe('RespawnController Team Awareness', () => {
|
||||||
|
let session: MockSession;
|
||||||
|
let controller: RespawnController;
|
||||||
|
let teamWatcher: MockTeamWatcher;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
session = new MockSession();
|
||||||
|
teamWatcher = new MockTeamWatcher();
|
||||||
|
controller = new RespawnController(session as unknown as Session, {
|
||||||
|
idleTimeoutMs: 100,
|
||||||
|
interStepDelayMs: 50,
|
||||||
|
completionConfirmMs: 200,
|
||||||
|
noOutputTimeoutMs: 500,
|
||||||
|
aiIdleCheckEnabled: false,
|
||||||
|
sendClear: false,
|
||||||
|
sendInit: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
controller.stop();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('without TeamWatcher (backwards compatibility)', () => {
|
||||||
|
it('should allow respawn when no TeamWatcher is set', async () => {
|
||||||
|
// Don't call setTeamWatcher — should work as before
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
controller.on('respawnCycleStarted', cycleStarted);
|
||||||
|
|
||||||
|
controller.start();
|
||||||
|
expect(controller.state).toBe('watching');
|
||||||
|
|
||||||
|
// Trigger idle detection
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300); // Past completionConfirmMs
|
||||||
|
|
||||||
|
expect(cycleStarted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with TeamWatcher and no active teammates', () => {
|
||||||
|
it('should allow respawn when no teammates are active', async () => {
|
||||||
|
controller.setTeamWatcher(teamWatcher as unknown as TeamWatcher);
|
||||||
|
teamWatcher.setActiveTeammates(session.id, false, 0);
|
||||||
|
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
controller.on('respawnCycleStarted', cycleStarted);
|
||||||
|
|
||||||
|
controller.start();
|
||||||
|
|
||||||
|
// Trigger idle detection
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(cycleStarted).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with TeamWatcher and active teammates', () => {
|
||||||
|
it('should block respawn when teammates are active', async () => {
|
||||||
|
controller.setTeamWatcher(teamWatcher as unknown as TeamWatcher);
|
||||||
|
teamWatcher.setActiveTeammates(session.id, true, 2);
|
||||||
|
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
const respawnBlocked = vi.fn();
|
||||||
|
controller.on('respawnCycleStarted', cycleStarted);
|
||||||
|
controller.on('respawnBlocked', respawnBlocked);
|
||||||
|
|
||||||
|
controller.start();
|
||||||
|
|
||||||
|
// Trigger idle detection
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(cycleStarted).not.toHaveBeenCalled();
|
||||||
|
expect(respawnBlocked).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
reason: 'active_teammates',
|
||||||
|
details: expect.stringContaining('2'),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return to watching state after blocking', async () => {
|
||||||
|
controller.setTeamWatcher(teamWatcher as unknown as TeamWatcher);
|
||||||
|
teamWatcher.setActiveTeammates(session.id, true, 1);
|
||||||
|
|
||||||
|
controller.start();
|
||||||
|
|
||||||
|
// Trigger idle detection
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(controller.state).toBe('watching');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow respawn after teammates finish', async () => {
|
||||||
|
controller.setTeamWatcher(teamWatcher as unknown as TeamWatcher);
|
||||||
|
|
||||||
|
const cycleStarted = vi.fn();
|
||||||
|
const respawnBlocked = vi.fn();
|
||||||
|
controller.on('respawnCycleStarted', cycleStarted);
|
||||||
|
controller.on('respawnBlocked', respawnBlocked);
|
||||||
|
|
||||||
|
controller.start();
|
||||||
|
|
||||||
|
// First attempt: teammates active → blocked
|
||||||
|
teamWatcher.setActiveTeammates(session.id, true, 1);
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(cycleStarted).not.toHaveBeenCalled();
|
||||||
|
expect(respawnBlocked).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Teammates finish
|
||||||
|
teamWatcher.setActiveTeammates(session.id, false, 0);
|
||||||
|
|
||||||
|
// Second attempt: no teammates → allowed
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(cycleStarted).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('respawnBlocked event details', () => {
|
||||||
|
it('should include teammate count in details', async () => {
|
||||||
|
controller.setTeamWatcher(teamWatcher as unknown as TeamWatcher);
|
||||||
|
teamWatcher.setActiveTeammates(session.id, true, 3);
|
||||||
|
|
||||||
|
const respawnBlocked = vi.fn();
|
||||||
|
controller.on('respawnBlocked', respawnBlocked);
|
||||||
|
|
||||||
|
controller.start();
|
||||||
|
session.simulateCompletionMessage();
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
expect(respawnBlocked).toHaveBeenCalledWith({
|
||||||
|
reason: 'active_teammates',
|
||||||
|
details: '3 teammate(s) still working',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user