diff --git a/package.json b/package.json index 9212bc9d..fa1dd358 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "dev:kill": "node bin/kill-dev.js", "opencode:prove-production": "node ./scripts/prove-opencode-production.mjs", "opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs", - "opencode:prove-team-provisioning": "OPENCODE_E2E=1 OPENCODE_E2E_TEAM_PROVISIONING=1 pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/OpenCodeTeamProvisioning.live.test.ts", + "opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs", + "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", "build": "electron-vite build", "dist": "electron-builder --mac --win --linux", diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs new file mode 100644 index 00000000..73661131 --- /dev/null +++ b/scripts/lib/opencode-live-preflight.mjs @@ -0,0 +1,192 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +export async function preflightOpenCodeLiveEnvironment(input) { + const repoRoot = input.repoRoot; + const opencodeBin = process.env.OPENCODE_BIN?.trim() || '/opt/homebrew/bin/opencode'; + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-live-preflight-')); + const xdgDataHome = path.join(tempRoot, 'xdg-data'); + const env = { + ...process.env, + XDG_DATA_HOME: xdgDataHome, + OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', + }; + + try { + if (!fs.existsSync(opencodeBin)) { + return skip(`OpenCode binary not found at ${opencodeBin}`); + } + + const models = runOpenCodeCommand(opencodeBin, ['models'], repoRoot, env); + if (!models.ok) { + return skip(`opencode models failed: ${models.output}`); + } + + const agents = runOpenCodeCommand(opencodeBin, ['agent', 'list'], repoRoot, env); + if (!agents.ok) { + return skip(`opencode agent list failed: ${agents.output}`); + } + + const loopback = await canBindLoopback(); + if (!loopback.ok) { + return skip(`127.0.0.1 loopback bind failed: ${loopback.reason}`); + } + + const host = await canStartOpenCodeHost(opencodeBin, repoRoot, env); + if (!host.ok) { + return skip(`opencode serve health check failed: ${host.reason}`); + } + + return { ok: true }; + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +} + +export function exitForSkippedPreflight(result) { + if (result.ok) { + return false; + } + console.warn(`SKIPPED: ${result.reason}`); + process.exit(process.env.OPENCODE_E2E_STRICT === '1' ? 1 : 0); +} + +function runOpenCodeCommand(opencodeBin, args, cwd, env) { + const result = spawnSync(opencodeBin, args, { + cwd, + env, + encoding: 'utf8', + timeout: 20_000, + maxBuffer: 256_000, + }); + if (result.status === 0) { + return { ok: true, output: '' }; + } + return { + ok: false, + output: compactOutput(result.stderr || result.stdout || result.error?.message || 'unknown'), + }; +} + +function canBindLoopback() { + return new Promise((resolve) => { + const server = net.createServer(); + const timeout = setTimeout(() => { + server.close(() => undefined); + resolve({ ok: false, reason: 'timed out allocating loopback port' }); + }, 5_000); + server.once('error', (error) => { + clearTimeout(timeout); + resolve({ ok: false, reason: error.message }); + }); + server.listen(0, '127.0.0.1', () => { + clearTimeout(timeout); + server.close((error) => { + resolve(error ? { ok: false, reason: error.message } : { ok: true }); + }); + }); + }); +} + +async function canStartOpenCodeHost(opencodeBin, cwd, env) { + const port = await allocateLoopbackPort(); + const child = spawn(opencodeBin, ['serve', '--hostname', '127.0.0.1', '--port', String(port)], { + cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + let spawnError = ''; + const append = (chunk) => { + output = compactOutput(`${output}\n${chunk.toString('utf8')}`); + }; + child.stdout?.on('data', append); + child.stderr?.on('data', append); + child.once('error', (error) => { + spawnError = error.message; + append(error.message); + }); + + try { + const deadline = Date.now() + 15_000; + while (Date.now() < deadline) { + if (spawnError) { + return { ok: false, reason: spawnError }; + } + if (child.exitCode != null) { + return { ok: false, reason: output || `process exited with code ${child.exitCode}` }; + } + try { + const response = await fetch(`http://127.0.0.1:${port}/global/health`); + if (response.ok) { + const data = await response.json().catch(() => ({})); + if (data?.healthy === true) { + return { ok: true }; + } + } + } catch { + // Host is still starting. + } + await sleep(250); + } + return { ok: false, reason: output || 'timed out waiting for /global/health' }; + } finally { + await stopChild(child); + } +} + +function stopChild(child) { + return new Promise((resolve) => { + if (child.exitCode != null || child.killed) { + resolve(); + return; + } + const timeout = setTimeout(() => { + if (child.exitCode == null) { + child.kill('SIGKILL'); + } + resolve(); + }, 3_000); + child.once('close', () => { + clearTimeout(timeout); + resolve(); + }); + child.kill('SIGTERM'); + }); +} + +function allocateLoopbackPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('failed to allocate loopback port'))); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function skip(reason) { + return { ok: false, reason }; +} + +function compactOutput(value) { + return value.replace(/\s+/g, ' ').trim().slice(0, 1_200); +} diff --git a/scripts/prove-opencode-mixed-recovery.mjs b/scripts/prove-opencode-mixed-recovery.mjs index c05689fc..9fa176da 100644 --- a/scripts/prove-opencode-mixed-recovery.mjs +++ b/scripts/prove-opencode-mixed-recovery.mjs @@ -5,6 +5,11 @@ import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; +import { + exitForSkippedPreflight, + preflightOpenCodeLiveEnvironment, +} from './lib/opencode-live-preflight.mjs'; + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim(); @@ -14,6 +19,7 @@ const env = { ...process.env, OPENCODE_E2E: '1', OPENCODE_E2E_MIXED_RECOVERY: '1', + OPENCODE_E2E_MIXED_RECOVERY_MULTI: process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI ?? '0', OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot, OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle', OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', @@ -28,6 +34,10 @@ console.log('Running OpenCode mixed recovery live smoke'); console.log(`Model: ${env.OPENCODE_E2E_MODEL}`); console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`); console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`); +console.log(`Multi-lane: ${env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? 'enabled' : 'disabled'}`); + +const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); +exitForSkippedPreflight(preflight); const result = spawnSync( 'pnpm', diff --git a/scripts/prove-opencode-team-provisioning.mjs b/scripts/prove-opencode-team-provisioning.mjs new file mode 100644 index 00000000..246e891a --- /dev/null +++ b/scripts/prove-opencode-team-provisioning.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { + exitForSkippedPreflight, + preflightOpenCodeLiveEnvironment, +} from './lib/opencode-live-preflight.mjs'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '..'); +const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim(); +const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator'); + +const env = { + ...process.env, + OPENCODE_E2E: '1', + OPENCODE_E2E_TEAM_PROVISIONING: '1', + OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot, + OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle', + OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', +}; + +if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) { + const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator; + env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli'); +} + +console.log('Running OpenCode team provisioning live smoke'); +console.log(`Model: ${env.OPENCODE_E2E_MODEL}`); +console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`); +console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`); + +const preflight = await preflightOpenCodeLiveEnvironment({ repoRoot }); +exitForSkippedPreflight(preflight); + +const result = spawnSync( + 'pnpm', + [ + 'exec', + 'vitest', + 'run', + '--maxWorkers', + '1', + '--minWorkers', + '1', + 'test/main/services/team/OpenCodeTeamProvisioning.live.test.ts', + ], + { + cwd: repoRoot, + env, + stdio: 'inherit', + shell: process.platform === 'win32', + } +); + +if (result.error) { + console.error(`Failed to run OpenCode team provisioning smoke: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts index bc4cdd53..f39822c6 100644 --- a/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts +++ b/src/features/team-runtime-lanes/core/domain/__tests__/buildMixedPersistedLaunchSnapshot.test.ts @@ -129,6 +129,7 @@ describe('buildMixedPersistedLaunchSnapshot', () => { runtimeAlive: true, bootstrapConfirmed: true, hardFailure: false, + runtimePid: 333, diagnostics: ['spawn accepted', 'late heartbeat received'], }, }, @@ -143,6 +144,7 @@ describe('buildMixedPersistedLaunchSnapshot', () => { launchState: 'confirmed_alive', runtimeAlive: true, bootstrapConfirmed: true, + runtimePid: 333, }); expect(snapshot.summary).toEqual({ confirmedCount: 2, diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 72730b79..9dc2a71b 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -37,6 +37,7 @@ export interface MixedSecondaryLaneMemberStateInput { hardFailure?: boolean; hardFailureReason?: string; pendingPermissionRequestIds?: string[]; + runtimePid?: number; diagnostics?: string[]; } | null; pendingReason?: string; @@ -217,6 +218,12 @@ function createSecondaryLaneMemberState( pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length ? [...new Set(evidence.pendingPermissionRequestIds)] : undefined, + runtimePid: + typeof evidence?.runtimePid === 'number' && + Number.isFinite(evidence.runtimePid) && + evidence.runtimePid > 0 + ? Math.trunc(evidence.runtimePid) + : undefined, firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined, lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined, lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index f19b2899..ae33938e 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -2553,14 +2553,15 @@ async function handleSendMessage( }); // Teammate inbox relay DISABLED (2026-03-23). - // Teammates read their own inbox files directly via fs.watch — confirmed empirically. + // Codex/Claude teammates read their own inbox files directly via fs.watch. // Relaying through the lead (relayMemberInboxMessages) caused multiple bugs: // 1. Lead responded to user instead of forwarding to the teammate // 2. Duplicate messages (relay loop: markInboxMessagesRead → FileWatcher → relay again) // 3. Fragile LLM-dependent prompt chain for routing - // The message is already persisted in inboxes/{member}.json above — that's sufficient. + // The message is already persisted in inboxes/{member}.json above. // Teammate responses go to inboxes/user.json and are read by TeamInboxReader. - // Lead relay (relayLeadInboxMessages) is still needed — lead reads stdin only, not inbox. + // Lead relay (relayLeadInboxMessages) is still needed because lead reads stdin only, not inbox. + // OpenCode secondary lanes do not watch these inbox files, so they need runtime bridge delivery. // // if (!isLeadRecipient && isAlive) { // try { @@ -2569,6 +2570,29 @@ async function handleSendMessage( // logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`); // } // } + if (!isLeadRecipient && isAlive) { + void provisioning + .deliverOpenCodeMemberMessage(tn, { + memberName, + text: memberDeliveryText, + messageId: result.messageId, + }) + .then((delivery) => { + if (delivery.delivered || delivery.reason === 'recipient_is_not_opencode') { + return; + } + logger.warn( + `OpenCode runtime delivery after sendMessage failed for teammate "${memberName}": ${ + delivery.reason ?? 'unknown error' + }` + ); + }) + .catch((e: unknown) => + logger.warn( + `OpenCode runtime delivery after sendMessage crashed for teammate "${memberName}": ${String(e)}` + ) + ); + } // Best-effort relay for lead via inbox if (isLeadRecipient && isAlive) { diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 3111e323..167cee29 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -53,6 +53,12 @@ function normalizePendingPermissionRequestIds(value: unknown): string[] | undefi return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined; } +function normalizeRuntimePid(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.trunc(value) + : undefined; +} + function normalizeMemberName(name: string): string { return name.trim(); } @@ -333,6 +339,7 @@ function normalizePersistedMemberState( pendingPermissionRequestIds: normalizePendingPermissionRequestIds( parsed.pendingPermissionRequestIds ), + runtimePid: normalizeRuntimePid(parsed.runtimePid), firstSpawnAcceptedAt: typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined, lastHeartbeatAt: @@ -395,12 +402,18 @@ export function createPersistedLaunchSnapshot(params: { if (launchPhase !== 'active') { for (const name of expectedMembers) { const member = members[name]; + const isRecoverableOpenCodeSecondaryLane = + member?.laneKind === 'secondary' && + member.laneOwnerProviderId === 'opencode' && + typeof member.laneId === 'string' && + member.laneId.trim().length > 0; if ( member?.launchState === 'starting' && !member.agentToolAccepted && !member.runtimeAlive && !member.bootstrapConfirmed && - !member.hardFailure + !member.hardFailure && + !isRecoverableOpenCodeSecondaryLane ) { member.hardFailure = true; member.hardFailureReason = diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 87b5d671..1d0751a0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -76,7 +76,10 @@ import { } from '@shared/utils/teammateMessageParser'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName'; -import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; import { extractToolPreview, extractToolResultPreview, @@ -134,6 +137,7 @@ import { TeamInboxReader } from './TeamInboxReader'; import { TeamInboxWriter } from './TeamInboxWriter'; import { createPersistedLaunchSnapshot, + deriveTeamLaunchAggregateState, hasMixedPersistedLaunchMetadata, snapshotFromRuntimeMemberStatuses, snapshotToMemberSpawnStatuses, @@ -146,6 +150,8 @@ import { TeamMetaStore } from './TeamMetaStore'; import { TeamRuntimeAdapterRegistry, type TeamLaunchRuntimeAdapter, + type OpenCodeTeamRuntimeMessageInput, + type OpenCodeTeamRuntimeMessageResult, type TeamRuntimeLaunchInput, type TeamRuntimeLaunchResult, type TeamRuntimeMemberLaunchEvidence, @@ -1389,6 +1395,39 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean { ); } +function summarizeMemberSpawnStatusRecord( + expectedMembers: readonly string[], + statuses: Record +): PersistedTeamLaunchSummary { + let confirmedCount = 0; + let pendingCount = 0; + let failedCount = 0; + let runtimeAlivePendingCount = 0; + const memberNames = Array.from(new Set([...expectedMembers, ...Object.keys(statuses)])); + + for (const memberName of memberNames) { + const entry = statuses[memberName]; + if (!entry) { + pendingCount += 1; + continue; + } + if (entry.launchState === 'confirmed_alive') { + confirmedCount += 1; + continue; + } + if (entry.launchState === 'failed_to_start') { + failedCount += 1; + continue; + } + pendingCount += 1; + if (entry.runtimeAlive) { + runtimeAlivePendingCount += 1; + } + } + + return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount }; +} + function buildRestartStillRunningReason(memberName: string): string { return ( `Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` + @@ -3881,6 +3920,94 @@ export class TeamProvisioningService { return this.runtimeAdapterRegistry.get('opencode'); } + private getOpenCodeRuntimeMessageAdapter(): + | (TeamLaunchRuntimeAdapter & { + sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise; + }) + | null { + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter || !('sendMessageToMember' in adapter)) { + return null; + } + return adapter as TeamLaunchRuntimeAdapter & { + sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise; + }; + } + + async deliverOpenCodeMemberMessage( + teamName: string, + input: { + memberName: string; + text: string; + messageId?: string; + } + ): Promise<{ delivered: boolean; reason?: string; diagnostics?: string[] }> { + const adapter = this.getOpenCodeRuntimeMessageAdapter(); + if (!adapter) { + return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; + } + + const [config, teamMeta, metaMembers] = await Promise.all([ + this.configReader.getConfig(teamName).catch(() => null), + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const normalizedMemberName = input.memberName.trim(); + const configMember = config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + const metaMember = metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalizedMemberName.toLowerCase() + ); + const providerId = + normalizeOptionalTeamProviderId(metaMember?.providerId) ?? + normalizeOptionalTeamProviderId(configMember?.providerId) ?? + inferTeamProviderIdFromModel(metaMember?.model ?? configMember?.model); + if (providerId !== 'opencode') { + return { delivered: false, reason: 'recipient_is_not_opencode' }; + } + + const leadMember = config?.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? + normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: normalizedMemberName, + providerId, + }, + }); + const cwd = + config?.projectPath?.trim() || + metaMember?.cwd?.trim() || + configMember?.cwd?.trim() || + this.readPersistedTeamProjectPath(teamName); + if (!cwd) { + return { delivered: false, reason: 'opencode_project_path_unavailable' }; + } + + const result = await adapter.sendMessageToMember({ + runId: this.getTrackedRunId(teamName) ?? randomUUID(), + teamName, + laneId: laneIdentity.laneId, + memberName: normalizedMemberName, + cwd, + text: input.text, + messageId: input.messageId, + }); + return { + delivered: result.ok, + ...(result.ok ? {} : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), + diagnostics: result.diagnostics, + }; + } + private shouldRouteOpenCodeToRuntimeAdapter(request: { providerId?: TeamProviderId; members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[]; @@ -6274,26 +6401,34 @@ export class TeamProvisioningService { summary?: PersistedTeamLaunchSummary; source?: 'live' | 'persisted' | 'merged'; }> { + const readPersistedStatuses = async (resolvedRunId: string | null) => { + const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName); + const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses); + const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined; + const summary = expectedMembers + ? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses) + : undefined; + return { + statuses: nextStatuses, + runId: resolvedRunId, + teamLaunchState: summary + ? deriveTeamLaunchAggregateState(summary) + : snapshot?.teamLaunchState, + launchPhase: snapshot?.launchPhase, + expectedMembers, + updatedAt: snapshot?.updatedAt, + summary: summary ?? snapshot?.summary, + source: 'persisted' as const, + }; + }; + const runId = this.getTrackedRunId(teamName); if (!runId) { - return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => { - return this.attachLiveRuntimeMetadataToStatuses(teamName, statuses).then( - (nextStatuses) => ({ - statuses: nextStatuses, - runId: null, - teamLaunchState: snapshot?.teamLaunchState, - launchPhase: snapshot?.launchPhase, - expectedMembers: snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined, - updatedAt: snapshot?.updatedAt, - summary: snapshot?.summary, - source: snapshot ? 'persisted' : 'persisted', - }) - ); - }); + return readPersistedStatuses(null); } const run = this.runs.get(runId); if (!run) { - return { statuses: {}, runId: null, source: 'persisted' }; + return readPersistedStatuses(runId); } await this.refreshMemberSpawnStatusesFromLeadInbox(run); @@ -6313,19 +6448,21 @@ export class TeamProvisioningService { launchPhase: run.provisioningComplete ? 'finished' : 'active', statuses: this.buildRuntimeSpawnStatusRecord(run), }); - const snapshot = persisted ?? liveSnapshot; + const snapshot = liveSnapshot ?? persisted; const statuses = await this.attachLiveRuntimeMetadataToStatuses( teamName, snapshotToMemberSpawnStatuses(snapshot) ); + const expectedMembers = this.getPersistedLaunchMemberNames(snapshot); + const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses); return { statuses, runId, - teamLaunchState: snapshot.teamLaunchState, + teamLaunchState: deriveTeamLaunchAggregateState(summary), launchPhase: snapshot.launchPhase, - expectedMembers: this.getPersistedLaunchMemberNames(snapshot), + expectedMembers, updatedAt: snapshot.updatedAt, - summary: snapshot.summary, + summary, source: persisted ? 'merged' : 'live', }; } @@ -6456,9 +6593,18 @@ export class TeamProvisioningService { false ); const rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid; - const isOpenCodeMember = - (launchMember?.providerId ?? normalizeOptionalTeamProviderId(member.providerId)) === - 'opencode'; + const runtimeModel = + liveRuntimeMember?.model ?? + launchMember?.model?.trim() ?? + member.model?.trim() ?? + undefined; + const memberProviderId = + launchMember?.providerId ?? + normalizeOptionalTeamProviderId(member.providerId) ?? + inferTeamProviderIdFromModel(runtimeModel) ?? + inferTeamProviderIdFromModel(launchMember?.model) ?? + inferTeamProviderIdFromModel(member.model); + const isOpenCodeMember = memberProviderId === 'opencode'; const isSharedOpenCodeHost = isOpenCodeMember && !liveRuntimeMember?.pid && @@ -6470,11 +6616,6 @@ export class TeamProvisioningService { : isSharedOpenCodeHost ? false : backendType !== 'in-process'; - const runtimeModel = - liveRuntimeMember?.model ?? - launchMember?.model?.trim() ?? - member.model?.trim() ?? - undefined; const launchSnapshotAlive = this.isTeamAlive(teamName) && (launchMember?.runtimeAlive === true || @@ -6498,7 +6639,7 @@ export class TeamProvisioningService { alive: liveRuntimeMember?.alive === true || launchSnapshotAlive, restartable, ...(backendType ? { backendType } : {}), - ...(launchMember?.providerId ? { providerId: launchMember.providerId } : {}), + ...(memberProviderId ? { providerId: memberProviderId } : {}), ...(launchMember?.providerBackendId ? { providerBackendId: launchMember.providerBackendId } : {}), @@ -10214,6 +10355,9 @@ export class TeamProvisioningService { // SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete // team files during cleanup. SIGKILL is uncatchable — files are preserved. killTeamProcess(run.child); + if (this.hasSecondaryRuntimeRuns(run.teamName)) { + void this.stopMixedSecondaryRuntimeLanes(run.teamName); + } const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user'); run.onProgress(progress); this.cleanupRun(run); @@ -11447,6 +11591,20 @@ export class TeamProvisioningService { ...(metadata.model ? { runtimeModel: metadata.model } : {}), }; const failureReason = current.hardFailureReason ?? current.error; + if ( + metadata.alive && + current.hardFailure !== true && + current.launchState !== 'failed_to_start' + ) { + nextEntry.status = 'online'; + nextEntry.agentToolAccepted = true; + nextEntry.runtimeAlive = true; + nextEntry.hardFailure = false; + nextEntry.hardFailureReason = undefined; + nextEntry.error = undefined; + nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process'; + nextEntry.launchState = deriveMemberLaunchState(nextEntry); + } if ( metadata.alive && current.launchState === 'failed_to_start' && @@ -11817,6 +11975,38 @@ export class TeamProvisioningService { }); } + const shouldReadPersistedOpenCodeLaunchSnapshot = + (run?.mixedSecondaryLanes?.length ?? 0) > 0 || + configuredMembers.some( + (member) => normalizeOptionalTeamProviderId(member.providerId) === 'opencode' + ) || + metaMembers.some( + (member) => normalizeOptionalTeamProviderId(member.providerId) === 'opencode' + ); + const persistedLaunchSnapshot = shouldReadPersistedOpenCodeLaunchSnapshot + ? await this.launchStateStore.read(teamName).catch(() => null) + : null; + for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) { + const memberName = persistedMember.name?.trim() ?? ''; + if ( + !memberName || + this.isMemberRemovedInMeta(metaMembers, memberName) || + persistedMember.providerId !== 'opencode' || + persistedMember.laneKind !== 'secondary' || + persistedMember.laneOwnerProviderId !== 'opencode' + ) { + continue; + } + upsertMetadata(memberName, { + backendType: 'process', + alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true, + ...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}), + ...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0 + ? { metricsPid: persistedMember.runtimePid } + : {}), + }); + } + const paneIds = [...metadataByMember.values()] .map((metadata) => metadata.tmuxPaneId?.trim() ?? '') .filter((paneId) => paneId.length > 0); @@ -12331,6 +12521,7 @@ export class TeamProvisioningService { hardFailure: evidenceEntry.hardFailure, hardFailureReason: evidenceEntry.hardFailureReason, pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds, + runtimePid: evidenceEntry.runtimePid, diagnostics: evidenceEntry.diagnostics, } : finishedWithoutRuntimeEvidence @@ -12363,6 +12554,29 @@ export class TeamProvisioningService { return hasMixedPersistedLaunchMetadata(snapshot); } + private shouldRecoverStalePersistedMixedLaunchSnapshot( + snapshot: PersistedTeamLaunchSnapshot + ): boolean { + if (snapshot.teamLaunchState !== 'partial_pending') { + return false; + } + const updatedAtMs = Date.parse(snapshot.updatedAt); + if (Number.isFinite(updatedAtMs) && Date.now() - updatedAtMs < MEMBER_LAUNCH_GRACE_MS) { + return false; + } + + return Object.values(snapshot.members).some((member) => { + if (member.launchState === 'confirmed_alive' || member.launchState === 'failed_to_start') { + return false; + } + return ( + member.laneKind === 'secondary' && + member.laneOwnerProviderId === 'opencode' && + typeof member.laneId === 'string' + ); + }); + } + private async persistLaunchStateSnapshot( run: ProvisioningRun, launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete @@ -12474,6 +12688,10 @@ export class TeamProvisioningService { ], previousLaunchState, }); + if (run.cancelRequested || run.processKilled) { + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + return; + } lane.state = 'finished'; lane.result = result; lane.warnings = [...result.warnings]; @@ -12496,6 +12714,10 @@ export class TeamProvisioningService { this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); } } catch (error) { + if (run.cancelRequested || run.processKilled) { + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + return; + } const message = error instanceof Error ? error.message : String(error); lane.state = 'finished'; lane.result = { @@ -12591,6 +12813,10 @@ export class TeamProvisioningService { try { await this.launchSingleMixedSecondaryLane(run, lane); } catch (error) { + if (run.cancelRequested || run.processKilled) { + this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); + return; + } const message = error instanceof Error ? error.message : String(error); logger.warn( `[${run.teamName}] OpenCode secondary lane ${lane.laneId} crashed during launch orchestration: ${message}` @@ -12620,6 +12846,10 @@ export class TeamProvisioningService { private async launchMixedSecondaryLaneIfNeeded( run: ProvisioningRun ): Promise { + if (run.cancelRequested || run.processKilled) { + return this.launchStateStore.read(run.teamName).catch(() => null); + } + const mixedSecondaryLanes = run.mixedSecondaryLanes ?? []; if (mixedSecondaryLanes.length === 0) { return this.persistLaunchStateSnapshot(run, 'finished'); @@ -12668,7 +12898,11 @@ export class TeamProvisioningService { bootstrapSnapshot: PersistedTeamLaunchSnapshot | null, persistedSnapshot: PersistedTeamLaunchSnapshot | null ): Promise { - if (persistedSnapshot && this.hasMixedLaunchMetadata(persistedSnapshot)) { + if ( + persistedSnapshot && + this.hasMixedLaunchMetadata(persistedSnapshot) && + !this.shouldRecoverStalePersistedMixedLaunchSnapshot(persistedSnapshot) + ) { return persistedSnapshot; } @@ -12730,6 +12964,7 @@ export class TeamProvisioningService { hardFailure?: boolean; hardFailureReason?: string; pendingPermissionRequestIds?: string[]; + runtimePid?: number; diagnostics?: string[]; }; pendingReason?: string; @@ -12776,6 +13011,7 @@ export class TeamProvisioningService { hardFailure: runtimeEvidence.hardFailure, hardFailureReason: runtimeEvidence.hardFailureReason, pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds, + runtimePid: runtimeEvidence.runtimePid, diagnostics: runtimeEvidence.diagnostics, }, }); @@ -16465,7 +16701,7 @@ export class TeamProvisioningService { peekAutoResumeService()?.cancelPendingAutoResume(run.teamName); } - if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) { + if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) { void this.persistLaunchStateSnapshot(run, 'finished'); } this.resetRuntimeToolActivity(run); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index d61f522b..7d5c42f6 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -9,6 +9,7 @@ export type OpenCodeBridgeCommandName = | 'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' + | 'opencode.sendMessage' | 'opencode.answerPermission' | 'opencode.listRuntimePermissions' | 'opencode.getRuntimeTranscript' @@ -118,6 +119,27 @@ export interface OpenCodeStopTeamCommandData { runtimeStoreManifestHighWatermark?: number | null; } +export interface OpenCodeSendMessageCommandBody { + runId?: string; + laneId: string; + teamId: string; + teamName: string; + projectPath: string; + memberName: string; + text: string; + messageId?: string; + agent?: string; + noReply?: boolean; +} + +export interface OpenCodeSendMessageCommandData { + accepted: boolean; + sessionId?: string; + memberName: string; + runtimePid?: number; + diagnostics: OpenCodeTeamBridgeDiagnostic[]; +} + export type OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator'; export type OpenCodeBridgeFailureKind = @@ -258,6 +280,7 @@ const VALID_COMMANDS: ReadonlySet = new Set([ 'opencode.launchTeam', 'opencode.reconcileTeam', 'opencode.stopTeam', + 'opencode.sendMessage', 'opencode.answerPermission', 'opencode.listRuntimePermissions', 'opencode.getRuntimeTranscript', diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 14bad398..f022e09e 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -22,6 +22,8 @@ import type { OpenCodeLaunchTeamCommandBody, OpenCodeLaunchTeamCommandData, OpenCodeReconcileTeamCommandBody, + OpenCodeSendMessageCommandBody, + OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandData, OpenCodeTeamLaunchMode, @@ -46,6 +48,7 @@ export interface OpenCodeReadinessBridgeOptions { timeoutMs?: number; launchTimeoutMs?: number; reconcileTimeoutMs?: number; + sendTimeoutMs?: number; stopTimeoutMs?: number; stateChangingCommands?: Pick; productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort; @@ -76,6 +79,7 @@ export interface OpenCodeReadinessBridgeCommandBody { const DEFAULT_READINESS_TIMEOUT_MS = 120_000; const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000; const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000; +const DEFAULT_SEND_TIMEOUT_MS = 30_000; const DEFAULT_STOP_TIMEOUT_MS = 30_000; export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { @@ -276,6 +280,37 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { }; } + async sendOpenCodeTeamMessage( + input: OpenCodeSendMessageCommandBody + ): Promise { + const result = await this.bridge.execute< + OpenCodeSendMessageCommandBody, + OpenCodeSendMessageCommandData + >('opencode.sendMessage', input, { + cwd: input.projectPath, + timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS, + }); + if (result.ok) { + return result.data; + } + return { + accepted: false, + memberName: input.memberName, + diagnostics: [ + { + code: result.error.kind, + severity: 'error', + message: `OpenCode message bridge failed: ${result.error.message}`, + }, + ...result.diagnostics.map((event) => ({ + code: event.type, + severity: event.severity, + message: event.message, + })), + ], + }; + } + private async executeStateChangingCommand( command: OpenCodeStateChangingTeamCommandName, body: TBody, diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 543817d6..9e9bb0c7 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -6,6 +6,8 @@ import type { OpenCodeLaunchTeamCommandData, OpenCodeBridgeRuntimeSnapshot, OpenCodeReconcileTeamCommandBody, + OpenCodeSendMessageCommandBody, + OpenCodeSendMessageCommandData, OpenCodeStopTeamCommandBody, OpenCodeStopTeamCommandData, OpenCodeTeamLaunchMode, @@ -37,6 +39,9 @@ export interface OpenCodeTeamRuntimeBridgePort { input: OpenCodeReconcileTeamCommandBody ): Promise; stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise; + sendOpenCodeTeamMessage?( + input: OpenCodeSendMessageCommandBody + ): Promise; } export interface OpenCodeTeamRuntimeAdapterOptions { @@ -47,6 +52,25 @@ export interface OpenCodeTeamRuntimeAdapterOptions { launchEnabled?: boolean; } +export interface OpenCodeTeamRuntimeMessageInput { + runId?: string; + teamName: string; + laneId: string; + memberName: string; + cwd: string; + text: string; + messageId?: string; +} + +export interface OpenCodeTeamRuntimeMessageResult { + ok: boolean; + providerId: 'opencode'; + memberName: string; + sessionId?: string; + runtimePid?: number; + diagnostics: string[]; +} + export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract'; const REQUIRED_READY_CHECKPOINTS = new Set([ @@ -139,6 +163,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } async launch(input: TeamRuntimeLaunchInput): Promise { + const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers); + if (memberValidationDiagnostics.length > 0) { + return blockedLaunchResult( + input, + 'opencode_invalid_expected_members', + memberValidationDiagnostics + ); + } + const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options); const prepared = await this.prepare(input); if (!prepared.ok) { @@ -182,6 +215,26 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } async reconcile(input: TeamRuntimeReconcileInput): Promise { + const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers); + if (memberValidationDiagnostics.length > 0) { + return { + ...blockedLaunchResult( + { + runId: input.runId, + teamName: input.teamName, + cwd: input.expectedMembers[0]?.cwd ?? '', + providerId: this.providerId, + skipPermissions: false, + expectedMembers: input.expectedMembers, + previousLaunchState: input.previousLaunchState, + }, + 'opencode_invalid_expected_members', + memberValidationDiagnostics + ), + snapshot: input.previousLaunchState, + }; + } + if (this.bridge.reconcileOpenCodeTeam) { const projectPath = input.expectedMembers[0]?.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); @@ -263,6 +316,40 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { }; } + async sendMessageToMember( + input: OpenCodeTeamRuntimeMessageInput + ): Promise { + if (!this.bridge.sendOpenCodeTeamMessage) { + return { + ok: false, + providerId: this.providerId, + memberName: input.memberName, + diagnostics: ['OpenCode message bridge is not registered.'], + }; + } + + const data = await this.bridge.sendOpenCodeTeamMessage({ + runId: input.runId, + laneId: input.laneId, + teamId: input.teamName, + teamName: input.teamName, + projectPath: input.cwd, + memberName: input.memberName, + text: input.text, + messageId: input.messageId, + agent: 'teammate', + }); + + return { + ok: data.accepted, + providerId: this.providerId, + memberName: input.memberName, + sessionId: data.sessionId, + runtimePid: data.runtimePid, + diagnostics: data.diagnostics.map((diagnostic) => diagnostic.message), + }; + } + async stop(input: TeamRuntimeStopInput): Promise { if (this.bridge.stopOpenCodeTeam) { const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName); @@ -355,10 +442,21 @@ function mapOpenCodeLaunchDataToRuntimeResult( checkpointNames.has(name) ); const bridgeReady = data.teamLaunchState === 'ready'; - const success = bridgeReady && readyCheckpointsPresent; + const missingExpectedMembers = input.expectedMembers + .map((member) => member.name) + .filter((memberName) => data.members[memberName] == null); + const unconfirmedExpectedMembers = input.expectedMembers + .map((member) => member.name) + .filter((memberName) => data.members[memberName]?.launchState !== 'confirmed_alive'); + const anyExpectedMemberFailed = input.expectedMembers.some( + (member) => data.members[member.name]?.launchState === 'failed' + ); + const allExpectedMembersConfirmed = + input.expectedMembers.length > 0 && unconfirmedExpectedMembers.length === 0; + const success = bridgeReady && readyCheckpointsPresent && allExpectedMembersConfirmed; const checkpointDiagnostic = success ? [] - : bridgeReady + : bridgeReady && !readyCheckpointsPresent ? [ `OpenCode bridge reported ready without all required durable checkpoints: missing ${[ ...REQUIRED_READY_CHECKPOINTS, @@ -367,6 +465,12 @@ function mapOpenCodeLaunchDataToRuntimeResult( .join(', ')}`, ] : []; + const incompleteReadyDiagnostic = + bridgeReady && readyCheckpointsPresent && !allExpectedMembersConfirmed + ? [ + `OpenCode bridge reported ready before all expected members were confirmed: pending ${unconfirmedExpectedMembers.join(', ')}`, + ] + : []; const members = Object.fromEntries( input.expectedMembers.map((member) => { @@ -396,6 +500,7 @@ function mapOpenCodeLaunchDataToRuntimeResult( (evidence) => `${evidence.kind} at ${evidence.observedAt}` ), ...checkpointDiagnostic, + ...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []), ] ), ]; @@ -407,17 +512,25 @@ function mapOpenCodeLaunchDataToRuntimeResult( teamName: input.teamName, launchPhase: success ? 'finished' - : data.teamLaunchState === 'launching' + : data.teamLaunchState === 'launching' || (bridgeReady && !anyExpectedMemberFailed) ? 'active' : 'finished', teamLaunchState: success ? 'clean_success' - : data.teamLaunchState === 'launching' || data.teamLaunchState === 'permission_blocked' - ? 'partial_pending' - : 'partial_failure', + : anyExpectedMemberFailed || data.teamLaunchState === 'failed' + ? 'partial_failure' + : data.teamLaunchState === 'launching' || + data.teamLaunchState === 'permission_blocked' || + bridgeReady + ? 'partial_pending' + : 'partial_failure', members, warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)], - diagnostics: [...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), ...checkpointDiagnostic], + diagnostics: [ + ...data.diagnostics.map(formatOpenCodeBridgeDiagnostic), + ...checkpointDiagnostic, + ...incompleteReadyDiagnostic, + ], }; } @@ -482,6 +595,24 @@ function buildMemberBootstrapPrompt(input: TeamRuntimeLaunchInput, memberName: s return `Join team "${input.teamName}" as "${memberName}" and wait for app MCP task delivery.`; } +function validateOpenCodeRuntimeMembers( + members: TeamRuntimeLaunchInput['expectedMembers'] +): string[] { + if (members.length === 0) { + return ['OpenCode runtime adapter requires at least one expected OpenCode member.']; + } + + return members.flatMap((member, index) => { + const name = member.name.trim() || ``; + if (member.providerId === 'opencode') { + return []; + } + return [ + `OpenCode runtime adapter received non-OpenCode member "${name}" with provider "${member.providerId}".`, + ]; + }); +} + function formatOpenCodeBridgeDiagnostic(diagnostic: { code: string; severity: 'info' | 'warning' | 'error'; @@ -496,6 +627,8 @@ function blockedLaunchResult( diagnostics: string[], warnings: string[] = [] ): TeamRuntimeLaunchResult { + const hardFailureReason = + reason === 'unknown_error' && diagnostics[0]?.trim() ? diagnostics[0].trim() : reason; const members = Object.fromEntries( input.expectedMembers.map((member) => [ member.name, @@ -507,7 +640,7 @@ function blockedLaunchResult( runtimeAlive: false, bootstrapConfirmed: false, hardFailure: true, - hardFailureReason: reason, + hardFailureReason, diagnostics, }, ]) diff --git a/src/main/services/team/runtime/index.ts b/src/main/services/team/runtime/index.ts index fa3ed5fe..37102a84 100644 --- a/src/main/services/team/runtime/index.ts +++ b/src/main/services/team/runtime/index.ts @@ -1,6 +1,8 @@ export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter'; export type { OpenCodeTeamLaunchMode, + OpenCodeTeamRuntimeMessageInput, + OpenCodeTeamRuntimeMessageResult, OpenCodeTeamRuntimeAdapterOptions, OpenCodeTeamRuntimeBridgePort, } from './OpenCodeTeamRuntimeAdapter'; diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index baa0b592..c9b81bbf 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -40,6 +40,10 @@ import { import { createChipFromSelection } from '@renderer/utils/chipUtils'; import { sumContextInjectionTokens } from '@renderer/utils/contextMath'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; +import { + hasUnresolvedMemberSpawnStatus, + MEMBER_SPAWN_STATUS_REFRESH_MS, +} from '@renderer/utils/memberSpawnStatusPolling'; import { formatProjectPath } from '@renderer/utils/pathDisplay'; import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy'; import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize'; @@ -374,27 +378,46 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({ isTeamProvisioning: boolean; isTeamAlive?: boolean; }): null { - const { leadActivity, memberSpawnStatuses, fetchMemberSpawnStatuses } = useStore( - useShallow((s) => ({ - leadActivity: s.leadActivityByTeam[teamName], - memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], - fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses, - })) - ); + const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } = + useStore( + useShallow((s) => ({ + leadActivity: s.leadActivityByTeam[teamName], + memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName], + memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName], + fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses, + })) + ); useEffect(() => { + const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus( + memberSpawnStatuses, + memberSpawnSnapshot + ); const shouldFetchSpawnStatuses = isTeamProvisioning || + hasUnresolvedSpawn || (memberSpawnStatuses == null && (isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle')); if (shouldFetchSpawnStatuses) { void fetchMemberSpawnStatuses(teamName); } + + if (!isTeamProvisioning && !hasUnresolvedSpawn) { + return; + } + + const interval = window.setInterval(() => { + void fetchMemberSpawnStatuses(teamName); + }, MEMBER_SPAWN_STATUS_REFRESH_MS); + return () => { + window.clearInterval(interval); + }; }, [ fetchMemberSpawnStatuses, isTeamAlive, isTeamProvisioning, leadActivity, + memberSpawnSnapshot, memberSpawnStatuses, teamName, ]); diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index d1d4ead5..6f07b69d 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -148,9 +148,15 @@ function areMemberSpawnStatusesEquivalent( leftEntry.status !== rightEntry?.status || leftEntry.launchState !== rightEntry.launchState || leftEntry.error !== rightEntry.error || + leftEntry.hardFailure !== rightEntry.hardFailure || + leftEntry.hardFailureReason !== rightEntry.hardFailureReason || leftEntry.livenessSource !== rightEntry.livenessSource || leftEntry.runtimeModel !== rightEntry.runtimeModel || - leftEntry.runtimeAlive !== rightEntry.runtimeAlive + leftEntry.runtimeAlive !== rightEntry.runtimeAlive || + leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed || + leftEntry.agentToolAccepted !== rightEntry.agentToolAccepted || + (leftEntry.pendingPermissionRequestIds ?? []).join('\0') !== + (rightEntry.pendingPermissionRequestIds ?? []).join('\0') ) { return false; } @@ -327,7 +333,7 @@ export const MemberList = memo(function MemberList({ isRemoved ? undefined : runtimeEntry )} spawnStatus={isRemoved ? undefined : spawnEntry?.status} - spawnError={isRemoved ? undefined : spawnEntry?.error} + spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)} spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource} spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState} spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive} diff --git a/src/renderer/components/team/provisioningSteps.ts b/src/renderer/components/team/provisioningSteps.ts index ba8906cb..aeafdc17 100644 --- a/src/renderer/components/team/provisioningSteps.ts +++ b/src/renderer/components/team/provisioningSteps.ts @@ -51,13 +51,53 @@ function getSpawnEntry( return memberSpawnStatuses[memberName]; } +function parseStatusUpdatedAtMs(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { + return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; +} + +function shouldPreferSnapshotEntryOverLive( + liveEntry: MemberSpawnStatusEntry | undefined, + snapshotEntry: MemberSpawnStatusEntry | undefined, + snapshotUpdatedAt: string | undefined +): boolean { + if (!liveEntry || !snapshotEntry) { + return false; + } + if (!isFailedSpawnEntry(liveEntry) || isFailedSpawnEntry(snapshotEntry)) { + return false; + } + + const liveUpdatedAtMs = parseStatusUpdatedAtMs(liveEntry.updatedAt); + const snapshotUpdatedAtMs = + parseStatusUpdatedAtMs(snapshotEntry.updatedAt) ?? parseStatusUpdatedAtMs(snapshotUpdatedAt); + return ( + snapshotUpdatedAtMs != null && + (liveUpdatedAtMs == null || snapshotUpdatedAtMs >= liveUpdatedAtMs) + ); +} + function summarizeLiveLaunchJoinMilestones(params: { teammateNames: readonly string[]; memberSpawnStatuses?: MemberSpawnStatusCollection; + memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; }): Omit & { observedTeammateCount: number; } { - const { teammateNames, memberSpawnStatuses } = params; + const { + teammateNames, + memberSpawnStatuses, + memberSpawnSnapshotStatuses, + memberSpawnSnapshotUpdatedAt, + } = params; let heartbeatConfirmedCount = 0; let processOnlyAliveCount = 0; let pendingSpawnCount = 0; @@ -65,7 +105,15 @@ function summarizeLiveLaunchJoinMilestones(params: { let observedTeammateCount = 0; for (const memberName of teammateNames) { - const entry = getSpawnEntry(memberSpawnStatuses, memberName); + const liveEntry = getSpawnEntry(memberSpawnStatuses, memberName); + const snapshotEntry = memberSpawnSnapshotStatuses?.[memberName]; + const entry = shouldPreferSnapshotEntryOverLive( + liveEntry, + snapshotEntry, + memberSpawnSnapshotUpdatedAt + ) + ? snapshotEntry + : liveEntry; if (!entry) { pendingSpawnCount += 1; continue; @@ -111,7 +159,10 @@ export function getLaunchJoinMilestonesFromMembers({ }: { members: readonly LaunchJoinMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; - memberSpawnSnapshot?: Pick & { + memberSpawnSnapshot?: Pick< + MemberSpawnStatusesSnapshot, + 'expectedMembers' | 'summary' | 'updatedAt' + > & { statuses?: MemberSpawnStatusesSnapshot['statuses']; }; }): LaunchJoinMilestones { @@ -140,6 +191,8 @@ export function getLaunchJoinMilestonesFromMembers({ const liveSummary = summarizeLiveLaunchJoinMilestones({ teammateNames, memberSpawnStatuses, + memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, }); if (snapshotSummary) { @@ -263,6 +316,10 @@ export function getDisplayStepIndex({ return 3; } + if (failedSpawnCount > 0) { + return 2; + } + const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount; if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) { diff --git a/src/renderer/utils/bootstrapPromptSanitizer.ts b/src/renderer/utils/bootstrapPromptSanitizer.ts index 77eda2e6..4d58919f 100644 --- a/src/renderer/utils/bootstrapPromptSanitizer.ts +++ b/src/renderer/utils/bootstrapPromptSanitizer.ts @@ -55,6 +55,29 @@ function matchField(text: string, pattern: RegExp): string | undefined { return value ? value : undefined; } +function matchOverrideField( + text: string, + fieldName: 'Provider override' | 'Model override' | 'Effort override' +): string | undefined { + const fieldPattern = new RegExp(`${fieldName}(?: for this teammate)?:\\s*`, 'i'); + const fieldMatch = fieldPattern.exec(text); + if (!fieldMatch) { + return undefined; + } + + const rest = text.slice(fieldMatch.index + fieldMatch[0].length); + const nextOverrideMatch = + /\.\s+(?:Provider override|Model override|Effort override)(?: for this teammate)?:/i.exec(rest); + const newlineIndex = rest.indexOf('\n'); + const stopCandidates = [ + nextOverrideMatch?.index, + newlineIndex >= 0 ? newlineIndex : undefined, + ].filter((index): index is number => typeof index === 'number' && index >= 0); + const end = stopCandidates.length > 0 ? Math.min(...stopCandidates) : rest.length; + const value = rest.slice(0, end).trim().replace(/\.$/, '').trim(); + return value ? value : undefined; +} + function buildRuntimeSummary( providerId: TeamProviderId | null, model: string | undefined, @@ -63,7 +86,7 @@ function buildRuntimeSummary( if (providerId) { const providerLabel = getTeamProviderLabel(providerId) ?? 'Anthropic'; const modelLabel = model ? (getTeamModelLabel(model) ?? model) : 'Default'; - const effortLabel = getTeamEffortLabel(effort); + const effortLabel = effort ? getTeamEffortLabel(effort) : undefined; const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand( providerId, modelLabel @@ -117,11 +140,9 @@ export function getBootstrapPromptDisplay( matchField(text, /^You are\s+([^,\n]+),/m) ?? (typeof message.to === 'string' ? message.to.trim() : undefined); const teamName = matchField(text, /on team "([^"]+)"/); - const providerId = parseProviderId( - matchField(text, /Provider override(?: for this teammate)?:\s*([^\.\n]+)/i) - ); - const model = matchField(text, /Model override(?: for this teammate)?:\s*([^\.\n]+)/i); - const effort = matchField(text, /Effort override(?: for this teammate)?:\s*([^\.\n]+)/i); + const providerId = parseProviderId(matchOverrideField(text, 'Provider override')); + const model = matchOverrideField(text, 'Model override'); + const effort = matchOverrideField(text, 'Effort override'); const runtime = buildRuntimeSummary(providerId, model, effort); const displayName = teammateName ? displayMemberName(teammateName) : 'teammate'; const summary = `Starting ${displayName}`; diff --git a/src/renderer/utils/memberRuntimeSummary.ts b/src/renderer/utils/memberRuntimeSummary.ts index 95137dec..42528ad3 100644 --- a/src/renderer/utils/memberRuntimeSummary.ts +++ b/src/renderer/utils/memberRuntimeSummary.ts @@ -48,17 +48,21 @@ export function resolveMemberRuntimeSummary( ): string | undefined { const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string }) .providerBackendId; + const memberModel = member.model?.trim() || ''; + const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); + const inferredMemberProvider = + inferTeamProviderIdFromModel(memberModel) ?? inferTeamProviderIdFromModel(runtimeModel); const configuredProvider: TeamProviderId = - member.providerId ?? launchParams?.providerId ?? 'anthropic'; + member.providerId ?? inferredMemberProvider ?? launchParams?.providerId ?? 'anthropic'; + const memberProviderForInheritance = member.providerId ?? inferredMemberProvider; const inheritsLeadRuntimeDefaults = - member.providerId == null || + memberProviderForInheritance == null || launchParams?.providerId == null || - member.providerId === launchParams.providerId; + memberProviderForInheritance === launchParams.providerId; const configuredModel = - member.model?.trim() || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : ''); + memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : ''); const configuredEffort = member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined); - const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); const configuredProviderBackendId = memberProviderBackendId ?? (inheritsLeadRuntimeDefaults ? launchParams?.providerBackendId : undefined); diff --git a/src/renderer/utils/memberSpawnStatusPolling.ts b/src/renderer/utils/memberSpawnStatusPolling.ts new file mode 100644 index 00000000..fb77e74a --- /dev/null +++ b/src/renderer/utils/memberSpawnStatusPolling.ts @@ -0,0 +1,29 @@ +import type { MemberSpawnStatusEntry } from '@shared/types'; + +export const MEMBER_SPAWN_STATUS_REFRESH_MS = 2_500; + +export function hasUnresolvedMemberSpawnStatus( + memberSpawnStatuses: Record | undefined, + memberSpawnSnapshot: + | { + statuses?: Record; + summary?: { pendingCount?: number }; + } + | undefined +): boolean { + if ((memberSpawnSnapshot?.summary?.pendingCount ?? 0) > 0) { + return true; + } + const entries = [ + ...Object.values(memberSpawnStatuses ?? {}), + ...Object.values(memberSpawnSnapshot?.statuses ?? {}), + ]; + return entries.some( + (entry) => + entry.status === 'waiting' || + entry.status === 'spawning' || + entry.launchState === 'starting' || + entry.launchState === 'runtime_pending_bootstrap' || + entry.launchState === 'runtime_pending_permission' + ); +} diff --git a/src/renderer/utils/teamProvisioningPresentation.ts b/src/renderer/utils/teamProvisioningPresentation.ts index 08dd3ca7..49528d1d 100644 --- a/src/renderer/utils/teamProvisioningPresentation.ts +++ b/src/renderer/utils/teamProvisioningPresentation.ts @@ -32,9 +32,54 @@ interface FailedSpawnDetail { reason: string | null; } +function parseStatusUpdatedAtMs(value: string | undefined): number | null { + if (!value) { + return null; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean { + return entry?.launchState === 'failed_to_start' || entry?.status === 'error'; +} + +function shouldPreferSnapshotEntryOverLive(params: { + liveEntry: MemberSpawnStatusEntry | undefined; + snapshotEntry: MemberSpawnStatusEntry | undefined; + snapshotUpdatedAt?: string; +}): boolean { + const { liveEntry, snapshotEntry, snapshotUpdatedAt } = params; + if (!liveEntry || !snapshotEntry) { + return false; + } + if (!isFailedSpawnEntry(liveEntry) || isFailedSpawnEntry(snapshotEntry)) { + return false; + } + + const liveUpdatedAtMs = parseStatusUpdatedAtMs(liveEntry.updatedAt); + const snapshotUpdatedAtMs = + parseStatusUpdatedAtMs(snapshotEntry.updatedAt) ?? parseStatusUpdatedAtMs(snapshotUpdatedAt); + return ( + snapshotUpdatedAtMs != null && + (liveUpdatedAtMs == null || snapshotUpdatedAtMs >= liveUpdatedAtMs) + ); +} + +function getPreferredSpawnEntry(params: { + liveEntry: MemberSpawnStatusEntry | undefined; + snapshotEntry: MemberSpawnStatusEntry | undefined; + snapshotUpdatedAt?: string; +}): MemberSpawnStatusEntry | undefined { + return shouldPreferSnapshotEntryOverLive(params) + ? params.snapshotEntry + : (params.liveEntry ?? params.snapshotEntry); +} + function countPermissionBlockedMembers(params: { memberSpawnStatuses: MemberSpawnStatusCollection; memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; }): number { const names = new Set(); if (params.memberSpawnStatuses instanceof Map) { @@ -57,7 +102,11 @@ function countPermissionBlockedMembers(params: { ? params.memberSpawnStatuses.get(name) : params.memberSpawnStatuses?.[name]; const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; - const entry = liveEntry ?? snapshotEntry; + const entry = getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, + }); if (!entry) { continue; } @@ -89,6 +138,7 @@ const ACTIVE_PROVISIONING_STATES = new Set([ function getFailedSpawnDetails(params: { memberSpawnStatuses: MemberSpawnStatusCollection; memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses']; + memberSpawnSnapshotUpdatedAt?: string; }): FailedSpawnDetail[] { const names = new Set(); if (params.memberSpawnStatuses instanceof Map) { @@ -115,7 +165,14 @@ function getFailedSpawnDetails(params: { ? params.memberSpawnStatuses.get(name) : params.memberSpawnStatuses?.[name]; const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name]; - return [name, liveEntry ?? snapshotEntry] as const; + return [ + name, + getPreferredSpawnEntry({ + liveEntry, + snapshotEntry, + snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt, + }), + ] as const; }) .filter( ([, entry]) => entry && (entry.launchState === 'failed_to_start' || entry.status === 'error') @@ -229,7 +286,10 @@ export function buildTeamProvisioningPresentation({ progress: TeamProvisioningProgress | null | undefined; members: readonly ProvisioningMemberLike[]; memberSpawnStatuses?: MemberSpawnStatusCollection; - memberSpawnSnapshot?: Pick & { + memberSpawnSnapshot?: Pick< + MemberSpawnStatusesSnapshot, + 'expectedMembers' | 'summary' | 'updatedAt' + > & { statuses?: MemberSpawnStatusesSnapshot['statuses']; }; }): TeamProvisioningPresentation | null { @@ -265,6 +325,7 @@ export function buildTeamProvisioningPresentation({ const failedSpawnDetails = getFailedSpawnDetails({ memberSpawnStatuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, }); const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails); const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails); @@ -275,6 +336,7 @@ export function buildTeamProvisioningPresentation({ const permissionBlockedCount = countPermissionBlockedMembers({ memberSpawnStatuses, memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses, + memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt, }); const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } = @@ -369,7 +431,6 @@ export function buildTeamProvisioningPresentation({ isReady: true, isFailed: false, canCancel: false, - currentStepIndex: hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX, expectedTeammateCount, heartbeatConfirmedCount, processOnlyAliveCount, @@ -394,6 +455,8 @@ export function buildTeamProvisioningPresentation({ compactDetail: readyCompactDetail, compactTone: failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success', + currentStepIndex: + failedSpawnCount > 0 ? 2 : hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX, }; } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index e51a6a9e..9c69bfd8 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -935,6 +935,7 @@ export interface PersistedTeamLaunchMemberState { hardFailure: boolean; hardFailureReason?: string; pendingPermissionRequestIds?: string[]; + runtimePid?: number; firstSpawnAcceptedAt?: string; lastHeartbeatAt?: string; lastRuntimeAliveAt?: string; diff --git a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts index b91460f0..6328ddc0 100644 --- a/test/main/services/team/OpenCodeMixedRecovery.live.test.ts +++ b/test/main/services/team/OpenCodeMixedRecovery.live.test.ts @@ -46,6 +46,7 @@ const liveDescribe = process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1' ? describe : describe.skip; +const liveMultiLaneIt = process.env.OPENCODE_E2E_MIXED_RECOVERY_MULTI === '1' ? it : it.skip; const PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd(); const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli'; @@ -174,7 +175,7 @@ liveDescribe('OpenCode mixed recovery live e2e', () => { 240_000 ); - it( + liveMultiLaneIt( 'recovers multiple active mixed OpenCode side lanes from live runtime reconcile', async () => { const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL; diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index b3d3c846..bbdfff17 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -42,12 +42,13 @@ describe('OpenCodeTeamRuntimeAdapter', () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null })); const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); - await expect(adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))).resolves - .toMatchObject({ - ok: true, - providerId: 'opencode', - modelId: null, - }); + await expect( + adapter.prepare(launchInput({ model: undefined, runtimeOnly: true })) + ).resolves.toMatchObject({ + ok: true, + providerId: 'opencode', + modelId: null, + }); expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({ projectPath: '/repo', @@ -57,11 +58,38 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); + it('surfaces unknown readiness failures with the concrete bridge diagnostic on launch', async () => { + const bridge = bridgePort( + readiness({ + state: 'unknown_error', + launchAllowed: false, + diagnostics: [ + 'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out', + ], + missing: ['OpenCode bridge command timed out'], + }) + ); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + + await expect(adapter.launch(launchInput())).resolves.toMatchObject({ + teamLaunchState: 'partial_failure', + members: { + alice: { + launchState: 'failed_to_start', + hardFailureReason: + 'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out', + diagnostics: [ + 'OpenCode readiness bridge failed: timeout: OpenCode bridge command timed out', + 'OpenCode bridge command timed out', + ], + }, + }, + }); + }); + it('fails closed when launch mode is disabled', async () => { const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true })); - const adapter = new OpenCodeTeamRuntimeAdapter( - bridge - ); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge); await expect(adapter.prepare(launchInput())).resolves.toMatchObject({ ok: false, @@ -72,27 +100,81 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled(); }); + it('rejects non-OpenCode members before readiness or launch bridge dispatch', async () => { + const launchOpenCodeTeam = vi.fn(); + const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + launchOpenCodeTeam, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + + const result = await adapter.launch( + launchInput({ + expectedMembers: [ + { + name: 'bob', + providerId: 'codex', + model: 'gpt-5.4-mini', + cwd: '/repo', + }, + ], + }) + ); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.members.bob).toMatchObject({ + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'opencode_invalid_expected_members', + diagnostics: [ + 'OpenCode runtime adapter received non-OpenCode member "bob" with provider "codex".', + ], + }); + expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled(); + expect(launchOpenCodeTeam).not.toHaveBeenCalled(); + }); + + it('rejects empty OpenCode rosters before readiness or launch bridge dispatch', async () => { + const launchOpenCodeTeam = vi.fn(); + const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + launchOpenCodeTeam, + }); + const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' }); + + const result = await adapter.launch(launchInput({ expectedMembers: [] })); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.members).toEqual({}); + expect(result.diagnostics).toEqual([ + 'OpenCode runtime adapter requires at least one expected OpenCode member.', + ]); + expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled(); + expect(launchOpenCodeTeam).not.toHaveBeenCalled(); + }); + it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => { - const launchOpenCodeTeam = vi.fn(async () => ({ - runId: 'run-1', - teamLaunchState: 'ready', - members: { - alice: { - sessionId: 'oc-session-1', - launchState: 'confirmed_alive', - runtimePid: 123, - model: 'openai/gpt-5.4-mini', - evidence: [ - { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' }, - ], - }, - }, - warnings: [], - diagnostics: [], - }) satisfies OpenCodeLaunchTeamCommandData); + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'ready', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'confirmed_alive', + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData + ); const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({ @@ -130,6 +212,70 @@ describe('OpenCodeTeamRuntimeAdapter', () => { ); }); + it('does not mark the lane clean_success when ready bridge data omits an expected member', async () => { + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'ready', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'confirmed_alive', + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [], + durableCheckpoints: [ + { name: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { name: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { name: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + manifestHighWatermark: null, + runtimeStoreManifestHighWatermark: null, + }) satisfies OpenCodeLaunchTeamCommandData + ); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + launchOpenCodeTeam, + }), + { launchMode: 'dogfood' } + ); + + const result = await adapter.launch({ + ...launchInput(), + expectedMembers: [ + ...launchInput().expectedMembers, + { + name: 'bob', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + cwd: '/repo', + }, + ], + }); + + expect(result.teamLaunchState).toBe('partial_pending'); + expect(result.launchPhase).toBe('active'); + expect(result.members.alice?.launchState).toBe('confirmed_alive'); + expect(result.members.bob).toMatchObject({ + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + hardFailure: false, + }); + expect(result.members.bob?.diagnostics).toContain( + 'OpenCode bridge response did not include bob; keeping the member pending until lane state materializes.' + ); + }); + it('reconciles from existing persisted launch snapshot without treating OpenCode as truth', async () => { const snapshot = launchSnapshot(); const adapter = new OpenCodeTeamRuntimeAdapter( @@ -162,24 +308,72 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); }); - it('keeps missing bridge members pending while reconcile is still launching', async () => { - const reconcileOpenCodeTeam = vi.fn(async () => ({ - runId: 'run-1', - teamLaunchState: 'launching', - members: { - alice: { - sessionId: 'oc-session-1', - launchState: 'confirmed_alive', - model: 'openai/gpt-5.4-mini', - evidence: [{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }], - }, - }, - warnings: [], + it('sends direct teammate messages through the OpenCode message bridge', async () => { + const sendOpenCodeTeamMessage = vi.fn(async () => ({ + accepted: true, + sessionId: 'oc-session-bob', + memberName: 'bob', + runtimePid: 456, diagnostics: [], - durableCheckpoints: [], - manifestHighWatermark: null, - runtimeStoreManifestHighWatermark: null, - }) satisfies OpenCodeLaunchTeamCommandData); + })); + const adapter = new OpenCodeTeamRuntimeAdapter( + bridgePort(readiness({ state: 'ready', launchAllowed: true }), { + sendOpenCodeTeamMessage, + }) + ); + + await expect( + adapter.sendMessageToMember({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + text: 'hello bob', + messageId: 'msg-1', + }) + ).resolves.toEqual({ + ok: true, + providerId: 'opencode', + memberName: 'bob', + sessionId: 'oc-session-bob', + runtimePid: 456, + diagnostics: [], + }); + expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith({ + runId: 'run-1', + laneId: 'secondary:opencode:bob', + teamId: 'team-a', + teamName: 'team-a', + projectPath: '/repo', + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-1', + agent: 'teammate', + }); + }); + + it('keeps missing bridge members pending while reconcile is still launching', async () => { + const reconcileOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'launching', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'confirmed_alive', + model: 'openai/gpt-5.4-mini', + evidence: [{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' }], + }, + }, + warnings: [], + diagnostics: [], + durableCheckpoints: [], + manifestHighWatermark: null, + runtimeStoreManifestHighWatermark: null, + }) satisfies OpenCodeLaunchTeamCommandData + ); const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { reconcileOpenCodeTeam, @@ -244,27 +438,30 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); it('maps permission-blocked bridge members to runtime_pending_permission instead of bootstrap pending', async () => { - const launchOpenCodeTeam = vi.fn(async () => ({ - runId: 'run-1', - teamLaunchState: 'permission_blocked', - members: { - alice: { - sessionId: 'oc-session-1', - launchState: 'permission_blocked', - pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'], - diagnostics: ['waiting for permission approval'], - runtimePid: 123, - model: 'openai/gpt-5.4-mini', - evidence: [ - { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' }, - ], - }, - }, - warnings: [], - diagnostics: [], - }) satisfies OpenCodeLaunchTeamCommandData); + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'permission_blocked', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'permission_blocked', + pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'], + diagnostics: ['waiting for permission approval'], + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData + ); const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({ @@ -305,27 +502,30 @@ describe('OpenCodeTeamRuntimeAdapter', () => { }); it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => { - const launchOpenCodeTeam = vi.fn(async () => ({ - runId: 'run-1', - teamLaunchState: 'permission_blocked', - members: { - alice: { - sessionId: 'oc-session-1', - launchState: 'permission_blocked', - pendingPermissionRequestIds: ['perm-1'], - diagnostics: ['waiting for permission approval'], - runtimePid: 123, - model: 'openai/gpt-5.4-mini', - evidence: [ - { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, - { kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' }, - ], - }, - }, - warnings: [], - diagnostics: [], - }) satisfies OpenCodeLaunchTeamCommandData); + const launchOpenCodeTeam = vi.fn( + async () => + ({ + runId: 'run-1', + teamLaunchState: 'permission_blocked', + members: { + alice: { + sessionId: 'oc-session-1', + launchState: 'permission_blocked', + pendingPermissionRequestIds: ['perm-1'], + diagnostics: ['waiting for permission approval'], + runtimePid: 123, + model: 'openai/gpt-5.4-mini', + evidence: [ + { kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' }, + { kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' }, + ], + }, + }, + warnings: [], + diagnostics: [], + }) satisfies OpenCodeLaunchTeamCommandData + ); const adapter = new OpenCodeTeamRuntimeAdapter( bridgePort(readiness({ state: 'ready', launchAllowed: true }), { getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({ diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts new file mode 100644 index 00000000..db3e4feb --- /dev/null +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -0,0 +1,2105 @@ +import { promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; +import { + TeamRuntimeAdapterRegistry, + type TeamLaunchRuntimeAdapter, + type TeamRuntimeLaunchInput, + type TeamRuntimeMemberLaunchEvidence, + type TeamRuntimeMemberSpec, + type TeamRuntimeLaunchResult, + type TeamRuntimePrepareResult, + type TeamRuntimeReconcileInput, + type TeamRuntimeReconcileResult, + type TeamRuntimeStopInput, + type TeamRuntimeStopResult, +} from '../../../../src/main/services/team/runtime/TeamRuntimeAdapter'; +import { + getTeamsBasePath, + setClaudeBasePathOverride, +} from '../../../../src/main/utils/pathDecoder'; +import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; +import { + readOpenCodeRuntimeLaneIndex, + upsertOpenCodeRuntimeLaneIndexEntry, +} from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; + +import type { TeamProvisioningProgress } from '../../../../src/shared/types'; + +describe('Team agent launch matrix safe e2e', () => { + let tempDir: string; + let tempClaudeRoot: string; + let projectPath: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-launch-matrix-e2e-')); + tempClaudeRoot = path.join(tempDir, '.claude'); + projectPath = path.join(tempDir, 'project'); + await fs.mkdir(projectPath, { recursive: true }); + await fs.mkdir(tempClaudeRoot, { recursive: true }); + setClaudeBasePathOverride(tempClaudeRoot); + }); + + afterEach(async () => { + setClaudeBasePathOverride(null); + await removeTempDirWithRetries(tempDir); + }); + + it('launches a pure OpenCode team through the runtime adapter and exposes live members', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const progressEvents: TeamProvisioningProgress[] = []; + + const { runId } = await svc.createTeam( + { + teamName: 'pure-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [ + { name: 'alice', role: 'Developer', providerId: 'opencode' }, + { name: 'bob', role: 'Reviewer', providerId: 'opencode' }, + ], + }, + (progress) => progressEvents.push(progress) + ); + + expect(runId).toBe(adapter.launchInputs[0]?.runId); + expect(adapter.launchInputs).toHaveLength(1); + expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([ + 'alice', + 'bob', + ]); + expect(progressEvents.at(-1)).toMatchObject({ + state: 'ready', + message: 'OpenCode team launch is ready', + }); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('pure-opencode-safe-e2e'); + expect(runtimeSnapshot.members.alice).toMatchObject({ + alive: true, + providerId: 'opencode', + runtimeModel: 'opencode/big-pickle', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + alive: true, + providerId: 'opencode', + runtimeModel: 'opencode/big-pickle', + }); + + await expect( + fs.readFile(path.join(getTeamsBasePath(), 'pure-opencode-safe-e2e', 'launch-state.json'), { + encoding: 'utf8', + }) + ).resolves.toContain('"teamLaunchState": "clean_success"'); + }); + + it('keeps failed OpenCode runtime adapter launches out of alive teams', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter('partial_failure'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const progressEvents: TeamProvisioningProgress[] = []; + + await svc.createTeam( + { + teamName: 'failed-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + (progress) => progressEvents.push(progress) + ); + + expect(progressEvents.at(-1)).toMatchObject({ + state: 'failed', + message: 'OpenCode team launch failed readiness gate', + }); + expect(svc.isTeamAlive('failed-opencode-safe-e2e')).toBe(false); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('failed-opencode-safe-e2e'); + expect(runtimeSnapshot.members.alice).toMatchObject({ + alive: false, + providerId: 'opencode', + runtimeModel: 'opencode/big-pickle', + }); + }); + + it('launches an existing pure OpenCode team config through the runtime adapter', async () => { + await writeOpenCodeTeamConfig({ + teamName: 'existing-opencode-safe-e2e', + projectPath, + members: ['alice', 'bob'], + }); + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const progressEvents: TeamProvisioningProgress[] = []; + + const { runId } = await svc.launchTeam( + { + teamName: 'existing-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + }, + (progress) => progressEvents.push(progress) + ); + + expect(runId).toBe(adapter.launchInputs[0]?.runId); + expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([ + 'alice', + 'bob', + ]); + expect(progressEvents.at(-1)).toMatchObject({ + state: 'ready', + message: 'OpenCode team launch is ready', + }); + + const statuses = await svc.getMemberSpawnStatuses('existing-opencode-safe-e2e'); + expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + }); + + it('keeps permission-pending OpenCode members pending instead of reading the team as fully ready', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const progressEvents: TeamProvisioningProgress[] = []; + + await svc.createTeam( + { + teamName: 'permission-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: false, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + (progress) => progressEvents.push(progress) + ); + + expect(progressEvents.at(-1)).toMatchObject({ + state: 'ready', + message: 'OpenCode team launch is waiting for runtime evidence or permissions', + messageSeverity: 'warning', + }); + expect(svc.isTeamAlive('permission-opencode-safe-e2e')).toBe(true); + + const statuses = await svc.getMemberSpawnStatuses('permission-opencode-safe-e2e'); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_permission', + runtimeAlive: true, + pendingPermissionRequestIds: ['perm-alice'], + }); + expect(statuses.summary?.pendingCount).toBe(1); + }); + + it('preserves mixed OpenCode per-member outcomes after a partial runtime adapter launch', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter('partial_failure', { + alice: 'confirmed', + bob: 'permission', + tom: 'failed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await svc.createTeam( + { + teamName: 'mixed-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: false, + members: [ + { name: 'alice', role: 'Developer', providerId: 'opencode' }, + { name: 'bob', role: 'Reviewer', providerId: 'opencode' }, + { name: 'tom', role: 'Developer', providerId: 'opencode' }, + ], + }, + () => undefined + ); + + expect(svc.isTeamAlive('mixed-opencode-safe-e2e')).toBe(false); + + const statuses = await svc.getMemberSpawnStatuses('mixed-opencode-safe-e2e'); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_permission', + runtimeAlive: true, + pendingPermissionRequestIds: ['perm-bob'], + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + hardFailure: true, + hardFailureReason: 'fake_open_code_launch_failure', + }); + expect(statuses.summary).toMatchObject({ + confirmedCount: 1, + pendingCount: 1, + failedCount: 1, + }); + }); + + it('stops a pure OpenCode runtime adapter team and clears alive tracking', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await svc.createTeam( + { + teamName: 'stoppable-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + expect(svc.isTeamAlive('stoppable-opencode-safe-e2e')).toBe(true); + + svc.stopTeam('stoppable-opencode-safe-e2e'); + + await waitForCondition(() => adapter.stopInputs.length === 1); + await waitForCondition(() => !svc.isTeamAlive('stoppable-opencode-safe-e2e')); + expect(adapter.stopInputs[0]).toMatchObject({ + teamName: 'stoppable-opencode-safe-e2e', + providerId: 'opencode', + reason: 'user_requested', + force: true, + }); + }); + + it('recovers mixed Codex/OpenCode launch truth from persisted state after service restart', async () => { + const teamName = 'mixed-persisted-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + tom: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['perm-tom'], + }), + }, + }); + + const restartedService = new TeamProvisioningService(); + const statuses = await restartedService.getMemberSpawnStatuses(teamName); + + expect(statuses.expectedMembers).toEqual(['alice', 'bob', 'tom']); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 0, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.tom).toMatchObject({ + launchState: 'runtime_pending_permission', + pendingPermissionRequestIds: ['perm-tom'], + }); + + const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName); + expect(runtimeSnapshot.providerBackendId).toBe('codex-native'); + expect(runtimeSnapshot.members.alice).toMatchObject({ + providerId: 'codex', + providerBackendId: 'codex-native', + laneKind: 'primary', + runtimeModel: 'gpt-5.4-mini', + }); + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + runtimeModel: 'opencode/minimax-m2.5-free', + }); + expect(runtimeSnapshot.members.tom).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + runtimeModel: 'opencode/nemotron-3-super-free', + }); + }); + + it('exposes shared OpenCode side-lane runtime memory in the team runtime snapshot', async () => { + const teamName = 'mixed-opencode-runtime-memory-safe-e2e'; + const sharedHostPid = 24_242; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + }, + }); + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + metricsPid: sharedHostPid, + model: 'opencode/minimax-m2.5-free', + }, + ], + ]); + (svc as any).readProcessRssBytesByPid = async () => + new Map([[sharedHostPid, 183.9 * 1024 * 1024]]); + + const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName); + + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + alive: true, + restartable: false, + pid: sharedHostPid, + runtimeModel: 'opencode/minimax-m2.5-free', + rssBytes: 183.9 * 1024 * 1024, + }); + expect(runtimeSnapshot.members.bob.providerBackendId).toBeUndefined(); + }); + + it('infers OpenCode runtime provider from model after restart when provider metadata is missing', async () => { + const teamName = 'mixed-opencode-model-inference-safe-e2e'; + const sharedHostPid = 24_243; + await writeMixedTeamConfigWithoutOpenCodeProviderMetadata({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }), + }, + }); + const restartedService = new TeamProvisioningService(); + (restartedService as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + metricsPid: sharedHostPid, + model: 'opencode/minimax-m2.5-free', + }, + ], + ]); + (restartedService as any).readProcessRssBytesByPid = async () => + new Map([[sharedHostPid, 188.4 * 1024 * 1024]]); + + const runtimeSnapshot = await restartedService.getTeamAgentRuntimeSnapshot(teamName); + + expect(runtimeSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + alive: true, + restartable: false, + pid: sharedHostPid, + runtimeModel: 'opencode/minimax-m2.5-free', + rssBytes: 188.4 * 1024 * 1024, + }); + expect(runtimeSnapshot.members.bob.providerBackendId).toBeUndefined(); + }); + + it('clears stale never-spawned OpenCode side-lane failures when live runtime metadata proves the member is alive', async () => { + const teamName = 'mixed-opencode-stale-failure-clears-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + }), + }, + }); + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + model: 'opencode/minimax-m2.5-free', + }, + ], + ]); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + hardFailure: false, + runtimeModel: 'opencode/minimax-m2.5-free', + }); + expect(statuses.statuses.bob.hardFailureReason).toBeUndefined(); + expect(statuses.statuses.bob.error).toBeUndefined(); + }); + + it('promotes starting OpenCode side-lane members to runtime-pending when live metadata sees the process', async () => { + const teamName = 'mixed-opencode-starting-promotes-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + }), + }, + }); + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + model: 'opencode/minimax-m2.5-free', + }, + ], + ]); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 1, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: true, + livenessSource: 'process', + hardFailure: false, + runtimeModel: 'opencode/minimax-m2.5-free', + }); + }); + + it('does not clear definitive OpenCode side-lane failures from unrelated live runtime metadata', async () => { + const teamName = 'mixed-opencode-definitive-failure-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode raw model id "minimax-m2.5-free" was not found.', + }), + }, + }); + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = async () => + new Map([ + [ + 'bob', + { + alive: true, + model: 'opencode/minimax-m2.5-free', + }, + ], + ]); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 1, + failedCount: 1, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + hardFailure: true, + hardFailureReason: 'OpenCode raw model id "minimax-m2.5-free" was not found.', + runtimeModel: 'opencode/minimax-m2.5-free', + }); + }); + + it('runs mixed live secondary OpenCode lanes and preserves primary Codex status', async () => { + const teamName = 'mixed-live-lanes-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'permission', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + const initialSnapshot = await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(initialSnapshot).toMatchObject({ + teamName, + launchPhase: 'active', + teamLaunchState: 'partial_pending', + }); + expect(initialSnapshot.members.alice).toMatchObject({ + providerId: 'codex', + laneKind: 'primary', + launchState: 'confirmed_alive', + }); + expect(initialSnapshot.members.bob).toMatchObject({ + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + launchState: 'starting', + }); + + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + await waitForCondition(() => run.memberSpawnStatuses.get('bob')?.launchState === 'confirmed_alive'); + await waitForCondition(() => run.memberSpawnStatuses.get('tom')?.launchState === 'runtime_pending_permission'); + + expect(adapter.launchInputs.map((input) => input.laneId).sort()).toEqual([ + 'secondary:opencode:bob', + 'secondary:opencode:tom', + ]); + expect(adapter.launchInputs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + model: 'opencode/minimax-m2.5-free', + expectedMembers: [expect.objectContaining({ name: 'bob', providerId: 'opencode' })], + }), + expect.objectContaining({ + laneId: 'secondary:opencode:tom', + model: 'opencode/nemotron-3-super-free', + expectedMembers: [expect.objectContaining({ name: 'tom', providerId: 'opencode' })], + }), + ]) + ); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + pendingCount: 1, + failedCount: 0, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + bootstrapConfirmed: true, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_permission', + runtimeAlive: true, + pendingPermissionRequestIds: ['perm-tom'], + }); + }); + + it('keeps Codex primary online when a mixed OpenCode secondary lane fails', async () => { + const teamName = 'mixed-live-secondary-failure-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'failed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + await waitForCondition(() => run.memberSpawnStatuses.get('bob')?.launchState === 'failed_to_start'); + await waitForCondition(() => run.memberSpawnStatuses.get('tom')?.launchState === 'confirmed_alive'); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.summary).toMatchObject({ + confirmedCount: 2, + failedCount: 1, + }); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + hardFailureReason: 'fake_open_code_launch_failure', + }); + }); + + it('restarts one mixed OpenCode secondary lane without touching other live teammates', async () => { + const teamName = 'mixed-opencode-manual-restart-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + + adapter.setLaunchResult('partial_pending', { bob: 'permission' }); + + await svc.restartMember(teamName, 'bob'); + + await waitForCondition(() => adapter.launchInputs.length === 3); + expect(adapter.stopInputs).toHaveLength(1); + expect(adapter.stopInputs[0]).toMatchObject({ + laneId: 'secondary:opencode:bob', + reason: 'relaunch', + }); + expect(adapter.launchInputs.at(-1)).toMatchObject({ + laneId: 'secondary:opencode:bob', + expectedMembers: [expect.objectContaining({ name: 'bob', providerId: 'opencode' })], + }); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_permission', + pendingPermissionRequestIds: ['perm-bob'], + hardFailure: false, + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + }); + + it('detaches one mixed OpenCode secondary lane and keeps remaining teammates launchable', async () => { + const teamName = 'mixed-opencode-detach-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + + await svc.detachOpenCodeOwnedMemberLane(teamName, 'bob'); + + expect(adapter.stopInputs).toHaveLength(1); + expect(adapter.stopInputs[0]).toMatchObject({ + laneId: 'secondary:opencode:bob', + reason: 'cleanup', + }); + expect(run.mixedSecondaryLanes.map((lane: { member: { name: string } }) => lane.member.name)).toEqual([ + 'tom', + ]); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.expectedMembers).toEqual(['alice', 'tom']); + expect(statuses.statuses.bob).toBeUndefined(); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:tom': { state: 'active' }, + }, + }); + }); + + it('shows mixed OpenCode secondary lanes as spawning while runtime adapter launch is in flight', async () => { + const teamName = 'mixed-live-inflight-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new BlockingOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + const initialSnapshot = await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(initialSnapshot.teamLaunchState).toBe('partial_pending'); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + + const inFlightStatuses = await svc.getMemberSpawnStatuses(teamName); + expect(inFlightStatuses.teamLaunchState).toBe('partial_pending'); + expect(inFlightStatuses.summary).toMatchObject({ + confirmedCount: 1, + pendingCount: 2, + failedCount: 0, + }); + expect(inFlightStatuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(inFlightStatuses.statuses.bob).toMatchObject({ + status: 'spawning', + launchState: 'starting', + hardFailure: false, + }); + expect(inFlightStatuses.statuses.tom).toMatchObject({ + status: 'spawning', + launchState: 'starting', + hardFailure: false, + }); + + adapter.releaseLaunches(); + + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + const finalStatuses = await svc.getMemberSpawnStatuses(teamName); + expect(finalStatuses.teamLaunchState).toBe('clean_success'); + expect(finalStatuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(finalStatuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + }); + + it('does not double-dispatch mixed OpenCode secondary lanes when launch handoff is retried in flight', async () => { + const teamName = 'mixed-retry-inflight-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new BlockingOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + const firstLaneRunIds = run.mixedSecondaryLanes.map( + (lane: { runId: string | null }) => lane.runId + ); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(adapter.pendingLaunchInputs).toHaveLength(2); + expect(adapter.launchInputs).toHaveLength(0); + expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ + 'launching', + 'launching', + ]); + expect(run.mixedSecondaryLanes.map((lane: { runId: string | null }) => lane.runId)).toEqual( + firstLaneRunIds + ); + + adapter.releaseLaunches(); + await waitForCondition(() => adapter.launchInputs.length === 2); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(adapter.launchInputs).toHaveLength(2); + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('clean_success'); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + }); + + it('does not dispatch mixed OpenCode secondary lanes after the primary launch run is cancelled', async () => { + const teamName = 'mixed-cancel-before-handoff-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new BlockingOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + run.cancelRequested = true; + run.processKilled = true; + + const snapshot = await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + + expect(snapshot).toBeNull(); + expect(adapter.pendingLaunchInputs).toHaveLength(0); + expect(adapter.launchInputs).toHaveLength(0); + expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ + 'queued', + 'queued', + ]); + }); + + it('does not resurrect a stopped mixed launch when in-flight OpenCode lanes finish late', async () => { + const teamName = 'mixed-stop-inflight-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new BlockingOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + + svc.stopTeam(teamName); + + await waitForCondition(() => !svc.isTeamAlive(teamName)); + await waitForCondition(() => adapter.stopInputs.length === 2); + expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ + 'secondary:opencode:bob', + 'secondary:opencode:tom', + ]); + + adapter.releaseLaunches(); + await waitForCondition(() => adapter.launchInputs.length === 2); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(svc.isTeamAlive(teamName)).toBe(false); + expect(statuses.teamLaunchState).not.toBe('clean_success'); + expect(statuses.statuses.bob?.launchState).not.toBe('confirmed_alive'); + expect(statuses.statuses.tom?.launchState).not.toBe('confirmed_alive'); + }); + + it('does not let a stopped run late result overwrite newer mixed launch truth', async () => { + const teamName = 'mixed-late-old-result-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new BlockingOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const oldRun = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, oldRun); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(oldRun); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + + svc.stopTeam(teamName); + await waitForCondition(() => !svc.isTeamAlive(teamName)); + await waitForCondition(() => adapter.stopInputs.length === 2); + + await writeMixedTeamLaunchState({ + teamName, + members: { + alice: mixedMemberState({ + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + }), + bob: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'runtime_pending_permission', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: false, + hardFailure: false, + pendingPermissionRequestIds: ['new-perm-bob'], + }), + tom: mixedMemberState({ + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'new run explicit failure', + }), + }, + }); + + adapter.releaseLaunches(); + await waitForCondition(() => adapter.launchInputs.length === 2); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.bob).toMatchObject({ + launchState: 'runtime_pending_permission', + pendingPermissionRequestIds: ['new-perm-bob'], + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailureReason: 'new run explicit failure', + }); + }); + + it('does not degrade stopped mixed launch lanes when in-flight OpenCode launch errors late', async () => { + const teamName = 'mixed-stop-late-error-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new RejectingBlockingOpenCodeRuntimeAdapter('late fake bridge failure'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + + svc.stopTeam(teamName); + await waitForCondition(() => !svc.isTeamAlive(teamName)); + await waitForCondition(() => adapter.stopInputs.length === 2); + + adapter.releaseLaunches(); + await waitForCondition(() => adapter.rejectedLaunchCount === 2); + + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: {}, + }); + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).not.toBe('partial_failure'); + expect(statuses.statuses.bob).toMatchObject({ + hardFailure: false, + }); + expect(statuses.statuses.bob?.launchState).not.toBe('failed_to_start'); + expect(statuses.statuses.tom).toMatchObject({ + hardFailure: false, + }); + expect(statuses.statuses.tom?.launchState).not.toBe('failed_to_start'); + }); + + it('stops mixed OpenCode secondary lanes when provisioning is cancelled mid-launch', async () => { + const teamName = 'mixed-cancel-inflight-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new BlockingOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + + await svc.cancelProvisioning(run.runId); + + await waitForCondition(() => adapter.stopInputs.length === 2); + expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ + 'secondary:opencode:bob', + 'secondary:opencode:tom', + ]); + expect(svc.isTeamAlive(teamName)).toBe(false); + + adapter.releaseLaunches(); + await waitForCondition(() => adapter.launchInputs.length === 2); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).not.toBe('clean_success'); + expect(statuses.statuses.bob?.launchState).not.toBe('confirmed_alive'); + expect(statuses.statuses.tom?.launchState).not.toBe('confirmed_alive'); + }); + + it('does not degrade mixed OpenCode lanes when in-flight launch errors after cancel', async () => { + const teamName = 'mixed-cancel-late-error-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + const adapter = new RejectingBlockingOpenCodeRuntimeAdapter('late fake cancel bridge failure'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await waitForCondition(() => adapter.pendingLaunchInputs.length === 2); + + await svc.cancelProvisioning(run.runId); + await waitForCondition(() => adapter.stopInputs.length === 2); + + adapter.releaseLaunches(); + await waitForCondition(() => adapter.rejectedLaunchCount === 2); + + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: {}, + }); + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.teamLaunchState).not.toBe('partial_failure'); + expect(statuses.statuses.bob).toMatchObject({ + hardFailure: false, + }); + expect(statuses.statuses.bob?.launchState).not.toBe('failed_to_start'); + expect(statuses.statuses.tom).toMatchObject({ + hardFailure: false, + }); + expect(statuses.statuses.tom?.launchState).not.toBe('failed_to_start'); + }); + + it('degrades stale active mixed OpenCode lanes when lane state is missing on disk', async () => { + const teamName = 'mixed-stale-lanes-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:bob', + state: 'active', + }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:tom', + state: 'active', + }); + + const svc = new TeamProvisioningService(); + const statuses = await svc.getMemberSpawnStatuses(teamName); + + expect(statuses.teamLaunchState).toBe('partial_failure'); + expect(statuses.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob', 'tom'])); + expect(statuses.statuses.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + error: expect.stringContaining('no lane state exists on disk'), + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + hardFailure: true, + error: expect.stringContaining('no lane state exists on disk'), + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:bob': { state: 'degraded' }, + 'secondary:opencode:tom': { state: 'degraded' }, + }, + }); + }); + + it('recovers stale active mixed OpenCode lanes from runtime reconcile before degrading them', async () => { + const teamName = 'mixed-runtime-recover-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:bob', + state: 'active', + }); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:tom', + state: 'active', + }); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + const statuses = await svc.getMemberSpawnStatuses(teamName); + + expect(adapter.reconcileInputs.map((input) => input.laneId).sort()).toEqual([ + 'secondary:opencode:bob', + 'secondary:opencode:tom', + ]); + expect(statuses.teamLaunchState).toBe('partial_pending'); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(statuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:bob': { state: 'active' }, + 'secondary:opencode:tom': { state: 'active' }, + }, + }); + }); + + it('recovers pure OpenCode launch statuses from disk after service restart', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter(); + const firstService = new TeamProvisioningService(); + firstService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await firstService.createTeam( + { + teamName: 'restart-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [ + { name: 'alice', role: 'Developer', providerId: 'opencode' }, + { name: 'bob', role: 'Reviewer', providerId: 'opencode' }, + ], + }, + () => undefined + ); + + const restartedService = new TeamProvisioningService(); + const statuses = await restartedService.getMemberSpawnStatuses('restart-opencode-safe-e2e'); + + expect(statuses).toMatchObject({ + source: 'persisted', + teamLaunchState: 'clean_success', + }); + expect(statuses.expectedMembers).toEqual(['alice', 'bob']); + expect(statuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + }); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + runtimeAlive: true, + }); + }); + + it('relaunches an OpenCode team after a failed runtime adapter launch and replaces stale failures', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter('partial_failure'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await svc.createTeam( + { + teamName: 'failed-then-relaunch-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + const failedStatuses = await svc.getMemberSpawnStatuses( + 'failed-then-relaunch-opencode-safe-e2e' + ); + expect(failedStatuses.teamLaunchState).toBe('partial_failure'); + expect(failedStatuses.statuses.alice).toMatchObject({ + status: 'error', + hardFailure: true, + }); + + adapter.setLaunchResult('clean_success'); + + await svc.launchTeam( + { + teamName: 'failed-then-relaunch-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + }, + () => undefined + ); + + const relaunchedStatuses = await svc.getMemberSpawnStatuses( + 'failed-then-relaunch-opencode-safe-e2e' + ); + expect(relaunchedStatuses.teamLaunchState).toBe('clean_success'); + expect(relaunchedStatuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + expect(relaunchedStatuses.statuses.alice?.hardFailureReason).toBeUndefined(); + }); + + it('relaunches an OpenCode team after permission-pending stop and clears pending permissions', async () => { + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending'); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await svc.createTeam( + { + teamName: 'pending-then-relaunch-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: false, + members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }], + }, + () => undefined + ); + + const pendingStatuses = await svc.getMemberSpawnStatuses( + 'pending-then-relaunch-opencode-safe-e2e' + ); + expect(pendingStatuses.statuses.alice).toMatchObject({ + launchState: 'runtime_pending_permission', + pendingPermissionRequestIds: ['perm-alice'], + }); + + svc.stopTeam('pending-then-relaunch-opencode-safe-e2e'); + await waitForCondition(() => adapter.stopInputs.length === 1); + adapter.setLaunchResult('clean_success'); + + await svc.launchTeam( + { + teamName: 'pending-then-relaunch-opencode-safe-e2e', + cwd: projectPath, + providerId: 'opencode', + model: 'opencode/big-pickle', + skipPermissions: true, + }, + () => undefined + ); + + const relaunchedStatuses = await svc.getMemberSpawnStatuses( + 'pending-then-relaunch-opencode-safe-e2e' + ); + expect(relaunchedStatuses.teamLaunchState).toBe('clean_success'); + expect(relaunchedStatuses.statuses.alice).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + bootstrapConfirmed: true, + }); + expect(relaunchedStatuses.statuses.alice?.pendingPermissionRequestIds).toBeUndefined(); + }); +}); + +type FakeMemberOutcome = 'confirmed' | 'permission' | 'failed'; + +class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter { + readonly providerId = 'opencode' as const; + readonly launchInputs: TeamRuntimeLaunchInput[] = []; + readonly reconcileInputs: TeamRuntimeReconcileInput[] = []; + readonly stopInputs: TeamRuntimeStopInput[] = []; + + constructor( + private launchState: TeamRuntimeLaunchResult['teamLaunchState'] = 'clean_success', + private memberOutcomes: Record = {} + ) {} + + setLaunchResult( + launchState: TeamRuntimeLaunchResult['teamLaunchState'], + memberOutcomes: Record = {} + ): void { + this.launchState = launchState; + this.memberOutcomes = memberOutcomes; + } + + async prepare(input: TeamRuntimeLaunchInput): Promise { + return { + ok: true, + providerId: 'opencode', + modelId: input.model ?? null, + diagnostics: [], + warnings: [], + }; + } + + async launch(input: TeamRuntimeLaunchInput): Promise { + this.launchInputs.push(input); + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'finished', + teamLaunchState: this.aggregateLaunchState(input.expectedMembers), + members: Object.fromEntries( + input.expectedMembers.map((member, index) => [ + member.name, + this.buildMemberEvidence(member, index), + ]) + ), + warnings: [], + diagnostics: this.launchState === 'partial_failure' + ? ['fake OpenCode launch failed'] + : this.launchState === 'partial_pending' + ? ['fake OpenCode launch awaiting permission'] + : ['fake OpenCode launch ready'], + }; + } + + async reconcile(input: TeamRuntimeReconcileInput): Promise { + this.reconcileInputs.push(input); + const members = Object.fromEntries( + input.expectedMembers.map((member, index) => [ + member.name, + this.buildMemberEvidence(member, index), + ]) + ); + return { + runId: input.runId, + teamName: input.teamName, + launchPhase: 'reconciled', + teamLaunchState: this.aggregateLaunchState(input.expectedMembers), + members, + snapshot: null, + warnings: [], + diagnostics: ['fake reconcile'], + }; + } + + async stop(input: TeamRuntimeStopInput): Promise { + this.stopInputs.push(input); + return { + runId: input.runId, + teamName: input.teamName, + stopped: true, + members: {}, + warnings: [], + diagnostics: ['fake stop'], + }; + } + + private defaultOutcome(): FakeMemberOutcome { + if (this.launchState === 'partial_failure') { + return 'failed'; + } + if (this.launchState === 'partial_pending') { + return 'permission'; + } + return 'confirmed'; + } + + private buildMemberEvidence( + member: Pick, + index: number + ): TeamRuntimeMemberLaunchEvidence { + const outcome = this.memberOutcomes[member.name] ?? this.defaultOutcome(); + const failed = outcome === 'failed'; + const permissionPending = outcome === 'permission'; + return { + memberName: member.name, + providerId: 'opencode', + launchState: failed + ? 'failed_to_start' + : permissionPending + ? 'runtime_pending_permission' + : 'confirmed_alive', + agentToolAccepted: !failed, + runtimeAlive: !failed, + bootstrapConfirmed: !failed && !permissionPending, + hardFailure: failed, + hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined, + pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined, + runtimePid: failed ? undefined : 10_000 + index, + diagnostics: failed + ? ['fake OpenCode launch failure'] + : permissionPending + ? ['fake OpenCode launch awaiting permission'] + : ['fake OpenCode launch ready'], + }; + } + + private aggregateLaunchState( + members: readonly Pick[] + ): TeamRuntimeLaunchResult['teamLaunchState'] { + const outcomes = members.map((member) => this.memberOutcomes[member.name] ?? this.defaultOutcome()); + if (outcomes.some((outcome) => outcome === 'failed')) { + return 'partial_failure'; + } + if (outcomes.some((outcome) => outcome === 'permission')) { + return 'partial_pending'; + } + return 'clean_success'; + } +} + +class BlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { + readonly pendingLaunchInputs: TeamRuntimeLaunchInput[] = []; + private releaseGate: (() => void) | null = null; + private readonly gate = new Promise((resolve) => { + this.releaseGate = resolve; + }); + + override async launch(input: TeamRuntimeLaunchInput): Promise { + this.pendingLaunchInputs.push(input); + await this.gate; + return super.launch(input); + } + + releaseLaunches(): void { + this.releaseGate?.(); + } +} + +class RejectingBlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter { + readonly pendingLaunchInputs: TeamRuntimeLaunchInput[] = []; + rejectedLaunchCount = 0; + private releaseGate: (() => void) | null = null; + private readonly gate = new Promise((resolve) => { + this.releaseGate = resolve; + }); + + constructor(private readonly errorMessage: string) { + super(); + } + + override async launch(input: TeamRuntimeLaunchInput): Promise { + this.pendingLaunchInputs.push(input); + await this.gate; + this.rejectedLaunchCount += 1; + throw new Error(this.errorMessage); + } + + releaseLaunches(): void { + this.releaseGate?.(); + } +} + +async function waitForCondition(assertion: () => boolean): Promise { + const startedAt = Date.now(); + while (Date.now() - startedAt < 2_000) { + if (assertion()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(assertion()).toBe(true); +} + +async function removeTempDirWithRetries(dir: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rm(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 20 }); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1))); + } + } + throw lastError; +} + +function createMixedLiveRun(input: { teamName: string; projectPath: string }): any { + const now = '2026-04-23T10:00:00.000Z'; + return { + runId: `run-${input.teamName}`, + teamName: input.teamName, + startedAt: now, + detectedSessionId: 'lead-session', + isLaunch: true, + provisioningComplete: false, + processKilled: false, + cancelRequested: false, + request: { + teamName: input.teamName, + cwd: input.projectPath, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + skipPermissions: false, + members: [], + }, + progress: { + state: 'finalizing', + message: 'Finishing launch - waiting for secondary runtime lanes', + updatedAt: now, + assistantOutput: null, + }, + onProgress: () => undefined, + launchIdentity: { + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.4', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.4', + catalogId: 'gpt-5.4', + catalogSource: 'bundled', + catalogFetchedAt: now, + selectedEffort: 'medium', + resolvedEffort: 'medium', + selectedFastMode: null, + resolvedFastMode: null, + fastResolutionReason: null, + }, + expectedMembers: ['alice'], + effectiveMembers: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + }, + ], + allEffectiveMembers: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + { + name: 'tom', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + }, + ], + memberSpawnStatuses: new Map([ + [ + 'alice', + { + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastHeartbeatAt: now, + lastRuntimeAliveAt: now, + lastEvaluatedAt: now, + updatedAt: now, + livenessSource: 'heartbeat', + }, + ], + ]), + mixedSecondaryLanes: [ + { + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + member: { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + }, + { + laneId: 'secondary:opencode:tom', + providerId: 'opencode', + member: { + name: 'tom', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + }, + runId: null, + state: 'queued', + result: null, + warnings: [], + diagnostics: [], + }, + ], + memberSpawnToolUseIds: new Map(), + pendingMemberRestarts: new Map(), + pendingApprovals: new Map(), + memberSpawnLeadInboxCursorByMember: new Map(), + provisioningOutputParts: [], + stdoutBuffer: '', + stderrBuffer: '', + claudeLogLines: [], + activeToolCalls: new Map(), + activeCrossTeamReplyHints: [], + pendingInboxRelayCandidates: [], + mcpConfigPath: null, + bootstrapSpecPath: null, + bootstrapUserPromptPath: null, + }; +} + +function trackLiveRun(svc: TeamProvisioningService, run: any): void { + (svc as any).runs.set(run.runId, run); + (svc as any).provisioningRunByTeam.set(run.teamName, run.runId); + (svc as any).aliveRunByTeam.set(run.teamName, run.runId); +} + +async function writeOpenCodeTeamConfig(input: { + teamName: string; + projectPath: string; + members: string[]; +}): Promise { + const teamDir = path.join(getTeamsBasePath(), input.teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + `${JSON.stringify( + { + name: input.teamName, + projectPath: input.projectPath, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'opencode', + model: 'opencode/big-pickle', + }, + ...input.members.map((name) => ({ + name, + role: 'Developer', + providerId: 'opencode', + model: 'opencode/big-pickle', + })), + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function writeMixedTeamConfig(input: { + teamName: string; + projectPath: string; +}): Promise { + const teamDir = path.join(getTeamsBasePath(), input.teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + `${JSON.stringify( + { + name: input.teamName, + projectPath: input.projectPath, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }, + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + { + name: 'tom', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function writeMixedTeamConfigWithoutOpenCodeProviderMetadata(input: { + teamName: string; + projectPath: string; +}): Promise { + const teamDir = path.join(getTeamsBasePath(), input.teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'config.json'), + `${JSON.stringify( + { + name: input.teamName, + projectPath: input.projectPath, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }, + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + }, + { + name: 'bob', + role: 'Developer', + model: 'opencode/minimax-m2.5-free', + }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function writeMixedTeamLaunchState(input: { + teamName: string; + members: Record>; +}): Promise { + const teamDir = path.join(getTeamsBasePath(), input.teamName); + await fs.mkdir(teamDir, { recursive: true }); + const snapshot = createPersistedLaunchSnapshot({ + teamName: input.teamName, + leadSessionId: 'lead-session', + launchPhase: 'active', + expectedMembers: Object.keys(input.members), + bootstrapExpectedMembers: ['alice'], + members: input.members as any, + }); + await fs.writeFile( + path.join(teamDir, 'launch-state.json'), + `${JSON.stringify(snapshot, null, 2)}\n`, + 'utf8' + ); +} + +function mixedMemberState(overrides: Record): Record { + return { + name: overrides.name, + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + ...overrides, + }; +} + +async function writeTeamMeta(teamName: string, projectPath: string): Promise { + const teamDir = path.join(getTeamsBasePath(), teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'team.meta.json'), + `${JSON.stringify( + { + version: 1, + cwd: projectPath, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + createdAt: Date.now(), + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + +async function writeMembersMeta(teamName: string): Promise { + const teamDir = path.join(getTeamsBasePath(), teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'members.meta.json'), + `${JSON.stringify( + { + version: 1, + providerBackendId: 'codex-native', + members: [ + { + name: 'alice', + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4-mini', + }, + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + { + name: 'tom', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index 91cdc576..a942f6b5 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -268,10 +268,7 @@ function writeBootstrapState( ); } -function writeTeamMeta( - teamName: string, - overrides: Record = {} -): void { +function writeTeamMeta(teamName: string, overrides: Record = {}): void { const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); fs.writeFileSync( @@ -1104,22 +1101,22 @@ describe('TeamProvisioningService', () => { (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); (svc as any).runs.set('run-1', run); vi.mocked(pidusage).mockReset(); - vi - .mocked(pidusage) - .mockImplementation(async (target: number | string | Array) => { + vi.mocked(pidusage).mockImplementation( + async (target: number | string | Array) => { if (Array.isArray(target)) { return { '111': createPidusageStat(111, 123_000_000), } as any; } - if (target === 333) { - return createPidusageStat(333, 456_000_000) as any; + if (target === 333) { + return createPidusageStat(333, 456_000_000) as any; + } + if (target === 111) { + return createPidusageStat(111, 123_000_000) as any; + } + throw new Error(`Unexpected pidusage target: ${String(target)}`); } - if (target === 111) { - return createPidusageStat(111, 123_000_000) as any; - } - throw new Error(`Unexpected pidusage target: ${String(target)}`); - }); + ); const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); @@ -1134,6 +1131,78 @@ describe('TeamProvisioningService', () => { rssBytes: 456_000_000, }); }); + + it('shows RSS for persisted OpenCode secondary lane runtime pids after the launch run is gone', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [{ name: 'team-lead', agentType: 'team-lead' }], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + (svc as any).launchStateStore = { + read: vi.fn(async () => + createPersistedLaunchSnapshot({ + teamName: 'runtime-team', + expectedMembers: ['bob'], + launchPhase: 'finished', + members: { + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 333, + lastEvaluatedAt: '2026-04-23T12:26:31.563Z', + }, + }, + updatedAt: '2026-04-23T12:26:31.563Z', + }) + ), + }; + vi.mocked(pidusage).mockReset(); + vi.mocked(pidusage).mockImplementation( + async (target: number | string | Array) => { + if (Array.isArray(target)) { + return { + '333': createPidusageStat(333, 456_000_000), + } as any; + } + if (target === 333) { + return createPidusageStat(333, 456_000_000) as any; + } + throw new Error(`Unexpected pidusage target: ${String(target)}`); + } + ); + + const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 }); + expect(snapshot.members.bob).toMatchObject({ + memberName: 'bob', + alive: true, + restartable: false, + pid: 333, + providerId: 'opencode', + runtimeModel: 'opencode/minimax-m2.5-free', + rssBytes: 456_000_000, + }); + }); }); describe('restartMember', () => { @@ -1349,7 +1418,7 @@ describe('TeamProvisioningService', () => { expect(restartMessage).not.toContain('nemotron-3-super-free'); }); - it('requires the OpenCode runtime adapter before restarting a secondary-lane teammate', async () => { + it('requires the OpenCode runtime adapter before restarting a secondary-lane teammate', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ teamName: 'mixed-team', @@ -1408,7 +1477,7 @@ describe('TeamProvisioningService', () => { ); }); - it('still allows restarting a primary-lane teammate when another mixed secondary lane exists', async () => { + it('still allows restarting a primary-lane teammate when another mixed secondary lane exists', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ teamName: 'mixed-team', @@ -2200,9 +2269,9 @@ describe('TeamProvisioningService', () => { const launchSummary = (svc as any).getMemberLaunchSummary(run); expect((svc as any).hasPendingLaunchMembers(run, launchSummary, null)).toBe(true); - expect((svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)).toBe( - 'Finishing launch — 1 teammate awaiting permission approval' - ); + expect( + (svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary) + ).toBe('Finishing launch — 1 teammate awaiting permission approval'); }); it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => { @@ -2482,6 +2551,75 @@ describe('TeamProvisioningService', () => { ); }); + it('delivers direct messages to OpenCode secondary lanes through the runtime adapter', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + ok: true, + providerId: 'opencode', + memberName: String(input.memberName), + sessionId: 'oc-session-bob', + runtimePid: 456, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + projectPath: '/repo', + members: [ + { name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' }, + { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + launchIdentity: { providerId: 'codex' }, + providerId: 'codex', + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + }, + ]), + }; + + await expect( + svc.deliverOpenCodeMemberMessage('team-a', { + memberName: 'bob', + text: 'hello bob', + messageId: 'msg-1', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + expect(sendMessageToMember).toHaveBeenCalledWith({ + runId: 'run-1', + teamName: 'team-a', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + text: 'hello bob', + messageId: 'msg-1', + }); + }); + it('marks an OpenCode secondary lane degraded when readiness fails before runtime materializes', async () => { const teamName = 'mixed-prelaunch-failure'; const svc = new TeamProvisioningService(); @@ -2786,9 +2924,9 @@ describe('TeamProvisioningService', () => { }); expect(write).toHaveBeenCalledTimes(1); - const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record] | undefined)?.[1] as - | { members?: Record } - | undefined; + const writtenSnapshot = ( + write.mock.calls[0] as unknown as [string, Record] | undefined + )?.[1] as { members?: Record } | undefined; expect(writtenSnapshot?.members?.bob).toMatchObject({ name: 'bob', providerId: 'opencode', @@ -2866,9 +3004,9 @@ describe('TeamProvisioningService', () => { }); expect(write).toHaveBeenCalledTimes(1); - const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record] | undefined)?.[1] as - | { expectedMembers?: string[] } - | undefined; + const writtenSnapshot = ( + write.mock.calls[0] as unknown as [string, Record] | undefined + )?.[1] as { expectedMembers?: string[] } | undefined; expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']); }); @@ -2904,7 +3042,10 @@ describe('TeamProvisioningService', () => { it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => { const svc = new TeamProvisioningService(); - const delivered = new Map(); + const delivered = new Map< + string, + { kind: 'member_inbox'; teamName: string; memberName: string; messageId: string } + >(); (svc as any).aliveRunByTeam.set('mixed-team', 'lead-run'); (svc as any).runs.set('lead-run', { @@ -3267,7 +3408,9 @@ describe('TeamProvisioningService', () => { await (svc as any).stopSingleMixedSecondaryRuntimeLane(run, lane, 'relaunch'); - await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, run.teamName)).resolves.toMatchObject({ + await expect( + readOpenCodeRuntimeLaneIndex(tempTeamsBase, run.teamName) + ).resolves.toMatchObject({ lanes: {}, }); await expect( @@ -4276,6 +4419,486 @@ describe('TeamProvisioningService', () => { ); }); + describe('safe app launch matrix', () => { + function createSafeLaunchService() { + const mcpConfigBuilder = { + writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')), + removeConfigFile: vi.fn(async () => {}), + }; + const membersMetaStore = { + writeMembers: vi.fn(async () => {}), + getMeta: vi.fn(async () => null), + }; + const teamMetaStore = { + writeMeta: vi.fn(async () => {}), + deleteMeta: vi.fn(async () => {}), + getMeta: vi.fn(async () => null), + }; + const svc = new TeamProvisioningService( + undefined, + undefined, + membersMetaStore as any, + undefined, + mcpConfigBuilder as any, + teamMetaStore as any + ); + + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { CODEX_API_KEY: 'test' }, + authSource: 'codex_runtime', + })); + (svc as any).validateAgentTeamsMcpRuntime = vi.fn(async () => {}); + (svc as any).pathExists = vi.fn(async () => false); + (svc as any).startFilesystemMonitor = vi.fn(); + (svc as any).stopFilesystemMonitor = vi.fn(); + (svc as any).startStallWatchdog = vi.fn(); + (svc as any).stopStallWatchdog = vi.fn(); + (svc as any).attachStdoutHandler = vi.fn(); + (svc as any).attachStderrHandler = vi.fn(); + (svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({ + providerId: 'codex', + providerBackendId: 'codex-native', + selectedModel: 'gpt-5.4', + selectedModelKind: 'explicit', + resolvedLaunchModel: 'gpt-5.4', + catalogId: 'gpt-5.4', + catalogSource: 'test', + catalogFetchedAt: '2026-04-23T00:00:00.000Z', + selectedEffort: 'medium', + resolvedEffort: 'medium', + selectedFastMode: null, + resolvedFastMode: null, + fastResolutionReason: null, + })); + + return { svc, mcpConfigBuilder, membersMetaStore, teamMetaStore }; + } + + function readBootstrapSpecFromSpawnArgs(spawnArgs: string[]) { + const specIdx = spawnArgs.indexOf('--team-bootstrap-spec'); + expect(specIdx).toBeGreaterThanOrEqual(0); + return JSON.parse(fs.readFileSync(spawnArgs[specIdx + 1], 'utf8')) as { + mode: string; + team: { name: string; cwd: string }; + members: Array<{ + name: string; + provider?: string; + model?: string; + effort?: string; + role?: string; + }>; + }; + } + + it('starts a pure Codex team through the app createTeam path without a real CLI process', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any); + + const { svc, membersMetaStore } = createSafeLaunchService(); + const progress: string[] = []; + const { runId } = await svc.createTeam( + { + teamName: 'safe-codex-only-launch', + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + members: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'low', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + }, + ], + }, + (event) => progress.push(event.state) + ); + + const spawnCall = vi.mocked(spawnCli).mock.calls[0]; + expect(spawnCall?.[0]).toBe('/mock/claude'); + expect(spawnCall?.[2]).toMatchObject({ + cwd: tempClaudeRoot, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const spawnArgs = spawnCall?.[1] as string[]; + expect(spawnArgs).toEqual(expect.arrayContaining(['--model', 'gpt-5.4', '--effort', 'medium'])); + + const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs); + expect(bootstrapSpec).toMatchObject({ + mode: 'create', + team: { name: 'safe-codex-only-launch', cwd: tempClaudeRoot }, + }); + expect(bootstrapSpec.members).toEqual([ + expect.objectContaining({ + name: 'alice', + provider: 'codex', + model: 'gpt-5.4-mini', + effort: 'low', + role: 'Reviewer', + }), + expect.objectContaining({ + name: 'bob', + provider: 'codex', + model: 'gpt-5.4-mini', + effort: 'medium', + role: 'Developer', + }), + ]); + + const run = (svc as any).runs.get(runId); + expect(run.expectedMembers).toEqual(['alice', 'bob']); + expect(run.allEffectiveMembers.map((member: { name: string }) => member.name)).toEqual([ + 'alice', + 'bob', + ]); + expect(run.mixedSecondaryLanes).toEqual([]); + expect(membersMetaStore.writeMembers).toHaveBeenCalledWith( + 'safe-codex-only-launch', + expect.arrayContaining([ + expect.objectContaining({ name: 'alice', providerId: 'codex' }), + expect.objectContaining({ name: 'bob', providerId: 'codex' }), + ]), + expect.objectContaining({ providerBackendId: 'codex-native' }) + ); + expect(progress).toEqual(expect.arrayContaining(['validating', 'spawning', 'configuring'])); + + await svc.cancelProvisioning(runId); + }); + + it('routes a pure OpenCode team directly through the runtime adapter without spawning the CLI lane', async () => { + allowConsoleLogs(); + const adapterLaunch = vi.fn(async (input: Record) => { + const expectedMembers = input.expectedMembers as Array<{ name: string }>; + return { + runId: String(input.runId), + teamName: String(input.teamName), + launchPhase: 'finished', + teamLaunchState: 'clean_success', + leadSessionId: 'opencode-lead-session', + members: Object.fromEntries( + expectedMembers.map((member) => [ + member.name, + { + memberName: member.name, + providerId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + diagnostics: [], + }, + ]) + ), + warnings: [], + diagnostics: [], + }; + }); + + const { svc, membersMetaStore } = createSafeLaunchService(); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]) + ); + const progress: string[] = []; + + const { runId } = await svc.createTeam( + { + teamName: 'safe-opencode-only-launch', + cwd: tempClaudeRoot, + providerId: 'opencode', + providerBackendId: 'adapter', + model: 'big-pickle', + effort: 'medium', + members: [ + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + { + name: 'tom', + role: 'Developer', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }, + ], + }, + (event) => progress.push(event.state) + ); + + expect(runId).toEqual(expect.any(String)); + expect(spawnCli).not.toHaveBeenCalled(); + expect(ClaudeBinaryResolver.resolve).not.toHaveBeenCalled(); + expect(adapterLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: 'primary', + providerId: 'opencode', + model: 'big-pickle', + effort: 'medium', + cwd: tempClaudeRoot, + expectedMembers: [ + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }), + ], + }) + ); + expect(membersMetaStore.writeMembers).toHaveBeenCalledWith( + 'safe-opencode-only-launch', + expect.arrayContaining([ + expect.objectContaining({ name: 'bob', providerId: 'opencode' }), + expect.objectContaining({ name: 'tom', providerId: 'opencode' }), + ]), + expect.objectContaining({ providerBackendId: 'adapter' }) + ); + + const config = JSON.parse( + fs.readFileSync(path.join(tempTeamsBase, 'safe-opencode-only-launch', 'config.json'), 'utf8') + ) as { members: Array<{ name: string; providerId?: string; model?: string }> }; + expect(config.members).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'team-lead', providerId: 'opencode', model: 'big-pickle' }), + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }), + ]) + ); + + const publicStatuses = await svc.getMemberSpawnStatuses('safe-opencode-only-launch'); + expect(publicStatuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(publicStatuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(publicStatuses.teamLaunchState).toBe('clean_success'); + expect(progress).toEqual(expect.arrayContaining(['validating', 'spawning', 'ready'])); + }); + + it('keeps Codex in the primary CLI lane and starts OpenCode teammates as secondary runtime lanes', async () => { + allowConsoleLogs(); + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any); + + const adapterLaunch = vi.fn(async (input: Record) => { + const expectedMembers = input.expectedMembers as Array<{ name: string }>; + const memberName = expectedMembers[0]?.name ?? 'unknown'; + return { + runId: String(input.runId), + teamName: String(input.teamName), + launchPhase: 'finished', + teamLaunchState: 'clean_success', + members: { + [memberName]: { + memberName, + providerId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + diagnostics: [], + }, + }, + warnings: [], + diagnostics: [], + }; + }); + const adapterStop = vi.fn(async () => {}); + + const { svc, membersMetaStore } = createSafeLaunchService(); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: adapterStop, + } as any, + ]) + ); + + const { runId } = await svc.createTeam( + { + teamName: 'safe-mixed-codex-opencode-launch', + cwd: tempClaudeRoot, + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + members: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + effort: 'low', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }, + { + name: 'tom', + role: 'Developer', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }, + ], + }, + () => {} + ); + + const spawnArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[]; + const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs); + expect(bootstrapSpec.members).toEqual([ + expect.objectContaining({ + name: 'alice', + provider: 'codex', + model: 'gpt-5.4-mini', + }), + ]); + + const run = (svc as any).runs.get(runId); + expect(run.expectedMembers).toEqual(['alice']); + expect(run.effectiveMembers.map((member: { name: string }) => member.name)).toEqual([ + 'alice', + ]); + expect(run.allEffectiveMembers.map((member: { name: string }) => member.name)).toEqual([ + 'alice', + 'bob', + 'tom', + ]); + expect(run.mixedSecondaryLanes).toEqual([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + state: 'queued', + member: expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + }), + expect.objectContaining({ + laneId: 'secondary:opencode:tom', + state: 'queued', + member: expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }), + }), + ]); + expect(membersMetaStore.writeMembers).toHaveBeenCalledWith( + 'safe-mixed-codex-opencode-launch', + expect.arrayContaining([ + expect.objectContaining({ name: 'alice', providerId: 'codex' }), + expect.objectContaining({ name: 'bob', providerId: 'opencode' }), + expect.objectContaining({ name: 'tom', providerId: 'opencode' }), + ]), + expect.objectContaining({ providerBackendId: 'codex-native' }) + ); + + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2)); + expect(adapterLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + cwd: tempClaudeRoot, + expectedMembers: [ + expect.objectContaining({ + name: 'bob', + providerId: 'opencode', + model: 'minimax-m2.5-free', + }), + ], + }) + ); + expect(adapterLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: 'secondary:opencode:tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + cwd: tempClaudeRoot, + expectedMembers: [ + expect.objectContaining({ + name: 'tom', + providerId: 'opencode', + model: 'nemotron-3-super-free', + }), + ], + }) + ); + await vi.waitFor(() => { + expect(run.mixedSecondaryLanes).toEqual([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + state: 'finished', + result: expect.objectContaining({ teamLaunchState: 'clean_success' }), + }), + expect.objectContaining({ + laneId: 'secondary:opencode:tom', + state: 'finished', + result: expect.objectContaining({ teamLaunchState: 'clean_success' }), + }), + ]); + }); + const publicStatuses = await svc.getMemberSpawnStatuses('safe-mixed-codex-opencode-launch'); + expect(publicStatuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(publicStatuses.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + }); + expect(publicStatuses.expectedMembers).toEqual(expect.arrayContaining(['alice', 'bob', 'tom'])); + + await svc.cancelProvisioning(runId); + }); + }); + it('removes generated MCP config when launchTeam spawn fails synchronously', async () => { allowConsoleLogs(); const teamName = 'launch-cleanup-team'; @@ -6681,6 +7304,107 @@ describe('TeamProvisioningService', () => { }); }); + it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => { + const teamName = 'signal-ops-7'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }); + writeMembersMeta(teamName, [ + { + name: 'alice', + providerId: 'codex', + model: 'gpt-5.4-mini', + }, + { + name: 'jack', + providerId: 'opencode', + model: 'opencode/ling-2.6-flash-free', + }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['alice']); + writeBootstrapState(teamName, [{ name: 'alice', status: 'registered' }]); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: tempTeamsBase, + teamName, + laneId: 'secondary:opencode:jack', + state: 'active', + }); + + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + { + version: 2, + teamName, + updatedAt: '2026-04-23T10:00:00.000Z', + expectedMembers: ['alice', 'jack'], + bootstrapExpectedMembers: ['alice'], + leadSessionId: 'lead-session', + launchPhase: 'finished', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + jack: { + name: 'jack', + providerId: 'opencode', + model: 'opencode/ling-2.6-flash-free', + laneId: 'secondary:opencode:jack', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'starting', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + diagnostics: ['Launching through OpenCode secondary lane.'], + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_pending', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.teamLaunchState).toBe('partial_failure'); + expect(result.statuses.jack).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + error: expect.stringContaining('no lane state exists on disk'), + }); + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:jack': { + state: 'degraded', + }, + }, + }); + }); + it('includes queued OpenCode secondary lanes in live spawn statuses before the final mixed snapshot settles', async () => { const svc = new TeamProvisioningService(); vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined); diff --git a/test/renderer/components/team/TeamProvisioningBanner.test.ts b/test/renderer/components/team/TeamProvisioningBanner.test.ts index 1698a896..f3fc17d3 100644 --- a/test/renderer/components/team/TeamProvisioningBanner.test.ts +++ b/test/renderer/components/team/TeamProvisioningBanner.test.ts @@ -337,6 +337,73 @@ describe('TeamProvisioningBanner launch-step alignment', () => { }); }); + it('does not mark Members joining complete when launch finishes with failed teammates', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.memberSpawnStatusesByTeam['northstar-core'] = { + alice: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + agentToolAccepted: true, + }, + bob: { + status: 'error', + launchState: 'failed_to_start', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode lane failed before bootstrap', + agentToolAccepted: false, + }, + jack: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-09T10:00:00.000Z', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + agentToolAccepted: true, + }, + } as Record; + storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { + runId: 'run-1', + expectedMembers: ['alice', 'bob', 'jack'], + statuses: {}, + summary: { + confirmedCount: 2, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + source: 'merged', + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TeamProvisioningBanner, { teamName: 'northstar-core' })); + await Promise.resolve(); + }); + + const block = host.querySelector('[data-testid="progress-block"]'); + expect(block?.getAttribute('data-current-step-index')).toBe('2'); + expect(block?.getAttribute('data-loading')).toBe('false'); + expect(block?.getAttribute('data-success-severity')).toBe('warning'); + expect(block?.textContent).toContain('Launch finished with errors'); + expect(block?.textContent).toContain('bob failed to start'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('uses info severity while runtimes are online but teammate contact is still pending', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); storeState.memberSpawnSnapshotsByTeam['northstar-core'] = { diff --git a/test/renderer/components/team/members/MemberList.test.ts b/test/renderer/components/team/members/MemberList.test.ts new file mode 100644 index 00000000..36ba0fdb --- /dev/null +++ b/test/renderer/components/team/members/MemberList.test.ts @@ -0,0 +1,101 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { MemberSpawnStatusEntry, ResolvedTeamMember } from '@shared/types'; + +vi.mock('@renderer/components/team/members/MemberCard', () => ({ + MemberCard: ({ + member, + spawnError, + }: { + member: ResolvedTeamMember; + spawnError?: string; + }) => React.createElement('div', { 'data-testid': `member-${member.name}` }, spawnError ?? ''), +})); + +import { MemberList } from '@renderer/components/team/members/MemberList'; + +const member: ResolvedTeamMember = { + name: 'bob', + status: 'unknown', + taskCount: 0, + currentTaskId: null, + lastActiveAt: null, + messageCount: 0, + color: 'blue', + agentType: 'developer', + role: 'Developer', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + removedAt: undefined, +}; + +function failedSpawnStatus(reason: string): MemberSpawnStatusEntry { + return { + status: 'error', + launchState: 'failed_to_start', + updatedAt: '2026-04-23T10:00:00.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: reason, + agentToolAccepted: false, + }; +} + +describe('MemberList spawn-status memoization', () => { + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class ResizeObserver { + observe(): void {} + disconnect(): void {} + } + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + document.body.innerHTML = ''; + }); + + it('rerenders cards when only the hard failure reason changes', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + const members = [member]; + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: new Map([['bob', failedSpawnStatus('initial OpenCode failure')]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('initial OpenCode failure'); + + await act(async () => { + root.render( + React.createElement(MemberList, { + members, + isTeamAlive: true, + memberSpawnStatuses: new Map([['bob', failedSpawnStatus('updated OpenCode failure')]]), + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('updated OpenCode failure'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/utils/bootstrapPromptSanitizer.test.ts b/test/renderer/utils/bootstrapPromptSanitizer.test.ts index 509d08dd..e5780ee8 100644 --- a/test/renderer/utils/bootstrapPromptSanitizer.test.ts +++ b/test/renderer/utils/bootstrapPromptSanitizer.test.ts @@ -49,4 +49,19 @@ Do NOT send acknowledgement-only messages such as "ready" or "online".`); expect(display?.summary).toBe('Starting alice'); expect(getSanitizedInboxMessageText(message)).toContain('Startup instructions are hidden in the UI.'); }); + + it('keeps dotted model ids intact and does not show implicit default effort', () => { + const message = makeMessage(`You are alice, a reviewer on team "forge-labs" (forge-labs). Provider override: codex. Model override: gpt-5.4-mini. +The team has already been created and you are being attached as a persistent teammate. +Your FIRST action: call MCP tool member_briefing with: +{ teamName: "forge-labs", memberName: "alice" } +Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a delegated helper for this bootstrap step. +If member_briefing fails, send one short natural-language message to "team-lead" with the exact error text. +After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally. +Do NOT send acknowledgement-only messages such as "ready" or "online".`); + + const display = getBootstrapPromptDisplay(message); + + expect(display?.runtime).toBe('GPT-5.4 Mini'); + }); }); diff --git a/test/renderer/utils/memberRuntimeSummary.test.ts b/test/renderer/utils/memberRuntimeSummary.test.ts index e7828086..20dad8ab 100644 --- a/test/renderer/utils/memberRuntimeSummary.test.ts +++ b/test/renderer/utils/memberRuntimeSummary.test.ts @@ -169,4 +169,58 @@ describe('resolveMemberRuntimeSummary', () => { ) ).toBe('nemotron-3-super-free · via OpenCode'); }); + + it('infers OpenCode from an OpenCode model when member provider metadata is missing', () => { + const member = createMember({ + providerId: undefined, + providerBackendId: undefined, + model: 'opencode/minimax-m2.5-free', + effort: undefined, + }); + + expect( + resolveMemberRuntimeSummary( + member, + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + limitContext: false, + }, + undefined + ) + ).toBe('minimax-m2.5-free · via OpenCode'); + }); + + it('appends memory for OpenCode side-lane runtime snapshots without adding Codex backend text', () => { + const member = createMember({ + providerId: 'opencode', + providerBackendId: undefined, + model: 'opencode/minimax-m2.5-free', + effort: undefined, + }); + + expect( + resolveMemberRuntimeSummary( + member, + { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + effort: 'medium', + limitContext: false, + }, + undefined, + { + memberName: 'alice', + alive: true, + restartable: false, + runtimeModel: 'opencode/minimax-m2.5-free', + rssBytes: 183.9 * 1024 * 1024, + updatedAt: '2026-04-18T18:00:00.000Z', + } + ) + ).toBe('minimax-m2.5-free · via OpenCode · 183.9 MB'); + }); }); diff --git a/test/renderer/utils/memberSpawnStatusPolling.test.ts b/test/renderer/utils/memberSpawnStatusPolling.test.ts new file mode 100644 index 00000000..9134a894 --- /dev/null +++ b/test/renderer/utils/memberSpawnStatusPolling.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; + +import { hasUnresolvedMemberSpawnStatus } from '@renderer/utils/memberSpawnStatusPolling'; + +describe('hasUnresolvedMemberSpawnStatus', () => { + it('continues polling while any launch member is still starting', () => { + expect( + hasUnresolvedMemberSpawnStatus( + { + bob: { + status: 'spawning', + launchState: 'starting', + updatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + undefined + ) + ).toBe(true); + }); + + it('continues polling after ready while snapshot summary still has pending members', () => { + expect( + hasUnresolvedMemberSpawnStatus( + { + alice: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + { + summary: { + pendingCount: 1, + }, + } + ) + ).toBe(true); + }); + + it('stops polling when every member is terminal confirmed or failed', () => { + expect( + hasUnresolvedMemberSpawnStatus( + { + alice: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + status: 'error', + launchState: 'failed_to_start', + updatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + { + summary: { + pendingCount: 0, + }, + } + ) + ).toBe(false); + }); +}); diff --git a/test/renderer/utils/teamProvisioningPresentation.test.ts b/test/renderer/utils/teamProvisioningPresentation.test.ts index c02b2515..3d4ea4e3 100644 --- a/test/renderer/utils/teamProvisioningPresentation.test.ts +++ b/test/renderer/utils/teamProvisioningPresentation.test.ts @@ -150,9 +150,12 @@ describe('buildTeamProvisioningPresentation', () => { }, }); - expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start'); + expect(presentation?.successMessage).toBe( + 'Launch finished with errors - 1/1 teammates failed to start' + ); expect(presentation?.panelMessage).toContain('requested model is not available'); expect(presentation?.compactDetail).toBe('jack failed to start'); + expect(presentation?.currentStepIndex).toBe(2); }); it('keeps a generic failed teammate message when only persisted failure counts remain', () => { @@ -201,9 +204,93 @@ describe('buildTeamProvisioningPresentation', () => { }, }); - expect(presentation?.successMessage).toBe('Launch finished with errors - 1/1 teammates failed to start'); + expect(presentation?.successMessage).toBe( + 'Launch finished with errors - 1/1 teammates failed to start' + ); expect(presentation?.panelMessage).toBe('1 teammate failed to start'); expect(presentation?.compactDetail).toBe('1 teammate failed to start'); + expect(presentation?.currentStepIndex).toBe(2); + }); + + it('keeps Members joining incomplete while active launch already has failed teammates', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-3c', + teamName: 'mixed-team', + state: 'finalizing', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:08.000Z', + message: 'Finishing launch', + messageSeverity: undefined, + pid: 4321, + configReady: true, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'alice', + agentType: 'reviewer', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + alice: { + status: 'online', + launchState: 'confirmed_alive', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + agentToolAccepted: true, + }, + bob: { + status: 'error', + launchState: 'failed_to_start', + updatedAt: '2026-04-13T10:00:07.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode lane failed', + agentToolAccepted: false, + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['alice', 'bob'], + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.currentStepIndex).toBe(2); + expect(presentation?.panelMessage).toContain('bob failed to start'); + expect(presentation?.compactTone).toBe('warning'); }); it('prefers live member spawn statuses over a stale persisted launch summary', () => { @@ -269,6 +356,81 @@ describe('buildTeamProvisioningPresentation', () => { expect(presentation?.panelMessage).toBe('1 teammate still joining'); }); + it('does not let stale live failures override a newer persisted pending snapshot', () => { + const presentation = buildTeamProvisioningPresentation({ + progress: { + runId: 'run-4-stale-live-failure', + teamName: 'mixed-team', + state: 'ready', + startedAt: '2026-04-13T10:00:00.000Z', + updatedAt: '2026-04-13T10:00:10.000Z', + message: 'Launch completed', + messageSeverity: undefined, + pid: 4321, + cliLogsTail: '', + assistantOutput: '', + }, + members: [ + { + name: 'team-lead', + agentType: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'jack', + agentType: 'developer', + status: 'unknown', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + }, + ], + memberSpawnStatuses: { + jack: { + status: 'error', + launchState: 'failed_to_start', + hardFailureReason: 'Teammate was never spawned during launch.', + updatedAt: '2026-04-13T10:00:05.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + agentToolAccepted: false, + }, + }, + memberSpawnSnapshot: { + expectedMembers: ['jack'], + updatedAt: '2026-04-13T10:00:09.000Z', + statuses: { + jack: { + status: 'waiting', + launchState: 'starting', + updatedAt: '2026-04-13T10:00:09.000Z', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + agentToolAccepted: false, + }, + }, + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + }, + }); + + expect(presentation?.successMessage).toBe('Finishing launch'); + expect(presentation?.panelMessage).toBe('1 teammate still joining'); + expect(presentation?.compactDetail).toBe('1 teammate still joining'); + expect(presentation?.failedSpawnCount).toBe(0); + }); + it('surfaces permission-blocked teammates as awaiting approval while launch is finishing', () => { const presentation = buildTeamProvisioningPresentation({ progress: {