feat(team): harden member runtime liveness

This commit is contained in:
777genius 2026-04-24 16:18:12 +03:00
parent 267a192329
commit d517f2b320
34 changed files with 4089 additions and 294 deletions

File diff suppressed because it is too large Load diff

View file

@ -279,7 +279,13 @@ function drawLaunchStage(
for (let index = 0; index < 3; index += 1) { for (let index = 0; index < 3; index += 1) {
const angle = time * 1.2 + (Math.PI * 2 * index) / 3; const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
ctx.beginPath(); ctx.beginPath();
ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2); ctx.arc(
x + Math.cos(angle) * dotOrbit,
y + Math.sin(angle) * dotOrbit,
1.7,
0,
Math.PI * 2
);
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72); ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
ctx.fill(); ctx.fill();
} }
@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
return hexWithAlpha('#f59e0b', 0.92); return hexWithAlpha('#f59e0b', 0.92);
case 'runtime_pending': case 'runtime_pending':
return hexWithAlpha('#67e8f9', 0.9); return hexWithAlpha('#67e8f9', 0.9);
case 'shell_only':
case 'runtime_candidate':
return hexWithAlpha('#f97316', 0.9);
case 'registered_only':
return hexWithAlpha('#a1a1aa', 0.82);
case 'stale_runtime':
return hexWithAlpha('#ef4444', 0.82);
case 'settling': case 'settling':
return hexWithAlpha('#22c55e', 0.9); return hexWithAlpha('#22c55e', 0.9);
case 'error': case 'error':

View file

@ -22,6 +22,10 @@ export type GraphLaunchVisualState =
| 'spawning' | 'spawning'
| 'permission_pending' | 'permission_pending'
| 'runtime_pending' | 'runtime_pending'
| 'shell_only'
| 'runtime_candidate'
| 'registered_only'
| 'stale_runtime'
| 'settling' | 'settling'
| 'error'; | 'error';

View file

@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim()) ? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
: defaultRuntimeCacheRoot; : defaultRuntimeCacheRoot;
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path'); const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
const runtimeDisplayName = 'teams orchestrator';
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
function shouldUseWindowsShell(cmd) { function shouldUseWindowsShell(cmd) {
@ -108,9 +109,10 @@ function getPlatformAssetKey() {
} }
function getReleaseAssetUrl(runtimeLock, asset) { function getReleaseAssetUrl(runtimeLock, asset) {
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0 const releaseTag =
? runtimeLock.releaseTag.trim() typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
: runtimeLock.sourceRef; ? runtimeLock.releaseTag.trim()
: runtimeLock.sourceRef;
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`; return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
} }
@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
function buildProgressBar(progressRatio, width) { function buildProgressBar(progressRatio, width) {
const safeWidth = Math.max(10, width); const safeWidth = Math.max(10, width);
const clampedRatio = Number.isFinite(progressRatio) const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
? Math.min(1, Math.max(0, progressRatio))
: 0;
const filledWidth = Math.round(safeWidth * clampedRatio); const filledWidth = Math.round(safeWidth * clampedRatio);
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`; return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
} }
@ -164,7 +164,8 @@ function supportsProgressRedraw() {
} }
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) { function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100; const columns =
process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
const ratio = hasTotal ? writtenBytes / totalBytes : 0; const ratio = hasTotal ? writtenBytes / totalBytes : 0;
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : ''; const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
const bytesText = hasTotal const bytesText = hasTotal
@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
return runAndCapture(binaryPath, ['--version']); return runAndCapture(binaryPath, ['--version']);
} }
function formatRuntimeVersionForDisplay(versionText) {
const trimmed = versionText.trim();
if (!trimmed) {
return runtimeDisplayName;
}
const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, '');
return `${versionOnly} (${runtimeDisplayName})`;
}
function isExecutable(filePath) { function isExecutable(filePath) {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
return false; return false;
@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
readline.clearLine(process.stdout, 0); readline.clearLine(process.stdout, 0);
readline.cursorTo(process.stdout, 0); readline.cursorTo(process.stdout, 0);
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`); process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
} else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) { } else if (
(hasTotal && lastLoggedPercent < 100) ||
(!hasTotal && writtenBytes !== lastLoggedBytes)
) {
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`); process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
} }
} }
@ -511,7 +525,9 @@ async function main() {
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) { if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`); process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
} }
process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`); process.stdout.write(
`Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n`
);
const uiEnv = { const uiEnv = {
...process.env, ...process.env,

View file

@ -1,5 +1,9 @@
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor'; import {
TmuxPlatformCommandExecutor,
type RuntimeProcessTableRow,
type TmuxPaneRuntimeInfo,
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
const runtimeStatusSource = new TmuxStatusSourceAdapter(); const runtimeStatusSource = new TmuxStatusSourceAdapter();
const runtimeCommandExecutor = new TmuxPlatformCommandExecutor(); const runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform(
return runtimeCommandExecutor.listPanePids(paneIds); return runtimeCommandExecutor.listPanePids(paneIds);
} }
export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
paneIds: readonly string[]
): Promise<Map<string, TmuxPaneRuntimeInfo>> {
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
}
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
RuntimeProcessTableRow[]
> {
return runtimeCommandExecutor.listRuntimeProcesses();
}
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
runtimeCommandExecutor.killPaneSync(paneId); runtimeCommandExecutor.killPaneSync(paneId);
invalidateTmuxRuntimeStatusCache(); invalidateTmuxRuntimeStatusCache();

View file

@ -9,5 +9,11 @@ export {
isTmuxRuntimeReadyForCurrentPlatform, isTmuxRuntimeReadyForCurrentPlatform,
killTmuxPaneForCurrentPlatform, killTmuxPaneForCurrentPlatform,
killTmuxPaneForCurrentPlatformSync, killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform, listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
} from './composition/runtimeSupport'; } from './composition/runtimeSupport';
export type {
RuntimeProcessTableRow,
TmuxPaneRuntimeInfo,
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';

View file

@ -12,6 +12,43 @@ interface ExecResult {
stderr: string; stderr: string;
} }
export interface TmuxPaneRuntimeInfo {
paneId: string;
panePid: number;
currentCommand?: string;
currentPath?: string;
sessionName?: string;
windowName?: string;
}
export interface RuntimeProcessTableRow {
pid: number;
ppid: number;
command: string;
}
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
const rows: RuntimeProcessTableRow[] = [];
for (const line of output.split('\n')) {
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
if (!match) continue;
const pid = Number.parseInt(match[1], 10);
const ppid = Number.parseInt(match[2], 10);
const command = match[3]?.trim() ?? '';
if (
Number.isFinite(pid) &&
pid > 0 &&
Number.isFinite(ppid) &&
ppid >= 0 &&
command.length > 0
) {
rows.push({ pid, ppid, command });
}
}
return rows;
}
export class TmuxPlatformCommandExecutor { export class TmuxPlatformCommandExecutor {
readonly #wslService: TmuxWslService; readonly #wslService: TmuxWslService;
readonly #packageManagerResolver: TmuxPackageManagerResolver; readonly #packageManagerResolver: TmuxPackageManagerResolver;
@ -54,34 +91,70 @@ export class TmuxPlatformCommandExecutor {
} }
} }
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> { async listPaneRuntimeInfo(paneIds: readonly string[]): Promise<Map<string, TmuxPaneRuntimeInfo>> {
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))]; const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
if (normalizedPaneIds.length === 0) { if (normalizedPaneIds.length === 0) {
return new Map(); return new Map();
} }
const result = await this.execTmux( const format = [
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], '#{pane_id}',
3_000 '#{pane_pid}',
); '#{pane_current_command}',
'#{pane_current_path}',
'#{session_name}',
'#{window_name}',
].join('\t');
const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000);
if (result.exitCode !== 0) { if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list tmux panes'); throw new Error(result.stderr || 'Failed to list tmux panes');
} }
const wanted = new Set(normalizedPaneIds); const wanted = new Set(normalizedPaneIds);
const panePidById = new Map<string, number>(); const paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
for (const line of result.stdout.split('\n')) { for (const line of result.stdout.split('\n')) {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) continue; if (!trimmed) continue;
const [paneId = '', rawPid = ''] = trimmed.split('\t'); const [
paneId = '',
rawPid = '',
currentCommand = '',
currentPath = '',
sessionName = '',
windowName = '',
] = trimmed.split('\t');
const normalizedPaneId = paneId.trim(); const normalizedPaneId = paneId.trim();
if (!wanted.has(normalizedPaneId)) continue; if (!wanted.has(normalizedPaneId)) continue;
const pid = Number.parseInt(rawPid.trim(), 10); const pid = Number.parseInt(rawPid.trim(), 10);
if (Number.isFinite(pid) && pid > 0) { if (Number.isFinite(pid) && pid > 0) {
panePidById.set(normalizedPaneId, pid); paneInfoById.set(normalizedPaneId, {
paneId: normalizedPaneId,
panePid: pid,
currentCommand: currentCommand.trim() || undefined,
currentPath: currentPath.trim() || undefined,
sessionName: sessionName.trim() || undefined,
windowName: windowName.trim() || undefined,
});
} }
} }
return panePidById; return paneInfoById;
}
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
const info = await this.listPaneRuntimeInfo(paneIds);
return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid]));
}
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
const result =
process.platform === 'win32'
? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command='])
: await this.#execNativePs();
if (result.exitCode !== 0) {
throw new Error(result.stderr || 'Failed to list runtime processes');
}
return parseRuntimeProcessTable(result.stdout);
} }
killPaneSync(paneId: string): void { killPaneSync(paneId: string): void {
@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor {
return [...candidates]; return [...candidates];
} }
async #execNativePs(): Promise<ExecResult> {
await resolveInteractiveShellEnv();
const env = buildEnrichedEnv();
return new Promise((resolve) => {
execFile(
'ps',
['-ax', '-o', 'pid=,ppid=,command='],
{ env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
(error, stdout, stderr) => {
const errorCode =
typeof error === 'object' && error !== null && 'code' in error
? (error as NodeJS.ErrnoException).code
: undefined;
resolve({
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
stdout: String(stdout),
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
});
}
);
});
}
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> { async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
const platform = const platform =
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32' process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'

View file

@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => {
); );
vi.spyOn(executor, 'execTmux').mockResolvedValue({ vi.spyOn(executor, 'execTmux').mockResolvedValue({
exitCode: 0, exitCode: 0,
stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n', stdout:
'%1\t111\tzsh\t/tmp\tteam\tmain\n%2\t222\tnode\t/project\tteam\tworker\n%3\tnot-a-pid\tzsh\t/tmp\tteam\tmain\n',
stderr: '', stderr: '',
}); });
@ -86,7 +87,12 @@ describe('TmuxPlatformCommandExecutor', () => {
new Map([['%2', 222]]) new Map([['%2', 222]])
); );
expect(executor.execTmux).toHaveBeenCalledWith( expect(executor.execTmux).toHaveBeenCalledWith(
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'], [
'list-panes',
'-a',
'-F',
'#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{session_name}\t#{window_name}',
],
3_000 3_000
); );
}); });

View file

@ -268,6 +268,23 @@ export class TmuxWslService {
return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout); return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout);
} }
async execInPreferredDistro(
args: string[],
preferredDistroName?: string | null,
timeout = 5_000
): Promise<ExecWslResult> {
const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName;
if (!distroName) {
return {
exitCode: 1,
stdout: '',
stderr: 'No WSL distribution is available.',
};
}
return this.#run(['-d', distroName, '-e', ...args], timeout);
}
getPersistedPreferredDistroSync(): string | null { getPersistedPreferredDistroSync(): string | null {
return this.#preferenceStore.getPreferredDistroSync(); return this.#preferenceStore.getPreferredDistroSync();
} }

View file

@ -12,6 +12,9 @@ import type {
PersistedTeamLaunchSnapshot, PersistedTeamLaunchSnapshot,
PersistedTeamLaunchSummary, PersistedTeamLaunchSummary,
ProviderModelLaunchIdentity, ProviderModelLaunchIdentity,
TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamLaunchAggregateState, TeamLaunchAggregateState,
} from '@shared/types'; } from '@shared/types';
@ -37,8 +40,13 @@ type RuntimeMemberSpawnState = Pick<
| 'bootstrapConfirmed' | 'bootstrapConfirmed'
| 'hardFailure' | 'hardFailure'
| 'pendingPermissionRequestIds' | 'pendingPermissionRequestIds'
| 'livenessKind'
| 'runtimeDiagnostic'
| 'runtimeDiagnosticSeverity'
| 'livenessLastCheckedAt'
| 'firstSpawnAcceptedAt' | 'firstSpawnAcceptedAt'
| 'lastHeartbeatAt' | 'lastHeartbeatAt'
| 'runtimeModel'
| 'updatedAt' | 'updatedAt'
>; >;
@ -59,6 +67,41 @@ function normalizeRuntimePid(value: unknown): number | undefined {
: undefined; : undefined;
} }
function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined {
return value === 'confirmed_bootstrap' ||
value === 'runtime_process' ||
value === 'runtime_process_candidate' ||
value === 'permission_blocked' ||
value === 'shell_only' ||
value === 'registered_only' ||
value === 'stale_metadata' ||
value === 'not_found'
? value
: undefined;
}
function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
return value === 'lead_process' ||
value === 'tmux_pane' ||
value === 'tmux_child' ||
value === 'agent_process_table' ||
value === 'opencode_bridge' ||
value === 'runtime_bootstrap' ||
value === 'persisted_metadata'
? value
: undefined;
}
function normalizeDiagnosticSeverity(
value: unknown
): TeamAgentRuntimeDiagnosticSeverity | undefined {
return value === 'info' || value === 'warning' || value === 'error' ? value : undefined;
}
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function normalizeMemberName(name: string): string { function normalizeMemberName(name: string): string {
return name.trim(); return name.trim();
} }
@ -110,6 +153,11 @@ export function summarizePersistedLaunchMembers(
let pendingCount = 0; let pendingCount = 0;
let failedCount = 0; let failedCount = 0;
let runtimeAlivePendingCount = 0; let runtimeAlivePendingCount = 0;
let shellOnlyPendingCount = 0;
let runtimeProcessPendingCount = 0;
let runtimeCandidatePendingCount = 0;
let noRuntimePendingCount = 0;
let permissionPendingCount = 0;
const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean); const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean);
const memberNames = Array.from( const memberNames = Array.from(
new Set([ new Set([
@ -136,9 +184,31 @@ export function summarizePersistedLaunchMembers(
if (entry.runtimeAlive) { if (entry.runtimeAlive) {
runtimeAlivePendingCount += 1; runtimeAlivePendingCount += 1;
} }
if (entry.launchState === 'runtime_pending_permission') {
permissionPendingCount += 1;
}
if (entry.livenessKind === 'shell_only') {
shellOnlyPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process') {
runtimeProcessPendingCount += 1;
} else if (entry.livenessKind === 'runtime_process_candidate') {
runtimeCandidatePendingCount += 1;
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
noRuntimePendingCount += 1;
}
} }
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; return {
confirmedCount,
pendingCount,
failedCount,
runtimeAlivePendingCount,
shellOnlyPendingCount,
runtimeProcessPendingCount,
runtimeCandidatePendingCount,
noRuntimePendingCount,
permissionPendingCount,
};
} }
export function hasMixedPersistedLaunchMetadata( export function hasMixedPersistedLaunchMetadata(
@ -340,6 +410,12 @@ function normalizePersistedMemberState(
parsed.pendingPermissionRequestIds parsed.pendingPermissionRequestIds
), ),
runtimePid: normalizeRuntimePid(parsed.runtimePid), runtimePid: normalizeRuntimePid(parsed.runtimePid),
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
livenessKind: normalizeLivenessKind(parsed.livenessKind),
pidSource: normalizePidSource(parsed.pidSource),
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
runtimeLastSeenAt: normalizeOptionalString(parsed.runtimeLastSeenAt),
firstSpawnAcceptedAt: firstSpawnAcceptedAt:
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined, typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
lastHeartbeatAt: lastHeartbeatAt:
@ -492,6 +568,10 @@ export function snapshotFromRuntimeMemberStatuses(params: {
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
? [...new Set(runtime.pendingPermissionRequestIds)] ? [...new Set(runtime.pendingPermissionRequestIds)]
: undefined, : undefined,
livenessKind: runtime?.livenessKind,
runtimeDiagnostic: runtime?.runtimeDiagnostic,
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt, firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
lastHeartbeatAt: runtime?.lastHeartbeatAt, lastHeartbeatAt: runtime?.lastHeartbeatAt,
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined, lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
@ -555,6 +635,10 @@ export function snapshotToMemberSpawnStatuses(
bootstrapConfirmed: entry.bootstrapConfirmed, bootstrapConfirmed: entry.bootstrapConfirmed,
hardFailure: entry.hardFailure, hardFailure: entry.hardFailure,
pendingPermissionRequestIds: entry.pendingPermissionRequestIds, pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
livenessKind: entry.livenessKind,
runtimeDiagnostic: entry.runtimeDiagnostic,
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt,
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt, firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
lastHeartbeatAt: entry.lastHeartbeatAt, lastHeartbeatAt: entry.lastHeartbeatAt,
updatedAt: entry.lastEvaluatedAt, updatedAt: entry.lastEvaluatedAt,

View file

@ -19,6 +19,11 @@ export interface LaunchStateSummary {
pendingCount?: number; pendingCount?: number;
failedCount?: number; failedCount?: number;
runtimeAlivePendingCount?: number; runtimeAlivePendingCount?: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
runtimeCandidatePendingCount?: number;
noRuntimePendingCount?: number;
permissionPendingCount?: number;
} }
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary { export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
@ -73,6 +78,11 @@ export function createLaunchStateSummary(
pendingCount: snapshot.summary.pendingCount, pendingCount: snapshot.summary.pendingCount,
failedCount: snapshot.summary.failedCount, failedCount: snapshot.summary.failedCount,
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount, runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
runtimeCandidatePendingCount: snapshot.summary.runtimeCandidatePendingCount,
noRuntimePendingCount: snapshot.summary.noRuntimePendingCount,
permissionPendingCount: snapshot.summary.permissionPendingCount,
}; };
} }
@ -147,6 +157,27 @@ export function normalizePersistedLaunchSummaryProjection(
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) { if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount; normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
} }
if (typeof record.shellOnlyPendingCount === 'number' && record.shellOnlyPendingCount >= 0) {
normalized.shellOnlyPendingCount = record.shellOnlyPendingCount;
}
if (
typeof record.runtimeProcessPendingCount === 'number' &&
record.runtimeProcessPendingCount >= 0
) {
normalized.runtimeProcessPendingCount = record.runtimeProcessPendingCount;
}
if (
typeof record.runtimeCandidatePendingCount === 'number' &&
record.runtimeCandidatePendingCount >= 0
) {
normalized.runtimeCandidatePendingCount = record.runtimeCandidatePendingCount;
}
if (typeof record.noRuntimePendingCount === 'number' && record.noRuntimePendingCount >= 0) {
normalized.noRuntimePendingCount = record.noRuntimePendingCount;
}
if (typeof record.permissionPendingCount === 'number' && record.permissionPendingCount >= 0) {
normalized.permissionPendingCount = record.permissionPendingCount;
}
normalized.launchUpdatedAt = updatedAt; normalized.launchUpdatedAt = updatedAt;
return normalized; return normalized;
} }

View file

@ -227,17 +227,7 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`); logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
} }
// 2. Dev mode — prefer built dist for reliable direct execution // 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
const builtEntry = getBuiltServerEntry();
checked.push(builtEntry);
if (await pathExists(builtEntry)) {
return {
command: await resolveNodePath(),
args: [builtEntry],
};
}
// 3. Dev mode fallback — run source directly through a local tsx binary
const sourceEntry = getSourceServerEntry(); const sourceEntry = getSourceServerEntry();
checked.push(sourceEntry); checked.push(sourceEntry);
if (await pathExists(sourceEntry)) { if (await pathExists(sourceEntry)) {
@ -252,6 +242,16 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
} }
} }
// 3. Dev mode fallback — use built dist when source execution is unavailable
const builtEntry = getBuiltServerEntry();
checked.push(builtEntry);
if (await pathExists(builtEntry)) {
return {
command: await resolveNodePath(),
args: [builtEntry],
};
}
throw new Error( throw new Error(
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}` `agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
); );

View file

@ -0,0 +1,14 @@
export type TeamMemberLivenessMode = 'diagnostics' | 'strict';
export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE';
export function resolveTeamMemberLivenessModeFromEnv(
env: NodeJS.ProcessEnv = process.env
): TeamMemberLivenessMode {
const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase();
return raw === 'strict' ? 'strict' : 'diagnostics';
}
export function isStrictTeamMemberLivenessMode(env: NodeJS.ProcessEnv = process.env): boolean {
return resolveTeamMemberLivenessModeFromEnv(env) === 'strict';
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,350 @@
import type { RuntimeProcessTableRow, TmuxPaneRuntimeInfo } from '@features/tmux-installer/main';
import type {
MemberSpawnStatusEntry,
TeamAgentRuntimeBackendType,
TeamAgentRuntimeDiagnosticSeverity,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamProviderId,
} from '@shared/types';
export interface ResolveTeamMemberRuntimeLivenessInput {
teamName: string;
memberName: string;
agentId?: string;
backendType?: TeamAgentRuntimeBackendType;
providerId?: TeamProviderId;
tmuxPaneId?: string;
persistedRuntimePid?: number;
persistedRuntimeSessionId?: string;
trackedSpawnStatus?: MemberSpawnStatusEntry;
runtimePid?: number;
runtimeSessionId?: string;
pane?: TmuxPaneRuntimeInfo;
processRows: readonly RuntimeProcessTableRow[];
processTableAvailable: boolean;
nowIso: string;
}
export interface ResolvedTeamMemberRuntimeLiveness {
alive: boolean;
livenessKind: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
metricsPid?: number;
panePid?: number;
paneCurrentCommand?: string;
processCommand?: string;
runtimeSessionId?: string;
runtimeLastSeenAt?: string;
runtimeDiagnostic: string;
runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
diagnostics: string[];
}
const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']);
const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
function basenameCommand(command: string | undefined): string {
const firstToken = command?.trim().split(/\s+/, 1)[0] ?? '';
const base = firstToken.split(/[\\/]/).pop() ?? firstToken;
return base.replace(/^-/, '').toLowerCase();
}
export function isShellLikeCommand(command: string | undefined): boolean {
return SHELL_COMMAND_NAMES.has(basenameCommand(command));
}
export function sanitizeProcessCommandForDiagnostics(
command: string | undefined
): string | undefined {
const trimmed = command?.trim();
if (!trimmed) return undefined;
return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500);
}
function escapeRegexLiteral(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function extractCliArgValues(command: string, argName: string): string[] {
const escapedArg = escapeRegexLiteral(argName);
const pattern = new RegExp(
`(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`,
'g'
);
const values: string[] = [];
for (const match of command.matchAll(pattern)) {
const value = (match[2] ?? match[3] ?? match[4] ?? '').trim();
if (value) values.push(value);
}
return values;
}
export function commandArgEquals(
command: string,
argName: string,
expected: string | undefined
): boolean {
const normalizedExpected = expected?.trim();
if (!normalizedExpected) return false;
return extractCliArgValues(command, argName).some((value) => value === normalizedExpected);
}
function collectDescendants(
rows: readonly RuntimeProcessTableRow[],
rootPid: number
): RuntimeProcessTableRow[] {
const childrenByParent = new Map<number, RuntimeProcessTableRow[]>();
for (const row of rows) {
const current = childrenByParent.get(row.ppid) ?? [];
current.push(row);
childrenByParent.set(row.ppid, current);
}
const descendants: RuntimeProcessTableRow[] = [];
const queue = [...(childrenByParent.get(rootPid) ?? [])];
const seen = new Set<number>();
while (queue.length > 0) {
const row = queue.shift();
if (!row || seen.has(row.pid)) continue;
seen.add(row.pid);
descendants.push(row);
queue.push(...(childrenByParent.get(row.pid) ?? []));
}
return descendants;
}
function isVerifiedRuntimeProcess(params: {
row: RuntimeProcessTableRow;
teamName: string;
agentId?: string;
}): boolean {
return (
commandArgEquals(params.row.command, '--team-name', params.teamName) &&
commandArgEquals(params.row.command, '--agent-id', params.agentId)
);
}
function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
return Boolean(
input.agentId?.trim() ||
input.tmuxPaneId?.trim() ||
input.persistedRuntimePid ||
input.runtimePid ||
input.persistedRuntimeSessionId?.trim() ||
input.runtimeSessionId?.trim() ||
input.backendType
);
}
function result(params: {
alive: boolean;
livenessKind: TeamAgentRuntimeLivenessKind;
runtimeDiagnostic: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
pidSource?: TeamAgentRuntimePidSource;
pid?: number;
metricsPid?: number;
panePid?: number;
paneCurrentCommand?: string;
processCommand?: string;
runtimeSessionId?: string;
runtimeLastSeenAt?: string;
}): ResolvedTeamMemberRuntimeLiveness {
return {
alive: params.alive,
livenessKind: params.livenessKind,
runtimeDiagnostic: params.runtimeDiagnostic,
runtimeDiagnosticSeverity: params.runtimeDiagnosticSeverity ?? 'info',
diagnostics: params.diagnostics ?? [params.runtimeDiagnostic],
...(params.pidSource ? { pidSource: params.pidSource } : {}),
...(typeof params.pid === 'number' && params.pid > 0 ? { pid: params.pid } : {}),
...(typeof params.metricsPid === 'number' && params.metricsPid > 0
? { metricsPid: params.metricsPid }
: {}),
...(typeof params.panePid === 'number' && params.panePid > 0
? { panePid: params.panePid }
: {}),
...(params.paneCurrentCommand ? { paneCurrentCommand: params.paneCurrentCommand } : {}),
...(params.processCommand ? { processCommand: params.processCommand } : {}),
...(params.runtimeSessionId ? { runtimeSessionId: params.runtimeSessionId } : {}),
...(params.runtimeLastSeenAt ? { runtimeLastSeenAt: params.runtimeLastSeenAt } : {}),
};
}
export function resolveTeamMemberRuntimeLiveness(
input: ResolveTeamMemberRuntimeLivenessInput
): ResolvedTeamMemberRuntimeLiveness {
const tracked = input.trackedSpawnStatus;
const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId;
const diagnostics: string[] = [];
if (!input.processTableAvailable) {
diagnostics.push('process table unavailable');
}
if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') {
return result({
alive: true,
livenessKind: 'confirmed_bootstrap',
pidSource: 'runtime_bootstrap',
runtimeSessionId,
runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt,
runtimeDiagnostic: 'bootstrap confirmed',
diagnostics: [...diagnostics, 'bootstrap confirmed'],
});
}
if (
tracked?.launchState === 'runtime_pending_permission' ||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
) {
return result({
alive: false,
livenessKind: 'permission_blocked',
runtimeSessionId,
runtimeDiagnostic: 'waiting for permission approval',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'permission approval pending'],
});
}
const verifiedProcess = input.processRows
.filter((row) =>
isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId })
)
.sort((left, right) => right.pid - left.pid)[0];
if (verifiedProcess) {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'agent_process_table',
pid: verifiedProcess.pid,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(verifiedProcess.command),
runtimeDiagnostic: 'verified runtime process detected',
diagnostics: [...diagnostics, 'matched process table by team-name and agent-id'],
});
}
const runtimePid = input.runtimePid ?? input.persistedRuntimePid;
const runtimePidRow =
typeof runtimePid === 'number' && runtimePid > 0
? input.processRows.find((row) => row.pid === runtimePid)
: undefined;
if (runtimePidRow && input.providerId === 'opencode') {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'opencode_bridge',
pid: runtimePidRow.pid,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command),
runtimeDiagnostic: 'OpenCode runtime process detected',
diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'],
});
}
const pane = input.pane;
if (pane) {
const descendants = collectDescendants(input.processRows, pane.panePid);
const verifiedDescendant = descendants
.filter((row) =>
isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId })
)
.sort((left, right) => right.pid - left.pid)[0];
if (verifiedDescendant) {
return result({
alive: true,
livenessKind: 'runtime_process',
pidSource: 'tmux_child',
pid: verifiedDescendant.pid,
panePid: pane.panePid,
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(verifiedDescendant.command),
runtimeDiagnostic: 'verified tmux runtime child detected',
diagnostics: [...diagnostics, 'matched tmux descendant by team-name and agent-id'],
});
}
const candidate = descendants.find((row) => !isShellLikeCommand(row.command));
if (candidate) {
return result({
alive: false,
livenessKind: 'runtime_process_candidate',
pidSource: 'tmux_child',
pid: candidate.pid,
panePid: pane.panePid,
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
processCommand: sanitizeProcessCommandForDiagnostics(candidate.command),
runtimeDiagnostic: 'runtime process candidate detected',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'],
});
}
const shellOnly = isShellLikeCommand(pane.currentCommand);
return result({
alive: false,
livenessKind: shellOnly ? 'shell_only' : 'runtime_process_candidate',
pidSource: 'tmux_pane',
pid: pane.panePid,
panePid: pane.panePid,
paneCurrentCommand: pane.currentCommand,
runtimeSessionId,
runtimeDiagnostic: shellOnly
? `tmux pane foreground command is ${pane.currentCommand ?? 'a shell'}`
: 'tmux pane is alive, but runtime identity is not verified',
runtimeDiagnosticSeverity: shellOnly ? 'warning' : 'info',
diagnostics: [
...diagnostics,
shellOnly
? `tmux pane is alive, but foreground command is ${pane.currentCommand ?? 'a shell'}`
: 'tmux pane exists, but no verified runtime process was found',
],
});
}
if (runtimePid && !runtimePidRow) {
return result({
alive: false,
livenessKind: 'stale_metadata',
pidSource: 'persisted_metadata',
pid: runtimePid,
runtimeSessionId,
runtimeDiagnostic: 'persisted runtime pid is not alive',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'persisted runtime pid was not found in process table'],
});
}
if (hasPersistedEvidence(input)) {
return result({
alive: false,
livenessKind: 'registered_only',
runtimeSessionId,
runtimeDiagnostic: 'registered runtime metadata without live process',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'member has persisted runtime metadata only'],
});
}
return result({
alive: false,
livenessKind: 'not_found',
runtimeDiagnostic: 'runtime process not found',
runtimeDiagnosticSeverity: 'warning',
diagnostics: [...diagnostics, 'runtime process not found'],
});
}
export function isStrongRuntimeEvidence(
value: { livenessKind?: TeamAgentRuntimeLivenessKind } | undefined
): boolean {
return value?.livenessKind === 'confirmed_bootstrap' || value?.livenessKind === 'runtime_process';
}

View file

@ -12,8 +12,12 @@
* diagnostics and completion-time reports. * diagnostics and completion-time reports.
*/ */
import type { TeamLaunchDiagnosticItem } from '@shared/types';
export const PROGRESS_LOG_TAIL_LINES = 200; export const PROGRESS_LOG_TAIL_LINES = 200;
export const PROGRESS_OUTPUT_TAIL_PARTS = 20; export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
/** /**
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
@ -50,3 +54,29 @@ export function buildProgressAssistantOutput(
const joined = tail.join('\n\n'); const joined = tail.join('\n\n');
return joined.trim().length === 0 ? undefined : joined; return joined.trim().length === 0 ? undefined : joined;
} }
function boundDiagnosticText(value: string | undefined): string | undefined {
const trimmed = value?.replace(/\s+/g, ' ').trim();
if (!trimmed) {
return undefined;
}
return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
: trimmed;
}
export function boundLaunchDiagnostics(
items: readonly TeamLaunchDiagnosticItem[] | undefined,
maxItems: number = PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT
): TeamLaunchDiagnosticItem[] | undefined {
if (!items || items.length === 0) {
return undefined;
}
const bounded = items.slice(0, Math.max(1, maxItems)).map((item) => ({
...item,
label: boundDiagnosticText(item.label) ?? item.code,
detail: boundDiagnosticText(item.detail),
}));
return bounded.length > 0 ? bounded : undefined;
}

View file

@ -546,7 +546,25 @@ function mapBridgeMemberToRuntimeEvidence(
const confirmed = launchState === 'confirmed_alive'; const confirmed = launchState === 'confirmed_alive';
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked'; const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
const failed = launchState === 'failed'; const failed = launchState === 'failed';
const pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized; const hasRuntimePid =
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
const pendingRuntimeObserved = launchState === 'created' && hasRuntimePid;
const livenessKind = confirmed
? 'confirmed_bootstrap'
: pendingRuntimeObserved
? 'runtime_process'
: launchState === 'permission_blocked'
? 'permission_blocked'
: runtimeMaterialized || sessionId
? 'runtime_process_candidate'
: 'registered_only';
const runtimeDiagnostic = pendingRuntimeObserved
? 'OpenCode runtime process reported by bridge'
: launchState === 'permission_blocked'
? 'OpenCode runtime is waiting for permission approval'
: runtimeMaterialized || sessionId
? 'OpenCode session exists without verified runtime pid'
: undefined;
return { return {
memberName, memberName,
providerId: 'opencode', providerId: 'opencode',
@ -557,7 +575,7 @@ function mapBridgeMemberToRuntimeEvidence(
: launchState === 'permission_blocked' : launchState === 'permission_blocked'
? 'runtime_pending_permission' ? 'runtime_pending_permission'
: 'runtime_pending_bootstrap', : 'runtime_pending_bootstrap',
agentToolAccepted: confirmed || pendingRuntimeObserved, agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized,
runtimeAlive: confirmed || pendingRuntimeObserved, runtimeAlive: confirmed || pendingRuntimeObserved,
bootstrapConfirmed: confirmed, bootstrapConfirmed: confirmed,
hardFailure: failed, hardFailure: failed,
@ -567,9 +585,10 @@ function mapBridgeMemberToRuntimeEvidence(
? [...new Set(pendingPermissionRequestIds)] ? [...new Set(pendingPermissionRequestIds)]
: undefined, : undefined,
sessionId, sessionId,
...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0 ...(hasRuntimePid ? { runtimePid } : {}),
? { runtimePid } livenessKind,
: {}), ...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
diagnostics, diagnostics,
}; };
} }

View file

@ -4,6 +4,8 @@ import type {
PersistedTeamLaunchPhase, PersistedTeamLaunchPhase,
PersistedTeamLaunchSnapshot, PersistedTeamLaunchSnapshot,
TeamAgentRuntimeBackendType, TeamAgentRuntimeBackendType,
TeamAgentRuntimeLivenessKind,
TeamAgentRuntimePidSource,
TeamLaunchAggregateState, TeamLaunchAggregateState,
} from '@shared/types'; } from '@shared/types';
@ -73,6 +75,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
sessionId?: string; sessionId?: string;
backendType?: TeamAgentRuntimeBackendType; backendType?: TeamAgentRuntimeBackendType;
runtimePid?: number; runtimePid?: number;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
diagnostics: string[]; diagnostics: string[];
} }

View file

@ -19,6 +19,7 @@ import { DISPLAY_STEPS } from './provisioningSteps';
import { StepProgressBar } from './StepProgressBar'; import { StepProgressBar } from './StepProgressBar';
import type { StepProgressBarStep } from './StepProgressBar'; import type { StepProgressBarStep } from './StepProgressBar';
import type { TeamLaunchDiagnosticItem } from '@shared/types';
/** Pre-built step definitions for the provisioning stepper. */ /** Pre-built step definitions for the provisioning stepper. */
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({ const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
@ -61,6 +62,8 @@ export interface ProvisioningProgressBlockProps {
cliLogsTail?: string; cliLogsTail?: string;
/** Accumulated assistant text output for live preview */ /** Accumulated assistant text output for live preview */
assistantOutput?: string; assistantOutput?: string;
/** Bounded structured launch diagnostics */
launchDiagnostics?: TeamLaunchDiagnosticItem[];
/** Visual surface chrome for the outer block */ /** Visual surface chrome for the outer block */
surface?: 'raised' | 'flat'; surface?: 'raised' | 'flat';
className?: string; className?: string;
@ -153,11 +156,13 @@ export const ProvisioningProgressBlock = ({
pid, pid,
cliLogsTail, cliLogsTail,
assistantOutput, assistantOutput,
launchDiagnostics,
surface = 'raised', surface = 'raised',
className, className,
}: ProvisioningProgressBlockProps): React.JSX.Element => { }: ProvisioningProgressBlockProps): React.JSX.Element => {
const elapsed = useElapsedTimer(startedAt, loading); const elapsed = useElapsedTimer(startedAt, loading);
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false); const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
const outputScrollRef = useRef<HTMLDivElement>(null); const outputScrollRef = useRef<HTMLDivElement>(null);
const isError = tone === 'error'; const isError = tone === 'error';
@ -293,6 +298,42 @@ export const ProvisioningProgressBlock = ({
errorIndex={errorStepIndex} errorIndex={errorStepIndex}
/> />
</div> </div>
{launchDiagnostics && launchDiagnostics.length > 0 ? (
<div className="mt-2">
<button
type="button"
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={() => setDiagnosticsOpen((v) => !v)}
>
{diagnosticsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
Diagnostics
</button>
{diagnosticsOpen ? (
<div className="mt-1 space-y-1 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
{launchDiagnostics.map((item) => (
<div key={item.id} className="text-[11px]">
<div
className={cn(
item.severity === 'error'
? 'text-red-400'
: item.severity === 'warning'
? 'text-amber-400'
: 'text-[var(--color-text-secondary)]'
)}
>
{item.label}
</div>
{item.detail ? (
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
{item.detail}
</div>
) : null}
</div>
))}
</div>
) : null}
</div>
) : null}
<div className="mt-2"> <div className="mt-2">
<button <button
type="button" type="button"

View file

@ -62,6 +62,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
pid={presentation.progress.pid} pid={presentation.progress.pid}
cliLogsTail={presentation.progress.cliLogsTail} cliLogsTail={presentation.progress.cliLogsTail}
assistantOutput={presentation.progress.assistantOutput} assistantOutput={presentation.progress.assistantOutput}
launchDiagnostics={presentation.progress.launchDiagnostics}
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen} defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
defaultLogsOpen={defaultLogsOpen} defaultLogsOpen={defaultLogsOpen}
onCancel={ onCancel={

View file

@ -25,6 +25,7 @@ import type {
MemberSpawnLivenessSource, MemberSpawnLivenessSource,
MemberSpawnStatus, MemberSpawnStatus,
ResolvedTeamMember, ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamTaskWithKanban, TeamTaskWithKanban,
} from '@shared/types'; } from '@shared/types';
@ -32,6 +33,7 @@ interface MemberCardProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
memberColor: string; memberColor: string;
runtimeSummary?: string; runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry;
taskCounts?: TaskStatusCounts | null; taskCounts?: TaskStatusCounts | null;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
@ -77,6 +79,7 @@ export const MemberCard = ({
member, member,
memberColor, memberColor,
runtimeSummary, runtimeSummary,
runtimeEntry,
taskCounts, taskCounts,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
@ -113,6 +116,7 @@ export const MemberCard = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
@ -128,7 +132,12 @@ export const MemberCard = ({
const launchVisualState = launchPresentation.launchVisualState; const launchVisualState = launchPresentation.launchVisualState;
const launchStatusLabel = launchPresentation.launchStatusLabel; const launchStatusLabel = launchPresentation.launchStatusLabel;
const displayPresenceLabel = const displayPresenceLabel =
launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel) ? (launchStatusLabel ?? presenceLabel)
: presenceLabel; : presenceLabel;
const colors = getTeamColorSet(memberColor); const colors = getTeamColorSet(memberColor);
@ -159,7 +168,11 @@ export const MemberCard = ({
!runtimeAdvisoryLabel && !runtimeAdvisoryLabel &&
(presenceLabel === 'starting' || (presenceLabel === 'starting' ||
presenceLabel === 'connecting' || presenceLabel === 'connecting' ||
launchVisualState === 'runtime_pending'); launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime');
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel; const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
const showRuntimeAdvisoryBadge = const showRuntimeAdvisoryBadge =
!isRemoved && !isRemoved &&
@ -283,12 +296,28 @@ export const MemberCard = ({
{(runtimeSummaryText || roleLabel) && memoryLabel ? ( {(runtimeSummaryText || roleLabel) && memoryLabel ? (
<span className="shrink-0 opacity-60"></span> <span className="shrink-0 opacity-60"></span>
) : null} ) : null}
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null} {memoryLabel ? (
<span
className="shrink-0"
title={
runtimeEntry?.pidSource === 'tmux_pane'
? 'RSS source: tmux pane shell'
: runtimeEntry?.pidSource
? `PID source: ${runtimeEntry.pidSource}`
: undefined
}
>
{memoryLabel}
</span>
) : null}
</div> </div>
) : null} ) : null}
</div> </div>
{showLaunchBadge ? ( {showLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1"> <span
className="flex shrink-0 items-center gap-1"
title={runtimeEntry?.runtimeDiagnostic}
>
<Loader2 <Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]" className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label={launchBadgeLabel} aria-label={launchBadgeLabel}

View file

@ -167,6 +167,7 @@ export const MemberDetailDialog = ({
spawnLaunchState={spawnEntry?.launchState} spawnLaunchState={spawnEntry?.launchState}
spawnLivenessSource={spawnEntry?.livenessSource} spawnLivenessSource={spawnEntry?.livenessSource}
spawnRuntimeAlive={spawnEntry?.runtimeAlive} spawnRuntimeAlive={spawnEntry?.runtimeAlive}
runtimeEntry={runtimeEntry}
isLaunchSettling={isLaunchSettling} isLaunchSettling={isLaunchSettling}
onUpdateRole={ onUpdateRole={
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
@ -253,6 +254,7 @@ export const MemberDetailDialog = ({
) : runtimeEntry?.pid ? ( ) : runtimeEntry?.pid ? (
<div className="mr-auto text-xs text-[var(--color-text-muted)]"> <div className="mr-auto text-xs text-[var(--color-text-muted)]">
PID {runtimeEntry.pid} PID {runtimeEntry.pid}
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''}
</div> </div>
) : ( ) : (
<div className="mr-auto" /> <div className="mr-auto" />

View file

@ -23,11 +23,13 @@ import type {
MemberSpawnLivenessSource, MemberSpawnLivenessSource,
MemberSpawnStatus, MemberSpawnStatus,
ResolvedTeamMember, ResolvedTeamMember,
TeamAgentRuntimeEntry,
} from '@shared/types'; } from '@shared/types';
interface MemberDetailHeaderProps { interface MemberDetailHeaderProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
runtimeSummary?: string; runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
@ -43,6 +45,7 @@ interface MemberDetailHeaderProps {
export const MemberDetailHeader = ({ export const MemberDetailHeader = ({
member, member,
runtimeSummary, runtimeSummary,
runtimeEntry,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
@ -75,6 +78,7 @@ export const MemberDetailHeader = ({
spawnLaunchState, spawnLaunchState,
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
@ -91,7 +95,12 @@ export const MemberDetailHeader = ({
const badgeLabel = const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel ? runtimeAdvisoryLabel
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' : launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel) ? (launchStatusLabel ?? presenceLabel)
: presenceLabel; : presenceLabel;

View file

@ -68,6 +68,7 @@ export const MemberHoverCard = ({
memberSpawnSnapshot, memberSpawnSnapshot,
memberSpawnStatuses, memberSpawnStatuses,
spawnEntry, spawnEntry,
runtimeEntry,
leadActivity, leadActivity,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
@ -89,6 +90,9 @@ export const MemberHoverCard = ({
spawnEntry: effectiveTeamName spawnEntry: effectiveTeamName
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name] ? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
: undefined, : undefined,
runtimeEntry: effectiveTeamName
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name]
: undefined,
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined, leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
})) }))
); );
@ -114,6 +118,7 @@ export const MemberHoverCard = ({
spawnLaunchState: spawnEntry?.launchState, spawnLaunchState: spawnEntry?.launchState,
spawnLivenessSource: spawnEntry?.livenessSource, spawnLivenessSource: spawnEntry?.livenessSource,
spawnRuntimeAlive: spawnEntry?.runtimeAlive, spawnRuntimeAlive: spawnEntry?.runtimeAlive,
runtimeEntry,
runtimeAdvisory: member.runtimeAdvisory, runtimeAdvisory: member.runtimeAdvisory,
isLaunchSettling, isLaunchSettling,
isTeamAlive, isTeamAlive,
@ -130,7 +135,12 @@ export const MemberHoverCard = ({
const badgeLabel = const badgeLabel =
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
? runtimeAdvisoryLabel ? runtimeAdvisoryLabel
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending' : launchVisualState === 'runtime_pending' ||
launchVisualState === 'permission_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel) ? (launchStatusLabel ?? presenceLabel)
: presenceLabel; : presenceLabel;
const currentTask: TeamTaskWithKanban | null = member.currentTaskId const currentTask: TeamTaskWithKanban | null = member.currentTaskId

View file

@ -151,6 +151,9 @@ function areMemberSpawnStatusesEquivalent(
leftEntry.hardFailure !== rightEntry.hardFailure || leftEntry.hardFailure !== rightEntry.hardFailure ||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason || leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
leftEntry.livenessSource !== rightEntry.livenessSource || leftEntry.livenessSource !== rightEntry.livenessSource ||
leftEntry.livenessKind !== rightEntry.livenessKind ||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
leftEntry.runtimeDiagnosticSeverity !== rightEntry.runtimeDiagnosticSeverity ||
leftEntry.runtimeModel !== rightEntry.runtimeModel || leftEntry.runtimeModel !== rightEntry.runtimeModel ||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive || leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed || leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
@ -196,7 +199,13 @@ function areMemberRuntimeEntriesEquivalent(
leftEntry.backendType !== rightEntry?.backendType || leftEntry.backendType !== rightEntry?.backendType ||
leftEntry.pid !== rightEntry?.pid || leftEntry.pid !== rightEntry?.pid ||
leftEntry.runtimeModel !== rightEntry?.runtimeModel || leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
leftEntry.rssBytes !== rightEntry?.rssBytes leftEntry.rssBytes !== rightEntry?.rssBytes ||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
leftEntry.pidSource !== rightEntry?.pidSource ||
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity ||
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt
) { ) {
return false; return false;
} }
@ -332,6 +341,7 @@ export const MemberList = memo(function MemberList({
isRemoved ? undefined : spawnEntry, isRemoved ? undefined : spawnEntry,
isRemoved ? undefined : runtimeEntry isRemoved ? undefined : runtimeEntry
)} )}
runtimeEntry={isRemoved ? undefined : runtimeEntry}
spawnStatus={isRemoved ? undefined : spawnEntry?.status} spawnStatus={isRemoved ? undefined : spawnEntry?.status}
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)} spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}

View file

@ -131,7 +131,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
entry.launchState === 'runtime_pending_bootstrap' || entry.launchState === 'runtime_pending_bootstrap' ||
entry.launchState === 'runtime_pending_permission' entry.launchState === 'runtime_pending_permission'
) { ) {
if (entry.runtimeAlive === true) { if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') {
processOnlyAliveCount += 1; processOnlyAliveCount += 1;
} else { } else {
pendingSpawnCount += 1; pendingSpawnCount += 1;
@ -199,7 +199,8 @@ export function getLaunchJoinMilestonesFromMembers({
const snapshotMilestones = { const snapshotMilestones = {
expectedTeammateCount, expectedTeammateCount,
heartbeatConfirmedCount: snapshotSummary.confirmedCount, heartbeatConfirmedCount: snapshotSummary.confirmedCount,
processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount, processOnlyAliveCount:
snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount,
pendingSpawnCount: Math.max( pendingSpawnCount: Math.max(
0, 0,
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount

View file

@ -242,6 +242,7 @@ export function initializeNotificationListeners(): () => void {
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>(); let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>(); let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
let inProgressChangePresencePollInFlight = false; let inProgressChangePresencePollInFlight = false;
let teamMessageFallbackPollInFlight = false; let teamMessageFallbackPollInFlight = false;
@ -286,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS); }, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
memberSpawnRefreshTimers.set(teamName, timer); memberSpawnRefreshTimers.set(teamName, timer);
}; };
const scheduleTeamAgentRuntimeRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
return;
}
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
return;
}
const timer = setTimeout(() => {
teamAgentRuntimeRefreshTimers.delete(teamName);
void useStore.getState().fetchTeamAgentRuntime(teamName);
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
teamAgentRuntimeRefreshTimers.set(teamName, timer);
};
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => { const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
if (!teamName || !shouldRefreshTeamMessages(teamName)) { if (!teamName || !shouldRefreshTeamMessages(teamName)) {
return; return;
@ -1194,6 +1208,7 @@ export function initializeNotificationListeners(): () => void {
} }
seedCurrentRunIdIfMissing(); seedCurrentRunIdIfMissing();
scheduleMemberSpawnStatusesRefresh(event.teamName); scheduleMemberSpawnStatusesRefresh(event.teamName);
scheduleTeamAgentRuntimeRefresh(event.teamName);
return; return;
} }
@ -1276,6 +1291,8 @@ export function initializeNotificationListeners(): () => void {
teamPresenceRefreshTimers = new Map(); teamPresenceRefreshTimers = new Map();
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t); for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
memberSpawnRefreshTimers = new Map(); memberSpawnRefreshTimers = new Map();
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
teamAgentRuntimeRefreshTimers = new Map();
for (const t of toolActivityTimers.values()) clearTimeout(t); for (const t of toolActivityTimers.values()) clearTimeout(t);
toolActivityTimers = new Map(); toolActivityTimers = new Map();
teamLastRelevantActivityAt.clear(); teamLastRelevantActivityAt.clear();

View file

@ -702,7 +702,12 @@ function areLaunchSummaryCountsEqual(
left.confirmedCount === right.confirmedCount && left.confirmedCount === right.confirmedCount &&
left.pendingCount === right.pendingCount && left.pendingCount === right.pendingCount &&
left.failedCount === right.failedCount && left.failedCount === right.failedCount &&
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
left.noRuntimePendingCount === right.noRuntimePendingCount &&
left.permissionPendingCount === right.permissionPendingCount
); );
} }
@ -739,6 +744,9 @@ function areMemberSpawnStatusEntriesEqual(
left.livenessSource === right.livenessSource && left.livenessSource === right.livenessSource &&
left.runtimeAlive === right.runtimeAlive && left.runtimeAlive === right.runtimeAlive &&
left.runtimeModel === right.runtimeModel && left.runtimeModel === right.runtimeModel &&
left.livenessKind === right.livenessKind &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.bootstrapConfirmed === right.bootstrapConfirmed && left.bootstrapConfirmed === right.bootstrapConfirmed &&
left.hardFailure === right.hardFailure && left.hardFailure === right.hardFailure &&
leftPendingPermissionIds.length === rightPendingPermissionIds.length && leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
@ -809,7 +817,13 @@ function areTeamAgentRuntimeEntriesEqual(
left.backendType === right.backendType && left.backendType === right.backendType &&
left.pid === right.pid && left.pid === right.pid &&
left.runtimeModel === right.runtimeModel && left.runtimeModel === right.runtimeModel &&
left.rssBytes === right.rssBytes left.rssBytes === right.rssBytes &&
left.livenessKind === right.livenessKind &&
left.pidSource === right.pidSource &&
left.paneCurrentCommand === right.paneCurrentCommand &&
left.runtimeDiagnostic === right.runtimeDiagnostic &&
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
left.runtimeLastSeenAt === right.runtimeLastSeenAt
); );
} }

View file

@ -15,6 +15,7 @@ import type {
MemberSpawnStatus, MemberSpawnStatus,
MemberStatus, MemberStatus,
ResolvedTeamMember, ResolvedTeamMember,
TeamAgentRuntimeEntry,
TeamProviderId, TeamProviderId,
TeamReviewState, TeamReviewState,
TeamTaskStatus, TeamTaskStatus,
@ -531,6 +532,10 @@ export type MemberLaunchVisualState =
| 'spawning' | 'spawning'
| 'permission_pending' | 'permission_pending'
| 'runtime_pending' | 'runtime_pending'
| 'shell_only'
| 'runtime_candidate'
| 'registered_only'
| 'stale_runtime'
| 'settling' | 'settling'
| 'error' | 'error'
| null; | null;
@ -556,7 +561,15 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
case 'permission_pending': case 'permission_pending':
return 'awaiting permission'; return 'awaiting permission';
case 'runtime_pending': case 'runtime_pending':
return 'connecting'; return 'waiting for bootstrap';
case 'shell_only':
return 'shell only';
case 'runtime_candidate':
return 'process candidate';
case 'registered_only':
return 'registered';
case 'stale_runtime':
return 'stale runtime';
case 'settling': case 'settling':
return 'joining team'; return 'joining team';
case 'error': case 'error':
@ -573,6 +586,7 @@ export function buildMemberLaunchPresentation({
spawnLivenessSource, spawnLivenessSource,
spawnRuntimeAlive, spawnRuntimeAlive,
runtimeAdvisory, runtimeAdvisory,
runtimeEntry,
isLaunchSettling = false, isLaunchSettling = false,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
@ -584,6 +598,7 @@ export function buildMemberLaunchPresentation({
spawnLivenessSource: MemberSpawnLivenessSource | undefined; spawnLivenessSource: MemberSpawnLivenessSource | undefined;
spawnRuntimeAlive: boolean | undefined; spawnRuntimeAlive: boolean | undefined;
runtimeAdvisory: MemberRuntimeAdvisory | undefined; runtimeAdvisory: MemberRuntimeAdvisory | undefined;
runtimeEntry?: TeamAgentRuntimeEntry;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
@ -630,10 +645,21 @@ export function buildMemberLaunchPresentation({
launchVisualState = 'error'; launchVisualState = 'error';
} else if (spawnLaunchState === 'runtime_pending_permission') { } else if (spawnLaunchState === 'runtime_pending_permission') {
launchVisualState = 'permission_pending'; launchVisualState = 'permission_pending';
} else if (runtimeEntry?.livenessKind === 'shell_only') {
launchVisualState = 'shell_only';
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
launchVisualState = 'runtime_candidate';
} else if (runtimeEntry?.livenessKind === 'registered_only') {
launchVisualState = 'registered_only';
} else if (
runtimeEntry?.livenessKind === 'stale_metadata' ||
runtimeEntry?.livenessKind === 'not_found'
) {
launchVisualState = 'stale_runtime';
} else if ( } else if (
spawnLaunchState === 'runtime_pending_bootstrap' && spawnLaunchState === 'runtime_pending_bootstrap' &&
spawnStatus === 'online' && (runtimeEntry?.livenessKind === 'runtime_process' ||
spawnRuntimeAlive === true (spawnStatus === 'online' && spawnRuntimeAlive === true))
) { ) {
launchVisualState = 'runtime_pending'; launchVisualState = 'runtime_pending';
} else if ( } else if (
@ -655,6 +681,15 @@ export function buildMemberLaunchPresentation({
} }
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState); const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
const displayPresenceLabel =
launchVisualState === 'permission_pending' ||
launchVisualState === 'runtime_pending' ||
launchVisualState === 'shell_only' ||
launchVisualState === 'runtime_candidate' ||
launchVisualState === 'registered_only' ||
launchVisualState === 'stale_runtime'
? (launchStatusLabel ?? presenceLabel)
: presenceLabel;
const spawnBadgeLabel = const spawnBadgeLabel =
spawnStatus && spawnStatus !== 'online' spawnStatus && spawnStatus !== 'online'
? spawnStatus === 'waiting' || spawnStatus === 'spawning' ? spawnStatus === 'waiting' || spawnStatus === 'spawning'
@ -663,7 +698,7 @@ export function buildMemberLaunchPresentation({
: null; : null;
return { return {
presenceLabel, presenceLabel: displayPresenceLabel,
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass, dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
cardClass, cardClass,
runtimeAdvisoryLabel, runtimeAdvisoryLabel,

View file

@ -126,6 +126,27 @@ function buildAwaitingPermissionPhrase(count: number): string {
: `${count} teammates awaiting permission approval`; : `${count} teammates awaiting permission approval`;
} }
function buildPendingDiagnosticPhrase(
summary: MemberSpawnStatusesSnapshot['summary'] | undefined,
fallbackJoiningPhrase: string
): string {
if (!summary) {
return fallbackJoiningPhrase;
}
const parts = [
summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '',
summary.runtimeProcessPendingCount
? `${summary.runtimeProcessPendingCount} waiting for bootstrap`
: '',
summary.runtimeCandidatePendingCount
? `${summary.runtimeCandidatePendingCount} process candidates`
: '',
summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '',
summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '',
].filter(Boolean);
return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase;
}
const ACTIVE_PROVISIONING_STATES = new Set([ const ACTIVE_PROVISIONING_STATES = new Set([
'validating', 'validating',
'spawning', 'spawning',
@ -394,7 +415,7 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount === remainingJoinCount; permissionBlockedCount === remainingJoinCount;
const pendingDetailPhrase = pendingMembersAwaitApproval const pendingDetailPhrase = pendingMembersAwaitApproval
? buildAwaitingPermissionPhrase(permissionBlockedCount) ? buildAwaitingPermissionPhrase(permissionBlockedCount)
: joiningPhrase; : buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase);
const readyCompactDetail = const readyCompactDetail =
failedSpawnCount > 0 failedSpawnCount > 0
? (failedSpawnCompactDetail ?? ? (failedSpawnCompactDetail ??
@ -471,7 +492,7 @@ export function buildTeamProvisioningPresentation({
permissionBlockedCount > 0 && permissionBlockedCount > 0 &&
permissionBlockedCount === remainingJoinCount permissionBlockedCount === remainingJoinCount
? buildAwaitingPermissionPhrase(permissionBlockedCount) ? buildAwaitingPermissionPhrase(permissionBlockedCount)
: activeJoiningPhrase; : buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase);
return { return {
progress, progress,
isActive: true, isActive: true,

View file

@ -82,6 +82,11 @@ export interface TeamSummary {
pendingCount?: number; pendingCount?: number;
failedCount?: number; failedCount?: number;
runtimeAlivePendingCount?: number; runtimeAlivePendingCount?: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
runtimeCandidatePendingCount?: number;
noRuntimePendingCount?: number;
permissionPendingCount?: number;
} }
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted'; export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
@ -941,6 +946,12 @@ export interface PersistedTeamLaunchMemberState {
hardFailureReason?: string; hardFailureReason?: string;
pendingPermissionRequestIds?: string[]; pendingPermissionRequestIds?: string[];
runtimePid?: number; runtimePid?: number;
runtimeSessionId?: string;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
runtimeLastSeenAt?: string;
firstSpawnAcceptedAt?: string; firstSpawnAcceptedAt?: string;
lastHeartbeatAt?: string; lastHeartbeatAt?: string;
lastRuntimeAliveAt?: string; lastRuntimeAliveAt?: string;
@ -954,6 +965,11 @@ export interface PersistedTeamLaunchSummary {
pendingCount: number; pendingCount: number;
failedCount: number; failedCount: number;
runtimeAlivePendingCount: number; runtimeAlivePendingCount: number;
shellOnlyPendingCount?: number;
runtimeProcessPendingCount?: number;
runtimeCandidatePendingCount?: number;
noRuntimePendingCount?: number;
permissionPendingCount?: number;
} }
export interface PersistedTeamLaunchSnapshot { export interface PersistedTeamLaunchSnapshot {
@ -984,6 +1000,27 @@ export type MemberSpawnLivenessSource = 'heartbeat' | 'process';
export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process'; export type TeamAgentRuntimeBackendType = 'lead' | 'tmux' | 'iterm2' | 'in-process' | 'process';
export type TeamAgentRuntimeLivenessKind =
| 'confirmed_bootstrap'
| 'runtime_process'
| 'runtime_process_candidate'
| 'permission_blocked'
| 'shell_only'
| 'registered_only'
| 'stale_metadata'
| 'not_found';
export type TeamAgentRuntimePidSource =
| 'lead_process'
| 'tmux_pane'
| 'tmux_child'
| 'agent_process_table'
| 'opencode_bridge'
| 'runtime_bootstrap'
| 'persisted_metadata';
export type TeamAgentRuntimeDiagnosticSeverity = 'info' | 'warning' | 'error';
export interface TeamAgentRuntimeEntry { export interface TeamAgentRuntimeEntry {
memberName: string; memberName: string;
alive: boolean; alive: boolean;
@ -996,6 +1033,19 @@ export interface TeamAgentRuntimeEntry {
pid?: number; pid?: number;
runtimeModel?: string; runtimeModel?: string;
rssBytes?: number; rssBytes?: number;
livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource;
processCommand?: string;
paneId?: string;
panePid?: number;
paneCurrentCommand?: string;
runtimePid?: number;
runtimeSessionId?: string;
runtimeLeaseExpiresAt?: string;
runtimeLastSeenAt?: string;
runtimeDiagnostic?: string;
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
diagnostics?: string[];
updatedAt: string; updatedAt: string;
} }
@ -1062,6 +1112,14 @@ export interface MemberSpawnStatusEntry {
lastHeartbeatAt?: string; lastHeartbeatAt?: string;
/** Live runtime model observed from the teammate process, when available. */ /** Live runtime model observed from the teammate process, when available. */
runtimeModel?: string; runtimeModel?: string;
/** Compact runtime liveness classification for launch UI. */
livenessKind?: TeamAgentRuntimeLivenessKind;
/** Short user-facing liveness diagnostic. */
runtimeDiagnostic?: string;
/** Visual severity for runtimeDiagnostic. */
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
/** ISO timestamp of the last liveness evaluation. */
livenessLastCheckedAt?: string;
/** ISO timestamp of the last status change. */ /** ISO timestamp of the last status change. */
updatedAt: string; updatedAt: string;
} }
@ -1176,6 +1234,28 @@ export interface TeamProvisioningProgress {
assistantOutput?: string; assistantOutput?: string;
/** True once provisioning has written a readable config.json for this team. */ /** True once provisioning has written a readable config.json for this team. */
configReady?: boolean; configReady?: boolean;
/** Bounded structured launch diagnostics for the progress UI. */
launchDiagnostics?: TeamLaunchDiagnosticItem[];
}
export interface TeamLaunchDiagnosticItem {
id: string;
memberName?: string;
severity: TeamAgentRuntimeDiagnosticSeverity;
code:
| 'spawn_accepted'
| 'runtime_process_detected'
| 'runtime_process_candidate'
| 'tmux_shell_only'
| 'runtime_not_found'
| 'permission_pending'
| 'bootstrap_confirmed'
| 'bootstrap_stalled'
| 'stale_runtime_event_rejected'
| 'process_table_unavailable';
label: string;
detail?: string;
observedAt: string;
} }
export interface TeamRuntimeState { export interface TeamRuntimeState {

View file

@ -60,26 +60,27 @@ describe('TeamLaunchStateEvaluator', () => {
}); });
it('counts persisted members in launch summary even when expectedMembers is stale', () => { it('counts persisted members in launch summary even when expectedMembers is stale', () => {
const summary = summarizePersistedLaunchMembers( const summary = summarizePersistedLaunchMembers(['alice'], {
['alice'], alice: {
{ launchState: 'runtime_pending_bootstrap',
alice: { runtimeAlive: false,
launchState: 'runtime_pending_bootstrap', },
runtimeAlive: false, bob: {
}, launchState: 'runtime_pending_permission',
bob: { runtimeAlive: true,
launchState: 'runtime_pending_permission', },
runtimeAlive: true, } as any);
},
} as any
);
expect(summary).toEqual({ expect(summary).toEqual({
confirmedCount: 0, confirmedCount: 0,
pendingCount: 2, pendingCount: 2,
failedCount: 0, failedCount: 0,
runtimeAlivePendingCount: 1, runtimeAlivePendingCount: 1,
shellOnlyPendingCount: 0,
runtimeProcessPendingCount: 0,
runtimeCandidatePendingCount: 0,
noRuntimePendingCount: 0,
permissionPendingCount: 1,
}); });
}); });
}); });

View file

@ -75,7 +75,9 @@ describe('TeamMcpConfigBuilder', () => {
return dir; return dir;
} }
function readGeneratedServer(configPath: string): { command?: string; args?: string[] } | undefined { function readGeneratedServer(
configPath: string
): { command?: string; args?: string[] } | undefined {
const raw = fs.readFileSync(configPath, 'utf8'); const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as { const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>; mcpServers?: Record<string, { command?: string; args?: string[] }>;
@ -83,26 +85,72 @@ describe('TeamMcpConfigBuilder', () => {
return parsed.mcpServers?.['agent-teams']; return parsed.mcpServers?.['agent-teams'];
} }
function expectNodeEntry(server: { command?: string; args?: string[] } | undefined, entry: string): void { function expectNodeEntry(
server: { command?: string; args?: string[] } | undefined,
entry: string
): void {
expect(server?.args).toEqual([entry]); expect(server?.args).toEqual([entry]);
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/); expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
} }
function mockPathExists(existingPaths: string[]): void { function expectTsxEntry(
server: { command?: string; args?: string[] } | undefined,
entry: string
): void {
expect(server?.args).toEqual([entry]);
expect(server?.command).toMatch(/[\\/]tsx(?:\.cmd)?$/);
}
function getBuiltWorkspaceEntry(): string {
return path.join(process.cwd(), 'mcp-server', 'dist', 'index.js');
}
function getSourceWorkspaceEntry(): string {
return path.join(process.cwd(), 'mcp-server', 'src', 'index.ts');
}
function getWorkspaceTsxBin(): string {
return path.join(process.cwd(), 'mcp-server', 'node_modules', '.bin', 'tsx');
}
function mockPathExists(existingPaths: string[], options: { strict?: boolean } = {}): void {
const originalAccess = fs.promises.access.bind(fs.promises); const originalAccess = fs.promises.access.bind(fs.promises);
vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => { vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => {
const normalizedPath = const normalizedPath =
typeof targetPath === 'string' ? targetPath : Buffer.isBuffer(targetPath) ? targetPath.toString() : `${targetPath}`; typeof targetPath === 'string'
? targetPath
: Buffer.isBuffer(targetPath)
? targetPath.toString()
: `${targetPath}`;
if (existingPaths.includes(normalizedPath)) { if (existingPaths.includes(normalizedPath)) {
return; return;
} }
if (options.strict) {
const error = new Error(
`ENOENT: no such file or directory, access '${normalizedPath}'`
) as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
}
await originalAccess(targetPath, mode); await originalAccess(targetPath, mode);
}); });
} }
function mockSourceWorkspaceEntryAvailable(): {
sourceEntry: string;
tsxBin: string;
builtEntry: string;
} {
const sourceEntry = getSourceWorkspaceEntry();
const tsxBin = getWorkspaceTsxBin();
const builtEntry = getBuiltWorkspaceEntry();
mockPathExists([sourceEntry, tsxBin, builtEntry], { strict: true });
return { sourceEntry, tsxBin, builtEntry };
}
function mockBuiltWorkspaceEntryAvailable(): string { function mockBuiltWorkspaceEntryAvailable(): string {
const builtEntry = path.join(process.cwd(), 'mcp-server', 'dist', 'index.js'); const builtEntry = getBuiltWorkspaceEntry();
mockPathExists([builtEntry]); mockPathExists([builtEntry], { strict: true });
return builtEntry; return builtEntry;
} }
@ -172,12 +220,26 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath); createdPaths.push(configPath);
const filename = path.basename(configPath); const filename = path.basename(configPath);
expect(filename).toMatch( expect(filename).toMatch(new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`));
new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`)
);
}); });
it('prefers the built workspace MCP entry when available', async () => { it('prefers the source workspace MCP entry in dev mode when available', async () => {
const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder();
const configPath = await builder.writeConfigFile();
createdPaths.push(configPath);
const raw = fs.readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as {
mcpServers?: Record<string, { command?: string; args?: string[] }>;
};
const server = parsed.mcpServers?.['agent-teams'];
expectTsxEntry(server, sourceEntry);
});
it('falls back to the built workspace MCP entry when source execution is unavailable', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable(); const builtEntry = mockBuiltWorkspaceEntryAvailable();
const builder = new TeamMcpConfigBuilder(); const builder = new TeamMcpConfigBuilder();
@ -232,7 +294,10 @@ describe('TeamMcpConfigBuilder', () => {
createdPaths.push(configPath); createdPaths.push(configPath);
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as { const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
mcpServers: Record<string, { command?: string; args?: string[]; type?: string; url?: string }>; mcpServers: Record<
string,
{ command?: string; args?: string[]; type?: string; url?: string }
>;
}; };
expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']); expect(Object.keys(parsed.mcpServers)).toEqual(['agent-teams']);
@ -246,7 +311,10 @@ describe('TeamMcpConfigBuilder', () => {
createdDirs.push(homeDir, projectDir); createdDirs.push(homeDir, projectDir);
mockHomeDir = homeDir; mockHomeDir = homeDir;
fs.writeFileSync(path.join(homeDir, '.claude.json'), JSON.stringify({ mcpServers: {} }, null, 2)); fs.writeFileSync(
path.join(homeDir, '.claude.json'),
JSON.stringify({ mcpServers: {} }, null, 2)
);
fs.writeFileSync( fs.writeFileSync(
path.join(projectDir, '.mcp.json'), path.join(projectDir, '.mcp.json'),
JSON.stringify( JSON.stringify(
@ -273,7 +341,7 @@ describe('TeamMcpConfigBuilder', () => {
}); });
it('generated agent-teams server ignores same-named user MCP entry', async () => { it('generated agent-teams server ignores same-named user MCP entry', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable(); const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-')); const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
createdDirs.push(homeDir); createdDirs.push(homeDir);
mockHomeDir = homeDir; mockHomeDir = homeDir;
@ -299,7 +367,7 @@ describe('TeamMcpConfigBuilder', () => {
mcpServers: Record<string, { command?: string; args?: string[] }>; mcpServers: Record<string, { command?: string; args?: string[] }>;
}; };
expectNodeEntry(parsed.mcpServers['agent-teams'], builtEntry); expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry);
}); });
it('ignores malformed user MCP file', async () => { it('ignores malformed user MCP file', async () => {
@ -494,7 +562,10 @@ describe('TeamMcpConfigBuilder', () => {
const configPath = await builder.writeConfigFile(); const configPath = await builder.writeConfigFile();
createdPaths.push(configPath); createdPaths.push(configPath);
expectNodeEntry(readGeneratedServer(configPath), path.join(resourcesDir, 'mcp-server', 'index.js')); expectNodeEntry(
readGeneratedServer(configPath),
path.join(resourcesDir, 'mcp-server', 'index.js')
);
}); });
it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => { it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => {
@ -526,8 +597,8 @@ describe('TeamMcpConfigBuilder', () => {
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js')); expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
}); });
it('packaged mode falls back to the built workspace MCP entry when resourcesPath bundle is missing', async () => { it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => {
const builtEntry = mockBuiltWorkspaceEntryAvailable(); const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
setPackagedMode(true, '6.0.0'); setPackagedMode(true, '6.0.0');
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-')); const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
createdDirs.push(resourcesDir); createdDirs.push(resourcesDir);
@ -537,6 +608,6 @@ describe('TeamMcpConfigBuilder', () => {
const configPath = await builder.writeConfigFile(); const configPath = await builder.writeConfigFile();
createdPaths.push(configPath); createdPaths.push(configPath);
expectNodeEntry(readGeneratedServer(configPath), builtEntry); expectTsxEntry(readGeneratedServer(configPath), sourceEntry);
}); });
}); });

View file

@ -27,7 +27,9 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
vi.mock('@features/tmux-installer/main', () => ({ vi.mock('@features/tmux-installer/main', () => ({
killTmuxPaneForCurrentPlatformSync: vi.fn(), killTmuxPaneForCurrentPlatformSync: vi.fn(),
listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []),
listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()), listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()),
listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()),
isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true), isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true),
})); }));
@ -144,7 +146,9 @@ import {
} from 'agent-teams-controller'; } from 'agent-teams-controller';
import { import {
killTmuxPaneForCurrentPlatformSync, killTmuxPaneForCurrentPlatformSync,
listRuntimeProcessesForCurrentTmuxPlatform,
listTmuxPanePidsForCurrentPlatform, listTmuxPanePidsForCurrentPlatform,
listTmuxPaneRuntimeInfoForCurrentPlatform,
} from '@features/tmux-installer/main'; } from '@features/tmux-installer/main';
import pidusage from 'pidusage'; import pidusage from 'pidusage';
@ -409,6 +413,13 @@ function createClaudeLogsRun(overrides: Record<string, unknown> = {}) {
describe('TeamProvisioningService', () => { describe('TeamProvisioningService', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(killTmuxPaneForCurrentPlatformSync).mockReset();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockReset();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockReset();
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValue(new Map());
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReset();
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(new Map());
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-')); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-'));
tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTeamsBase = path.join(tempClaudeRoot, 'teams');
tempTasksBase = path.join(tempClaudeRoot, 'tasks'); tempTasksBase = path.join(tempClaudeRoot, 'tasks');
@ -557,7 +568,18 @@ describe('TeamProvisioningService', () => {
cancelRequested: false, cancelRequested: false,
spawnContext: null, spawnContext: null,
}); });
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]])); vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValueOnce(
new Map([
[
'%1',
{
paneId: '%1',
panePid: 222,
currentCommand: 'codex',
},
],
])
);
vi.mocked(pidusage).mockResolvedValueOnce({ vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000), '111': createPidusageStat(111, 123_000_000),
@ -650,7 +672,18 @@ describe('TeamProvisioningService', () => {
cancelRequested: false, cancelRequested: false,
spawnContext: null, spawnContext: null,
}); });
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map([['%1', 222]])); vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValueOnce(
new Map([
[
'%1',
{
paneId: '%1',
panePid: 222,
currentCommand: 'codex',
},
],
])
);
vi.mocked(pidusage) vi.mocked(pidusage)
.mockRejectedValueOnce(new Error('ps: process exited')) .mockRejectedValueOnce(new Error('ps: process exited'))
@ -693,14 +726,14 @@ describe('TeamProvisioningService', () => {
cancelRequested: false, cancelRequested: false,
spawnContext: null, spawnContext: null,
}); });
(svc as any).readUnixProcessTableRows = vi.fn(() => [ vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{ {
pid: 333, pid: 333,
ppid: 1,
command: command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2', '/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
}, },
]); ]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(pidusage).mockResolvedValueOnce({ vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000), '111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000), '333': createPidusageStat(333, 456_000_000),
@ -746,19 +779,20 @@ describe('TeamProvisioningService', () => {
cancelRequested: false, cancelRequested: false,
spawnContext: null, spawnContext: null,
}); });
(svc as any).readUnixProcessTableRows = vi.fn(() => [ vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
{ {
pid: 222, pid: 222,
ppid: 1,
command: command:
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2', '/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model gpt-5.2',
}, },
{ {
pid: 333, pid: 333,
ppid: 1,
command: command:
'/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2', '/Users/belief/.bun/bin/bun cli.js --team-name nice-team --agent-id alice@nice-team --agent-name alice --model gpt-5.2',
}, },
]); ]);
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
vi.mocked(pidusage).mockResolvedValueOnce({ vi.mocked(pidusage).mockResolvedValueOnce({
'111': createPidusageStat(111, 123_000_000), '111': createPidusageStat(111, 123_000_000),
'333': createPidusageStat(333, 456_000_000), '333': createPidusageStat(333, 456_000_000),
@ -911,14 +945,20 @@ describe('TeamProvisioningService', () => {
]), ]),
}; };
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []); (svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).findLiveProcessPidByAgentId = vi.fn( vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
() => {
new Map([ pid: 17527,
['alice@signal-ops-6', 17527], ppid: 1,
['atlas@signal-ops-6', 17528], command:
]) '/Users/belief/.bun/bin/bun cli.js --agent-id alice@signal-ops-6 --agent-name alice --team-name signal-ops-6 --model gpt-5.4-mini',
); },
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map()); {
pid: 17528,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --agent-id atlas@signal-ops-6 --agent-name atlas --team-name signal-ops-6 --model gpt-5.3-codex',
},
]);
const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('signal-ops-6'); const metadata = await (svc as any).getLiveTeamAgentRuntimeMetadata('signal-ops-6');
@ -4009,6 +4049,9 @@ describe('TeamProvisioningService', () => {
const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow( const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow(
`Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).` `Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).`
); );
await vi.waitFor(() => {
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
});
await vi.advanceTimersByTimeAsync(1_500); await vi.advanceTimersByTimeAsync(1_500);
await restartPromise; await restartPromise;
@ -4056,9 +4099,14 @@ describe('TeamProvisioningService', () => {
backendType: 'process', backendType: 'process',
}, },
]); ]);
(svc as any).findLiveProcessPidByAgentId = vi.fn( vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
() => new Map([['forge@process-team', process.pid]]) {
); pid: process.pid,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --team-name process-team --agent-id forge@process-team --agent-name forge --model gpt-5.4',
},
]);
(svc as any).liveTeamAgentRuntimeMetadataCache.set('process-team', { (svc as any).liveTeamAgentRuntimeMetadataCache.set('process-team', {
expiresAtMs: Date.now() + 60_000, expiresAtMs: Date.now() + 60_000,
metadata: new Map([ metadata: new Map([
@ -4078,6 +4126,9 @@ describe('TeamProvisioningService', () => {
const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow( const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow(
`Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).` `Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).`
); );
await vi.waitFor(() => {
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
});
await vi.advanceTimersByTimeAsync(1_500); await vi.advanceTimersByTimeAsync(1_500);
await restartPromise; await restartPromise;
@ -4120,15 +4171,23 @@ describe('TeamProvisioningService', () => {
]), ]),
}; };
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []); (svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
(svc as any).findLiveProcessPidByAgentId = vi.fn( vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
() => new Map([['forge@process-team', process.pid]]) {
); pid: process.pid,
ppid: 1,
command:
'/Users/belief/.bun/bin/bun cli.js --team-name process-team --agent-id forge@process-team --agent-name forge --model gpt-5.4',
},
]);
(svc as any).aliveRunByTeam.set('process-team', run.runId); (svc as any).aliveRunByTeam.set('process-team', run.runId);
(svc as any).runs.set(run.runId, run); (svc as any).runs.set(run.runId, run);
const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow( const restartPromise = expect(svc.restartMember('process-team', 'forge')).rejects.toThrow(
`Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).` `Restart for teammate "forge" is still waiting for the previous process to exit (${process.pid}).`
); );
await vi.waitFor(() => {
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
});
await vi.advanceTimersByTimeAsync(1_500); await vi.advanceTimersByTimeAsync(1_500);
await restartPromise; await restartPromise;