mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
fix(subagent-watcher): discover workflow-nested agents + harden meta→transcript upgrade
Two follow-ups to db93491 (the 2026-06 CC meta.json format change), after
reverse-engineering the new on-disk layout with a live current-CC subagent +
1Hz fs poller:
(1) Workflow recursion — the Workflow tool nests its agents at
subagents/workflows/{wf}/agent-{id}.jsonl, one level below the flat
subagents/ scan, so they were never tracked. Add watchWorkflowDirs()
(driven from scanForSubagents) to descend and watch each workflow dir
(idempotent; fs.watch recursive is unsupported on Linux, so the ~5s
periodic scan re-drives it — same latency as new-session discovery).
Require the `agent-` prefix in the flat readdir + watch callback so a
workflow dir's sibling journal.jsonl can't register a bogus "journal" agent.
E2E verified against real ~/.claude/projects: 32 workflow-nested agents
discovered (wf_fa35c1d8-4a9), 0 bogus journal agents.
(2) Transcript timing — empirically the per-agent .jsonl IS written at the
standard subagents/ path and grows incrementally (tailable); the
/tmp/.../tasks/<id>.output the prior probe found is just a symlink back to
it. meta.json lands at spawn, the .jsonl a beat later. Add a meta→transcript
upgrade in registerAgentFile: when an agent registered meta-only gets its
sibling .jsonl, re-point filePath, drop the stale sidecar context, start
tailing, and emit subagent:updated (not a duplicate discovered). Corrects the
now-inaccurate "no transcript to tail" doc comment on registerAgentMeta.
Tests: 2 new cases (workflow-nested discovery; journal.jsonl not registered).
All 56 pass; tsc/lint/format clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+72
-16
@@ -13,6 +13,8 @@
|
|||||||
* - `SubagentEvents` — typed event map
|
* - `SubagentEvents` — typed event map
|
||||||
*
|
*
|
||||||
* Watched patterns: `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl`
|
* Watched patterns: `~/.claude/projects/{project}/{session}/subagents/agent-{id}.jsonl`
|
||||||
|
* plus the `agent-{id}.meta.json` discovery sidecar (2026-06 format) and nested
|
||||||
|
* workflow agents under `subagents/workflows/{workflowId}/agent-{id}.jsonl`.
|
||||||
* Parses JSONL entries: user/assistant messages, tool_use/tool_result blocks, progress events.
|
* Parses JSONL entries: user/assistant messages, tool_use/tool_result blocks, progress events.
|
||||||
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
* Tracks per-agent: status, token counts, model, description, tool call count, liveness (PID).
|
||||||
*
|
*
|
||||||
@@ -1150,6 +1152,10 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await statAsync(subagentDir);
|
await statAsync(subagentDir);
|
||||||
await this.watchSubagentDir(subagentDir, project, session);
|
await this.watchSubagentDir(subagentDir, project, session);
|
||||||
|
// Workflow agents nest one level deeper under subagents/workflows/{wf}/
|
||||||
|
// (each holds its own agent-{id}.jsonl/.meta.json) — the flat watcher
|
||||||
|
// above never sees them, so discover and watch each workflow dir too.
|
||||||
|
await this.watchWorkflowDirs(subagentDir, project, session);
|
||||||
} catch {
|
} catch {
|
||||||
// subagent dir doesn't exist - skip
|
// subagent dir doesn't exist - skip
|
||||||
}
|
}
|
||||||
@@ -1179,9 +1185,9 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
const files = await readdir(dir);
|
const files = await readdir(dir);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.endsWith('.jsonl')) {
|
if (file.startsWith('agent-') && file.endsWith('.jsonl')) {
|
||||||
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
|
||||||
} else if (file.endsWith('.meta.json')) {
|
} else if (file.startsWith('agent-') && file.endsWith('.meta.json')) {
|
||||||
// Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI
|
// Claude Code (2026-06) writes a `agent-{id}.meta.json` sidecar for TUI
|
||||||
// Task subagents and no longer always writes a per-agent `.jsonl` here.
|
// Task subagents and no longer always writes a per-agent `.jsonl` here.
|
||||||
await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true);
|
await this.registerAgentMeta(join(dir, file), projectHash, sessionId, true);
|
||||||
@@ -1194,8 +1200,11 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Single directory watcher handles both new files and file content changes
|
// Single directory watcher handles both new files and file content changes
|
||||||
try {
|
try {
|
||||||
const watcher = watch(dir, (_eventType, filename) => {
|
const watcher = watch(dir, (_eventType, filename) => {
|
||||||
const isJsonl = filename?.endsWith('.jsonl');
|
// Only agent-{id}.jsonl / agent-{id}.meta.json — ignore siblings like a
|
||||||
const isMeta = filename?.endsWith('.meta.json');
|
// workflow dir's journal.jsonl (would otherwise register a bogus "journal" agent).
|
||||||
|
const isAgent = !!filename && filename.startsWith('agent-');
|
||||||
|
const isJsonl = isAgent && filename.endsWith('.jsonl');
|
||||||
|
const isMeta = isAgent && filename.endsWith('.meta.json');
|
||||||
if (!isJsonl && !isMeta) return;
|
if (!isJsonl && !isMeta) return;
|
||||||
const filePath = join(dir, filename as string);
|
const filePath = join(dir, filename as string);
|
||||||
|
|
||||||
@@ -1239,6 +1248,40 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover and watch nested workflow agent directories.
|
||||||
|
*
|
||||||
|
* The Workflow tool runs its subagents under
|
||||||
|
* `subagents/workflows/{workflowId}/agent-{id}.jsonl` (+ `.meta.json`, alongside
|
||||||
|
* a `journal.jsonl` of orchestration events). The flat `subagents/` watcher does
|
||||||
|
* not recurse, and Node's `fs.watch({ recursive: true })` is unsupported on Linux,
|
||||||
|
* so each workflow dir gets its own watcher here. Idempotent via `knownSubagentDirs`
|
||||||
|
* and re-driven by the periodic scan, so newly created workflows are picked up
|
||||||
|
* within one scan cycle (~5s) — the same latency as a new session's `subagents/`.
|
||||||
|
*/
|
||||||
|
private async watchWorkflowDirs(subagentDir: string, projectHash: string, sessionId: string): Promise<void> {
|
||||||
|
const workflowsRoot = join(subagentDir, 'workflows');
|
||||||
|
let names: string[];
|
||||||
|
try {
|
||||||
|
names = await readdir(workflowsRoot);
|
||||||
|
} catch {
|
||||||
|
return; // no workflows for this session
|
||||||
|
}
|
||||||
|
for (const name of names) {
|
||||||
|
// Workflow ids are directories (e.g. `wf_<id>`); skip any stray files that
|
||||||
|
// share the root (a workflow id never carries a file extension).
|
||||||
|
if (name.endsWith('.jsonl') || name.endsWith('.json')) continue;
|
||||||
|
const wfDir = join(workflowsRoot, name);
|
||||||
|
try {
|
||||||
|
const st = await statAsync(wfDir);
|
||||||
|
if (!st.isDirectory()) continue;
|
||||||
|
await this.watchSubagentDir(wfDir, projectHash, sessionId);
|
||||||
|
} catch {
|
||||||
|
// workflow dir vanished mid-scan — skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle a file content change for an already-registered agent file.
|
* Handle a file content change for an already-registered agent file.
|
||||||
* Tails from last known position, updates info, retries description if missing.
|
* Tails from last known position, updates info, retries description if missing.
|
||||||
@@ -1305,6 +1348,16 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
|
|
||||||
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
|
||||||
|
|
||||||
|
// Meta→transcript upgrade: the agent may already be registered from its
|
||||||
|
// `.meta.json` sidecar (discovery-only — nothing to tail). Now that the real
|
||||||
|
// `.jsonl` transcript has appeared, re-point to it and drop the stale sidecar
|
||||||
|
// context, emitting `updated` below rather than a duplicate `discovered`.
|
||||||
|
const priorEntry = this.agentInfo.get(agentId);
|
||||||
|
const isMetaUpgrade = !!priorEntry && priorEntry.filePath.endsWith('.meta.json');
|
||||||
|
if (isMetaUpgrade && priorEntry) {
|
||||||
|
this.fileAgentContext.delete(priorEntry.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
// Initial info - handle race condition where file may be deleted between discovery and stat
|
// Initial info - handle race condition where file may be deleted between discovery and stat
|
||||||
let fileStat;
|
let fileStat;
|
||||||
try {
|
try {
|
||||||
@@ -1355,7 +1408,7 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
// Track file context for directory watcher change handling
|
// Track file context for directory watcher change handling
|
||||||
this.fileAgentContext.set(filePath, { projectHash, sessionId });
|
this.fileAgentContext.set(filePath, { projectHash, sessionId });
|
||||||
this.agentInfo.set(agentId, info);
|
this.agentInfo.set(agentId, info);
|
||||||
this.emit('subagent:discovered', info);
|
this.emit(isMetaUpgrade ? 'subagent:updated' : 'subagent:discovered', info);
|
||||||
|
|
||||||
// Read existing content
|
// Read existing content
|
||||||
this.tailFile(filePath, agentId, sessionId, 0)
|
this.tailFile(filePath, agentId, sessionId, 0)
|
||||||
@@ -1373,18 +1426,21 @@ export class SubagentWatcher extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Register a subagent discovered via its `agent-{id}.meta.json` sidecar.
|
* Register a subagent discovered via its `agent-{id}.meta.json` sidecar.
|
||||||
*
|
*
|
||||||
* As of the 2026-06 Claude Code format change, TUI Task subagents write a
|
* As of the 2026-06 Claude Code format change, TUI Task subagents write the
|
||||||
* `agent-{id}.meta.json` (`{ agentType, description, toolUseId }`) into the
|
* `agent-{id}.meta.json` sidecar (`{ agentType, description, toolUseId }`) at
|
||||||
* session's `subagents/` dir and no longer reliably write a per-agent
|
* spawn, a beat *before* the `agent-{id}.jsonl` transcript appears in the same
|
||||||
* `agent-{id}.jsonl` transcript there. The legacy `.jsonl`-only discovery
|
* dir. The legacy `.jsonl`-only discovery saw nothing in that window ("0
|
||||||
* therefore saw nothing ("0 tracked"); this surfaces the agent from the
|
* tracked"); this surfaces the agent from the sidecar immediately. The
|
||||||
* sidecar so it is tracked again. If a real `.jsonl` transcript exists
|
* transcript then lands within ~1s at the standard path and grows incrementally
|
||||||
* alongside (older agents / workflow agents), defer to registerAgentFile —
|
* (empirically verified — it is fully tailable, NOT a dead end), so:
|
||||||
* it's richer (live tool-call activity).
|
* - if the `.jsonl` already exists, defer to registerAgentFile (richer); else
|
||||||
|
* - register meta-only now, and when the sibling `.jsonl` arrives the dir
|
||||||
|
* watcher routes it to registerAgentFile, which detects the prior meta-only
|
||||||
|
* entry and *upgrades* it in place (re-points filePath, starts tailing).
|
||||||
*
|
*
|
||||||
* Limitation: a meta-only agent has no transcript to tail, so there is no
|
* Edge case: if the transcript never materializes (e.g. an agent that dies
|
||||||
* live tool-call feed and no completion signal in the sidecar — status is
|
* before writing one), the agent stays meta-only — no live feed, status ages
|
||||||
* left 'active' and ages out via the normal idle timer / stale cleanup.
|
* out via the idle timer / stale cleanup.
|
||||||
*/
|
*/
|
||||||
private async registerAgentMeta(
|
private async registerAgentMeta(
|
||||||
metaPath: string,
|
metaPath: string,
|
||||||
|
|||||||
@@ -475,6 +475,65 @@ describe('SubagentWatcher', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Workflow-nested subagents (subagents/workflows/{wf}/)', () => {
|
||||||
|
// The Workflow tool nests its agents one level deeper than TUI Task subagents:
|
||||||
|
// subagents/workflows/{workflowId}/agent-{id}.jsonl — plus a sibling journal.jsonl.
|
||||||
|
// The flat subagents/ scan misses them; watchWorkflowDirs() descends one level.
|
||||||
|
function mockWorkflowLayout(wfFiles: string[]) {
|
||||||
|
mockReaddirSync.mockImplementation((path: string) => {
|
||||||
|
const p = String(path);
|
||||||
|
if (p.endsWith('wf_test')) return wfFiles;
|
||||||
|
if (p.endsWith('workflows')) return ['wf_test'];
|
||||||
|
if (p.endsWith('subagents')) return ['workflows']; // no flat agents, just the workflows dir
|
||||||
|
if (p.includes('session1')) return ['subagents'];
|
||||||
|
if (p.includes('project1')) return ['session1'];
|
||||||
|
return ['project1'];
|
||||||
|
});
|
||||||
|
mockStatSync.mockReturnValue({
|
||||||
|
isDirectory: () => true,
|
||||||
|
birthtime: new Date(),
|
||||||
|
mtime: new Date(),
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
const mockRl = createMockRl();
|
||||||
|
mockCreateInterface.mockReturnValue(mockRl);
|
||||||
|
return mockRl;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should discover an agent nested under subagents/workflows/{wf}/', async () => {
|
||||||
|
mockExistsSync.mockReturnValue(true); // sibling .jsonl exists for the meta
|
||||||
|
const mockRl = mockWorkflowLayout(['agent-wf1.jsonl', 'agent-wf1.meta.json', 'journal.jsonl']);
|
||||||
|
|
||||||
|
const discoveredHandler = vi.fn();
|
||||||
|
watcher.on('subagent:discovered', discoveredHandler);
|
||||||
|
|
||||||
|
watcher.start();
|
||||||
|
await flushAsyncScan();
|
||||||
|
mockRl.emit('close');
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
expect(discoveredHandler).toHaveBeenCalled();
|
||||||
|
const info = discoveredHandler.mock.calls[0][0] as SubagentInfo;
|
||||||
|
expect(info.agentId).toBe('wf1');
|
||||||
|
expect(info.filePath.endsWith('.jsonl')).toBe(true);
|
||||||
|
expect(info.filePath).toContain('workflows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should NOT register a workflow dir journal.jsonl as a bogus agent', async () => {
|
||||||
|
mockExistsSync.mockReturnValue(true);
|
||||||
|
const mockRl = mockWorkflowLayout(['agent-wf1.jsonl', 'journal.jsonl']);
|
||||||
|
|
||||||
|
watcher.start();
|
||||||
|
await flushAsyncScan();
|
||||||
|
mockRl.emit('close');
|
||||||
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
|
||||||
|
const agents = watcher.getSubagents();
|
||||||
|
expect(agents).toHaveLength(1); // only agent-wf1, never "journal"
|
||||||
|
expect(agents.every((a) => a.agentId !== 'journal')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Status Lifecycle', () => {
|
describe('Status Lifecycle', () => {
|
||||||
it('should start agents as active', async () => {
|
it('should start agents as active', async () => {
|
||||||
const mockRl = createMockRl();
|
const mockRl = createMockRl();
|
||||||
|
|||||||
Reference in New Issue
Block a user