fix: stabilize opencode team launch recovery
This commit is contained in:
parent
f4e4ecca2e
commit
9ebc4368d0
31 changed files with 4628 additions and 189 deletions
|
|
@ -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",
|
||||
|
|
|
|||
192
scripts/lib/opencode-live-preflight.mjs
Normal file
192
scripts/lib/opencode-live-preflight.mjs
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
65
scripts/prove-opencode-team-provisioning.mjs
Normal file
65
scripts/prove-opencode-team-provisioning.mjs
Normal file
|
|
@ -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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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<string, MemberSpawnStatusEntry>
|
||||
): 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<OpenCodeTeamRuntimeMessageResult>;
|
||||
})
|
||||
| null {
|
||||
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||
if (!adapter || !('sendMessageToMember' in adapter)) {
|
||||
return null;
|
||||
}
|
||||
return adapter as TeamLaunchRuntimeAdapter & {
|
||||
sendMessageToMember(
|
||||
input: OpenCodeTeamRuntimeMessageInput
|
||||
): Promise<OpenCodeTeamRuntimeMessageResult>;
|
||||
};
|
||||
}
|
||||
|
||||
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<PersistedTeamLaunchSnapshot | null> {
|
||||
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<PersistedTeamLaunchSnapshot | null> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<OpenCodeBridgeCommandName> = new Set([
|
|||
'opencode.launchTeam',
|
||||
'opencode.reconcileTeam',
|
||||
'opencode.stopTeam',
|
||||
'opencode.sendMessage',
|
||||
'opencode.answerPermission',
|
||||
'opencode.listRuntimePermissions',
|
||||
'opencode.getRuntimeTranscript',
|
||||
|
|
|
|||
|
|
@ -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<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||
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<OpenCodeSendMessageCommandData> {
|
||||
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<TBody, TData>(
|
||||
command: OpenCodeStateChangingTeamCommandName,
|
||||
body: TBody,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import type {
|
|||
OpenCodeLaunchTeamCommandData,
|
||||
OpenCodeBridgeRuntimeSnapshot,
|
||||
OpenCodeReconcileTeamCommandBody,
|
||||
OpenCodeSendMessageCommandBody,
|
||||
OpenCodeSendMessageCommandData,
|
||||
OpenCodeStopTeamCommandBody,
|
||||
OpenCodeStopTeamCommandData,
|
||||
OpenCodeTeamLaunchMode,
|
||||
|
|
@ -37,6 +39,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
input: OpenCodeReconcileTeamCommandBody
|
||||
): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData>;
|
||||
sendOpenCodeTeamMessage?(
|
||||
input: OpenCodeSendMessageCommandBody
|
||||
): Promise<OpenCodeSendMessageCommandData>;
|
||||
}
|
||||
|
||||
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<TeamRuntimeLaunchResult> {
|
||||
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<TeamRuntimeReconcileResult> {
|
||||
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<OpenCodeTeamRuntimeMessageResult> {
|
||||
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<TeamRuntimeStopResult> {
|
||||
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() || `<index ${index}>`;
|
||||
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,
|
||||
},
|
||||
])
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
|
||||
export type {
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamRuntimeMessageInput,
|
||||
OpenCodeTeamRuntimeMessageResult,
|
||||
OpenCodeTeamRuntimeAdapterOptions,
|
||||
OpenCodeTeamRuntimeBridgePort,
|
||||
} from './OpenCodeTeamRuntimeAdapter';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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<LaunchJoinMilestones, 'expectedTeammateCount'> & {
|
||||
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<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'> & {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
29
src/renderer/utils/memberSpawnStatusPolling.ts
Normal file
29
src/renderer/utils/memberSpawnStatusPolling.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import type { MemberSpawnStatusEntry } from '@shared/types';
|
||||
|
||||
export const MEMBER_SPAWN_STATUS_REFRESH_MS = 2_500;
|
||||
|
||||
export function hasUnresolvedMemberSpawnStatus(
|
||||
memberSpawnStatuses: Record<string, MemberSpawnStatusEntry> | undefined,
|
||||
memberSpawnSnapshot:
|
||||
| {
|
||||
statuses?: Record<string, MemberSpawnStatusEntry>;
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string>();
|
||||
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<string>();
|
||||
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<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'> & {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -935,6 +935,7 @@ export interface PersistedTeamLaunchMemberState {
|
|||
hardFailure: boolean;
|
||||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
runtimePid?: number;
|
||||
firstSpawnAcceptedAt?: string;
|
||||
lastHeartbeatAt?: string;
|
||||
lastRuntimeAliveAt?: string;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => ({
|
||||
|
|
|
|||
2105
test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
Normal file
2105
test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -268,10 +268,7 @@ function writeBootstrapState(
|
|||
);
|
||||
}
|
||||
|
||||
function writeTeamMeta(
|
||||
teamName: string,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): void {
|
||||
function writeTeamMeta(teamName: string, overrides: Record<string, unknown> = {}): 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<number | string>) => {
|
||||
vi.mocked(pidusage).mockImplementation(
|
||||
async (target: number | string | Array<number | string>) => {
|
||||
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<number | string>) => {
|
||||
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<string, unknown>) => ({
|
||||
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<string, unknown>] | undefined)?.[1] as
|
||||
| { members?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const writtenSnapshot = (
|
||||
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
|
||||
)?.[1] as { members?: Record<string, unknown> } | 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<string, unknown>] | undefined)?.[1] as
|
||||
| { expectedMembers?: string[] }
|
||||
| undefined;
|
||||
const writtenSnapshot = (
|
||||
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | 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<string, { kind: 'member_inbox'; teamName: string; memberName: string; messageId: string }>();
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown>) => {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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'] = {
|
||||
|
|
|
|||
101
test/renderer/components/team/members/MemberList.test.ts
Normal file
101
test/renderer/components/team/members/MemberList.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
63
test/renderer/utils/memberSpawnStatusPolling.test.ts
Normal file
63
test/renderer/utils/memberSpawnStatusPolling.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue