fix(team): restart shell-only tmux teammates directly
This commit is contained in:
parent
4d5533585c
commit
42c9cbd227
6 changed files with 850 additions and 65 deletions
|
|
@ -40,6 +40,13 @@ export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
|
|||
return runtimeCommandExecutor.listRuntimeProcesses();
|
||||
}
|
||||
|
||||
export async function sendKeysToTmuxPaneForCurrentPlatform(
|
||||
paneId: string,
|
||||
command: string
|
||||
): Promise<void> {
|
||||
await runtimeCommandExecutor.sendKeysToPane(paneId, command);
|
||||
}
|
||||
|
||||
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
||||
runtimeCommandExecutor.killPaneSync(paneId);
|
||||
invalidateTmuxRuntimeStatusCache();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export {
|
|||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
sendKeysToTmuxPaneForCurrentPlatform,
|
||||
} from './composition/runtimeSupport';
|
||||
export type {
|
||||
RuntimeProcessTableRow,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
|
|
@ -19,6 +22,7 @@ export interface TmuxPaneRuntimeInfo {
|
|||
currentPath?: string;
|
||||
sessionName?: string;
|
||||
windowName?: string;
|
||||
socketName?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProcessTableRow {
|
||||
|
|
@ -61,16 +65,17 @@ export class TmuxPlatformCommandExecutor {
|
|||
this.#packageManagerResolver = packageManagerResolver;
|
||||
}
|
||||
|
||||
async execTmux(args: string[], timeout = 5_000): Promise<ExecResult> {
|
||||
async execTmux(args: string[], timeout = 5_000, socketName?: string): Promise<ExecResult> {
|
||||
const effectiveArgs = socketName ? ['-L', socketName, ...args] : args;
|
||||
if (process.platform === 'win32') {
|
||||
return this.#wslService.execTmux(args, null, timeout);
|
||||
return this.#wslService.execTmux(effectiveArgs, null, timeout);
|
||||
}
|
||||
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = buildEnrichedEnv();
|
||||
const executable = await this.#resolveNativeTmuxExecutable(env);
|
||||
return new Promise((resolve) => {
|
||||
execFile(executable, args, { env, timeout }, (error, stdout, stderr) => {
|
||||
execFile(executable, effectiveArgs, { env, timeout }, (error, stdout, stderr) => {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as NodeJS.ErrnoException).code
|
||||
|
|
@ -85,10 +90,16 @@ export class TmuxPlatformCommandExecutor {
|
|||
}
|
||||
|
||||
async killPane(paneId: string): Promise<void> {
|
||||
const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || `Failed to kill tmux pane ${paneId}`);
|
||||
const candidates = await this.#getTmuxSocketCandidates();
|
||||
let lastError = '';
|
||||
for (const socketName of candidates) {
|
||||
const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000, socketName);
|
||||
if (result.exitCode === 0) {
|
||||
return;
|
||||
}
|
||||
lastError = result.stderr || `Failed to kill tmux pane ${paneId}`;
|
||||
}
|
||||
throw new Error(lastError || `Failed to kill tmux pane ${paneId}`);
|
||||
}
|
||||
|
||||
async listPaneRuntimeInfo(paneIds: readonly string[]): Promise<Map<string, TmuxPaneRuntimeInfo>> {
|
||||
|
|
@ -106,37 +117,48 @@ export class TmuxPlatformCommandExecutor {
|
|||
'#{window_name}',
|
||||
].join('\t');
|
||||
|
||||
const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to list tmux panes');
|
||||
}
|
||||
|
||||
const wanted = new Set(normalizedPaneIds);
|
||||
const paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [
|
||||
paneId = '',
|
||||
rawPid = '',
|
||||
currentCommand = '',
|
||||
currentPath = '',
|
||||
sessionName = '',
|
||||
windowName = '',
|
||||
] = trimmed.split('\t');
|
||||
const normalizedPaneId = paneId.trim();
|
||||
if (!wanted.has(normalizedPaneId)) continue;
|
||||
const pid = Number.parseInt(rawPid.trim(), 10);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
paneInfoById.set(normalizedPaneId, {
|
||||
paneId: normalizedPaneId,
|
||||
panePid: pid,
|
||||
currentCommand: currentCommand.trim() || undefined,
|
||||
currentPath: currentPath.trim() || undefined,
|
||||
sessionName: sessionName.trim() || undefined,
|
||||
windowName: windowName.trim() || undefined,
|
||||
});
|
||||
const candidates = await this.#getTmuxSocketCandidates();
|
||||
let sawSuccessfulList = false;
|
||||
let lastError = '';
|
||||
|
||||
for (const socketName of candidates) {
|
||||
const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000, socketName);
|
||||
if (result.exitCode !== 0) {
|
||||
lastError = result.stderr || 'Failed to list tmux panes';
|
||||
continue;
|
||||
}
|
||||
sawSuccessfulList = true;
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [
|
||||
paneId = '',
|
||||
rawPid = '',
|
||||
currentCommand = '',
|
||||
currentPath = '',
|
||||
sessionName = '',
|
||||
windowName = '',
|
||||
] = trimmed.split('\t');
|
||||
const normalizedPaneId = paneId.trim();
|
||||
if (!wanted.has(normalizedPaneId) || paneInfoById.has(normalizedPaneId)) continue;
|
||||
const pid = Number.parseInt(rawPid.trim(), 10);
|
||||
if (Number.isFinite(pid) && pid > 0) {
|
||||
paneInfoById.set(normalizedPaneId, {
|
||||
paneId: normalizedPaneId,
|
||||
panePid: pid,
|
||||
currentCommand: currentCommand.trim() || undefined,
|
||||
currentPath: currentPath.trim() || undefined,
|
||||
sessionName: sessionName.trim() || undefined,
|
||||
windowName: windowName.trim() || undefined,
|
||||
...(socketName ? { socketName } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sawSuccessfulList) {
|
||||
throw new Error(lastError || 'Failed to list tmux panes');
|
||||
}
|
||||
return paneInfoById;
|
||||
}
|
||||
|
|
@ -157,6 +179,19 @@ export class TmuxPlatformCommandExecutor {
|
|||
return parseRuntimeProcessTable(result.stdout);
|
||||
}
|
||||
|
||||
async sendKeysToPane(paneId: string, command: string): Promise<void> {
|
||||
const paneInfo = await this.listPaneRuntimeInfo([paneId]);
|
||||
const socketName = paneInfo.get(paneId)?.socketName;
|
||||
const result = await this.execTmux(
|
||||
['send-keys', '-t', paneId, command, 'Enter'],
|
||||
3_000,
|
||||
socketName
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || `Failed to send command to tmux pane ${paneId}`);
|
||||
}
|
||||
}
|
||||
|
||||
killPaneSync(paneId: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
const preferredDistro = this.#wslService.getPersistedPreferredDistroSync();
|
||||
|
|
@ -183,8 +218,24 @@ export class TmuxPlatformCommandExecutor {
|
|||
throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used
|
||||
execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
|
||||
const candidates = this.#getTmuxSocketCandidatesSync();
|
||||
let lastError: Error | null = null;
|
||||
for (const socketName of candidates) {
|
||||
try {
|
||||
execFileSync(
|
||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used
|
||||
'tmux',
|
||||
[...(socketName ? ['-L', socketName] : []), 'kill-pane', '-t', paneId],
|
||||
{
|
||||
stdio: 'ignore',
|
||||
}
|
||||
);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`);
|
||||
}
|
||||
|
||||
#getWslExecutableCandidates(): string[] {
|
||||
|
|
@ -221,6 +272,69 @@ export class TmuxPlatformCommandExecutor {
|
|||
});
|
||||
}
|
||||
|
||||
async #getTmuxSocketCandidates(): Promise<(string | undefined)[]> {
|
||||
if (process.platform === 'win32') {
|
||||
return [undefined];
|
||||
}
|
||||
return [...(await this.#listNativeSwarmSocketNames()), undefined];
|
||||
}
|
||||
|
||||
#getTmuxSocketCandidatesSync(): (string | undefined)[] {
|
||||
if (process.platform === 'win32') {
|
||||
return [undefined];
|
||||
}
|
||||
return [...this.#listNativeSwarmSocketNamesSync(), undefined];
|
||||
}
|
||||
|
||||
async #listNativeSwarmSocketNames(): Promise<string[]> {
|
||||
const dirs = this.#getNativeTmuxSocketDirs();
|
||||
const names = new Set<string>();
|
||||
await Promise.all(
|
||||
dirs.map(async (dir) => {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.promises.readdir(dir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('claude-swarm-')) {
|
||||
names.add(entry);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return [...names].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
#listNativeSwarmSocketNamesSync(): string[] {
|
||||
const names = new Set<string>();
|
||||
for (const dir of this.#getNativeTmuxSocketDirs()) {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.startsWith('claude-swarm-')) {
|
||||
names.add(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...names].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
#getNativeTmuxSocketDirs(): string[] {
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : os.userInfo().uid;
|
||||
const candidates = [
|
||||
path.join('/tmp', `tmux-${uid}`),
|
||||
path.join('/private/tmp', `tmux-${uid}`),
|
||||
path.join(os.tmpdir(), `tmux-${uid}`),
|
||||
];
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
|
||||
const platform =
|
||||
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ vi.mock('node:child_process', async () => {
|
|||
});
|
||||
|
||||
import * as childProcess from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
import { TmuxPlatformCommandExecutor } from '../TmuxPlatformCommandExecutor';
|
||||
|
||||
|
|
@ -28,6 +29,7 @@ const originalWindir = process.env.WINDIR;
|
|||
describe('TmuxPlatformCommandExecutor', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('ENOENT'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -76,7 +78,7 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
} as never,
|
||||
{} as never
|
||||
);
|
||||
vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
||||
const execTmux = vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
||||
exitCode: 0,
|
||||
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',
|
||||
|
|
@ -86,14 +88,15 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
await expect(executor.listPanePids(['%2', '%3', '%2'])).resolves.toEqual(
|
||||
new Map([['%2', 222]])
|
||||
);
|
||||
expect(executor.execTmux).toHaveBeenCalledWith(
|
||||
expect(execTmux).toHaveBeenCalledWith(
|
||||
[
|
||||
'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,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
sendKeysToTmuxPaneForCurrentPlatform,
|
||||
type TmuxPaneRuntimeInfo,
|
||||
} from '@features/tmux-installer/main';
|
||||
import { ConfigManager } from '@main/services/infrastructure/ConfigManager';
|
||||
|
|
@ -642,6 +643,60 @@ const STALL_CHECK_INTERVAL_MS = 10_000;
|
|||
const STALL_WARNING_THRESHOLD_MS = 20_000;
|
||||
const APP_TEAM_RUNTIME_DISALLOWED_TOOLS =
|
||||
'TeamDelete,TodoWrite,TaskCreate,TaskUpdate,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop';
|
||||
const DIRECT_TMUX_RESTART_ENV_KEYS = [
|
||||
'CLAUDE_CONFIG_DIR',
|
||||
'CLAUDE_TEAM_CONTROL_URL',
|
||||
'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||
'CLAUDE_CODE_GEMINI_BACKEND',
|
||||
'CLAUDE_CODE_CODEX_BACKEND',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'GEMINI_BASE_URL',
|
||||
'GEMINI_API_VERSION',
|
||||
'GEMINI_API_KEY',
|
||||
'CODEX_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_APPLICATION_CREDENTIALS',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_CLOUD_PROJECT_ID',
|
||||
'GCLOUD_PROJECT',
|
||||
'HTTPS_PROXY',
|
||||
'https_proxy',
|
||||
'HTTP_PROXY',
|
||||
'http_proxy',
|
||||
'NO_PROXY',
|
||||
'no_proxy',
|
||||
'SSL_CERT_FILE',
|
||||
'NODE_EXTRA_CA_CERTS',
|
||||
'REQUESTS_CA_BUNDLE',
|
||||
'CURL_CA_BUNDLE',
|
||||
] as const;
|
||||
const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [
|
||||
'CLAUDE_CODE_USE_OPENAI',
|
||||
'CLAUDE_CODE_USE_BEDROCK',
|
||||
'CLAUDE_CODE_USE_VERTEX',
|
||||
'CLAUDE_CODE_USE_FOUNDRY',
|
||||
'CLAUDE_CODE_USE_GEMINI',
|
||||
'CLAUDE_CODE_ENTRY_PROVIDER',
|
||||
] as const;
|
||||
const INTERACTIVE_SHELL_COMMANDS = new Set([
|
||||
'bash',
|
||||
'zsh',
|
||||
'sh',
|
||||
'fish',
|
||||
'nu',
|
||||
'pwsh',
|
||||
'powershell',
|
||||
'cmd',
|
||||
'cmd.exe',
|
||||
]);
|
||||
const TEAM_JSON_READ_TIMEOUT_MS = 5_000;
|
||||
const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
|
@ -712,6 +767,69 @@ function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): st
|
|||
return mergeJsonSettingsArgs([...providerArgs, ...args]);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
if (value.length === 0) {
|
||||
return "''";
|
||||
}
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function isInteractiveShellCommand(command: string | undefined): boolean {
|
||||
const normalized = command?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized));
|
||||
}
|
||||
|
||||
function getDirectRestartEntryProvider(providerId: TeamProviderId): string {
|
||||
return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic';
|
||||
}
|
||||
|
||||
function buildDirectTmuxRestartEnvAssignments(
|
||||
env: NodeJS.ProcessEnv,
|
||||
providerId: TeamProviderId
|
||||
): string {
|
||||
const assignments = new Map<string, string>();
|
||||
assignments.set('CLAUDECODE', '1');
|
||||
assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1');
|
||||
|
||||
for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) {
|
||||
const value = env[key];
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
assignments.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) {
|
||||
assignments.set(key, '');
|
||||
}
|
||||
assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1');
|
||||
assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId));
|
||||
|
||||
return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' ');
|
||||
}
|
||||
|
||||
function buildDirectTmuxRestartCommand(input: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
providerId: TeamProviderId;
|
||||
binaryPath: string;
|
||||
args: string[];
|
||||
}): string {
|
||||
const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId);
|
||||
const command = [
|
||||
'cd',
|
||||
shellQuote(input.cwd),
|
||||
'&&',
|
||||
'env',
|
||||
envAssignments,
|
||||
shellQuote(input.binaryPath),
|
||||
...input.args.map(shellQuote),
|
||||
].join(' ');
|
||||
return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`;
|
||||
}
|
||||
|
||||
interface ProviderModelListCommandResponse {
|
||||
schemaVersion?: number;
|
||||
providers?: Record<
|
||||
|
|
@ -4781,8 +4899,7 @@ export class TeamProvisioningService {
|
|||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
let cleanup!: Promise<number>;
|
||||
cleanup = this.stopOpenCodeRuntimeLanesForStoppedTeamInternal(teamName).finally(() => {
|
||||
const cleanup = this.stopOpenCodeRuntimeLanesForStoppedTeamInternal(teamName).finally(() => {
|
||||
if (this.stoppedTeamOpenCodeRuntimeCleanupInFlight.get(teamName) === cleanup) {
|
||||
this.stoppedTeamOpenCodeRuntimeCleanupInFlight.delete(teamName);
|
||||
}
|
||||
|
|
@ -9536,6 +9653,282 @@ export class TeamProvisioningService {
|
|||
return snapshot;
|
||||
}
|
||||
|
||||
private getDirectTmuxRestartPaneId(
|
||||
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[],
|
||||
memberName: string
|
||||
): string | null {
|
||||
for (const persistedRuntimeMember of persistedRuntimeMembers) {
|
||||
const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase();
|
||||
const paneId =
|
||||
typeof persistedRuntimeMember.tmuxPaneId === 'string'
|
||||
? persistedRuntimeMember.tmuxPaneId.trim()
|
||||
: '';
|
||||
const runtimeMemberName =
|
||||
typeof persistedRuntimeMember.name === 'string' ? persistedRuntimeMember.name : '';
|
||||
if (
|
||||
backendType === 'tmux' &&
|
||||
paneId &&
|
||||
matchesMemberNameOrBase(runtimeMemberName, memberName)
|
||||
) {
|
||||
return paneId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private resolveDirectRestartRuntimeCwd(params: {
|
||||
configuredMember: NonNullable<
|
||||
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
|
||||
>;
|
||||
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[];
|
||||
config: TeamConfig;
|
||||
run: ProvisioningRun;
|
||||
}): string {
|
||||
const configuredCwd = params.configuredMember.cwd?.trim();
|
||||
if (configuredCwd) {
|
||||
return path.resolve(configuredCwd);
|
||||
}
|
||||
|
||||
for (const runtimeMember of params.persistedRuntimeMembers) {
|
||||
const cwd = typeof runtimeMember.cwd === 'string' ? runtimeMember.cwd.trim() : '';
|
||||
if (cwd) {
|
||||
return path.resolve(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
const projectPath = params.config.projectPath?.trim();
|
||||
if (projectPath) {
|
||||
return path.resolve(projectPath);
|
||||
}
|
||||
|
||||
const runCwd = this.getRunTrackedCwd(params.run);
|
||||
if (runCwd) {
|
||||
return path.resolve(runCwd);
|
||||
}
|
||||
|
||||
throw new Error('Cannot restart teammate because its runtime cwd is unavailable');
|
||||
}
|
||||
|
||||
private async updateDirectTmuxRestartMemberConfig(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
member: NonNullable<ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>>;
|
||||
agentId: string;
|
||||
color: string;
|
||||
prompt: string;
|
||||
paneId: string;
|
||||
cwd: string;
|
||||
providerId: TeamProviderId;
|
||||
joinedAt: number;
|
||||
bootstrapExpectedAfter: string;
|
||||
}): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), input.teamName, 'config.json');
|
||||
const raw = await tryReadRegularFileUtf8(configPath, {
|
||||
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
|
||||
maxBytes: TEAM_CONFIG_MAX_BYTES,
|
||||
});
|
||||
if (!raw) {
|
||||
throw new Error(`Team "${input.teamName}" configuration is no longer available`);
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw) as TeamConfig & { members?: Record<string, unknown>[] };
|
||||
const members = Array.isArray(parsed.members) ? parsed.members : [];
|
||||
const existingIndex = members.findIndex((member) => {
|
||||
const candidateName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
return (
|
||||
candidateName.length > 0 && matchesExactTeamMemberName(candidateName, input.memberName)
|
||||
);
|
||||
});
|
||||
const existing: Record<string, unknown> =
|
||||
existingIndex >= 0 ? (members[existingIndex] ?? {}) : {};
|
||||
const nextMember = {
|
||||
...existing,
|
||||
agentId: input.agentId,
|
||||
name: input.member.name,
|
||||
...(input.member.role ? { role: input.member.role } : {}),
|
||||
...(input.member.workflow ? { workflow: input.member.workflow } : {}),
|
||||
...(input.member.agentType ? { agentType: input.member.agentType } : {}),
|
||||
provider: input.providerId,
|
||||
providerId: input.providerId,
|
||||
...(input.member.model ? { model: input.member.model } : {}),
|
||||
...(input.member.effort ? { effort: input.member.effort } : {}),
|
||||
prompt: input.prompt,
|
||||
color: input.color,
|
||||
joinedAt: input.joinedAt,
|
||||
bootstrapExpectedAfter: input.bootstrapExpectedAfter,
|
||||
tmuxPaneId: input.paneId,
|
||||
cwd: input.cwd,
|
||||
subscriptions: Array.isArray(existing.subscriptions) ? existing.subscriptions : [],
|
||||
backendType: 'tmux',
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
members[existingIndex] = nextMember;
|
||||
} else {
|
||||
members.push(nextMember);
|
||||
}
|
||||
parsed.members = members;
|
||||
await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`);
|
||||
}
|
||||
|
||||
private enqueueDirectRestartPrompt(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
leadName: string;
|
||||
leadSessionId: string | null;
|
||||
prompt: string;
|
||||
}): void {
|
||||
const timestamp = nowIso();
|
||||
this.persistInboxMessage(input.teamName, input.memberName, {
|
||||
from: input.leadName,
|
||||
to: input.memberName,
|
||||
text: input.prompt,
|
||||
timestamp,
|
||||
read: false,
|
||||
source: 'system_notification',
|
||||
leadSessionId: input.leadSessionId ?? undefined,
|
||||
messageId: `direct-restart-${input.memberName}-${randomUUID()}`,
|
||||
summary: `Restart bootstrap instructions for ${input.memberName}`,
|
||||
});
|
||||
}
|
||||
|
||||
private async launchDirectTmuxMemberRestart(input: {
|
||||
run: ProvisioningRun;
|
||||
teamName: string;
|
||||
displayName: string;
|
||||
leadName: string;
|
||||
memberName: string;
|
||||
config: TeamConfig;
|
||||
configuredMember: NonNullable<
|
||||
ReturnType<TeamProvisioningService['resolveEffectiveConfiguredMember']>
|
||||
>;
|
||||
persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[];
|
||||
paneId: string;
|
||||
}): Promise<void> {
|
||||
const paneInfo = (await listTmuxPaneRuntimeInfoForCurrentPlatform([input.paneId])).get(
|
||||
input.paneId
|
||||
);
|
||||
if (!paneInfo) {
|
||||
throw new Error(
|
||||
`Cannot restart teammate "${input.memberName}" because tmux pane ${input.paneId} is not available`
|
||||
);
|
||||
}
|
||||
if (!isInteractiveShellCommand(paneInfo.currentCommand)) {
|
||||
throw new Error(
|
||||
`Cannot restart teammate "${input.memberName}" because tmux pane ${input.paneId} is busy (${paneInfo.currentCommand ?? 'unknown command'})`
|
||||
);
|
||||
}
|
||||
|
||||
const providerId = resolveTeamProviderId(input.configuredMember.providerId);
|
||||
const claudePath = await ClaudeBinaryResolver.resolve();
|
||||
if (!claudePath) {
|
||||
throw new Error('Claude CLI not found; install it or provide a valid path');
|
||||
}
|
||||
|
||||
const cwd = this.resolveDirectRestartRuntimeCwd({
|
||||
configuredMember: input.configuredMember,
|
||||
persistedRuntimeMembers: input.persistedRuntimeMembers,
|
||||
config: input.config,
|
||||
run: input.run,
|
||||
});
|
||||
await ensureCwdExists(cwd);
|
||||
|
||||
const provisioningEnv = await this.buildProvisioningEnv(
|
||||
providerId,
|
||||
input.configuredMember.providerBackendId
|
||||
);
|
||||
if (provisioningEnv.warning) {
|
||||
throw new Error(provisioningEnv.warning);
|
||||
}
|
||||
|
||||
const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd);
|
||||
const agentId = `${input.configuredMember.name}@${input.teamName}`;
|
||||
const color =
|
||||
input.config.members
|
||||
?.find((member) => matchesExactTeamMemberName(member.name, input.memberName))
|
||||
?.color?.trim() || getMemberColorByName(input.configuredMember.name);
|
||||
const parentSessionId =
|
||||
input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId;
|
||||
const prompt = buildMemberSpawnPrompt(
|
||||
{
|
||||
name: input.configuredMember.name,
|
||||
...(input.configuredMember.role ? { role: input.configuredMember.role } : {}),
|
||||
...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}),
|
||||
...(input.configuredMember.providerId
|
||||
? { providerId: input.configuredMember.providerId }
|
||||
: {}),
|
||||
...(input.configuredMember.model ? { model: input.configuredMember.model } : {}),
|
||||
...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}),
|
||||
},
|
||||
input.displayName,
|
||||
input.teamName,
|
||||
input.leadName
|
||||
);
|
||||
const bootstrapExpectedAfter = nowIso();
|
||||
|
||||
const runtimeArgs = mergeJsonSettingsArgs([
|
||||
'--agent-id',
|
||||
agentId,
|
||||
'--agent-name',
|
||||
input.configuredMember.name,
|
||||
'--team-name',
|
||||
input.teamName,
|
||||
'--agent-color',
|
||||
color,
|
||||
'--parent-session-id',
|
||||
parentSessionId,
|
||||
...(input.configuredMember.agentType
|
||||
? ['--agent-type', input.configuredMember.agentType]
|
||||
: []),
|
||||
'--mcp-config',
|
||||
mcpConfigPath,
|
||||
'--strict-mcp-config',
|
||||
'--disallowedTools',
|
||||
APP_TEAM_RUNTIME_DISALLOWED_TOOLS,
|
||||
...(input.run.request.skipPermissions !== false
|
||||
? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions']
|
||||
: ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']),
|
||||
...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []),
|
||||
...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []),
|
||||
...(provisioningEnv.providerArgs ?? []),
|
||||
]);
|
||||
const command = buildDirectTmuxRestartCommand({
|
||||
cwd,
|
||||
env: provisioningEnv.env,
|
||||
providerId,
|
||||
binaryPath: claudePath,
|
||||
args: runtimeArgs,
|
||||
});
|
||||
|
||||
await this.updateDirectTmuxRestartMemberConfig({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
member: input.configuredMember,
|
||||
agentId,
|
||||
color,
|
||||
prompt,
|
||||
paneId: input.paneId,
|
||||
cwd,
|
||||
providerId,
|
||||
joinedAt: Date.now(),
|
||||
bootstrapExpectedAfter,
|
||||
});
|
||||
this.enqueueDirectRestartPrompt({
|
||||
teamName: input.teamName,
|
||||
memberName: input.configuredMember.name,
|
||||
leadName: input.leadName,
|
||||
leadSessionId: parentSessionId,
|
||||
prompt,
|
||||
});
|
||||
await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command);
|
||||
this.appendMemberBootstrapDiagnostic(
|
||||
input.run,
|
||||
input.memberName,
|
||||
`restart command delivered to tmux pane ${input.paneId}`
|
||||
);
|
||||
this.setMemberSpawnStatus(input.run, input.memberName, 'waiting');
|
||||
}
|
||||
|
||||
async restartMember(teamName: string, memberName: string): Promise<void> {
|
||||
const runId = this.getAliveRunId(teamName);
|
||||
if (!runId) {
|
||||
|
|
@ -9573,8 +9966,9 @@ export class TeamProvisioningService {
|
|||
};
|
||||
};
|
||||
|
||||
let { config, configuredMembers, metaMembers, configuredMember } =
|
||||
await readCurrentConfiguredMember();
|
||||
let currentConfiguredMemberState = await readCurrentConfiguredMember();
|
||||
let config = currentConfiguredMemberState.config;
|
||||
let configuredMember = currentConfiguredMemberState.configuredMember;
|
||||
if (!config) {
|
||||
throw new Error(`Team "${teamName}" configuration is no longer available`);
|
||||
}
|
||||
|
|
@ -9609,6 +10003,10 @@ export class TeamProvisioningService {
|
|||
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
|
||||
return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName);
|
||||
});
|
||||
const directTmuxRestartCandidatePaneId = this.getDirectTmuxRestartPaneId(
|
||||
persistedRuntimeMembers,
|
||||
memberName
|
||||
);
|
||||
|
||||
const backendTypes = new Set(
|
||||
persistedRuntimeMembers
|
||||
|
|
@ -9645,31 +10043,51 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
const tmuxPaneIdsToVerify: string[] = [];
|
||||
for (const persistedRuntimeMember of persistedRuntimeMembers) {
|
||||
const paneId =
|
||||
typeof persistedRuntimeMember.tmuxPaneId === 'string'
|
||||
? persistedRuntimeMember.tmuxPaneId.trim()
|
||||
: '';
|
||||
const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase();
|
||||
if (!paneId || backendType !== 'tmux') {
|
||||
continue;
|
||||
}
|
||||
tmuxPaneIdsToVerify.push(paneId);
|
||||
let directTmuxRestartPaneId: string | null = null;
|
||||
if (directTmuxRestartCandidatePaneId) {
|
||||
try {
|
||||
killTmuxPaneForCurrentPlatformSync(paneId);
|
||||
logger.info(
|
||||
`[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart`
|
||||
);
|
||||
const paneInfo = (
|
||||
await listTmuxPaneRuntimeInfoForCurrentPlatform([directTmuxRestartCandidatePaneId])
|
||||
).get(directTmuxRestartCandidatePaneId);
|
||||
if (paneInfo && isInteractiveShellCommand(paneInfo.currentCommand)) {
|
||||
directTmuxRestartPaneId = directTmuxRestartCandidatePaneId;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${
|
||||
`[${teamName}] Direct tmux restart probe failed for ${memberName}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const tmuxPaneIdsToVerify: string[] = [];
|
||||
if (!directTmuxRestartPaneId) {
|
||||
for (const persistedRuntimeMember of persistedRuntimeMembers) {
|
||||
const paneId =
|
||||
typeof persistedRuntimeMember.tmuxPaneId === 'string'
|
||||
? persistedRuntimeMember.tmuxPaneId.trim()
|
||||
: '';
|
||||
const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase();
|
||||
if (!paneId || backendType !== 'tmux') {
|
||||
continue;
|
||||
}
|
||||
tmuxPaneIdsToVerify.push(paneId);
|
||||
try {
|
||||
killTmuxPaneForCurrentPlatformSync(paneId);
|
||||
logger.info(
|
||||
`[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.debug(
|
||||
`[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pid of livePids) {
|
||||
try {
|
||||
killProcessByPid(pid);
|
||||
|
|
@ -9729,8 +10147,9 @@ export class TeamProvisioningService {
|
|||
throw new Error(`Team "${teamName}" is not currently running`);
|
||||
}
|
||||
|
||||
({ config, configuredMembers, metaMembers, configuredMember } =
|
||||
await readCurrentConfiguredMember());
|
||||
currentConfiguredMemberState = await readCurrentConfiguredMember();
|
||||
config = currentConfiguredMemberState.config;
|
||||
configuredMember = currentConfiguredMemberState.configuredMember;
|
||||
if (!config) {
|
||||
throw new Error(`Team "${teamName}" configuration disappeared while restart was in progress`);
|
||||
}
|
||||
|
|
@ -9765,7 +10184,36 @@ export class TeamProvisioningService {
|
|||
},
|
||||
});
|
||||
|
||||
const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers);
|
||||
const leadName = this.resolveLeadMemberName(
|
||||
currentConfiguredMemberState.configuredMembers,
|
||||
currentConfiguredMemberState.metaMembers
|
||||
);
|
||||
if (directTmuxRestartPaneId) {
|
||||
try {
|
||||
await this.launchDirectTmuxMemberRestart({
|
||||
run,
|
||||
teamName,
|
||||
displayName: config?.name?.trim() || teamName,
|
||||
leadName,
|
||||
memberName,
|
||||
config,
|
||||
configuredMember,
|
||||
persistedRuntimeMembers,
|
||||
paneId: directTmuxRestartPaneId,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
run.pendingMemberRestarts.delete(memberName);
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
memberName,
|
||||
'error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const restartMessage = buildRestartMemberSpawnMessage(
|
||||
teamName,
|
||||
config?.name?.trim() || teamName,
|
||||
|
|
@ -17730,6 +18178,37 @@ export class TeamProvisioningService {
|
|||
return false;
|
||||
}
|
||||
|
||||
private needsBootstrapAcceptanceReconcile(
|
||||
snapshot: PersistedTeamLaunchSnapshot | null,
|
||||
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null
|
||||
): boolean {
|
||||
if (!snapshot || !bootstrapSnapshot) {
|
||||
return false;
|
||||
}
|
||||
for (const expected of this.getPersistedLaunchMemberNames(snapshot)) {
|
||||
const current = snapshot.members[expected];
|
||||
const bootstrapMember = bootstrapSnapshot.members[expected];
|
||||
if (!current || !bootstrapMember) {
|
||||
continue;
|
||||
}
|
||||
const bootstrapProvesSpawnAcceptance =
|
||||
bootstrapMember.agentToolAccepted === true ||
|
||||
typeof bootstrapMember.firstSpawnAcceptedAt === 'string';
|
||||
if (!bootstrapProvesSpawnAcceptance) {
|
||||
continue;
|
||||
}
|
||||
const currentProvesSpawnAcceptance =
|
||||
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
|
||||
if (!currentProvesSpawnAcceptance) {
|
||||
return true;
|
||||
}
|
||||
if (isNeverSpawnedDuringLaunchReason(current.hardFailureReason)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async reconcilePersistedLaunchState(teamName: string): Promise<{
|
||||
snapshot: ReturnType<typeof createPersistedLaunchSnapshot> | null;
|
||||
statuses: Record<string, MemberSpawnStatusEntry>;
|
||||
|
|
@ -17745,8 +18224,15 @@ export class TeamProvisioningService {
|
|||
const filteredRecoveredMixedSnapshot = recoveredMixedSnapshot
|
||||
? this.filterRemovedMembersFromLaunchSnapshot(recoveredMixedSnapshot, metaMembers)
|
||||
: null;
|
||||
const filteredBootstrapSnapshot = bootstrapSnapshot
|
||||
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
|
||||
: null;
|
||||
if (
|
||||
filteredRecoveredMixedSnapshot &&
|
||||
!this.needsBootstrapAcceptanceReconcile(
|
||||
filteredRecoveredMixedSnapshot,
|
||||
filteredBootstrapSnapshot
|
||||
) &&
|
||||
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredRecoveredMixedSnapshot))
|
||||
) {
|
||||
return {
|
||||
|
|
@ -17754,9 +18240,6 @@ export class TeamProvisioningService {
|
|||
statuses: snapshotToMemberSpawnStatuses(filteredRecoveredMixedSnapshot),
|
||||
};
|
||||
}
|
||||
const filteredBootstrapSnapshot = bootstrapSnapshot
|
||||
? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers)
|
||||
: null;
|
||||
const filteredPersisted =
|
||||
filteredRecoveredMixedSnapshot ??
|
||||
(persisted ? this.filterRemovedMembersFromLaunchSnapshot(persisted, metaMembers) : null);
|
||||
|
|
@ -17805,6 +18288,7 @@ export class TeamProvisioningService {
|
|||
if (
|
||||
this.hasPrimaryOnlyLaneAwareLaunchMetadata(filteredPersisted) &&
|
||||
!this.hasLeadInboxLaunchReconcileHeartbeat(filteredPersisted, leadInboxMessages) &&
|
||||
!this.needsBootstrapAcceptanceReconcile(filteredPersisted, filteredBootstrapSnapshot) &&
|
||||
!(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredPersisted))
|
||||
) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ vi.mock('@features/tmux-installer/main', () => ({
|
|||
listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []),
|
||||
listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()),
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()),
|
||||
sendKeysToTmuxPaneForCurrentPlatform: vi.fn(async () => undefined),
|
||||
isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
|
|
@ -152,6 +153,7 @@ import {
|
|||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
sendKeysToTmuxPaneForCurrentPlatform,
|
||||
} from '@features/tmux-installer/main';
|
||||
import pidusage from 'pidusage';
|
||||
|
||||
|
|
@ -444,6 +446,8 @@ describe('TeamProvisioningService', () => {
|
|||
vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValue(new Map());
|
||||
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReset();
|
||||
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(new Map());
|
||||
vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mockReset();
|
||||
vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mockResolvedValue(undefined);
|
||||
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-'));
|
||||
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
||||
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
|
||||
|
|
@ -1685,6 +1689,178 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('restarts a tmux teammate directly in its shell-only pane after the runtime process disappeared', async () => {
|
||||
const teamName = 'forge-labs-10';
|
||||
const teamDir = path.join(tempTeamsBase, teamName);
|
||||
const projectPath = path.join(tempClaudeRoot, 'forge-project');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.mkdirSync(projectPath, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'Forge Labs 10',
|
||||
projectPath,
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
agentType: 'general-purpose',
|
||||
tmuxPaneId: '%1',
|
||||
backendType: 'tmux',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(
|
||||
new Map([
|
||||
[
|
||||
'%1',
|
||||
{
|
||||
paneId: '%1',
|
||||
panePid: 4242,
|
||||
currentCommand: 'zsh',
|
||||
currentPath: projectPath,
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, {
|
||||
writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'),
|
||||
} as any);
|
||||
const run = createMemberSpawnRun({
|
||||
teamName,
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate was never spawned during launch.',
|
||||
error: 'Teammate was never spawned during launch.',
|
||||
agentToolAccepted: false,
|
||||
firstSpawnAcceptedAt: undefined,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
run.detectedSessionId = 'lead-session-1';
|
||||
run.request = { providerId: 'codex', skipPermissions: true };
|
||||
|
||||
const sendMessageToRun = vi.fn(async () => {});
|
||||
(svc as any).sendMessageToRun = sendMessageToRun;
|
||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||
env: { OPENAI_API_KEY: 'test-openai-key' },
|
||||
authSource: 'openai_api_key',
|
||||
providerArgs: [],
|
||||
}));
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'Forge Labs 10',
|
||||
projectPath,
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
},
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [
|
||||
{
|
||||
name: 'bob',
|
||||
role: 'Developer',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
agentType: 'general-purpose',
|
||||
},
|
||||
]),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||
{
|
||||
name: 'bob',
|
||||
agentId: 'bob@forge-labs-10',
|
||||
backendType: 'tmux',
|
||||
tmuxPaneId: '%1',
|
||||
cwd: projectPath,
|
||||
},
|
||||
]);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
(svc as any).aliveRunByTeam.set(teamName, run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
|
||||
await svc.restartMember(teamName, 'bob');
|
||||
|
||||
expect(killTmuxPaneForCurrentPlatformSync).not.toHaveBeenCalled();
|
||||
expect(sendMessageToRun).not.toHaveBeenCalled();
|
||||
expect(sendKeysToTmuxPaneForCurrentPlatform).toHaveBeenCalledTimes(1);
|
||||
const [paneId, command] = vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mock.calls[0] ?? [];
|
||||
expect(paneId).toBe('%1');
|
||||
expect(command).toContain("cd '");
|
||||
expect(command).toContain(projectPath);
|
||||
expect(command).toContain("'/mock/claude'");
|
||||
expect(command).toContain("'--agent-id' 'bob@forge-labs-10'");
|
||||
expect(command).toContain("'--team-name' 'forge-labs-10'");
|
||||
expect(command).toContain("'--parent-session-id' 'lead-session-1'");
|
||||
expect(command).toContain("'--mcp-config' '/mock/mcp-config.json'");
|
||||
expect(command).toContain("'--model' 'gpt-5.4'");
|
||||
expect(command).toContain("'--effort' 'high'");
|
||||
expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__');
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(true);
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
hardFailure: false,
|
||||
});
|
||||
|
||||
const updatedConfig = JSON.parse(
|
||||
fs.readFileSync(path.join(teamDir, 'config.json'), 'utf8')
|
||||
) as { members: Array<Record<string, unknown>> };
|
||||
expect(updatedConfig.members.find((member) => member.name === 'bob')).toMatchObject({
|
||||
agentId: 'bob@forge-labs-10',
|
||||
tmuxPaneId: '%1',
|
||||
backendType: 'tmux',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
});
|
||||
const inbox = JSON.parse(
|
||||
fs.readFileSync(path.join(teamDir, 'inboxes', 'bob.json'), 'utf8')
|
||||
) as Array<Record<string, unknown>>;
|
||||
expect(inbox.at(-1)).toMatchObject({
|
||||
from: 'team-lead',
|
||||
to: 'bob',
|
||||
source: 'system_notification',
|
||||
leadSessionId: 'lead-session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a failed teammate for the current launch without marking it alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
|
|
|
|||
Loading…
Reference in a new issue