feat(team): enhance Claude logs handling and improve retrieval logic

- Updated `getClaudeLogs` method to support asynchronous fetching of logs.
- Introduced new interfaces for retained logs and transcript cache entries.
- Added logic to retain and retrieve Claude logs even after cleanup of live runs.
- Implemented fallback mechanism to use persisted transcripts when no live run exists.
- Updated tests to cover new log retention and retrieval scenarios.
This commit is contained in:
777genius 2026-04-19 01:38:58 +03:00
parent c4dba278b0
commit 6ff9a28ccc
5 changed files with 439 additions and 41 deletions

View file

@ -1315,7 +1315,7 @@ async function handleGetClaudeLogs(
}
return wrapTeamHandler('getClaudeLogs', async () => {
const data = getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
const data = await getTeamProvisioningService().getClaudeLogs(validated.value!, parsed);
return {
lines: data.lines,
total: data.total,

View file

@ -69,6 +69,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import pidusage from 'pidusage';
import * as readline from 'readline';
import {
type GeminiRuntimeAuthState,
@ -121,6 +122,7 @@ import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
/**
* Kill a team CLI process using SIGKILL (uncatchable).
@ -2402,6 +2404,87 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
}
interface RetainedClaudeLogsSnapshot {
lines: string[];
updatedAt?: string;
}
interface PersistedTranscriptClaudeLogsCacheEntry {
transcriptPath: string;
mtimeMs: number;
size: number;
snapshot: RetainedClaudeLogsSnapshot;
}
function buildRetainedClaudeLogsSnapshot(run: ProvisioningRun): RetainedClaudeLogsSnapshot | null {
if (run.claudeLogLines.length > 0) {
return {
lines: [...run.claudeLogLines],
updatedAt: run.claudeLogsUpdatedAt,
};
}
const fallback = extractCliLogsFromRun(run);
if (!fallback) {
return null;
}
const lines = fallback
.split('\n')
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
.filter((line) => line.length > 0);
if (lines.length === 0) {
return null;
}
return {
lines,
updatedAt: run.claudeLogsUpdatedAt ?? run.progress.updatedAt,
};
}
function sliceClaudeLogs(
linesChronological: string[],
updatedAt: string | undefined,
query?: { offset?: number; limit?: number }
): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } {
const offsetRaw = query?.offset ?? 0;
const limitRaw = query?.limit ?? 100;
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
const limit = Number.isFinite(limitRaw) ? Math.max(1, Math.min(1000, Math.floor(limitRaw))) : 100;
const total = linesChronological.length;
if (total === 0) {
return { lines: [], total: 0, hasMore: false, updatedAt };
}
const newestExclusive = Math.max(0, total - offset);
const oldestInclusive = Math.max(0, newestExclusive - limit);
const normalizeLine = (line: string): string => {
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
if (line.startsWith('[stdout] ') && line !== '[stdout]') {
return line.slice('[stdout] '.length);
}
if (line.startsWith('[stderr] ') && line !== '[stderr]') {
return line.slice('[stderr] '.length);
}
return line;
};
const lines = linesChronological
.slice(oldestInclusive, newestExclusive)
.map(normalizeLine)
.toReversed();
return {
lines,
total,
hasMore: oldestInclusive > 0,
updatedAt,
};
}
/**
* Emit a throttled progress update for the renderer. Payloads are capped to a
* tail window so that the hot emission path (called every LOG_PROGRESS_THROTTLE_MS
@ -2532,6 +2615,11 @@ export class TeamProvisioningService {
private readonly runs = new Map<string, ProvisioningRun>();
private readonly provisioningRunByTeam = new Map<string, string>();
private readonly aliveRunByTeam = new Map<string, string>();
private readonly retainedClaudeLogsByTeam = new Map<string, RetainedClaudeLogsSnapshot>();
private readonly persistedTranscriptClaudeLogsCache = new Map<
string,
PersistedTranscriptClaudeLogsCacheEntry
>();
private readonly teamOpLocks = new Map<string, Promise<void>>();
private readonly leadInboxRelayInFlight = new Map<string, Promise<number>>();
private readonly relayedLeadInboxMessageIds = new Map<string, Set<string>>();
@ -2554,6 +2642,7 @@ export class TeamProvisioningService {
>();
private readonly launchStateStore = new TeamLaunchStateStore();
private readonly memberLogsFinder: TeamMemberLogsFinder;
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
private helpOutputCache: string | null = null;
private helpOutputCacheTime = 0;
@ -2589,6 +2678,7 @@ export class TeamProvisioningService {
this.inboxReader,
this.membersMetaStore
);
this.transcriptProjectResolver = new TeamTranscriptProjectResolver(this.configReader);
}
setCrossTeamSender(
@ -2613,52 +2703,28 @@ export class TeamProvisioningService {
this.controlApiBaseUrlResolver = resolver;
}
getClaudeLogs(
async getClaudeLogs(
teamName: string,
query?: { offset?: number; limit?: number }
): { lines: string[]; total: number; hasMore: boolean; updatedAt?: string } {
): Promise<{ lines: string[]; total: number; hasMore: boolean; updatedAt?: string }> {
const runId = this.getTrackedRunId(teamName);
if (!runId) {
return { lines: [], total: 0, hasMore: false };
}
const run = this.runs.get(runId);
if (!run) {
return { lines: [], total: 0, hasMore: false };
if (runId) {
const run = this.runs.get(runId);
if (run) {
return sliceClaudeLogs(run.claudeLogLines, run.claudeLogsUpdatedAt, query);
}
}
const offsetRaw = query?.offset ?? 0;
const limitRaw = query?.limit ?? 100;
const offset = Number.isFinite(offsetRaw) ? Math.max(0, Math.floor(offsetRaw)) : 0;
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
: 100;
const total = run.claudeLogLines.length;
if (total === 0) {
return { lines: [], total: 0, hasMore: false, updatedAt: run.claudeLogsUpdatedAt };
const retained = this.retainedClaudeLogsByTeam.get(teamName);
if (!retained) {
const transcriptSnapshot = await this.getPersistedTranscriptClaudeLogs(teamName);
if (!transcriptSnapshot) {
return { lines: [], total: 0, hasMore: false };
}
return sliceClaudeLogs(transcriptSnapshot.lines, transcriptSnapshot.updatedAt, query);
}
const newestExclusive = Math.max(0, total - offset);
const oldestInclusive = Math.max(0, newestExclusive - limit);
const normalizeLine = (line: string): string => {
// Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
if (line.startsWith('[stdout] ') && line !== '[stdout]')
return line.slice('[stdout] '.length);
if (line.startsWith('[stderr] ') && line !== '[stderr]')
return line.slice('[stderr] '.length);
return line;
};
const lines = run.claudeLogLines
.slice(oldestInclusive, newestExclusive)
.map(normalizeLine)
.toReversed();
return {
lines,
total,
hasMore: oldestInclusive > 0,
updatedAt: run.claudeLogsUpdatedAt,
};
return sliceClaudeLogs(retained.lines, retained.updatedAt, query);
}
private getProvisioningRunId(teamName: string): string | null {
@ -2673,6 +2739,85 @@ export class TeamProvisioningService {
return this.getProvisioningRunId(teamName) ?? this.getAliveRunId(teamName);
}
private async getPersistedTranscriptClaudeLogs(
teamName: string
): Promise<RetainedClaudeLogsSnapshot | null> {
const context = await this.transcriptProjectResolver.getContext(teamName);
const leadSessionId =
typeof context?.config.leadSessionId === 'string' ? context.config.leadSessionId.trim() : '';
if (!context || leadSessionId.length === 0) {
this.persistedTranscriptClaudeLogsCache.delete(teamName);
return null;
}
const transcriptPath = path.join(context.projectDir, `${leadSessionId}.jsonl`);
let stat: fs.Stats;
try {
stat = await fs.promises.stat(transcriptPath);
} catch {
this.persistedTranscriptClaudeLogsCache.delete(teamName);
return null;
}
if (!stat.isFile()) {
this.persistedTranscriptClaudeLogsCache.delete(teamName);
return null;
}
const cached = this.persistedTranscriptClaudeLogsCache.get(teamName);
if (
cached &&
cached.transcriptPath === transcriptPath &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size
) {
return cached.snapshot;
}
const lines = await this.readTranscriptClaudeLogLines(transcriptPath);
if (lines.length === 0) {
this.persistedTranscriptClaudeLogsCache.delete(teamName);
return null;
}
const snapshot = {
lines,
updatedAt: stat.mtime.toISOString(),
};
this.persistedTranscriptClaudeLogsCache.set(teamName, {
transcriptPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
snapshot,
});
return snapshot;
}
private async readTranscriptClaudeLogLines(filePath: string): Promise<string[]> {
const lines: string[] = [];
const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
try {
for await (const rawLine of rl) {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (!line.trim()) {
continue;
}
lines.push(line);
if (lines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) {
lines.splice(0, lines.length - TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT);
}
}
} finally {
rl.close();
stream.close();
}
return lines;
}
private clearSameTeamRetryTimers(teamName: string): void {
for (const suffix of ['deferred', 'persist']) {
const key = `same-team-${suffix}:${teamName}`;
@ -2686,6 +2831,8 @@ export class TeamProvisioningService {
private resetTeamScopedTransientStateForNewRun(teamName: string): void {
peekAutoResumeService()?.cancelPendingAutoResume(teamName);
this.retainedClaudeLogsByTeam.delete(teamName);
this.persistedTranscriptClaudeLogsCache.delete(teamName);
this.leadInboxRelayInFlight.delete(teamName);
this.relayedLeadInboxMessageIds.delete(teamName);
this.pendingCrossTeamFirstReplies.delete(teamName);
@ -11481,6 +11628,7 @@ export class TeamProvisioningService {
private cleanupRun(run: ProvisioningRun): void {
const currentTrackedRunId = this.getTrackedRunId(run.teamName);
const hasNewerTrackedRun = currentTrackedRunId !== null && currentTrackedRunId !== run.runId;
const retainedClaudeLogs = hasNewerTrackedRun ? null : buildRetainedClaudeLogsSnapshot(run);
if (!hasNewerTrackedRun) {
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
@ -11573,6 +11721,13 @@ export class TeamProvisioningService {
void removeDeterministicBootstrapUserPromptFile(run.bootstrapUserPromptPath);
run.bootstrapUserPromptPath = null;
}
if (!hasNewerTrackedRun) {
if (retainedClaudeLogs) {
this.retainedClaudeLogsByTeam.set(run.teamName, retainedClaudeLogs);
} else {
this.retainedClaudeLogsByTeam.delete(run.teamName);
}
}
// Remove from runs Map to free memory (stdoutBuffer, stderrBuffer, claudeLogLines)
this.runs.delete(run.runId);
}

View file

@ -192,7 +192,7 @@ const AIExecutionGroup = ({
>
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
Claude
Agent
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-muted)]">
{enhanced.itemsSummary}

View file

@ -73,6 +73,7 @@ import { createPersistedLaunchSnapshot } from '@main/services/team/TeamLaunchSta
import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore';
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
import { spawnCli } from '@main/utils/childProcess';
import { encodePath } from '@main/utils/pathDecoder';
import { AGENT_TEAMS_NAMESPACED_TEAMMATE_OPERATIONAL_TOOL_NAMES } from 'agent-teams-controller';
import { listTmuxPanePidsForCurrentPlatform } from '@features/tmux-installer/main';
import pidusage from 'pidusage';
@ -234,6 +235,40 @@ function createMemberSpawnRun(params?: {
} as any;
}
function createClaudeLogsRun(overrides: Record<string, unknown> = {}) {
return {
runId: 'run-logs-1',
teamName: 'logs-team',
startedAt: '2026-04-19T10:00:00.000Z',
isLaunch: false,
provisioningComplete: true,
processKilled: false,
cancelRequested: false,
timeoutHandle: null,
fsMonitorHandle: null,
stallCheckHandle: null,
silentUserDmForwardClearHandle: null,
child: null,
leadActivityState: 'idle',
activeToolCalls: new Map(),
pendingDirectCrossTeamSendRefresh: false,
memberSpawnStatuses: new Map(),
activeCrossTeamReplyHints: [],
pendingInboxRelayCandidates: [],
pendingApprovals: new Map(),
mcpConfigPath: null,
bootstrapSpecPath: null,
bootstrapUserPromptPath: null,
claudeLogLines: ['[stdout]', 'first line', '[stderr]', 'boom'],
claudeLogsUpdatedAt: '2026-04-19T10:00:01.000Z',
progress: {
updatedAt: '2026-04-19T10:00:01.000Z',
state: 'ready',
},
...overrides,
} as any;
}
describe('TeamProvisioningService', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -283,6 +318,80 @@ describe('TeamProvisioningService', () => {
});
});
describe('getClaudeLogs', () => {
it('retains the last logs after cleanupRun removes the live run', async () => {
const svc = new TeamProvisioningService();
const run = createClaudeLogsRun();
(svc as any).runs.set(run.runId, run);
(svc as any).aliveRunByTeam.set(run.teamName, run.runId);
await expect(svc.getClaudeLogs(run.teamName)).resolves.toEqual({
lines: ['boom', '[stderr]', 'first line', '[stdout]'],
total: 4,
hasMore: false,
updatedAt: '2026-04-19T10:00:01.000Z',
});
(svc as any).cleanupRun(run);
await expect(svc.getClaudeLogs(run.teamName)).resolves.toEqual({
lines: ['boom', '[stderr]', 'first line', '[stdout]'],
total: 4,
hasMore: false,
updatedAt: '2026-04-19T10:00:01.000Z',
});
});
it('falls back to the persisted lead transcript when no live run exists', async () => {
const svc = new TeamProvisioningService();
const teamName = 'offline-logs-team';
const projectPath = '/tmp/offline-logs-project';
const leadSessionId = 'lead-session-1';
const projectDir = path.join(tempProjectsBase, encodePath(projectPath));
writeLaunchConfig(teamName, projectPath, leadSessionId, []);
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, `${leadSessionId}.jsonl`),
[
'{"type":"user","message":{"role":"user","content":"first"}}',
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"second"}]}}',
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"third"}]}}',
].join('\n') + '\n',
'utf8'
);
await expect(svc.getClaudeLogs(teamName)).resolves.toEqual({
lines: [
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"third"}]}}',
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"second"}]}}',
'{"type":"user","message":{"role":"user","content":"first"}}',
],
total: 3,
hasMore: false,
updatedAt: expect.any(String),
});
});
it('clears retained logs when a new run starts for the same team', async () => {
const svc = new TeamProvisioningService();
(svc as any).retainedClaudeLogsByTeam.set('logs-team', {
lines: ['[stdout]', 'stale line'],
updatedAt: '2026-04-19T10:00:01.000Z',
});
(svc as any).resetTeamScopedTransientStateForNewRun('logs-team');
await expect(svc.getClaudeLogs('logs-team')).resolves.toEqual({
lines: [],
total: 0,
hasMore: false,
});
});
});
describe('getTeamAgentRuntimeSnapshot', () => {
it('uses batched pidusage rss values for lead and teammates', async () => {
const svc = new TeamProvisioningService();

View file

@ -0,0 +1,134 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ClaudeLogsController } from '@renderer/components/team/useClaudeLogsController';
const cliLogsRichViewState = vi.hoisted(() => ({
calls: [] as Array<Record<string, unknown>>,
}));
vi.mock('@renderer/components/team/CliLogsRichView', () => ({
CliLogsRichView: (props: Record<string, unknown>) => {
cliLogsRichViewState.calls.push(props);
return React.createElement(
'div',
{ 'data-testid': 'cli-logs-rich-view' },
String(props.cliLogsTail ?? '')
);
},
}));
vi.mock('@renderer/components/team/ClaudeLogsFilterPopover', () => ({
ClaudeLogsFilterPopover: () => React.createElement('div', { 'data-testid': 'logs-filter' }),
}));
vi.mock('@renderer/components/ui/button', () => ({
Button: ({
children,
onClick,
disabled,
}: {
children: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
}) => React.createElement('button', { type: 'button', onClick, disabled }, children),
}));
import { ClaudeLogsPanel } from '@renderer/components/team/ClaudeLogsPanel';
function createController(overrides: Partial<ClaudeLogsController> = {}): ClaudeLogsController {
return {
data: { lines: [], total: 0, hasMore: false },
loading: false,
loadingMore: false,
error: null,
pendingNewCount: 0,
isAlive: false,
filteredText: '',
online: false,
badge: undefined,
showMoreVisible: false,
lastLogPreview: null,
searchQuery: '',
setSearchQuery: vi.fn(),
filter: { streams: new Set(), kinds: new Set() } as ClaudeLogsController['filter'],
setFilter: vi.fn(),
filterOpen: false,
setFilterOpen: vi.fn(),
viewerState: {} as ClaudeLogsController['viewerState'],
onViewerStateChange: vi.fn(),
applyPending: vi.fn(async () => {}),
loadOlderLogs: vi.fn(async () => {}),
containerRefCallback: vi.fn(),
handleScroll: vi.fn(),
...overrides,
};
}
describe('ClaudeLogsPanel', () => {
afterEach(() => {
document.body.innerHTML = '';
cliLogsRichViewState.calls = [];
vi.unstubAllGlobals();
});
it('renders logs even when the team is offline if log lines are available', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const ctrl = createController({
isAlive: false,
data: {
lines: ['second line', 'first line'],
total: 2,
hasMore: false,
updatedAt: '2026-04-19T10:00:01.000Z',
},
filteredText: '[stdout]\nfirst line\nsecond line',
badge: 2,
});
await act(async () => {
root.render(React.createElement(ClaudeLogsPanel, { ctrl }));
await Promise.resolve();
});
expect(host.textContent).toContain('2 lines');
expect(host.textContent).toContain('first line');
expect(host.textContent).not.toContain('Team is not running.');
expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull();
expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe('[stdout]\nfirst line\nsecond line');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('shows the offline empty state only when no logs exist', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const ctrl = createController({
isAlive: false,
data: { lines: [], total: 0, hasMore: false },
filteredText: '',
});
await act(async () => {
root.render(React.createElement(ClaudeLogsPanel, { ctrl }));
await Promise.resolve();
});
expect(host.textContent).toContain('Team is not running.');
expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});