From 84598b6900aea770d78a63fb158509af78da35f8 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Thu, 30 Jul 2026 23:33:45 -0700 Subject: [PATCH] fix(live): repo-root project labels, pending-tool liveness, subagent tailing Three live-dashboard evidence gaps made genuinely working sessions read as idle or land under phantom projects: - resolveProjectLabel now walks up from the record cwd to the nearest .git marker, so a session running from a repository subdirectory (for example tub-vault/scripts) files under its repository instead of a phantom project named after the subdirectory. Linked-worktree pointers keep working, now also from worktree subdirectories, and retained paths that no longer exist keep the basename fallback. Only one basename is ever exposed. - sweepLiveProjection holds a session active while it has a started-but-unfinished resource node (tool/mcp/skill/plugin), bounded by a new pendingExpiryMs (default 30m). Transcripts only append when a tool call finishes, so long executions previously expired the session at 5m right when it looked busiest. Host-agnostic: Claude and Codex adapters both emit tool.started/tool.completed, and structured ruflo/aqe events (the OpenCode path) flow through the same reducer and sweep. - Claude store discovery depth 2 -> 3 reaches //subagents/agent-*.jsonl. Those records carry the parent sessionId plus agentId, so worker activity groups under the parent session and keeps it observable while the main transcript idles during delegation. --- src/lib/live/live-sessions-service.mjs | 6 ++- src/lib/live/project-label.mjs | 27 +++++++++-- src/lib/live/projection.mjs | 19 ++++++-- tests/kit/live-core.test.mjs | 66 ++++++++++++++++++++++++++ tests/kit/live-service.test.mjs | 31 ++++++++++++ 5 files changed, 140 insertions(+), 9 deletions(-) diff --git a/src/lib/live/live-sessions-service.mjs b/src/lib/live/live-sessions-service.mjs index 0212c1c..fca4859 100644 --- a/src/lib/live/live-sessions-service.mjs +++ b/src/lib/live/live-sessions-service.mjs @@ -102,6 +102,7 @@ export class LiveSessionsService { now: options.now ?? (() => new Date().toISOString()), quiescentMs: options.quiescentMs ?? 30_000, expiryMs: options.expiryMs ?? 300_000, + pendingExpiryMs: options.pendingExpiryMs ?? 1_800_000, maxSessions: options.maxSessions ?? 100, maxNodesPerSession: options.maxNodesPerSession ?? 1000, }; @@ -162,6 +163,7 @@ export class LiveSessionsService { now: this.#options.now(), quiescentMs: this.#options.quiescentMs, expiryMs: this.#options.expiryMs, + pendingExpiryMs: this.#options.pendingExpiryMs, }); } @@ -179,8 +181,10 @@ export class LiveSessionsService { const remaining = Math.max(0, this.#options.maxFiles - this.#tailers.size); const claudeLimit = Math.ceil(remaining / 2); const codexLimit = remaining - claudeLimit; + // Depth 3 reaches `//subagents/agent-*.jsonl`; a session + // delegating to workers stays observable while its own transcript is idle. const claude = discoverJsonl(this.#options.roots.claude, { - maxDepth: 2, maxFiles: claudeLimit, accept: () => true, + maxDepth: 3, maxFiles: claudeLimit, accept: () => true, }); const codex = discoverJsonl(this.#options.roots.codex, { maxDepth: 4, maxFiles: codexLimit, accept: (name) => name.startsWith('rollout-'), diff --git a/src/lib/live/project-label.mjs b/src/lib/live/project-label.mjs index e80f8d0..8c7ec3e 100644 --- a/src/lib/live/project-label.mjs +++ b/src/lib/live/project-label.mjs @@ -29,11 +29,28 @@ export function resolveProjectLabel(cwd) { return safeProjectLabel(segments[index - 1]); } } - try { - const pointer = fs.readFileSync(path.join(cwd, '.git'), 'utf8').trim(); - const match = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/i.exec(pointer); - if (match) return safeProjectLabel(match[1]); - } catch { /* normal repositories use a .git directory; retained paths may be gone */ } + // Sessions frequently run from a subdirectory of their repository, so walk + // toward the root for the nearest .git marker: "repo/scripts" belongs to + // "repo", not to a phantom "scripts" project. Only one basename is ever + // exposed, and retained paths that no longer exist keep the cwd fallback. + let current = cwd; + for (let depth = 0; depth < 32; depth++) { + let marker = null; + try { marker = fs.statSync(path.join(current, '.git')).isDirectory() ? 'dir' : 'file'; } + catch { /* not a repository boundary; retained paths may be gone entirely */ } + if (marker === 'file') { + try { + const pointer = fs.readFileSync(path.join(current, '.git'), 'utf8').trim(); + const match = /^gitdir:\s*(.+?)[\\/]\.git[\\/]worktrees[\\/][^\\/]+$/i.exec(pointer); + if (match) return safeProjectLabel(match[1]); + } catch { /* unreadable pointer: the marker directory is still the root */ } + return safeProjectLabel(current); + } + if (marker === 'dir') return safeProjectLabel(current); + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } return safeProjectLabel(cwd); } diff --git a/src/lib/live/projection.mjs b/src/lib/live/projection.mjs index dc610db..a9859d9 100644 --- a/src/lib/live/projection.mjs +++ b/src/lib/live/projection.mjs @@ -151,9 +151,18 @@ function boundSessions(sessions, limit, currentId) { } } +/** A started-but-unfinished resource means work is executing right now. */ +function hasPendingResource(session) { + for (const node of session.nodes.values()) { + if (RESOURCE_KINDS.has(node.kind) && node.lastAction === 'tool.started' + && !TERMINAL.has(node.status)) return true; + } + return false; +} + /** Mark inactivity without claiming that work completed. */ export function sweepLiveProjection(projection, { - now = Date.now(), quiescentMs = 30_000, expiryMs = 300_000, + now = Date.now(), quiescentMs = 30_000, expiryMs = 300_000, pendingExpiryMs = 1_800_000, } = {}) { const current = typeof now === 'number' ? now : Date.parse(now); if (!Number.isFinite(current)) return projection; @@ -162,8 +171,12 @@ export function sweepLiveProjection(projection, { for (const [id, prior] of sessions) { if (TERMINAL.has(prior.status) || prior.lifecycle === 'historical') continue; const age = Math.max(0, current - Date.parse(prior.updatedAt)); - const lifecycle = age >= expiryMs ? 'expired' - : (age >= quiescentMs ? 'quiescent' : 'active'); + // Transcripts only append when a tool call finishes, so a long-running + // tool produces no events while it is the strongest liveness evidence + // available. Hold such sessions active for a bounded pending window. + const lifecycle = age < pendingExpiryMs && hasPendingResource(prior) ? 'active' + : age >= expiryMs ? 'expired' + : (age >= quiescentMs ? 'quiescent' : 'active'); if (prior.lifecycle !== lifecycle) { sessions.set(id, { ...prior, lifecycle }); changed = true; diff --git a/tests/kit/live-core.test.mjs b/tests/kit/live-core.test.mjs index 74f0867..6fb371c 100644 --- a/tests/kit/live-core.test.mjs +++ b/tests/kit/live-core.test.mjs @@ -71,6 +71,28 @@ test('project identity resolves linked and retained worktrees to their owning re assert.equal(resolveProjectLabel('/Development/agentic-kit'), 'agentic-kit'); }); +test('project identity resolves repository subdirectories to the repository root', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-live-project-')); + const repository = path.join(root, 'tub-vault'); + fs.mkdirSync(path.join(repository, '.git'), { recursive: true }); + fs.mkdirSync(path.join(repository, 'scripts', 'lib'), { recursive: true }); + + assert.equal(resolveProjectLabel(repository), 'tub-vault'); + assert.equal(resolveProjectLabel(path.join(repository, 'scripts')), 'tub-vault'); + assert.equal(resolveProjectLabel(path.join(repository, 'scripts', 'lib')), 'tub-vault'); +}); + +test('project identity resolves subdirectories of a linked worktree to the owning repository', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-live-project-')); + const repository = path.join(root, 'agentic-kit'); + const worktree = path.join(root, 'm3-persona-grounding'); + fs.mkdirSync(path.join(worktree, 'scripts'), { recursive: true }); + fs.writeFileSync(path.join(worktree, '.git'), + `gitdir: ${path.join(repository, '.git', 'worktrees', 'm3')}\n`); + + assert.equal(resolveProjectLabel(path.join(worktree, 'scripts')), 'agentic-kit'); +}); + test('replay stream assigns monotonic ids, bounds history and detects stale cursors', () => { const stream = new LiveReplayStream({ capacity: 2, prefix: 'live' }); const one = stream.publish(createLiveEvent(base())); @@ -231,6 +253,50 @@ test('lifecycle sweep marks quiescent and expired but never completed', () => { assert.equal(expired.sessions.get('claude:s1').status, 'running'); }); +test('lifecycle sweep holds sessions with executing tools active until the pending window closes', () => { + const running = { ...createLiveEvent(base()), eventId: 'ak:1' }; + const started = { + ...createLiveEvent(base({ + action: 'tool.started', + target: { id: 'call-1', kind: 'tool', label: 'Write' }, + attributes: { toolCategory: 'Write' }, + })), eventId: 'ak:2', + }; + const active = reduceLiveEvent(reduceLiveEvent(emptyLiveProjection(), running), started); + // Transcripts only append when a tool finishes; a 14-minute execution must + // not demote the session even though expiryMs elapsed with no events. + const midExecution = sweepLiveProjection(active, { + now: '2026-07-27T12:14:00Z', quiescentMs: 10_000, expiryMs: 120_000, + }); + assert.equal(midExecution.sessions.get('claude:s1').lifecycle, 'active'); + const abandoned = sweepLiveProjection(active, { + now: '2026-07-27T13:00:00Z', quiescentMs: 10_000, expiryMs: 120_000, + pendingExpiryMs: 1_800_000, + }); + assert.equal(abandoned.sessions.get('claude:s1').lifecycle, 'expired'); +}); + +test('lifecycle sweep resumes normal demotion once the pending tool completes', () => { + const running = { ...createLiveEvent(base()), eventId: 'ak:1' }; + const started = { + ...createLiveEvent(base({ + action: 'tool.started', target: { id: 'call-1', kind: 'tool' }, + })), eventId: 'ak:2', + }; + const completed = { + ...createLiveEvent(base({ + action: 'tool.completed', status: 'completed', + target: { id: 'call-1', kind: 'tool' }, + })), eventId: 'ak:3', + }; + const projection = [running, started, completed] + .reduce((state, event) => reduceLiveEvent(state, event), emptyLiveProjection()); + const swept = sweepLiveProjection(projection, { + now: '2026-07-27T12:14:00Z', quiescentMs: 10_000, expiryMs: 120_000, + }); + assert.equal(swept.sessions.get('claude:s1').lifecycle, 'expired'); +}); + test('only fresh running execution evidence is active; ledger and unknown evidence are historical', () => { const unknown = { ...createLiveEvent(base({ diff --git a/tests/kit/live-service.test.mjs b/tests/kit/live-service.test.mjs index 5425484..585c513 100644 --- a/tests/kit/live-service.test.mjs +++ b/tests/kit/live-service.test.mjs @@ -56,6 +56,37 @@ test('service bootstraps safe metadata then tails existing files from end', asyn assert.equal(service.snapshot().health.claude.status, 'ok'); }); +test('service discovers nested subagent transcripts and files them under the parent session', async (t) => { + const sb = sandbox(); + const nested = path.join(sb.claude, 'c1', 'subagents'); + fs.mkdirSync(nested, { recursive: true }); + const file = path.join(nested, 'agent-w1.jsonl'); + fs.writeFileSync(file, line({ + type: 'user', sessionId: 'c1', agentId: 'w1', isSidechain: true, + timestamp: '2026-07-27T11:59:00Z', cwd: '/Users/private-user/work/visible-project', + message: { content: 'private worker prompt' }, + })); + const service = new LiveSessionsService({ + roots: sb.roots, intervalMs: 10, readCodexState: () => null, + now: () => '2026-07-27T12:00:00Z', + }); + t.after(() => service.close()); + service.start(); + assert.equal(service.snapshot().sessions.length, 1); + assert.equal(service.snapshot().sessions[0].id, 'c1'); + assert.ok(service.snapshot().sessions[0].nodes.some( + (node) => node.id === 'w1' && node.kind === 'subagent', + )); + fs.appendFileSync(file, line({ + type: 'assistant', sessionId: 'c1', agentId: 'w1', isSidechain: true, + timestamp: '2026-07-27T12:00:01Z', cwd: '/Users/private-user/work/visible-project', + message: { content: [] }, + })); + await waitUntil(() => service.snapshot().sessions[0].lifecycle === 'active', + 'subagent transcript activity never marked the parent session live'); + assert.ok(!JSON.stringify(service.snapshot()).includes('private worker prompt')); +}); + test('native discovery chooses newest files when the tailer budget is bounded', () => { const sb = sandbox(); const old = path.join(sb.claude, 'old.jsonl');