Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/lib/live/live-sessions-service.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -162,6 +163,7 @@ export class LiveSessionsService {
now: this.#options.now(),
quiescentMs: this.#options.quiescentMs,
expiryMs: this.#options.expiryMs,
pendingExpiryMs: this.#options.pendingExpiryMs,
});
}

Expand All @@ -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 `<project>/<session>/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-'),
Expand Down
27 changes: 22 additions & 5 deletions src/lib/live/project-label.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
19 changes: 16 additions & 3 deletions src/lib/live/projection.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions tests/kit/live-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()));
Expand Down Expand Up @@ -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({
Expand Down
31 changes: 31 additions & 0 deletions tests/kit/live-service.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading