mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
fix: subagent title extraction race condition
- Add 100ms debounce for new file detection to allow content to be written - Add subagent:updated event for retroactive description updates - Extract description in processEntry when first user message is processed - Add extractDescriptionFromFile helper with retry in file change handler - Update SubagentTranscriptEntry.content type to support string | array - Add test coverage for subagent:updated event Fixes 43% failure rate where subagents displayed raw IDs instead of descriptions due to race condition when files were discovered before first line was written. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md
|
|||||||
|
|
||||||
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
||||||
|
|
||||||
**Version**: 0.1358 (must match `package.json`)
|
**Version**: 0.1359 (must match `package.json`)
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||||
|
|
||||||
@@ -102,10 +102,16 @@ npx vitest run -t "should create session" # By pattern
|
|||||||
| 3120 | session-cleanup.test.ts |
|
| 3120 | session-cleanup.test.ts |
|
||||||
| 3125 | ralph-integration.test.ts |
|
| 3125 | ralph-integration.test.ts |
|
||||||
| 3127 | respawn-integration.test.ts (reserved) |
|
| 3127 | respawn-integration.test.ts (reserved) |
|
||||||
|
| 3130 | hooks-config.test.ts (Hook Event API) |
|
||||||
|
| 3131 | hooks-config.test.ts (Hook Data Sanitization) |
|
||||||
|
| 3150 | browser-e2e.test.ts (main browser tests) |
|
||||||
|
| 3151 | browser-e2e.test.ts (SSE events tests) |
|
||||||
|
| 3152 | browser-e2e.test.ts (hook events tests) |
|
||||||
|
| 3153 | browser-e2e.test.ts (Ralph panel tests) |
|
||||||
|
|
||||||
**Next available port**: 3128
|
**Next available port**: 3154
|
||||||
|
|
||||||
Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, hooks-config, ai-idle-checker, ai-plan-checker
|
Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, ai-idle-checker, ai-plan-checker
|
||||||
|
|
||||||
**Test Utilities**: `test/respawn-test-utils.ts` provides MockSession, MockAiIdleChecker, MockAiPlanChecker, time controller, state tracker, and event recorder for respawn controller testing. See `test/respawn-test-plan.md` for architecture and `test/respawn-scenarios.md` for comprehensive test scenarios.
|
**Test Utilities**: `test/respawn-test-utils.ts` provides MockSession, MockAiIdleChecker, MockAiPlanChecker, time controller, state tracker, and event recorder for respawn controller testing. See `test/respawn-test-plan.md` for architecture and `test/respawn-scenarios.md` for comprehensive test scenarios.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1358",
|
"version": "0.1359",
|
||||||
"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",
|
||||||
|
|||||||
+163
-55
@@ -61,7 +61,7 @@ export interface SubagentTranscriptEntry {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
message?: {
|
message?: {
|
||||||
role: string;
|
role: string;
|
||||||
content: Array<{
|
content: string | Array<{
|
||||||
type: 'text' | 'tool_use' | 'tool_result';
|
type: 'text' | 'tool_use' | 'tool_result';
|
||||||
text?: string;
|
text?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -78,6 +78,7 @@ export interface SubagentTranscriptEntry {
|
|||||||
|
|
||||||
export interface SubagentEvents {
|
export interface SubagentEvents {
|
||||||
'subagent:discovered': (info: SubagentInfo) => void;
|
'subagent:discovered': (info: SubagentInfo) => void;
|
||||||
|
'subagent:updated': (info: SubagentInfo) => void;
|
||||||
'subagent:tool_call': (data: SubagentToolCall) => void;
|
'subagent:tool_call': (data: SubagentToolCall) => void;
|
||||||
'subagent:progress': (data: SubagentProgress) => void;
|
'subagent:progress': (data: SubagentProgress) => void;
|
||||||
'subagent:message': (data: SubagentMessage) => void;
|
'subagent:message': (data: SubagentMessage) => void;
|
||||||
@@ -377,24 +378,41 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
if (entry.type === 'progress' && entry.data) {
|
if (entry.type === 'progress' && entry.data) {
|
||||||
lines.push(this.formatProgress(entry));
|
lines.push(this.formatProgress(entry));
|
||||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||||
for (const content of entry.message.content) {
|
// Handle both string and array content formats
|
||||||
if (content.type === 'tool_use' && content.name) {
|
if (typeof entry.message.content === 'string') {
|
||||||
lines.push(this.formatToolCall(entry.timestamp, content.name, content.input || {}));
|
const text = entry.message.content.trim();
|
||||||
} else if (content.type === 'text' && content.text) {
|
if (text.length > 0) {
|
||||||
const text = content.text.trim();
|
const preview = text.length > 200 ? text.substring(0, 200) + '...' : text;
|
||||||
if (text.length > 0) {
|
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
||||||
const preview = text.length > 200 ? text.substring(0, 200) + '...' : text;
|
}
|
||||||
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
} else {
|
||||||
|
for (const content of entry.message.content) {
|
||||||
|
if (content.type === 'tool_use' && content.name) {
|
||||||
|
lines.push(this.formatToolCall(entry.timestamp, content.name, content.input || {}));
|
||||||
|
} else if (content.type === 'text' && content.text) {
|
||||||
|
const text = content.text.trim();
|
||||||
|
if (text.length > 0) {
|
||||||
|
const preview = text.length > 200 ? text.substring(0, 200) + '...' : text;
|
||||||
|
lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (entry.type === 'user' && entry.message?.content) {
|
} else if (entry.type === 'user' && entry.message?.content) {
|
||||||
const firstContent = entry.message.content[0];
|
// Handle both string and array content formats
|
||||||
if (firstContent?.type === 'text' && firstContent.text) {
|
if (typeof entry.message.content === 'string') {
|
||||||
const text = firstContent.text.trim();
|
const text = entry.message.content.trim();
|
||||||
if (text.length < 100 && !text.includes('{')) {
|
if (text.length < 100 && !text.includes('{')) {
|
||||||
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`);
|
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const firstContent = entry.message.content[0];
|
||||||
|
if (firstContent?.type === 'text' && firstContent.text) {
|
||||||
|
const text = firstContent.text.trim();
|
||||||
|
if (text.length < 100 && !text.includes('{')) {
|
||||||
|
lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,6 +487,41 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
return truncated.replace(/[.!?,:\s]+$/, '');
|
return truncated.replace(/[.!?,:\s]+$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract description from agent file by finding first user message
|
||||||
|
*/
|
||||||
|
private extractDescriptionFromFile(filePath: string): string | undefined {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(filePath, 'utf8');
|
||||||
|
const lines = content.split('\n').filter((l) => l.trim());
|
||||||
|
|
||||||
|
for (const line of lines.slice(0, 5)) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line);
|
||||||
|
if (entry.type === 'user' && entry.message?.content) {
|
||||||
|
let text: string | undefined;
|
||||||
|
if (typeof entry.message.content === 'string') {
|
||||||
|
text = entry.message.content.trim();
|
||||||
|
} else if (Array.isArray(entry.message.content)) {
|
||||||
|
const firstContent = entry.message.content[0];
|
||||||
|
if (firstContent?.type === 'text' && firstContent.text) {
|
||||||
|
text = firstContent.text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
return this.extractSmartTitle(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip malformed lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Failed to read file
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan for all subagent directories
|
* Scan for all subagent directories
|
||||||
*/
|
*/
|
||||||
@@ -530,14 +583,20 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch for new files
|
// Watch for new files with debounce to allow content to be written
|
||||||
try {
|
try {
|
||||||
const watcher = watch(dir, (_eventType, filename) => {
|
const watcher = watch(dir, (_eventType, filename) => {
|
||||||
if (filename?.endsWith('.jsonl')) {
|
if (filename?.endsWith('.jsonl')) {
|
||||||
const filePath = join(dir, filename);
|
const filePath = join(dir, filename);
|
||||||
if (existsSync(filePath)) {
|
// Wait 100ms for file content to be written before processing
|
||||||
this.watchAgentFile(filePath, projectHash, sessionId);
|
// 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);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -637,6 +696,15 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Stat failed
|
// Stat failed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry description extraction if missing (race condition fix)
|
||||||
|
if (!existingInfo.description) {
|
||||||
|
const extractedDescription = this.extractDescriptionFromFile(filePath);
|
||||||
|
if (extractedDescription) {
|
||||||
|
existingInfo.description = extractedDescription;
|
||||||
|
this.emit('subagent:updated', existingInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Reset idle timer
|
// Reset idle timer
|
||||||
this.resetIdleTimer(agentId);
|
this.resetIdleTimer(agentId);
|
||||||
}
|
}
|
||||||
@@ -666,11 +734,12 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
const rl = createInterface({ input: stream });
|
const rl = createInterface({ input: stream });
|
||||||
|
|
||||||
rl.on('line', (line) => {
|
rl.on('line', (line) => {
|
||||||
position += Buffer.byteLength(line, 'utf8') + 1;
|
const lineBytes = Buffer.byteLength(line, 'utf8') + 1;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(line) as SubagentTranscriptEntry;
|
const entry = JSON.parse(line) as SubagentTranscriptEntry;
|
||||||
this.processEntry(entry, agentId, sessionId);
|
this.processEntry(entry, agentId, sessionId);
|
||||||
|
position += lineBytes; // Only advance on successful parse
|
||||||
|
|
||||||
// Update entry count
|
// Update entry count
|
||||||
const info = this.agentInfo.get(agentId);
|
const info = this.agentInfo.get(agentId);
|
||||||
@@ -678,7 +747,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
info.entryCount++;
|
info.entryCount++;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Skip malformed lines
|
// Don't advance position - will retry on next change event
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -696,6 +765,24 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
* Process a transcript entry and emit appropriate events
|
* Process a transcript entry and emit appropriate events
|
||||||
*/
|
*/
|
||||||
private processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
|
private processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void {
|
||||||
|
// Check if this is first user message and description is missing
|
||||||
|
const info = this.agentInfo.get(agentId);
|
||||||
|
if (info && !info.description && entry.type === 'user' && entry.message?.content) {
|
||||||
|
let text: string | undefined;
|
||||||
|
if (typeof entry.message.content === 'string') {
|
||||||
|
text = entry.message.content.trim();
|
||||||
|
} else if (Array.isArray(entry.message.content)) {
|
||||||
|
const firstContent = entry.message.content[0];
|
||||||
|
if (firstContent?.type === 'text' && firstContent.text) {
|
||||||
|
text = firstContent.text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
info.description = this.extractSmartTitle(text);
|
||||||
|
this.emit('subagent:updated', info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (entry.type === 'progress' && entry.data) {
|
if (entry.type === 'progress' && entry.data) {
|
||||||
const progress: SubagentProgress = {
|
const progress: SubagentProgress = {
|
||||||
agentId,
|
agentId,
|
||||||
@@ -707,50 +794,71 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
};
|
};
|
||||||
this.emit('subagent:progress', progress);
|
this.emit('subagent:progress', progress);
|
||||||
} else if (entry.type === 'assistant' && entry.message?.content) {
|
} else if (entry.type === 'assistant' && entry.message?.content) {
|
||||||
for (const content of entry.message.content) {
|
// Handle both string and array content formats
|
||||||
if (content.type === 'tool_use' && content.name) {
|
if (typeof entry.message.content === 'string') {
|
||||||
const toolCall: SubagentToolCall = {
|
const text = entry.message.content.trim();
|
||||||
agentId,
|
if (text.length > 0) {
|
||||||
sessionId,
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
tool: content.name,
|
|
||||||
input: content.input || {},
|
|
||||||
};
|
|
||||||
this.emit('subagent:tool_call', toolCall);
|
|
||||||
|
|
||||||
// Update tool call count
|
|
||||||
const info = this.agentInfo.get(agentId);
|
|
||||||
if (info) {
|
|
||||||
info.toolCallCount++;
|
|
||||||
}
|
|
||||||
} else if (content.type === 'text' && content.text) {
|
|
||||||
const text = content.text.trim();
|
|
||||||
if (text.length > 0) {
|
|
||||||
const message: SubagentMessage = {
|
|
||||||
agentId,
|
|
||||||
sessionId,
|
|
||||||
timestamp: entry.timestamp,
|
|
||||||
role: 'assistant',
|
|
||||||
text: text.substring(0, 500), // Limit text length
|
|
||||||
};
|
|
||||||
this.emit('subagent:message', message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (entry.type === 'user' && entry.message?.content) {
|
|
||||||
const firstContent = entry.message.content[0];
|
|
||||||
if (firstContent?.type === 'text' && firstContent.text) {
|
|
||||||
const text = firstContent.text.trim();
|
|
||||||
if (text.length > 0 && text.length < 500) {
|
|
||||||
const message: SubagentMessage = {
|
const message: SubagentMessage = {
|
||||||
agentId,
|
agentId,
|
||||||
sessionId,
|
sessionId,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
role: 'user',
|
role: 'assistant',
|
||||||
text,
|
text: text.substring(0, 500),
|
||||||
};
|
};
|
||||||
this.emit('subagent:message', message);
|
this.emit('subagent:message', message);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for (const content of entry.message.content) {
|
||||||
|
if (content.type === 'tool_use' && content.name) {
|
||||||
|
const toolCall: SubagentToolCall = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
tool: content.name,
|
||||||
|
input: content.input || {},
|
||||||
|
};
|
||||||
|
this.emit('subagent:tool_call', toolCall);
|
||||||
|
|
||||||
|
// Update tool call count
|
||||||
|
const agentInfo = this.agentInfo.get(agentId);
|
||||||
|
if (agentInfo) {
|
||||||
|
agentInfo.toolCallCount++;
|
||||||
|
}
|
||||||
|
} else if (content.type === 'text' && content.text) {
|
||||||
|
const text = content.text.trim();
|
||||||
|
if (text.length > 0) {
|
||||||
|
const message: SubagentMessage = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
role: 'assistant',
|
||||||
|
text: text.substring(0, 500), // Limit text length
|
||||||
|
};
|
||||||
|
this.emit('subagent:message', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (entry.type === 'user' && entry.message?.content) {
|
||||||
|
// Handle both string and array content formats
|
||||||
|
let userText: string | undefined;
|
||||||
|
if (typeof entry.message.content === 'string') {
|
||||||
|
userText = entry.message.content.trim();
|
||||||
|
} else {
|
||||||
|
const firstContent = entry.message.content[0];
|
||||||
|
if (firstContent?.type === 'text' && firstContent.text) {
|
||||||
|
userText = firstContent.text.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (userText && userText.length > 0 && userText.length < 500) {
|
||||||
|
const message: SubagentMessage = {
|
||||||
|
agentId,
|
||||||
|
sessionId,
|
||||||
|
timestamp: entry.timestamp,
|
||||||
|
role: 'user',
|
||||||
|
text: userText,
|
||||||
|
};
|
||||||
|
this.emit('subagent:message', message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1308,6 +1308,23 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.eventSource.addEventListener('subagent:updated', (e) => {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
const existing = this.subagents.get(data.agentId);
|
||||||
|
if (existing) {
|
||||||
|
// Merge updated fields (especially description)
|
||||||
|
Object.assign(existing, data);
|
||||||
|
this.subagents.set(data.agentId, existing);
|
||||||
|
} else {
|
||||||
|
this.subagents.set(data.agentId, data);
|
||||||
|
}
|
||||||
|
this.renderSubagentPanel();
|
||||||
|
// Update floating window if open
|
||||||
|
if (this.subagentWindows.has(data.agentId)) {
|
||||||
|
this.renderSubagentWindowContent(data.agentId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.eventSource.addEventListener('subagent:tool_call', (e) => {
|
this.eventSource.addEventListener('subagent:tool_call', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
const activity = this.subagentActivity.get(data.agentId) || [];
|
const activity = this.subagentActivity.get(data.agentId) || [];
|
||||||
|
|||||||
@@ -303,6 +303,10 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast('subagent:discovered', info);
|
this.broadcast('subagent:discovered', info);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
subagentWatcher.on('subagent:updated', (info: SubagentInfo) => {
|
||||||
|
this.broadcast('subagent:updated', info);
|
||||||
|
});
|
||||||
|
|
||||||
subagentWatcher.on('subagent:tool_call', (data: SubagentToolCall) => {
|
subagentWatcher.on('subagent:tool_call', (data: SubagentToolCall) => {
|
||||||
this.broadcast('subagent:tool_call', data);
|
this.broadcast('subagent:tool_call', data);
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user