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",
|
"dev:kill": "node bin/kill-dev.js",
|
||||||
"opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
|
"opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
|
||||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.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",
|
"prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"dist": "electron-builder --mac --win --linux",
|
"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 process from 'node:process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import {
|
||||||
|
exitForSkippedPreflight,
|
||||||
|
preflightOpenCodeLiveEnvironment,
|
||||||
|
} from './lib/opencode-live-preflight.mjs';
|
||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
const repoRoot = path.resolve(scriptDir, '..');
|
||||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||||
|
|
@ -14,6 +19,7 @@ const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
OPENCODE_E2E: '1',
|
OPENCODE_E2E: '1',
|
||||||
OPENCODE_E2E_MIXED_RECOVERY: '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_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
|
||||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
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(`Model: ${env.OPENCODE_E2E_MODEL}`);
|
||||||
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
||||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_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(
|
const result = spawnSync(
|
||||||
'pnpm',
|
'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,
|
runtimeAlive: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
runtimePid: 333,
|
||||||
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -143,6 +144,7 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
|
runtimePid: 333,
|
||||||
});
|
});
|
||||||
expect(snapshot.summary).toEqual({
|
expect(snapshot.summary).toEqual({
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ export interface MixedSecondaryLaneMemberStateInput {
|
||||||
hardFailure?: boolean;
|
hardFailure?: boolean;
|
||||||
hardFailureReason?: string;
|
hardFailureReason?: string;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
|
runtimePid?: number;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
pendingReason?: string;
|
pendingReason?: string;
|
||||||
|
|
@ -217,6 +218,12 @@ function createSecondaryLaneMemberState(
|
||||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
|
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length
|
||||||
? [...new Set(evidence.pendingPermissionRequestIds)]
|
? [...new Set(evidence.pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
runtimePid:
|
||||||
|
typeof evidence?.runtimePid === 'number' &&
|
||||||
|
Number.isFinite(evidence.runtimePid) &&
|
||||||
|
evidence.runtimePid > 0
|
||||||
|
? Math.trunc(evidence.runtimePid)
|
||||||
|
: undefined,
|
||||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||||
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
|
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
|
||||||
|
|
|
||||||
|
|
@ -2553,14 +2553,15 @@ async function handleSendMessage(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Teammate inbox relay DISABLED (2026-03-23).
|
// 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:
|
// Relaying through the lead (relayMemberInboxMessages) caused multiple bugs:
|
||||||
// 1. Lead responded to user instead of forwarding to the teammate
|
// 1. Lead responded to user instead of forwarding to the teammate
|
||||||
// 2. Duplicate messages (relay loop: markInboxMessagesRead → FileWatcher → relay again)
|
// 2. Duplicate messages (relay loop: markInboxMessagesRead → FileWatcher → relay again)
|
||||||
// 3. Fragile LLM-dependent prompt chain for routing
|
// 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.
|
// 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) {
|
// if (!isLeadRecipient && isAlive) {
|
||||||
// try {
|
// try {
|
||||||
|
|
@ -2569,6 +2570,29 @@ async function handleSendMessage(
|
||||||
// logger.warn(`Relay after sendMessage failed for teammate "${memberName}": ${String(e)}`);
|
// 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
|
// Best-effort relay for lead via inbox
|
||||||
if (isLeadRecipient && isAlive) {
|
if (isLeadRecipient && isAlive) {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,12 @@ function normalizePendingPermissionRequestIds(value: unknown): string[] | undefi
|
||||||
return normalized.length > 0 ? Array.from(new Set(normalized)) : undefined;
|
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 {
|
function normalizeMemberName(name: string): string {
|
||||||
return name.trim();
|
return name.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -333,6 +339,7 @@ function normalizePersistedMemberState(
|
||||||
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
||||||
parsed.pendingPermissionRequestIds
|
parsed.pendingPermissionRequestIds
|
||||||
),
|
),
|
||||||
|
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||||
firstSpawnAcceptedAt:
|
firstSpawnAcceptedAt:
|
||||||
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
||||||
lastHeartbeatAt:
|
lastHeartbeatAt:
|
||||||
|
|
@ -395,12 +402,18 @@ export function createPersistedLaunchSnapshot(params: {
|
||||||
if (launchPhase !== 'active') {
|
if (launchPhase !== 'active') {
|
||||||
for (const name of expectedMembers) {
|
for (const name of expectedMembers) {
|
||||||
const member = members[name];
|
const member = members[name];
|
||||||
|
const isRecoverableOpenCodeSecondaryLane =
|
||||||
|
member?.laneKind === 'secondary' &&
|
||||||
|
member.laneOwnerProviderId === 'opencode' &&
|
||||||
|
typeof member.laneId === 'string' &&
|
||||||
|
member.laneId.trim().length > 0;
|
||||||
if (
|
if (
|
||||||
member?.launchState === 'starting' &&
|
member?.launchState === 'starting' &&
|
||||||
!member.agentToolAccepted &&
|
!member.agentToolAccepted &&
|
||||||
!member.runtimeAlive &&
|
!member.runtimeAlive &&
|
||||||
!member.bootstrapConfirmed &&
|
!member.bootstrapConfirmed &&
|
||||||
!member.hardFailure
|
!member.hardFailure &&
|
||||||
|
!isRecoverableOpenCodeSecondaryLane
|
||||||
) {
|
) {
|
||||||
member.hardFailure = true;
|
member.hardFailure = true;
|
||||||
member.hardFailureReason =
|
member.hardFailureReason =
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,10 @@ import {
|
||||||
} from '@shared/utils/teammateMessageParser';
|
} from '@shared/utils/teammateMessageParser';
|
||||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
import {
|
||||||
|
inferTeamProviderIdFromModel,
|
||||||
|
normalizeOptionalTeamProviderId,
|
||||||
|
} from '@shared/utils/teamProvider';
|
||||||
import {
|
import {
|
||||||
extractToolPreview,
|
extractToolPreview,
|
||||||
extractToolResultPreview,
|
extractToolResultPreview,
|
||||||
|
|
@ -134,6 +137,7 @@ import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamInboxWriter } from './TeamInboxWriter';
|
import { TeamInboxWriter } from './TeamInboxWriter';
|
||||||
import {
|
import {
|
||||||
createPersistedLaunchSnapshot,
|
createPersistedLaunchSnapshot,
|
||||||
|
deriveTeamLaunchAggregateState,
|
||||||
hasMixedPersistedLaunchMetadata,
|
hasMixedPersistedLaunchMetadata,
|
||||||
snapshotFromRuntimeMemberStatuses,
|
snapshotFromRuntimeMemberStatuses,
|
||||||
snapshotToMemberSpawnStatuses,
|
snapshotToMemberSpawnStatuses,
|
||||||
|
|
@ -146,6 +150,8 @@ import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import {
|
import {
|
||||||
TeamRuntimeAdapterRegistry,
|
TeamRuntimeAdapterRegistry,
|
||||||
type TeamLaunchRuntimeAdapter,
|
type TeamLaunchRuntimeAdapter,
|
||||||
|
type OpenCodeTeamRuntimeMessageInput,
|
||||||
|
type OpenCodeTeamRuntimeMessageResult,
|
||||||
type TeamRuntimeLaunchInput,
|
type TeamRuntimeLaunchInput,
|
||||||
type TeamRuntimeLaunchResult,
|
type TeamRuntimeLaunchResult,
|
||||||
type TeamRuntimeMemberLaunchEvidence,
|
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 {
|
function buildRestartStillRunningReason(memberName: string): string {
|
||||||
return (
|
return (
|
||||||
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
|
`Restart for teammate "${memberName}" was skipped because the previous runtime still appears ` +
|
||||||
|
|
@ -3881,6 +3920,94 @@ export class TeamProvisioningService {
|
||||||
return this.runtimeAdapterRegistry.get('opencode');
|
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: {
|
private shouldRouteOpenCodeToRuntimeAdapter(request: {
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[];
|
members?: readonly { providerId?: TeamProviderId; provider?: TeamProviderId }[];
|
||||||
|
|
@ -6274,26 +6401,34 @@ export class TeamProvisioningService {
|
||||||
summary?: PersistedTeamLaunchSummary;
|
summary?: PersistedTeamLaunchSummary;
|
||||||
source?: 'live' | 'persisted' | 'merged';
|
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);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
return this.reconcilePersistedLaunchState(teamName).then(({ snapshot, statuses }) => {
|
return readPersistedStatuses(null);
|
||||||
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',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const run = this.runs.get(runId);
|
const run = this.runs.get(runId);
|
||||||
if (!run) {
|
if (!run) {
|
||||||
return { statuses: {}, runId: null, source: 'persisted' };
|
return readPersistedStatuses(runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
|
await this.refreshMemberSpawnStatusesFromLeadInbox(run);
|
||||||
|
|
@ -6313,19 +6448,21 @@ export class TeamProvisioningService {
|
||||||
launchPhase: run.provisioningComplete ? 'finished' : 'active',
|
launchPhase: run.provisioningComplete ? 'finished' : 'active',
|
||||||
statuses: this.buildRuntimeSpawnStatusRecord(run),
|
statuses: this.buildRuntimeSpawnStatusRecord(run),
|
||||||
});
|
});
|
||||||
const snapshot = persisted ?? liveSnapshot;
|
const snapshot = liveSnapshot ?? persisted;
|
||||||
const statuses = await this.attachLiveRuntimeMetadataToStatuses(
|
const statuses = await this.attachLiveRuntimeMetadataToStatuses(
|
||||||
teamName,
|
teamName,
|
||||||
snapshotToMemberSpawnStatuses(snapshot)
|
snapshotToMemberSpawnStatuses(snapshot)
|
||||||
);
|
);
|
||||||
|
const expectedMembers = this.getPersistedLaunchMemberNames(snapshot);
|
||||||
|
const summary = summarizeMemberSpawnStatusRecord(expectedMembers, statuses);
|
||||||
return {
|
return {
|
||||||
statuses,
|
statuses,
|
||||||
runId,
|
runId,
|
||||||
teamLaunchState: snapshot.teamLaunchState,
|
teamLaunchState: deriveTeamLaunchAggregateState(summary),
|
||||||
launchPhase: snapshot.launchPhase,
|
launchPhase: snapshot.launchPhase,
|
||||||
expectedMembers: this.getPersistedLaunchMemberNames(snapshot),
|
expectedMembers,
|
||||||
updatedAt: snapshot.updatedAt,
|
updatedAt: snapshot.updatedAt,
|
||||||
summary: snapshot.summary,
|
summary,
|
||||||
source: persisted ? 'merged' : 'live',
|
source: persisted ? 'merged' : 'live',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -6456,9 +6593,18 @@ export class TeamProvisioningService {
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid;
|
const rssPid = liveRuntimeMember?.pid ?? liveRuntimeMember?.metricsPid;
|
||||||
const isOpenCodeMember =
|
const runtimeModel =
|
||||||
(launchMember?.providerId ?? normalizeOptionalTeamProviderId(member.providerId)) ===
|
liveRuntimeMember?.model ??
|
||||||
'opencode';
|
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 =
|
const isSharedOpenCodeHost =
|
||||||
isOpenCodeMember &&
|
isOpenCodeMember &&
|
||||||
!liveRuntimeMember?.pid &&
|
!liveRuntimeMember?.pid &&
|
||||||
|
|
@ -6470,11 +6616,6 @@ export class TeamProvisioningService {
|
||||||
: isSharedOpenCodeHost
|
: isSharedOpenCodeHost
|
||||||
? false
|
? false
|
||||||
: backendType !== 'in-process';
|
: backendType !== 'in-process';
|
||||||
const runtimeModel =
|
|
||||||
liveRuntimeMember?.model ??
|
|
||||||
launchMember?.model?.trim() ??
|
|
||||||
member.model?.trim() ??
|
|
||||||
undefined;
|
|
||||||
const launchSnapshotAlive =
|
const launchSnapshotAlive =
|
||||||
this.isTeamAlive(teamName) &&
|
this.isTeamAlive(teamName) &&
|
||||||
(launchMember?.runtimeAlive === true ||
|
(launchMember?.runtimeAlive === true ||
|
||||||
|
|
@ -6498,7 +6639,7 @@ export class TeamProvisioningService {
|
||||||
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
|
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
|
||||||
restartable,
|
restartable,
|
||||||
...(backendType ? { backendType } : {}),
|
...(backendType ? { backendType } : {}),
|
||||||
...(launchMember?.providerId ? { providerId: launchMember.providerId } : {}),
|
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
||||||
...(launchMember?.providerBackendId
|
...(launchMember?.providerBackendId
|
||||||
? { providerBackendId: launchMember.providerBackendId }
|
? { providerBackendId: launchMember.providerBackendId }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -10214,6 +10355,9 @@ export class TeamProvisioningService {
|
||||||
// SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete
|
// SIGKILL: newer Claude CLI versions handle SIGTERM gracefully and delete
|
||||||
// team files during cleanup. SIGKILL is uncatchable — files are preserved.
|
// team files during cleanup. SIGKILL is uncatchable — files are preserved.
|
||||||
killTeamProcess(run.child);
|
killTeamProcess(run.child);
|
||||||
|
if (this.hasSecondaryRuntimeRuns(run.teamName)) {
|
||||||
|
void this.stopMixedSecondaryRuntimeLanes(run.teamName);
|
||||||
|
}
|
||||||
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
|
const progress = updateProgress(run, 'cancelled', 'Provisioning cancelled by user');
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
|
|
@ -11447,6 +11591,20 @@ export class TeamProvisioningService {
|
||||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||||
};
|
};
|
||||||
const failureReason = current.hardFailureReason ?? current.error;
|
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 (
|
if (
|
||||||
metadata.alive &&
|
metadata.alive &&
|
||||||
current.launchState === 'failed_to_start' &&
|
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()]
|
const paneIds = [...metadataByMember.values()]
|
||||||
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
||||||
.filter((paneId) => paneId.length > 0);
|
.filter((paneId) => paneId.length > 0);
|
||||||
|
|
@ -12331,6 +12521,7 @@ export class TeamProvisioningService {
|
||||||
hardFailure: evidenceEntry.hardFailure,
|
hardFailure: evidenceEntry.hardFailure,
|
||||||
hardFailureReason: evidenceEntry.hardFailureReason,
|
hardFailureReason: evidenceEntry.hardFailureReason,
|
||||||
pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds,
|
||||||
|
runtimePid: evidenceEntry.runtimePid,
|
||||||
diagnostics: evidenceEntry.diagnostics,
|
diagnostics: evidenceEntry.diagnostics,
|
||||||
}
|
}
|
||||||
: finishedWithoutRuntimeEvidence
|
: finishedWithoutRuntimeEvidence
|
||||||
|
|
@ -12363,6 +12554,29 @@ export class TeamProvisioningService {
|
||||||
return hasMixedPersistedLaunchMetadata(snapshot);
|
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(
|
private async persistLaunchStateSnapshot(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete
|
launchPhase: 'active' | 'finished' | 'reconciled' = run.provisioningComplete
|
||||||
|
|
@ -12474,6 +12688,10 @@ export class TeamProvisioningService {
|
||||||
],
|
],
|
||||||
previousLaunchState,
|
previousLaunchState,
|
||||||
});
|
});
|
||||||
|
if (run.cancelRequested || run.processKilled) {
|
||||||
|
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
lane.state = 'finished';
|
lane.state = 'finished';
|
||||||
lane.result = result;
|
lane.result = result;
|
||||||
lane.warnings = [...result.warnings];
|
lane.warnings = [...result.warnings];
|
||||||
|
|
@ -12496,6 +12714,10 @@ export class TeamProvisioningService {
|
||||||
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (run.cancelRequested || run.processKilled) {
|
||||||
|
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
lane.state = 'finished';
|
lane.state = 'finished';
|
||||||
lane.result = {
|
lane.result = {
|
||||||
|
|
@ -12591,6 +12813,10 @@ export class TeamProvisioningService {
|
||||||
try {
|
try {
|
||||||
await this.launchSingleMixedSecondaryLane(run, lane);
|
await this.launchSingleMixedSecondaryLane(run, lane);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (run.cancelRequested || run.processKilled) {
|
||||||
|
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[${run.teamName}] OpenCode secondary lane ${lane.laneId} crashed during launch orchestration: ${message}`
|
`[${run.teamName}] OpenCode secondary lane ${lane.laneId} crashed during launch orchestration: ${message}`
|
||||||
|
|
@ -12620,6 +12846,10 @@ export class TeamProvisioningService {
|
||||||
private async launchMixedSecondaryLaneIfNeeded(
|
private async launchMixedSecondaryLaneIfNeeded(
|
||||||
run: ProvisioningRun
|
run: ProvisioningRun
|
||||||
): Promise<PersistedTeamLaunchSnapshot | null> {
|
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||||
|
if (run.cancelRequested || run.processKilled) {
|
||||||
|
return this.launchStateStore.read(run.teamName).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
const mixedSecondaryLanes = run.mixedSecondaryLanes ?? [];
|
const mixedSecondaryLanes = run.mixedSecondaryLanes ?? [];
|
||||||
if (mixedSecondaryLanes.length === 0) {
|
if (mixedSecondaryLanes.length === 0) {
|
||||||
return this.persistLaunchStateSnapshot(run, 'finished');
|
return this.persistLaunchStateSnapshot(run, 'finished');
|
||||||
|
|
@ -12668,7 +12898,11 @@ export class TeamProvisioningService {
|
||||||
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null,
|
bootstrapSnapshot: PersistedTeamLaunchSnapshot | null,
|
||||||
persistedSnapshot: PersistedTeamLaunchSnapshot | null
|
persistedSnapshot: PersistedTeamLaunchSnapshot | null
|
||||||
): Promise<PersistedTeamLaunchSnapshot | null> {
|
): Promise<PersistedTeamLaunchSnapshot | null> {
|
||||||
if (persistedSnapshot && this.hasMixedLaunchMetadata(persistedSnapshot)) {
|
if (
|
||||||
|
persistedSnapshot &&
|
||||||
|
this.hasMixedLaunchMetadata(persistedSnapshot) &&
|
||||||
|
!this.shouldRecoverStalePersistedMixedLaunchSnapshot(persistedSnapshot)
|
||||||
|
) {
|
||||||
return persistedSnapshot;
|
return persistedSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -12730,6 +12964,7 @@ export class TeamProvisioningService {
|
||||||
hardFailure?: boolean;
|
hardFailure?: boolean;
|
||||||
hardFailureReason?: string;
|
hardFailureReason?: string;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
|
runtimePid?: number;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
};
|
};
|
||||||
pendingReason?: string;
|
pendingReason?: string;
|
||||||
|
|
@ -12776,6 +13011,7 @@ export class TeamProvisioningService {
|
||||||
hardFailure: runtimeEvidence.hardFailure,
|
hardFailure: runtimeEvidence.hardFailure,
|
||||||
hardFailureReason: runtimeEvidence.hardFailureReason,
|
hardFailureReason: runtimeEvidence.hardFailureReason,
|
||||||
pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds,
|
||||||
|
runtimePid: runtimeEvidence.runtimePid,
|
||||||
diagnostics: runtimeEvidence.diagnostics,
|
diagnostics: runtimeEvidence.diagnostics,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -16465,7 +16701,7 @@ export class TeamProvisioningService {
|
||||||
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
|
peekAutoResumeService()?.cancelPendingAutoResume(run.teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete) {
|
if (!hasNewerTrackedRun && run.isLaunch && !run.provisioningComplete && !run.cancelRequested) {
|
||||||
void this.persistLaunchStateSnapshot(run, 'finished');
|
void this.persistLaunchStateSnapshot(run, 'finished');
|
||||||
}
|
}
|
||||||
this.resetRuntimeToolActivity(run);
|
this.resetRuntimeToolActivity(run);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export type OpenCodeBridgeCommandName =
|
||||||
| 'opencode.launchTeam'
|
| 'opencode.launchTeam'
|
||||||
| 'opencode.reconcileTeam'
|
| 'opencode.reconcileTeam'
|
||||||
| 'opencode.stopTeam'
|
| 'opencode.stopTeam'
|
||||||
|
| 'opencode.sendMessage'
|
||||||
| 'opencode.answerPermission'
|
| 'opencode.answerPermission'
|
||||||
| 'opencode.listRuntimePermissions'
|
| 'opencode.listRuntimePermissions'
|
||||||
| 'opencode.getRuntimeTranscript'
|
| 'opencode.getRuntimeTranscript'
|
||||||
|
|
@ -118,6 +119,27 @@ export interface OpenCodeStopTeamCommandData {
|
||||||
runtimeStoreManifestHighWatermark?: number | null;
|
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 OpenCodeBridgePeerName = 'claude_team' | 'agent_teams_orchestrator';
|
||||||
|
|
||||||
export type OpenCodeBridgeFailureKind =
|
export type OpenCodeBridgeFailureKind =
|
||||||
|
|
@ -258,6 +280,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
|
||||||
'opencode.launchTeam',
|
'opencode.launchTeam',
|
||||||
'opencode.reconcileTeam',
|
'opencode.reconcileTeam',
|
||||||
'opencode.stopTeam',
|
'opencode.stopTeam',
|
||||||
|
'opencode.sendMessage',
|
||||||
'opencode.answerPermission',
|
'opencode.answerPermission',
|
||||||
'opencode.listRuntimePermissions',
|
'opencode.listRuntimePermissions',
|
||||||
'opencode.getRuntimeTranscript',
|
'opencode.getRuntimeTranscript',
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import type {
|
||||||
OpenCodeLaunchTeamCommandBody,
|
OpenCodeLaunchTeamCommandBody,
|
||||||
OpenCodeLaunchTeamCommandData,
|
OpenCodeLaunchTeamCommandData,
|
||||||
OpenCodeReconcileTeamCommandBody,
|
OpenCodeReconcileTeamCommandBody,
|
||||||
|
OpenCodeSendMessageCommandBody,
|
||||||
|
OpenCodeSendMessageCommandData,
|
||||||
OpenCodeStopTeamCommandBody,
|
OpenCodeStopTeamCommandBody,
|
||||||
OpenCodeStopTeamCommandData,
|
OpenCodeStopTeamCommandData,
|
||||||
OpenCodeTeamLaunchMode,
|
OpenCodeTeamLaunchMode,
|
||||||
|
|
@ -46,6 +48,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
launchTimeoutMs?: number;
|
launchTimeoutMs?: number;
|
||||||
reconcileTimeoutMs?: number;
|
reconcileTimeoutMs?: number;
|
||||||
|
sendTimeoutMs?: number;
|
||||||
stopTimeoutMs?: number;
|
stopTimeoutMs?: number;
|
||||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||||
productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort;
|
productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort;
|
||||||
|
|
@ -76,6 +79,7 @@ export interface OpenCodeReadinessBridgeCommandBody {
|
||||||
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
|
const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
||||||
|
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
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>(
|
private async executeStateChangingCommand<TBody, TData>(
|
||||||
command: OpenCodeStateChangingTeamCommandName,
|
command: OpenCodeStateChangingTeamCommandName,
|
||||||
body: TBody,
|
body: TBody,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import type {
|
||||||
OpenCodeLaunchTeamCommandData,
|
OpenCodeLaunchTeamCommandData,
|
||||||
OpenCodeBridgeRuntimeSnapshot,
|
OpenCodeBridgeRuntimeSnapshot,
|
||||||
OpenCodeReconcileTeamCommandBody,
|
OpenCodeReconcileTeamCommandBody,
|
||||||
|
OpenCodeSendMessageCommandBody,
|
||||||
|
OpenCodeSendMessageCommandData,
|
||||||
OpenCodeStopTeamCommandBody,
|
OpenCodeStopTeamCommandBody,
|
||||||
OpenCodeStopTeamCommandData,
|
OpenCodeStopTeamCommandData,
|
||||||
OpenCodeTeamLaunchMode,
|
OpenCodeTeamLaunchMode,
|
||||||
|
|
@ -37,6 +39,9 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
input: OpenCodeReconcileTeamCommandBody
|
input: OpenCodeReconcileTeamCommandBody
|
||||||
): Promise<OpenCodeLaunchTeamCommandData>;
|
): Promise<OpenCodeLaunchTeamCommandData>;
|
||||||
stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData>;
|
stopOpenCodeTeam?(input: OpenCodeStopTeamCommandBody): Promise<OpenCodeStopTeamCommandData>;
|
||||||
|
sendOpenCodeTeamMessage?(
|
||||||
|
input: OpenCodeSendMessageCommandBody
|
||||||
|
): Promise<OpenCodeSendMessageCommandData>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeTeamRuntimeAdapterOptions {
|
export interface OpenCodeTeamRuntimeAdapterOptions {
|
||||||
|
|
@ -47,6 +52,25 @@ export interface OpenCodeTeamRuntimeAdapterOptions {
|
||||||
launchEnabled?: boolean;
|
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';
|
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||||
|
|
||||||
const REQUIRED_READY_CHECKPOINTS = new Set([
|
const REQUIRED_READY_CHECKPOINTS = new Set([
|
||||||
|
|
@ -139,6 +163,15 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
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 configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||||
const prepared = await this.prepare(input);
|
const prepared = await this.prepare(input);
|
||||||
if (!prepared.ok) {
|
if (!prepared.ok) {
|
||||||
|
|
@ -182,6 +215,26 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async reconcile(input: TeamRuntimeReconcileInput): Promise<TeamRuntimeReconcileResult> {
|
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) {
|
if (this.bridge.reconcileOpenCodeTeam) {
|
||||||
const projectPath =
|
const projectPath =
|
||||||
input.expectedMembers[0]?.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
|
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> {
|
async stop(input: TeamRuntimeStopInput): Promise<TeamRuntimeStopResult> {
|
||||||
if (this.bridge.stopOpenCodeTeam) {
|
if (this.bridge.stopOpenCodeTeam) {
|
||||||
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
|
const projectPath = input.cwd ?? this.lastProjectPathByTeamName.get(input.teamName);
|
||||||
|
|
@ -355,10 +442,21 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
checkpointNames.has(name)
|
checkpointNames.has(name)
|
||||||
);
|
);
|
||||||
const bridgeReady = data.teamLaunchState === 'ready';
|
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
|
const checkpointDiagnostic = success
|
||||||
? []
|
? []
|
||||||
: bridgeReady
|
: bridgeReady && !readyCheckpointsPresent
|
||||||
? [
|
? [
|
||||||
`OpenCode bridge reported ready without all required durable checkpoints: missing ${[
|
`OpenCode bridge reported ready without all required durable checkpoints: missing ${[
|
||||||
...REQUIRED_READY_CHECKPOINTS,
|
...REQUIRED_READY_CHECKPOINTS,
|
||||||
|
|
@ -367,6 +465,12 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
.join(', ')}`,
|
.join(', ')}`,
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
const incompleteReadyDiagnostic =
|
||||||
|
bridgeReady && readyCheckpointsPresent && !allExpectedMembersConfirmed
|
||||||
|
? [
|
||||||
|
`OpenCode bridge reported ready before all expected members were confirmed: pending ${unconfirmedExpectedMembers.join(', ')}`,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
const members = Object.fromEntries(
|
const members = Object.fromEntries(
|
||||||
input.expectedMembers.map((member) => {
|
input.expectedMembers.map((member) => {
|
||||||
|
|
@ -396,6 +500,7 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
(evidence) => `${evidence.kind} at ${evidence.observedAt}`
|
(evidence) => `${evidence.kind} at ${evidence.observedAt}`
|
||||||
),
|
),
|
||||||
...checkpointDiagnostic,
|
...checkpointDiagnostic,
|
||||||
|
...(missingExpectedMembers.includes(member.name) ? incompleteReadyDiagnostic : []),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
@ -407,17 +512,25 @@ function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
launchPhase: success
|
launchPhase: success
|
||||||
? 'finished'
|
? 'finished'
|
||||||
: data.teamLaunchState === 'launching'
|
: data.teamLaunchState === 'launching' || (bridgeReady && !anyExpectedMemberFailed)
|
||||||
? 'active'
|
? 'active'
|
||||||
: 'finished',
|
: 'finished',
|
||||||
teamLaunchState: success
|
teamLaunchState: success
|
||||||
? 'clean_success'
|
? 'clean_success'
|
||||||
: data.teamLaunchState === 'launching' || data.teamLaunchState === 'permission_blocked'
|
: anyExpectedMemberFailed || data.teamLaunchState === 'failed'
|
||||||
? 'partial_pending'
|
? 'partial_failure'
|
||||||
: 'partial_failure',
|
: data.teamLaunchState === 'launching' ||
|
||||||
|
data.teamLaunchState === 'permission_blocked' ||
|
||||||
|
bridgeReady
|
||||||
|
? 'partial_pending'
|
||||||
|
: 'partial_failure',
|
||||||
members,
|
members,
|
||||||
warnings: [...prepareWarnings, ...data.warnings.map((warning) => warning.message)],
|
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.`;
|
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: {
|
function formatOpenCodeBridgeDiagnostic(diagnostic: {
|
||||||
code: string;
|
code: string;
|
||||||
severity: 'info' | 'warning' | 'error';
|
severity: 'info' | 'warning' | 'error';
|
||||||
|
|
@ -496,6 +627,8 @@ function blockedLaunchResult(
|
||||||
diagnostics: string[],
|
diagnostics: string[],
|
||||||
warnings: string[] = []
|
warnings: string[] = []
|
||||||
): TeamRuntimeLaunchResult {
|
): TeamRuntimeLaunchResult {
|
||||||
|
const hardFailureReason =
|
||||||
|
reason === 'unknown_error' && diagnostics[0]?.trim() ? diagnostics[0].trim() : reason;
|
||||||
const members = Object.fromEntries(
|
const members = Object.fromEntries(
|
||||||
input.expectedMembers.map((member) => [
|
input.expectedMembers.map((member) => [
|
||||||
member.name,
|
member.name,
|
||||||
|
|
@ -507,7 +640,7 @@ function blockedLaunchResult(
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: true,
|
hardFailure: true,
|
||||||
hardFailureReason: reason,
|
hardFailureReason,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
|
export { OpenCodeTeamRuntimeAdapter } from './OpenCodeTeamRuntimeAdapter';
|
||||||
export type {
|
export type {
|
||||||
OpenCodeTeamLaunchMode,
|
OpenCodeTeamLaunchMode,
|
||||||
|
OpenCodeTeamRuntimeMessageInput,
|
||||||
|
OpenCodeTeamRuntimeMessageResult,
|
||||||
OpenCodeTeamRuntimeAdapterOptions,
|
OpenCodeTeamRuntimeAdapterOptions,
|
||||||
OpenCodeTeamRuntimeBridgePort,
|
OpenCodeTeamRuntimeBridgePort,
|
||||||
} from './OpenCodeTeamRuntimeAdapter';
|
} from './OpenCodeTeamRuntimeAdapter';
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ import {
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
import { sumContextInjectionTokens } from '@renderer/utils/contextMath';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
hasUnresolvedMemberSpawnStatus,
|
||||||
|
MEMBER_SPAWN_STATUS_REFRESH_MS,
|
||||||
|
} from '@renderer/utils/memberSpawnStatusPolling';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
|
import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummaryCopy';
|
||||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
@ -374,27 +378,46 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
|
||||||
isTeamProvisioning: boolean;
|
isTeamProvisioning: boolean;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
}): null {
|
}): null {
|
||||||
const { leadActivity, memberSpawnStatuses, fetchMemberSpawnStatuses } = useStore(
|
const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } =
|
||||||
useShallow((s) => ({
|
useStore(
|
||||||
leadActivity: s.leadActivityByTeam[teamName],
|
useShallow((s) => ({
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
leadActivity: s.leadActivityByTeam[teamName],
|
||||||
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
}))
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
);
|
fetchMemberSpawnStatuses: s.fetchMemberSpawnStatuses,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus(
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshot
|
||||||
|
);
|
||||||
const shouldFetchSpawnStatuses =
|
const shouldFetchSpawnStatuses =
|
||||||
isTeamProvisioning ||
|
isTeamProvisioning ||
|
||||||
|
hasUnresolvedSpawn ||
|
||||||
(memberSpawnStatuses == null &&
|
(memberSpawnStatuses == null &&
|
||||||
(isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle'));
|
(isTeamAlive === true || leadActivity === 'active' || leadActivity === 'idle'));
|
||||||
if (shouldFetchSpawnStatuses) {
|
if (shouldFetchSpawnStatuses) {
|
||||||
void fetchMemberSpawnStatuses(teamName);
|
void fetchMemberSpawnStatuses(teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isTeamProvisioning && !hasUnresolvedSpawn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
void fetchMemberSpawnStatuses(teamName);
|
||||||
|
}, MEMBER_SPAWN_STATUS_REFRESH_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(interval);
|
||||||
|
};
|
||||||
}, [
|
}, [
|
||||||
fetchMemberSpawnStatuses,
|
fetchMemberSpawnStatuses,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
memberSpawnSnapshot,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
teamName,
|
teamName,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -148,9 +148,15 @@ function areMemberSpawnStatusesEquivalent(
|
||||||
leftEntry.status !== rightEntry?.status ||
|
leftEntry.status !== rightEntry?.status ||
|
||||||
leftEntry.launchState !== rightEntry.launchState ||
|
leftEntry.launchState !== rightEntry.launchState ||
|
||||||
leftEntry.error !== rightEntry.error ||
|
leftEntry.error !== rightEntry.error ||
|
||||||
|
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
||||||
|
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
||||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +333,7 @@ export const MemberList = memo(function MemberList({
|
||||||
isRemoved ? undefined : runtimeEntry
|
isRemoved ? undefined : runtimeEntry
|
||||||
)}
|
)}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
spawnError={isRemoved ? undefined : spawnEntry?.error}
|
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||||
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={isRemoved ? undefined : spawnEntry?.runtimeAlive}
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,53 @@ function getSpawnEntry(
|
||||||
return memberSpawnStatuses[memberName];
|
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: {
|
function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
teammateNames: readonly string[];
|
teammateNames: readonly string[];
|
||||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||||
|
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||||
|
memberSpawnSnapshotUpdatedAt?: string;
|
||||||
}): Omit<LaunchJoinMilestones, 'expectedTeammateCount'> & {
|
}): Omit<LaunchJoinMilestones, 'expectedTeammateCount'> & {
|
||||||
observedTeammateCount: number;
|
observedTeammateCount: number;
|
||||||
} {
|
} {
|
||||||
const { teammateNames, memberSpawnStatuses } = params;
|
const {
|
||||||
|
teammateNames,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshotStatuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt,
|
||||||
|
} = params;
|
||||||
let heartbeatConfirmedCount = 0;
|
let heartbeatConfirmedCount = 0;
|
||||||
let processOnlyAliveCount = 0;
|
let processOnlyAliveCount = 0;
|
||||||
let pendingSpawnCount = 0;
|
let pendingSpawnCount = 0;
|
||||||
|
|
@ -65,7 +105,15 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
let observedTeammateCount = 0;
|
let observedTeammateCount = 0;
|
||||||
|
|
||||||
for (const memberName of teammateNames) {
|
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) {
|
if (!entry) {
|
||||||
pendingSpawnCount += 1;
|
pendingSpawnCount += 1;
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -111,7 +159,10 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
}: {
|
}: {
|
||||||
members: readonly LaunchJoinMemberLike[];
|
members: readonly LaunchJoinMemberLike[];
|
||||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'> & {
|
memberSpawnSnapshot?: Pick<
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
|
'expectedMembers' | 'summary' | 'updatedAt'
|
||||||
|
> & {
|
||||||
statuses?: MemberSpawnStatusesSnapshot['statuses'];
|
statuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||||
};
|
};
|
||||||
}): LaunchJoinMilestones {
|
}): LaunchJoinMilestones {
|
||||||
|
|
@ -140,6 +191,8 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
const liveSummary = summarizeLiveLaunchJoinMilestones({
|
const liveSummary = summarizeLiveLaunchJoinMilestones({
|
||||||
teammateNames,
|
teammateNames,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (snapshotSummary) {
|
if (snapshotSummary) {
|
||||||
|
|
@ -263,6 +316,10 @@ export function getDisplayStepIndex({
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failedSpawnCount > 0) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
|
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
|
||||||
|
|
||||||
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,29 @@ function matchField(text: string, pattern: RegExp): string | undefined {
|
||||||
return value ? value : 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(
|
function buildRuntimeSummary(
|
||||||
providerId: TeamProviderId | null,
|
providerId: TeamProviderId | null,
|
||||||
model: string | undefined,
|
model: string | undefined,
|
||||||
|
|
@ -63,7 +86,7 @@ function buildRuntimeSummary(
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const providerLabel = getTeamProviderLabel(providerId) ?? 'Anthropic';
|
const providerLabel = getTeamProviderLabel(providerId) ?? 'Anthropic';
|
||||||
const modelLabel = model ? (getTeamModelLabel(model) ?? model) : 'Default';
|
const modelLabel = model ? (getTeamModelLabel(model) ?? model) : 'Default';
|
||||||
const effortLabel = getTeamEffortLabel(effort);
|
const effortLabel = effort ? getTeamEffortLabel(effort) : undefined;
|
||||||
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(
|
const modelAlreadyCarriesProviderBrand = doesTeamModelCarryProviderBrand(
|
||||||
providerId,
|
providerId,
|
||||||
modelLabel
|
modelLabel
|
||||||
|
|
@ -117,11 +140,9 @@ export function getBootstrapPromptDisplay(
|
||||||
matchField(text, /^You are\s+([^,\n]+),/m) ??
|
matchField(text, /^You are\s+([^,\n]+),/m) ??
|
||||||
(typeof message.to === 'string' ? message.to.trim() : undefined);
|
(typeof message.to === 'string' ? message.to.trim() : undefined);
|
||||||
const teamName = matchField(text, /on team "([^"]+)"/);
|
const teamName = matchField(text, /on team "([^"]+)"/);
|
||||||
const providerId = parseProviderId(
|
const providerId = parseProviderId(matchOverrideField(text, 'Provider override'));
|
||||||
matchField(text, /Provider override(?: for this teammate)?:\s*([^\.\n]+)/i)
|
const model = matchOverrideField(text, 'Model override');
|
||||||
);
|
const effort = matchOverrideField(text, 'Effort override');
|
||||||
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 runtime = buildRuntimeSummary(providerId, model, effort);
|
const runtime = buildRuntimeSummary(providerId, model, effort);
|
||||||
const displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
|
const displayName = teammateName ? displayMemberName(teammateName) : 'teammate';
|
||||||
const summary = `Starting ${displayName}`;
|
const summary = `Starting ${displayName}`;
|
||||||
|
|
|
||||||
|
|
@ -48,17 +48,21 @@ export function resolveMemberRuntimeSummary(
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string })
|
const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string })
|
||||||
.providerBackendId;
|
.providerBackendId;
|
||||||
|
const memberModel = member.model?.trim() || '';
|
||||||
|
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
|
||||||
|
const inferredMemberProvider =
|
||||||
|
inferTeamProviderIdFromModel(memberModel) ?? inferTeamProviderIdFromModel(runtimeModel);
|
||||||
const configuredProvider: TeamProviderId =
|
const configuredProvider: TeamProviderId =
|
||||||
member.providerId ?? launchParams?.providerId ?? 'anthropic';
|
member.providerId ?? inferredMemberProvider ?? launchParams?.providerId ?? 'anthropic';
|
||||||
|
const memberProviderForInheritance = member.providerId ?? inferredMemberProvider;
|
||||||
const inheritsLeadRuntimeDefaults =
|
const inheritsLeadRuntimeDefaults =
|
||||||
member.providerId == null ||
|
memberProviderForInheritance == null ||
|
||||||
launchParams?.providerId == null ||
|
launchParams?.providerId == null ||
|
||||||
member.providerId === launchParams.providerId;
|
memberProviderForInheritance === launchParams.providerId;
|
||||||
const configuredModel =
|
const configuredModel =
|
||||||
member.model?.trim() || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
|
memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
|
||||||
const configuredEffort =
|
const configuredEffort =
|
||||||
member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined);
|
member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined);
|
||||||
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
|
|
||||||
const configuredProviderBackendId =
|
const configuredProviderBackendId =
|
||||||
memberProviderBackendId ??
|
memberProviderBackendId ??
|
||||||
(inheritsLeadRuntimeDefaults ? launchParams?.providerBackendId : undefined);
|
(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;
|
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: {
|
function countPermissionBlockedMembers(params: {
|
||||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||||
|
memberSpawnSnapshotUpdatedAt?: string;
|
||||||
}): number {
|
}): number {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
if (params.memberSpawnStatuses instanceof Map) {
|
if (params.memberSpawnStatuses instanceof Map) {
|
||||||
|
|
@ -57,7 +102,11 @@ function countPermissionBlockedMembers(params: {
|
||||||
? params.memberSpawnStatuses.get(name)
|
? params.memberSpawnStatuses.get(name)
|
||||||
: params.memberSpawnStatuses?.[name];
|
: params.memberSpawnStatuses?.[name];
|
||||||
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
|
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
|
||||||
const entry = liveEntry ?? snapshotEntry;
|
const entry = getPreferredSpawnEntry({
|
||||||
|
liveEntry,
|
||||||
|
snapshotEntry,
|
||||||
|
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
||||||
|
});
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +138,7 @@ const ACTIVE_PROVISIONING_STATES = new Set([
|
||||||
function getFailedSpawnDetails(params: {
|
function getFailedSpawnDetails(params: {
|
||||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||||
|
memberSpawnSnapshotUpdatedAt?: string;
|
||||||
}): FailedSpawnDetail[] {
|
}): FailedSpawnDetail[] {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
if (params.memberSpawnStatuses instanceof Map) {
|
if (params.memberSpawnStatuses instanceof Map) {
|
||||||
|
|
@ -115,7 +165,14 @@ function getFailedSpawnDetails(params: {
|
||||||
? params.memberSpawnStatuses.get(name)
|
? params.memberSpawnStatuses.get(name)
|
||||||
: params.memberSpawnStatuses?.[name];
|
: params.memberSpawnStatuses?.[name];
|
||||||
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
|
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
|
||||||
return [name, liveEntry ?? snapshotEntry] as const;
|
return [
|
||||||
|
name,
|
||||||
|
getPreferredSpawnEntry({
|
||||||
|
liveEntry,
|
||||||
|
snapshotEntry,
|
||||||
|
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
||||||
|
}),
|
||||||
|
] as const;
|
||||||
})
|
})
|
||||||
.filter(
|
.filter(
|
||||||
([, entry]) => entry && (entry.launchState === 'failed_to_start' || entry.status === 'error')
|
([, entry]) => entry && (entry.launchState === 'failed_to_start' || entry.status === 'error')
|
||||||
|
|
@ -229,7 +286,10 @@ export function buildTeamProvisioningPresentation({
|
||||||
progress: TeamProvisioningProgress | null | undefined;
|
progress: TeamProvisioningProgress | null | undefined;
|
||||||
members: readonly ProvisioningMemberLike[];
|
members: readonly ProvisioningMemberLike[];
|
||||||
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
memberSpawnStatuses?: MemberSpawnStatusCollection;
|
||||||
memberSpawnSnapshot?: Pick<MemberSpawnStatusesSnapshot, 'expectedMembers' | 'summary'> & {
|
memberSpawnSnapshot?: Pick<
|
||||||
|
MemberSpawnStatusesSnapshot,
|
||||||
|
'expectedMembers' | 'summary' | 'updatedAt'
|
||||||
|
> & {
|
||||||
statuses?: MemberSpawnStatusesSnapshot['statuses'];
|
statuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||||
};
|
};
|
||||||
}): TeamProvisioningPresentation | null {
|
}): TeamProvisioningPresentation | null {
|
||||||
|
|
@ -265,6 +325,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
const failedSpawnDetails = getFailedSpawnDetails({
|
const failedSpawnDetails = getFailedSpawnDetails({
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||||
});
|
});
|
||||||
const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
|
const failedSpawnPanelMessage = buildFailedSpawnPanelMessage(failedSpawnDetails);
|
||||||
const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
|
const failedSpawnCompactDetail = buildFailedSpawnCompactDetail(failedSpawnDetails);
|
||||||
|
|
@ -275,6 +336,7 @@ export function buildTeamProvisioningPresentation({
|
||||||
const permissionBlockedCount = countPermissionBlockedMembers({
|
const permissionBlockedCount = countPermissionBlockedMembers({
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
|
const { allTeammatesConfirmedAlive, hasMembersStillJoining, remainingJoinCount } =
|
||||||
|
|
@ -369,7 +431,6 @@ export function buildTeamProvisioningPresentation({
|
||||||
isReady: true,
|
isReady: true,
|
||||||
isFailed: false,
|
isFailed: false,
|
||||||
canCancel: false,
|
canCancel: false,
|
||||||
currentStepIndex: hasMembersStillJoining ? 2 : DISPLAY_COMPLETE_STEP_INDEX,
|
|
||||||
expectedTeammateCount,
|
expectedTeammateCount,
|
||||||
heartbeatConfirmedCount,
|
heartbeatConfirmedCount,
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
|
|
@ -394,6 +455,8 @@ export function buildTeamProvisioningPresentation({
|
||||||
compactDetail: readyCompactDetail,
|
compactDetail: readyCompactDetail,
|
||||||
compactTone:
|
compactTone:
|
||||||
failedSpawnCount > 0 ? 'warning' : hasMembersStillJoining ? 'default' : 'success',
|
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;
|
hardFailure: boolean;
|
||||||
hardFailureReason?: string;
|
hardFailureReason?: string;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
|
runtimePid?: number;
|
||||||
firstSpawnAcceptedAt?: string;
|
firstSpawnAcceptedAt?: string;
|
||||||
lastHeartbeatAt?: string;
|
lastHeartbeatAt?: string;
|
||||||
lastRuntimeAliveAt?: string;
|
lastRuntimeAliveAt?: string;
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ const liveDescribe =
|
||||||
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1'
|
process.env.OPENCODE_E2E === '1' && process.env.OPENCODE_E2E_MIXED_RECOVERY === '1'
|
||||||
? describe
|
? describe
|
||||||
: describe.skip;
|
: 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 PROJECT_PATH = process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || process.cwd();
|
||||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
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
|
240_000
|
||||||
);
|
);
|
||||||
|
|
||||||
it(
|
liveMultiLaneIt(
|
||||||
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
|
'recovers multiple active mixed OpenCode side lanes from live runtime reconcile',
|
||||||
async () => {
|
async () => {
|
||||||
const selectedModel = process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL;
|
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 bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true, modelId: null }));
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge, { launchMode: 'production' });
|
||||||
|
|
||||||
await expect(adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))).resolves
|
await expect(
|
||||||
.toMatchObject({
|
adapter.prepare(launchInput({ model: undefined, runtimeOnly: true }))
|
||||||
ok: true,
|
).resolves.toMatchObject({
|
||||||
providerId: 'opencode',
|
ok: true,
|
||||||
modelId: null,
|
providerId: 'opencode',
|
||||||
});
|
modelId: null,
|
||||||
|
});
|
||||||
|
|
||||||
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledWith({
|
||||||
projectPath: '/repo',
|
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 () => {
|
it('fails closed when launch mode is disabled', async () => {
|
||||||
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }));
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||||
bridge
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
|
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
@ -72,27 +100,81 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
expect(bridge.checkOpenCodeTeamLaunchReadiness).not.toHaveBeenCalled();
|
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 () => {
|
it('maps ready bridge launch data to successful runtime evidence only with required checkpoints', async () => {
|
||||||
const launchOpenCodeTeam = vi.fn(async () => ({
|
const launchOpenCodeTeam = vi.fn(
|
||||||
runId: 'run-1',
|
async () =>
|
||||||
teamLaunchState: 'ready',
|
({
|
||||||
members: {
|
runId: 'run-1',
|
||||||
alice: {
|
teamLaunchState: 'ready',
|
||||||
sessionId: 'oc-session-1',
|
members: {
|
||||||
launchState: 'confirmed_alive',
|
alice: {
|
||||||
runtimePid: 123,
|
sessionId: 'oc-session-1',
|
||||||
model: 'openai/gpt-5.4-mini',
|
launchState: 'confirmed_alive',
|
||||||
evidence: [
|
runtimePid: 123,
|
||||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
model: 'openai/gpt-5.4-mini',
|
||||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
evidence: [
|
||||||
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
{ kind: 'run_ready', 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);
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
}) satisfies OpenCodeLaunchTeamCommandData
|
||||||
|
);
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
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 () => {
|
it('reconciles from existing persisted launch snapshot without treating OpenCode as truth', async () => {
|
||||||
const snapshot = launchSnapshot();
|
const snapshot = launchSnapshot();
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
|
|
@ -162,24 +308,72 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps missing bridge members pending while reconcile is still launching', async () => {
|
it('sends direct teammate messages through the OpenCode message bridge', async () => {
|
||||||
const reconcileOpenCodeTeam = vi.fn(async () => ({
|
const sendOpenCodeTeamMessage = vi.fn(async () => ({
|
||||||
runId: 'run-1',
|
accepted: true,
|
||||||
teamLaunchState: 'launching',
|
sessionId: 'oc-session-bob',
|
||||||
members: {
|
memberName: 'bob',
|
||||||
alice: {
|
runtimePid: 456,
|
||||||
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: [],
|
diagnostics: [],
|
||||||
durableCheckpoints: [],
|
}));
|
||||||
manifestHighWatermark: null,
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
runtimeStoreManifestHighWatermark: null,
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
}) satisfies OpenCodeLaunchTeamCommandData);
|
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(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
reconcileOpenCodeTeam,
|
reconcileOpenCodeTeam,
|
||||||
|
|
@ -244,27 +438,30 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps permission-blocked bridge members to runtime_pending_permission instead of bootstrap pending', async () => {
|
it('maps permission-blocked bridge members to runtime_pending_permission instead of bootstrap pending', async () => {
|
||||||
const launchOpenCodeTeam = vi.fn(async () => ({
|
const launchOpenCodeTeam = vi.fn(
|
||||||
runId: 'run-1',
|
async () =>
|
||||||
teamLaunchState: 'permission_blocked',
|
({
|
||||||
members: {
|
runId: 'run-1',
|
||||||
alice: {
|
teamLaunchState: 'permission_blocked',
|
||||||
sessionId: 'oc-session-1',
|
members: {
|
||||||
launchState: 'permission_blocked',
|
alice: {
|
||||||
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
|
sessionId: 'oc-session-1',
|
||||||
diagnostics: ['waiting for permission approval'],
|
launchState: 'permission_blocked',
|
||||||
runtimePid: 123,
|
pendingPermissionRequestIds: ['perm-1', 'perm-1', 'perm-2'],
|
||||||
model: 'openai/gpt-5.4-mini',
|
diagnostics: ['waiting for permission approval'],
|
||||||
evidence: [
|
runtimePid: 123,
|
||||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
model: 'openai/gpt-5.4-mini',
|
||||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
evidence: [
|
||||||
{ kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
|
{ 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);
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
}) satisfies OpenCodeLaunchTeamCommandData
|
||||||
|
);
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
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 () => {
|
it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
|
||||||
const launchOpenCodeTeam = vi.fn(async () => ({
|
const launchOpenCodeTeam = vi.fn(
|
||||||
runId: 'run-1',
|
async () =>
|
||||||
teamLaunchState: 'permission_blocked',
|
({
|
||||||
members: {
|
runId: 'run-1',
|
||||||
alice: {
|
teamLaunchState: 'permission_blocked',
|
||||||
sessionId: 'oc-session-1',
|
members: {
|
||||||
launchState: 'permission_blocked',
|
alice: {
|
||||||
pendingPermissionRequestIds: ['perm-1'],
|
sessionId: 'oc-session-1',
|
||||||
diagnostics: ['waiting for permission approval'],
|
launchState: 'permission_blocked',
|
||||||
runtimePid: 123,
|
pendingPermissionRequestIds: ['perm-1'],
|
||||||
model: 'openai/gpt-5.4-mini',
|
diagnostics: ['waiting for permission approval'],
|
||||||
evidence: [
|
runtimePid: 123,
|
||||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
model: 'openai/gpt-5.4-mini',
|
||||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
evidence: [
|
||||||
{ kind: 'permission_blocked', observedAt: '2026-04-21T00:00:00.000Z' },
|
{ 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);
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
}) satisfies OpenCodeLaunchTeamCommandData
|
||||||
|
);
|
||||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => ({
|
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(
|
function writeTeamMeta(teamName: string, overrides: Record<string, unknown> = {}): void {
|
||||||
teamName: string,
|
|
||||||
overrides: Record<string, unknown> = {}
|
|
||||||
): void {
|
|
||||||
const teamDir = path.join(tempTeamsBase, teamName);
|
const teamDir = path.join(tempTeamsBase, teamName);
|
||||||
fs.mkdirSync(teamDir, { recursive: true });
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|
@ -1104,22 +1101,22 @@ describe('TeamProvisioningService', () => {
|
||||||
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
||||||
(svc as any).runs.set('run-1', run);
|
(svc as any).runs.set('run-1', run);
|
||||||
vi.mocked(pidusage).mockReset();
|
vi.mocked(pidusage).mockReset();
|
||||||
vi
|
vi.mocked(pidusage).mockImplementation(
|
||||||
.mocked(pidusage)
|
async (target: number | string | Array<number | string>) => {
|
||||||
.mockImplementation(async (target: number | string | Array<number | string>) => {
|
|
||||||
if (Array.isArray(target)) {
|
if (Array.isArray(target)) {
|
||||||
return {
|
return {
|
||||||
'111': createPidusageStat(111, 123_000_000),
|
'111': createPidusageStat(111, 123_000_000),
|
||||||
} as any;
|
} as any;
|
||||||
}
|
}
|
||||||
if (target === 333) {
|
if (target === 333) {
|
||||||
return createPidusageStat(333, 456_000_000) as any;
|
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');
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||||
|
|
||||||
|
|
@ -1134,6 +1131,78 @@ describe('TeamProvisioningService', () => {
|
||||||
rssBytes: 456_000_000,
|
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', () => {
|
describe('restartMember', () => {
|
||||||
|
|
@ -1349,7 +1418,7 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(restartMessage).not.toContain('nemotron-3-super-free');
|
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 svc = new TeamProvisioningService();
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
teamName: 'mixed-team',
|
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 svc = new TeamProvisioningService();
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
teamName: 'mixed-team',
|
teamName: 'mixed-team',
|
||||||
|
|
@ -2200,9 +2269,9 @@ describe('TeamProvisioningService', () => {
|
||||||
const launchSummary = (svc as any).getMemberLaunchSummary(run);
|
const launchSummary = (svc as any).getMemberLaunchSummary(run);
|
||||||
|
|
||||||
expect((svc as any).hasPendingLaunchMembers(run, launchSummary, null)).toBe(true);
|
expect((svc as any).hasPendingLaunchMembers(run, launchSummary, null)).toBe(true);
|
||||||
expect((svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)).toBe(
|
expect(
|
||||||
'Finishing launch — 1 teammate awaiting permission approval'
|
(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', () => {
|
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 () => {
|
it('marks an OpenCode secondary lane degraded when readiness fails before runtime materializes', async () => {
|
||||||
const teamName = 'mixed-prelaunch-failure';
|
const teamName = 'mixed-prelaunch-failure';
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
|
|
@ -2786,9 +2924,9 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(write).toHaveBeenCalledTimes(1);
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined)?.[1] as
|
const writtenSnapshot = (
|
||||||
| { members?: Record<string, unknown> }
|
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
|
||||||
| undefined;
|
)?.[1] as { members?: Record<string, unknown> } | undefined;
|
||||||
expect(writtenSnapshot?.members?.bob).toMatchObject({
|
expect(writtenSnapshot?.members?.bob).toMatchObject({
|
||||||
name: 'bob',
|
name: 'bob',
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -2866,9 +3004,9 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(write).toHaveBeenCalledTimes(1);
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
const writtenSnapshot = (write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined)?.[1] as
|
const writtenSnapshot = (
|
||||||
| { expectedMembers?: string[] }
|
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
|
||||||
| undefined;
|
)?.[1] as { expectedMembers?: string[] } | undefined;
|
||||||
expect(writtenSnapshot?.expectedMembers).toEqual(['bob', 'alice']);
|
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 () => {
|
it('uses the secondary lane run id for OpenCode runtime delivery journal acceptance', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
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).aliveRunByTeam.set('mixed-team', 'lead-run');
|
||||||
(svc as any).runs.set('lead-run', {
|
(svc as any).runs.set('lead-run', {
|
||||||
|
|
@ -3267,7 +3408,9 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
await (svc as any).stopSingleMixedSecondaryRuntimeLane(run, lane, 'relaunch');
|
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: {},
|
lanes: {},
|
||||||
});
|
});
|
||||||
await expect(
|
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 () => {
|
it('removes generated MCP config when launchTeam spawn fails synchronously', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'launch-cleanup-team';
|
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 () => {
|
it('includes queued OpenCode secondary lanes in live spawn statuses before the final mixed snapshot settles', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
vi.spyOn(svc as any, 'refreshMemberSpawnStatusesFromLeadInbox').mockResolvedValue(undefined);
|
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 () => {
|
it('uses info severity while runtimes are online but teammate contact is still pending', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = {
|
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(display?.summary).toBe('Starting alice');
|
||||||
expect(getSanitizedInboxMessageText(message)).toContain('Startup instructions are hidden in the UI.');
|
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');
|
).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?.panelMessage).toContain('requested model is not available');
|
||||||
expect(presentation?.compactDetail).toBe('jack failed to start');
|
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', () => {
|
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?.panelMessage).toBe('1 teammate failed to start');
|
||||||
expect(presentation?.compactDetail).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', () => {
|
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');
|
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', () => {
|
it('surfaces permission-blocked teammates as awaiting approval while launch is finishing', () => {
|
||||||
const presentation = buildTeamProvisioningPresentation({
|
const presentation = buildTeamProvisioningPresentation({
|
||||||
progress: {
|
progress: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue