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) {
|
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':
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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')}`
|
||||||
);
|
);
|
||||||
|
|
|
||||||
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.
|
* 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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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={
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue