feat(team): harden member runtime liveness
This commit is contained in:
parent
267a192329
commit
d517f2b320
34 changed files with 4089 additions and 294 deletions
2336
docs/team-management/member-liveness-hardening-plan.md
Normal file
2336
docs/team-management/member-liveness-hardening-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -279,7 +279,13 @@ function drawLaunchStage(
|
|||
for (let index = 0; index < 3; index += 1) {
|
||||
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
|
||||
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.fill();
|
||||
}
|
||||
|
|
@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
|
|||
return hexWithAlpha('#f59e0b', 0.92);
|
||||
case 'runtime_pending':
|
||||
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':
|
||||
return hexWithAlpha('#22c55e', 0.9);
|
||||
case 'error':
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ export type GraphLaunchVisualState =
|
|||
| 'spawning'
|
||||
| 'permission_pending'
|
||||
| 'runtime_pending'
|
||||
| 'shell_only'
|
||||
| 'runtime_candidate'
|
||||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error';
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
|||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
const runtimeDisplayName = 'teams orchestrator';
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
function shouldUseWindowsShell(cmd) {
|
||||
|
|
@ -108,9 +109,10 @@ function getPlatformAssetKey() {
|
|||
}
|
||||
|
||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
? runtimeLock.releaseTag.trim()
|
||||
: runtimeLock.sourceRef;
|
||||
const releaseTag =
|
||||
typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
? runtimeLock.releaseTag.trim()
|
||||
: runtimeLock.sourceRef;
|
||||
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
|
||||
}
|
||||
|
||||
|
|
@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
|
|||
|
||||
function buildProgressBar(progressRatio, width) {
|
||||
const safeWidth = Math.max(10, width);
|
||||
const clampedRatio = Number.isFinite(progressRatio)
|
||||
? Math.min(1, Math.max(0, progressRatio))
|
||||
: 0;
|
||||
const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
|
||||
const filledWidth = Math.round(safeWidth * clampedRatio);
|
||||
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
||||
}
|
||||
|
|
@ -164,7 +164,8 @@ function supportsProgressRedraw() {
|
|||
}
|
||||
|
||||
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 percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
||||
const bytesText = hasTotal
|
||||
|
|
@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
|
|||
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) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
|
|
@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
|
|||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
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`);
|
||||
}
|
||||
}
|
||||
|
|
@ -511,7 +525,9 @@ async function main() {
|
|||
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
||||
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 = {
|
||||
...process.env,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
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 runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
|
||||
|
|
@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform(
|
|||
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 {
|
||||
runtimeCommandExecutor.killPaneSync(paneId);
|
||||
invalidateTmuxRuntimeStatusCache();
|
||||
|
|
|
|||
|
|
@ -9,5 +9,11 @@ export {
|
|||
isTmuxRuntimeReadyForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatformSync,
|
||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
} from './composition/runtimeSupport';
|
||||
export type {
|
||||
RuntimeProcessTableRow,
|
||||
TmuxPaneRuntimeInfo,
|
||||
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,43 @@ interface ExecResult {
|
|||
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 {
|
||||
readonly #wslService: TmuxWslService;
|
||||
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))];
|
||||
if (normalizedPaneIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = await this.execTmux(
|
||||
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
|
||||
3_000
|
||||
);
|
||||
const format = [
|
||||
'#{pane_id}',
|
||||
'#{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) {
|
||||
throw new Error(result.stderr || 'Failed to list tmux panes');
|
||||
}
|
||||
|
||||
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')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [paneId = '', rawPid = ''] = trimmed.split('\t');
|
||||
const [
|
||||
paneId = '',
|
||||
rawPid = '',
|
||||
currentCommand = '',
|
||||
currentPath = '',
|
||||
sessionName = '',
|
||||
windowName = '',
|
||||
] = trimmed.split('\t');
|
||||
const normalizedPaneId = paneId.trim();
|
||||
if (!wanted.has(normalizedPaneId)) continue;
|
||||
const pid = Number.parseInt(rawPid.trim(), 10);
|
||||
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 {
|
||||
|
|
@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor {
|
|||
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> {
|
||||
const platform =
|
||||
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
);
|
||||
vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
||||
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: '',
|
||||
});
|
||||
|
||||
|
|
@ -86,7 +87,12 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
new Map([['%2', 222]])
|
||||
);
|
||||
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
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -268,6 +268,23 @@ export class TmuxWslService {
|
|||
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 {
|
||||
return this.#preferenceStore.getPreferredDistroSync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import type {
|
|||
PersistedTeamLaunchSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
ProviderModelLaunchIdentity,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamLaunchAggregateState,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -37,8 +40,13 @@ type RuntimeMemberSpawnState = Pick<
|
|||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'pendingPermissionRequestIds'
|
||||
| 'livenessKind'
|
||||
| 'runtimeDiagnostic'
|
||||
| 'runtimeDiagnosticSeverity'
|
||||
| 'livenessLastCheckedAt'
|
||||
| 'firstSpawnAcceptedAt'
|
||||
| 'lastHeartbeatAt'
|
||||
| 'runtimeModel'
|
||||
| 'updatedAt'
|
||||
>;
|
||||
|
||||
|
|
@ -59,6 +67,41 @@ function normalizeRuntimePid(value: unknown): number | 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 {
|
||||
return name.trim();
|
||||
}
|
||||
|
|
@ -110,6 +153,11 @@ export function summarizePersistedLaunchMembers(
|
|||
let pendingCount = 0;
|
||||
let failedCount = 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 memberNames = Array.from(
|
||||
new Set([
|
||||
|
|
@ -136,9 +184,31 @@ export function summarizePersistedLaunchMembers(
|
|||
if (entry.runtimeAlive) {
|
||||
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(
|
||||
|
|
@ -340,6 +410,12 @@ function normalizePersistedMemberState(
|
|||
parsed.pendingPermissionRequestIds
|
||||
),
|
||||
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:
|
||||
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
||||
lastHeartbeatAt:
|
||||
|
|
@ -492,6 +568,10 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
|||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
livenessKind: runtime?.livenessKind,
|
||||
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
|
||||
|
|
@ -555,6 +635,10 @@ export function snapshotToMemberSpawnStatuses(
|
|||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
||||
hardFailure: entry.hardFailure,
|
||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||
livenessKind: entry.livenessKind,
|
||||
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
|
||||
livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt,
|
||||
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: entry.lastHeartbeatAt,
|
||||
updatedAt: entry.lastEvaluatedAt,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ export interface LaunchStateSummary {
|
|||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
runtimeCandidatePendingCount?: number;
|
||||
noRuntimePendingCount?: number;
|
||||
permissionPendingCount?: number;
|
||||
}
|
||||
|
||||
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
|
||||
|
|
@ -73,6 +78,11 @@ export function createLaunchStateSummary(
|
|||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
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) {
|
||||
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;
|
||||
return normalized;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,17 +227,7 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
|
||||
}
|
||||
|
||||
// 2. Dev mode — prefer built dist for reliable direct execution
|
||||
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
|
||||
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
checked.push(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(
|
||||
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
|
||||
);
|
||||
|
|
|
|||
14
src/main/services/team/TeamMemberLivenessMode.ts
Normal file
14
src/main/services/team/TeamMemberLivenessMode.ts
Normal 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
350
src/main/services/team/TeamRuntimeLivenessResolver.ts
Normal file
350
src/main/services/team/TeamRuntimeLivenessResolver.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -12,8 +12,12 @@
|
|||
* diagnostics and completion-time reports.
|
||||
*/
|
||||
|
||||
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||
|
||||
export const PROGRESS_LOG_TAIL_LINES = 200;
|
||||
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"
|
||||
|
|
@ -50,3 +54,29 @@ export function buildProgressAssistantOutput(
|
|||
const joined = tail.join('\n\n');
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -546,7 +546,25 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
const confirmed = launchState === 'confirmed_alive';
|
||||
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
|
||||
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 {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -557,7 +575,7 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: launchState === 'permission_blocked'
|
||||
? 'runtime_pending_permission'
|
||||
: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: confirmed || pendingRuntimeObserved,
|
||||
agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized,
|
||||
runtimeAlive: confirmed || pendingRuntimeObserved,
|
||||
bootstrapConfirmed: confirmed,
|
||||
hardFailure: failed,
|
||||
|
|
@ -567,9 +585,10 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
? [...new Set(pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
sessionId,
|
||||
...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0
|
||||
? { runtimePid }
|
||||
: {}),
|
||||
...(hasRuntimePid ? { runtimePid } : {}),
|
||||
livenessKind,
|
||||
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamAgentRuntimeBackendType,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamLaunchAggregateState,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -73,6 +75,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
sessionId?: string;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
runtimePid?: number;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { DISPLAY_STEPS } from './provisioningSteps';
|
|||
import { StepProgressBar } from './StepProgressBar';
|
||||
|
||||
import type { StepProgressBarStep } from './StepProgressBar';
|
||||
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||
|
||||
/** Pre-built step definitions for the provisioning stepper. */
|
||||
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
|
||||
|
|
@ -61,6 +62,8 @@ export interface ProvisioningProgressBlockProps {
|
|||
cliLogsTail?: string;
|
||||
/** Accumulated assistant text output for live preview */
|
||||
assistantOutput?: string;
|
||||
/** Bounded structured launch diagnostics */
|
||||
launchDiagnostics?: TeamLaunchDiagnosticItem[];
|
||||
/** Visual surface chrome for the outer block */
|
||||
surface?: 'raised' | 'flat';
|
||||
className?: string;
|
||||
|
|
@ -153,11 +156,13 @@ export const ProvisioningProgressBlock = ({
|
|||
pid,
|
||||
cliLogsTail,
|
||||
assistantOutput,
|
||||
launchDiagnostics,
|
||||
surface = 'raised',
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt, loading);
|
||||
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isError = tone === 'error';
|
||||
|
|
@ -293,6 +298,42 @@ export const ProvisioningProgressBlock = ({
|
|||
errorIndex={errorStepIndex}
|
||||
/>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
|
|||
pid={presentation.progress.pid}
|
||||
cliLogsTail={presentation.progress.cliLogsTail}
|
||||
assistantOutput={presentation.progress.assistantOutput}
|
||||
launchDiagnostics={presentation.progress.launchDiagnostics}
|
||||
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
|
||||
defaultLogsOpen={defaultLogsOpen}
|
||||
onCancel={
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import type {
|
|||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -32,6 +33,7 @@ interface MemberCardProps {
|
|||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
runtimeSummary?: string;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
taskCounts?: TaskStatusCounts | null;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -77,6 +79,7 @@ export const MemberCard = ({
|
|||
member,
|
||||
memberColor,
|
||||
runtimeSummary,
|
||||
runtimeEntry,
|
||||
taskCounts,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -113,6 +116,7 @@ export const MemberCard = ({
|
|||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
|
|
@ -128,7 +132,12 @@ export const MemberCard = ({
|
|||
const launchVisualState = launchPresentation.launchVisualState;
|
||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||
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)
|
||||
: presenceLabel;
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
|
|
@ -159,7 +168,11 @@ export const MemberCard = ({
|
|||
!runtimeAdvisoryLabel &&
|
||||
(presenceLabel === 'starting' ||
|
||||
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 showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
|
|
@ -283,12 +296,28 @@ export const MemberCard = ({
|
|||
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
|
||||
<span className="shrink-0 opacity-60">•</span>
|
||||
) : 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>
|
||||
) : null}
|
||||
</div>
|
||||
{showLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
title={runtimeEntry?.runtimeDiagnostic}
|
||||
>
|
||||
<Loader2
|
||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||
aria-label={launchBadgeLabel}
|
||||
|
|
|
|||
|
|
@ -167,6 +167,7 @@ export const MemberDetailDialog = ({
|
|||
spawnLaunchState={spawnEntry?.launchState}
|
||||
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||
runtimeEntry={runtimeEntry}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
onUpdateRole={
|
||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||
|
|
@ -253,6 +254,7 @@ export const MemberDetailDialog = ({
|
|||
) : runtimeEntry?.pid ? (
|
||||
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
||||
PID {runtimeEntry.pid}
|
||||
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mr-auto" />
|
||||
|
|
|
|||
|
|
@ -23,11 +23,13 @@ import type {
|
|||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
} from '@shared/types';
|
||||
|
||||
interface MemberDetailHeaderProps {
|
||||
member: ResolvedTeamMember;
|
||||
runtimeSummary?: string;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
|
|
@ -43,6 +45,7 @@ interface MemberDetailHeaderProps {
|
|||
export const MemberDetailHeader = ({
|
||||
member,
|
||||
runtimeSummary,
|
||||
runtimeEntry,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
|
|
@ -75,6 +78,7 @@ export const MemberDetailHeader = ({
|
|||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
|
|
@ -91,7 +95,12 @@ export const MemberDetailHeader = ({
|
|||
const badgeLabel =
|
||||
runtimeAdvisoryTone === 'error' && 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)
|
||||
: presenceLabel;
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const MemberHoverCard = ({
|
|||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
leadActivity,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -89,6 +90,9 @@ export const MemberHoverCard = ({
|
|||
spawnEntry: effectiveTeamName
|
||||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||
: undefined,
|
||||
runtimeEntry: effectiveTeamName
|
||||
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name]
|
||||
: undefined,
|
||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||
}))
|
||||
);
|
||||
|
|
@ -114,6 +118,7 @@ export const MemberHoverCard = ({
|
|||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
|
|
@ -130,7 +135,12 @@ export const MemberHoverCard = ({
|
|||
const badgeLabel =
|
||||
runtimeAdvisoryTone === 'error' && 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)
|
||||
: presenceLabel;
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@ function areMemberSpawnStatusesEquivalent(
|
|||
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
||||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||
leftEntry.livenessKind !== rightEntry.livenessKind ||
|
||||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
|
||||
leftEntry.runtimeDiagnosticSeverity !== rightEntry.runtimeDiagnosticSeverity ||
|
||||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
||||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
|
||||
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
|
||||
|
|
@ -196,7 +199,13 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
leftEntry.backendType !== rightEntry?.backendType ||
|
||||
leftEntry.pid !== rightEntry?.pid ||
|
||||
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;
|
||||
}
|
||||
|
|
@ -332,6 +341,7 @@ export const MemberList = memo(function MemberList({
|
|||
isRemoved ? undefined : spawnEntry,
|
||||
isRemoved ? undefined : runtimeEntry
|
||||
)}
|
||||
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
entry.launchState === 'runtime_pending_bootstrap' ||
|
||||
entry.launchState === 'runtime_pending_permission'
|
||||
) {
|
||||
if (entry.runtimeAlive === true) {
|
||||
if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') {
|
||||
processOnlyAliveCount += 1;
|
||||
} else {
|
||||
pendingSpawnCount += 1;
|
||||
|
|
@ -199,7 +199,8 @@ export function getLaunchJoinMilestonesFromMembers({
|
|||
const snapshotMilestones = {
|
||||
expectedTeammateCount,
|
||||
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
||||
processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount,
|
||||
processOnlyAliveCount:
|
||||
snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount,
|
||||
pendingSpawnCount: Math.max(
|
||||
0,
|
||||
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamPresenceRefreshTimers = 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 inProgressChangePresencePollInFlight = false;
|
||||
let teamMessageFallbackPollInFlight = false;
|
||||
|
|
@ -286,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
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 => {
|
||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||
return;
|
||||
|
|
@ -1194,6 +1208,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
||||
scheduleTeamAgentRuntimeRefresh(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1276,6 +1291,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
teamPresenceRefreshTimers = new Map();
|
||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||
memberSpawnRefreshTimers = new Map();
|
||||
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
|
||||
teamAgentRuntimeRefreshTimers = new Map();
|
||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||
toolActivityTimers = new Map();
|
||||
teamLastRelevantActivityAt.clear();
|
||||
|
|
|
|||
|
|
@ -702,7 +702,12 @@ function areLaunchSummaryCountsEqual(
|
|||
left.confirmedCount === right.confirmedCount &&
|
||||
left.pendingCount === right.pendingCount &&
|
||||
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.runtimeAlive === right.runtimeAlive &&
|
||||
left.runtimeModel === right.runtimeModel &&
|
||||
left.livenessKind === right.livenessKind &&
|
||||
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
||||
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||
left.hardFailure === right.hardFailure &&
|
||||
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
|
||||
|
|
@ -809,7 +817,13 @@ function areTeamAgentRuntimeEntriesEqual(
|
|||
left.backendType === right.backendType &&
|
||||
left.pid === right.pid &&
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
MemberSpawnStatus,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamProviderId,
|
||||
TeamReviewState,
|
||||
TeamTaskStatus,
|
||||
|
|
@ -531,6 +532,10 @@ export type MemberLaunchVisualState =
|
|||
| 'spawning'
|
||||
| 'permission_pending'
|
||||
| 'runtime_pending'
|
||||
| 'shell_only'
|
||||
| 'runtime_candidate'
|
||||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error'
|
||||
| null;
|
||||
|
|
@ -556,7 +561,15 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
|||
case 'permission_pending':
|
||||
return 'awaiting permission';
|
||||
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':
|
||||
return 'joining team';
|
||||
case 'error':
|
||||
|
|
@ -573,6 +586,7 @@ export function buildMemberLaunchPresentation({
|
|||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
runtimeAdvisory,
|
||||
runtimeEntry,
|
||||
isLaunchSettling = false,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -584,6 +598,7 @@ export function buildMemberLaunchPresentation({
|
|||
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
|
||||
spawnRuntimeAlive: boolean | undefined;
|
||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
isLaunchSettling?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -630,10 +645,21 @@ export function buildMemberLaunchPresentation({
|
|||
launchVisualState = 'error';
|
||||
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
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 (
|
||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||
spawnStatus === 'online' &&
|
||||
spawnRuntimeAlive === true
|
||||
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||
) {
|
||||
launchVisualState = 'runtime_pending';
|
||||
} else if (
|
||||
|
|
@ -655,6 +681,15 @@ export function buildMemberLaunchPresentation({
|
|||
}
|
||||
|
||||
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 =
|
||||
spawnStatus && spawnStatus !== 'online'
|
||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||
|
|
@ -663,7 +698,7 @@ export function buildMemberLaunchPresentation({
|
|||
: null;
|
||||
|
||||
return {
|
||||
presenceLabel,
|
||||
presenceLabel: displayPresenceLabel,
|
||||
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
|
||||
cardClass,
|
||||
runtimeAdvisoryLabel,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,27 @@ function buildAwaitingPermissionPhrase(count: number): string {
|
|||
: `${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([
|
||||
'validating',
|
||||
'spawning',
|
||||
|
|
@ -394,7 +415,7 @@ export function buildTeamProvisioningPresentation({
|
|||
permissionBlockedCount === remainingJoinCount;
|
||||
const pendingDetailPhrase = pendingMembersAwaitApproval
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: joiningPhrase;
|
||||
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase);
|
||||
const readyCompactDetail =
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnCompactDetail ??
|
||||
|
|
@ -471,7 +492,7 @@ export function buildTeamProvisioningPresentation({
|
|||
permissionBlockedCount > 0 &&
|
||||
permissionBlockedCount === remainingJoinCount
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: activeJoiningPhrase;
|
||||
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase);
|
||||
return {
|
||||
progress,
|
||||
isActive: true,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ export interface TeamSummary {
|
|||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
runtimeCandidatePendingCount?: number;
|
||||
noRuntimePendingCount?: number;
|
||||
permissionPendingCount?: number;
|
||||
}
|
||||
|
||||
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||
|
|
@ -941,6 +946,12 @@ export interface PersistedTeamLaunchMemberState {
|
|||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
runtimeLastSeenAt?: string;
|
||||
firstSpawnAcceptedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
lastRuntimeAliveAt?: string;
|
||||
|
|
@ -954,6 +965,11 @@ export interface PersistedTeamLaunchSummary {
|
|||
pendingCount: number;
|
||||
failedCount: number;
|
||||
runtimeAlivePendingCount: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
runtimeCandidatePendingCount?: number;
|
||||
noRuntimePendingCount?: number;
|
||||
permissionPendingCount?: number;
|
||||
}
|
||||
|
||||
export interface PersistedTeamLaunchSnapshot {
|
||||
|
|
@ -984,6 +1000,27 @@ export type MemberSpawnLivenessSource = 'heartbeat' | '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 {
|
||||
memberName: string;
|
||||
alive: boolean;
|
||||
|
|
@ -996,6 +1033,19 @@ export interface TeamAgentRuntimeEntry {
|
|||
pid?: number;
|
||||
runtimeModel?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -1062,6 +1112,14 @@ export interface MemberSpawnStatusEntry {
|
|||
lastHeartbeatAt?: string;
|
||||
/** Live runtime model observed from the teammate process, when available. */
|
||||
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. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -1176,6 +1234,28 @@ export interface TeamProvisioningProgress {
|
|||
assistantOutput?: string;
|
||||
/** True once provisioning has written a readable config.json for this team. */
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -60,26 +60,27 @@ describe('TeamLaunchStateEvaluator', () => {
|
|||
});
|
||||
|
||||
it('counts persisted members in launch summary even when expectedMembers is stale', () => {
|
||||
const summary = summarizePersistedLaunchMembers(
|
||||
['alice'],
|
||||
{
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
},
|
||||
bob: {
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
},
|
||||
} as any
|
||||
);
|
||||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
},
|
||||
bob: {
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(summary).toEqual({
|
||||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -75,7 +75,9 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
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 parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<string, { command?: string; args?: string[] }>;
|
||||
|
|
@ -83,26 +85,72 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
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?.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);
|
||||
vi.spyOn(fs.promises, 'access').mockImplementation(async (targetPath, mode) => {
|
||||
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)) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
const builtEntry = path.join(process.cwd(), 'mcp-server', 'dist', 'index.js');
|
||||
mockPathExists([builtEntry]);
|
||||
const builtEntry = getBuiltWorkspaceEntry();
|
||||
mockPathExists([builtEntry], { strict: true });
|
||||
return builtEntry;
|
||||
}
|
||||
|
||||
|
|
@ -172,12 +220,26 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
createdPaths.push(configPath);
|
||||
|
||||
const filename = path.basename(configPath);
|
||||
expect(filename).toMatch(
|
||||
new RegExp(`^agent-teams-mcp-${process.pid}-\\d+-[0-9a-f-]+\\.json$`)
|
||||
);
|
||||
expect(filename).toMatch(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 builder = new TeamMcpConfigBuilder();
|
||||
|
||||
|
|
@ -232,7 +294,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
createdPaths.push(configPath);
|
||||
|
||||
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']);
|
||||
|
|
@ -246,7 +311,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
createdDirs.push(homeDir, projectDir);
|
||||
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(
|
||||
path.join(projectDir, '.mcp.json'),
|
||||
JSON.stringify(
|
||||
|
|
@ -273,7 +341,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
});
|
||||
|
||||
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-'));
|
||||
createdDirs.push(homeDir);
|
||||
mockHomeDir = homeDir;
|
||||
|
|
@ -299,7 +367,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
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 () => {
|
||||
|
|
@ -494,7 +562,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
const configPath = await builder.writeConfigFile();
|
||||
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 () => {
|
||||
|
|
@ -526,8 +597,8 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
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 () => {
|
||||
const builtEntry = mockBuiltWorkspaceEntryAvailable();
|
||||
it('packaged mode falls back to the source workspace MCP entry when resourcesPath bundle is missing', async () => {
|
||||
const { sourceEntry } = mockSourceWorkspaceEntryAvailable();
|
||||
setPackagedMode(true, '6.0.0');
|
||||
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||
createdDirs.push(resourcesDir);
|
||||
|
|
@ -537,6 +608,6 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expectNodeEntry(readGeneratedServer(configPath), builtEntry);
|
||||
expectTsxEntry(readGeneratedServer(configPath), sourceEntry);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|||
|
||||
vi.mock('@features/tmux-installer/main', () => ({
|
||||
killTmuxPaneForCurrentPlatformSync: vi.fn(),
|
||||
listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []),
|
||||
listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()),
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()),
|
||||
isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
|
|
@ -144,7 +146,9 @@ import {
|
|||
} from 'agent-teams-controller';
|
||||
import {
|
||||
killTmuxPaneForCurrentPlatformSync,
|
||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
} from '@features/tmux-installer/main';
|
||||
import pidusage from 'pidusage';
|
||||
|
||||
|
|
@ -409,6 +413,13 @@ function createClaudeLogsRun(overrides: Record<string, unknown> = {}) {
|
|||
describe('TeamProvisioningService', () => {
|
||||
beforeEach(() => {
|
||||
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-'));
|
||||
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
||||
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
|
||||
|
|
@ -557,7 +568,18 @@ describe('TeamProvisioningService', () => {
|
|||
cancelRequested: false,
|
||||
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({
|
||||
'111': createPidusageStat(111, 123_000_000),
|
||||
|
|
@ -650,7 +672,18 @@ describe('TeamProvisioningService', () => {
|
|||
cancelRequested: false,
|
||||
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)
|
||||
.mockRejectedValueOnce(new Error('ps: process exited'))
|
||||
|
|
@ -693,14 +726,14 @@ describe('TeamProvisioningService', () => {
|
|||
cancelRequested: false,
|
||||
spawnContext: null,
|
||||
});
|
||||
(svc as any).readUnixProcessTableRows = vi.fn(() => [
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{
|
||||
pid: 333,
|
||||
ppid: 1,
|
||||
command:
|
||||
'/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({
|
||||
'111': createPidusageStat(111, 123_000_000),
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
|
|
@ -746,19 +779,20 @@ describe('TeamProvisioningService', () => {
|
|||
cancelRequested: false,
|
||||
spawnContext: null,
|
||||
});
|
||||
(svc as any).readUnixProcessTableRows = vi.fn(() => [
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{
|
||||
pid: 222,
|
||||
ppid: 1,
|
||||
command:
|
||||
'/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,
|
||||
ppid: 1,
|
||||
command:
|
||||
'/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({
|
||||
'111': createPidusageStat(111, 123_000_000),
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
|
|
@ -911,14 +945,20 @@ describe('TeamProvisioningService', () => {
|
|||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).findLiveProcessPidByAgentId = vi.fn(
|
||||
() =>
|
||||
new Map([
|
||||
['alice@signal-ops-6', 17527],
|
||||
['atlas@signal-ops-6', 17528],
|
||||
])
|
||||
);
|
||||
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValueOnce(new Map());
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{
|
||||
pid: 17527,
|
||||
ppid: 1,
|
||||
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',
|
||||
},
|
||||
{
|
||||
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');
|
||||
|
||||
|
|
@ -4009,6 +4049,9 @@ describe('TeamProvisioningService', () => {
|
|||
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}).`
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
await restartPromise;
|
||||
|
||||
|
|
@ -4056,9 +4099,14 @@ describe('TeamProvisioningService', () => {
|
|||
backendType: 'process',
|
||||
},
|
||||
]);
|
||||
(svc as any).findLiveProcessPidByAgentId = vi.fn(
|
||||
() => new Map([['forge@process-team', process.pid]])
|
||||
);
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{
|
||||
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', {
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
metadata: new Map([
|
||||
|
|
@ -4078,6 +4126,9 @@ describe('TeamProvisioningService', () => {
|
|||
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}).`
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
await restartPromise;
|
||||
|
||||
|
|
@ -4120,15 +4171,23 @@ describe('TeamProvisioningService', () => {
|
|||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => []);
|
||||
(svc as any).findLiveProcessPidByAgentId = vi.fn(
|
||||
() => new Map([['forge@process-team', process.pid]])
|
||||
);
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{
|
||||
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).runs.set(run.runId, run);
|
||||
|
||||
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}).`
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(vi.mocked(killProcessByPid)).toHaveBeenCalledWith(process.pid);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1_500);
|
||||
await restartPromise;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue