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:
Codeman maintainer
2026-06-15 01:43:15 +02:00
parent db93491dd1
commit f6a30d7335
2 changed files with 131 additions and 16 deletions
+59
View File
@@ -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', () => {
it('should start agents as active', async () => {
const mockRl = createMockRl();