feat(opencode): persist teammate worktree context

This commit is contained in:
777genius 2026-04-26 12:31:15 +03:00
parent 49982a1db8
commit 610bfc561d
15 changed files with 1093 additions and 53 deletions

View file

@ -156,6 +156,7 @@ function createPrimaryLaneMemberState(params: {
: undefined), : undefined),
model: params.member.model?.trim() || undefined, model: params.member.model?.trim() || undefined,
effort: params.member.effort, effort: params.member.effort,
cwd: params.member.cwd?.trim() || undefined,
selectedFastMode: selectedFastMode:
normalizeFastMode(params.member.fastMode) ?? normalizeFastMode(params.member.fastMode) ??
(providerId === params.leadDefaults.providerId (providerId === params.leadDefaults.providerId
@ -231,6 +232,7 @@ function createSecondaryLaneMemberState(
: undefined), : undefined),
model: params.member.model?.trim() || undefined, model: params.member.model?.trim() || undefined,
effort: params.member.effort, effort: params.member.effort,
cwd: params.member.cwd?.trim() || undefined,
selectedFastMode: selectedFastMode:
normalizeFastMode(params.member.fastMode) ?? normalizeFastMode(params.member.fastMode) ??
(providerId === params.leadDefaults.providerId (providerId === params.leadDefaults.providerId

View file

@ -10,6 +10,10 @@ import type {
export interface RuntimeLanePlannerMemberInput { export interface RuntimeLanePlannerMemberInput {
name: string; name: string;
role?: string;
workflow?: string;
isolation?: 'worktree';
cwd?: string;
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId; providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
@ -185,6 +189,10 @@ export function fromProvisioningMembers(
leadProviderId, leadProviderId,
members: members.map((member) => ({ members: members.map((member) => ({
name: member.name, name: member.name,
role: member.role,
workflow: member.workflow,
isolation: member.isolation,
cwd: member.cwd,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
providerBackendId: member.providerBackendId, providerBackendId: member.providerBackendId,
model: member.model, model: member.model,

View file

@ -296,6 +296,7 @@ async function cleanupOpenCodeHostsForLifecycle(reason: 'startup' | 'shutdown'):
mode: reason === 'shutdown' ? 'force' : 'stale', mode: reason === 'shutdown' ? 'force' : 'stale',
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null, staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null, leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
preflightLeaseStaleAgeMs: reason === 'startup' ? 2 * 60_000 : null,
}); });
if (result.cleaned > 0) { if (result.cleaned > 0) {
logger.info( logger.info(

View file

@ -0,0 +1,202 @@
import { getClaudeBasePath } from '@main/utils/pathDecoder';
import { createHash } from 'crypto';
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
export interface TeamMemberWorktreeRequest {
teamName: string;
memberName: string;
baseCwd: string;
}
export interface TeamMemberWorktreeResolution {
baseRepoPath: string;
worktreePath: string;
branchName: string;
}
interface GitWorktreeEntry {
worktree: string;
branch?: string;
}
const GIT_TIMEOUT_MS = 15_000;
function execGit(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
'git',
args,
{ cwd, timeout: GIT_TIMEOUT_MS, maxBuffer: 1024 * 1024 },
(error, stdout, stderr) => {
if (error) {
const message = String(stderr || error.message || 'git command failed').trim();
reject(new Error(message));
return;
}
resolve(String(stdout).trim());
}
);
});
}
function slugify(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'member'
);
}
function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 10);
}
async function realpathIfExists(candidate: string): Promise<string | null> {
try {
return await fs.promises.realpath(candidate);
} catch {
return null;
}
}
async function resolveGitPath(cwd: string, raw: string): Promise<string> {
const resolved = path.isAbsolute(raw) ? raw : path.resolve(cwd, raw);
return (await realpathIfExists(resolved)) ?? resolved;
}
function parseGitWorktreeList(raw: string): GitWorktreeEntry[] {
const entries: GitWorktreeEntry[] = [];
let current: GitWorktreeEntry | null = null;
for (const line of raw.split(/\r?\n/g)) {
if (!line.trim()) {
if (current) entries.push(current);
current = null;
continue;
}
const [key, ...rest] = line.split(' ');
const value = rest.join(' ').trim();
if (key === 'worktree') {
if (current) entries.push(current);
current = { worktree: value };
continue;
}
if (key === 'branch' && current) {
current.branch = value.replace(/^refs\/heads\//, '');
}
}
if (current) entries.push(current);
return entries;
}
export class TeamMemberWorktreeManager {
async ensureMemberWorktree(
request: TeamMemberWorktreeRequest
): Promise<TeamMemberWorktreeResolution> {
const baseRepoPath = await this.resolveBaseRepoPath(request.baseCwd);
const repoHash = shortHash(baseRepoPath);
const teamSlug = slugify(request.teamName);
const memberSlug = slugify(request.memberName);
const branchName = `agent-teams/${teamSlug}/${memberSlug}-${repoHash}`;
const worktreePath = path.join(
getClaudeBasePath(),
'team-worktrees',
repoHash,
teamSlug,
memberSlug
);
const existingStat = await fs.promises.stat(worktreePath).catch(() => null);
if (existingStat) {
if (!existingStat.isDirectory()) {
throw new Error(`Worktree path exists but is not a directory: ${worktreePath}`);
}
await this.assertExistingWorktreeMatchesRepo(worktreePath, baseRepoPath, branchName);
return { baseRepoPath, worktreePath, branchName };
}
await fs.promises.mkdir(path.dirname(worktreePath), { recursive: true });
await this.createWorktree({ baseRepoPath, worktreePath, branchName });
return { baseRepoPath, worktreePath, branchName };
}
private async resolveBaseRepoPath(baseCwd: string): Promise<string> {
if (!path.isAbsolute(baseCwd)) {
throw new Error('OpenCode worktree isolation requires an absolute project path.');
}
const root = await execGit(['rev-parse', '--show-toplevel'], baseCwd).catch((error) => {
throw new Error(
`OpenCode worktree isolation requires a git repository: ${
error instanceof Error ? error.message : String(error)
}`
);
});
return (await realpathIfExists(root)) ?? root;
}
private async assertExistingWorktreeMatchesRepo(
worktreePath: string,
baseRepoPath: string,
branchName: string
): Promise<void> {
const [baseCommonRaw, targetCommonRaw, targetBranchRaw] = await Promise.all([
execGit(['rev-parse', '--git-common-dir'], baseRepoPath),
execGit(['rev-parse', '--git-common-dir'], worktreePath),
execGit(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath),
]);
const [baseCommon, targetCommon] = await Promise.all([
resolveGitPath(baseRepoPath, baseCommonRaw),
resolveGitPath(worktreePath, targetCommonRaw),
]);
if (baseCommon !== targetCommon) {
throw new Error(`Worktree path belongs to a different git repository: ${worktreePath}`);
}
if (targetBranchRaw !== branchName) {
throw new Error(
`Worktree path is checked out on "${targetBranchRaw}", expected "${branchName}": ${worktreePath}`
);
}
}
private async createWorktree(params: {
baseRepoPath: string;
worktreePath: string;
branchName: string;
}): Promise<void> {
const branchExists = await execGit(
['rev-parse', '--verify', `refs/heads/${params.branchName}`],
params.baseRepoPath
)
.then(() => true)
.catch(() => false);
const listRaw = await execGit(['worktree', 'list', '--porcelain'], params.baseRepoPath);
const branchInUse = parseGitWorktreeList(listRaw).some(
(entry) => entry.branch === params.branchName
);
if (branchInUse) {
throw new Error(
`OpenCode worktree branch is already checked out elsewhere: ${params.branchName}`
);
}
if (branchExists) {
await execGit(
['worktree', 'add', params.worktreePath, params.branchName],
params.baseRepoPath
);
return;
}
await execGit(
['worktree', 'add', '-b', params.branchName, params.worktreePath, 'HEAD'],
params.baseRepoPath
);
}
}

View file

@ -200,6 +200,7 @@ import {
import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamLaunchStateStore } from './TeamLaunchStateStore';
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
import { TeamMembersMetaStore } from './TeamMembersMetaStore'; import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore'; import { TeamMetaStore } from './TeamMetaStore';
import { import {
@ -254,6 +255,7 @@ interface PersistedRuntimeMemberLike {
tmuxPaneId?: string; tmuxPaneId?: string;
backendType?: string; backendType?: string;
providerId?: string; providerId?: string;
cwd?: string;
runtimePid?: number; runtimePid?: number;
runtimeSessionId?: string; runtimeSessionId?: string;
} }
@ -1515,6 +1517,7 @@ interface LiveTeamAgentRuntimeMetadata {
backendType?: TeamAgentRuntimeBackendType; backendType?: TeamAgentRuntimeBackendType;
providerId?: TeamProviderId; providerId?: TeamProviderId;
agentId?: string; agentId?: string;
cwd?: string;
pid?: number; pid?: number;
metricsPid?: number; metricsPid?: number;
model?: string; model?: string;
@ -4000,7 +4003,8 @@ export class TeamProvisioningService {
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(), private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(), private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(), private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
private readonly openCodeTaskLogAttributionStore: OpenCodeTaskLogAttributionStore = new OpenCodeTaskLogAttributionStore() private readonly openCodeTaskLogAttributionStore: OpenCodeTaskLogAttributionStore = new OpenCodeTaskLogAttributionStore(),
private readonly memberWorktreeManager: TeamMemberWorktreeManager = new TeamMemberWorktreeManager()
) { ) {
this.memberLogsFinder = new TeamMemberLogsFinder( this.memberLogsFinder = new TeamMemberLogsFinder(
this.configReader, this.configReader,
@ -5251,11 +5255,15 @@ export class TeamProvisioningService {
) { ) {
return { delivered: false, reason: 'opencode_runtime_not_active' }; return { delivered: false, reason: 'opencode_runtime_not_active' };
} }
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
const cwd = const cwd =
config?.projectPath?.trim() || laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
metaMember?.cwd?.trim() || ? memberRuntimeCwd ||
configMember?.cwd?.trim() || config?.projectPath?.trim() ||
this.readPersistedTeamProjectPath(teamName); this.readPersistedTeamProjectPath(teamName)
: config?.projectPath?.trim() ||
memberRuntimeCwd ||
this.readPersistedTeamProjectPath(teamName);
if (!cwd) { if (!cwd) {
return { delivered: false, reason: 'opencode_project_path_unavailable' }; return { delivered: false, reason: 'opencode_project_path_unavailable' };
} }
@ -5419,23 +5427,6 @@ export class TeamProvisioningService {
} }
if (ledgerRecord && ledger && messageId) { if (ledgerRecord && ledger && messageId) {
if (ledgerRecord.status === 'failed_terminal') {
this.logOpenCodePromptDeliveryEvent(
'opencode_prompt_delivery_terminal_failure',
ledgerRecord
);
return {
delivered: false,
accepted: false,
responsePending: false,
responseState: ledgerRecord.responseState,
ledgerStatus: ledgerRecord.status,
ledgerRecordId: ledgerRecord.id,
laneId: laneIdentity.laneId,
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
diagnostics: ledgerRecord.diagnostics,
};
}
let proof = await this.applyOpenCodeVisibleDestinationProof({ let proof = await this.applyOpenCodeVisibleDestinationProof({
ledger, ledger,
ledgerRecord, ledgerRecord,
@ -5471,6 +5462,24 @@ export class TeamProvisioningService {
}; };
} }
if (ledgerRecord.status === 'failed_terminal') {
this.logOpenCodePromptDeliveryEvent(
'opencode_prompt_delivery_terminal_failure',
ledgerRecord
);
return {
delivered: false,
accepted: false,
responsePending: false,
responseState: ledgerRecord.responseState,
ledgerStatus: ledgerRecord.status,
ledgerRecordId: ledgerRecord.id,
laneId: laneIdentity.laneId,
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
diagnostics: ledgerRecord.diagnostics,
};
}
const attemptDue = isOpenCodePromptDeliveryAttemptDue(ledgerRecord); const attemptDue = isOpenCodePromptDeliveryAttemptDue(ledgerRecord);
if (ledgerRecord.status !== 'pending' && !attemptDue) { if (ledgerRecord.status !== 'pending' && !attemptDue) {
const nextAttemptMs = ledgerRecord.nextAttemptAt const nextAttemptMs = ledgerRecord.nextAttemptAt
@ -6191,6 +6200,7 @@ export class TeamProvisioningService {
...(configuredMember.role ? { role: configuredMember.role } : {}), ...(configuredMember.role ? { role: configuredMember.role } : {}),
...(configuredMember.workflow ? { workflow: configuredMember.workflow } : {}), ...(configuredMember.workflow ? { workflow: configuredMember.workflow } : {}),
...(configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}), ...(configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(configuredMember.cwd ? { cwd: configuredMember.cwd } : {}),
...(configuredMember.providerId ? { providerId: configuredMember.providerId } : {}), ...(configuredMember.providerId ? { providerId: configuredMember.providerId } : {}),
...(configuredMember.providerBackendId ...(configuredMember.providerBackendId
? { providerBackendId: configuredMember.providerBackendId } ? { providerBackendId: configuredMember.providerBackendId }
@ -6208,6 +6218,7 @@ export class TeamProvisioningService {
role: member.role?.trim() || undefined, role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined, workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
cwd: member.cwd?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId), providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
@ -8591,6 +8602,7 @@ export class TeamProvisioningService {
const updatedAt = nowIso(); const updatedAt = nowIso();
const run = runId ? (this.runs.get(runId) ?? null) : null; const run = runId ? (this.runs.get(runId) ?? null) : null;
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null); const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
let configuredMembers: TeamConfig['members'] = []; let configuredMembers: TeamConfig['members'] = [];
@ -8718,6 +8730,10 @@ export class TeamProvisioningService {
inferTeamProviderIdFromModel(launchMember?.model) ?? inferTeamProviderIdFromModel(launchMember?.model) ??
inferTeamProviderIdFromModel(member.model); inferTeamProviderIdFromModel(member.model);
const isOpenCodeMember = memberProviderId === 'opencode'; const isOpenCodeMember = memberProviderId === 'opencode';
const configuredCwd = typeof member.cwd === 'string' ? member.cwd.trim() : '';
const runtimeCwd =
liveRuntimeMember?.cwd ??
(configuredCwd || (isOpenCodeMember ? currentRuntimeAdapterRun?.cwd : undefined));
const metricsPid = liveRuntimeMember?.metricsPid; const metricsPid = liveRuntimeMember?.metricsPid;
const isSharedOpenCodeHost = const isSharedOpenCodeHost =
isOpenCodeMember && isOpenCodeMember &&
@ -8760,6 +8776,7 @@ export class TeamProvisioningService {
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}), ...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
...(displayPid ? { pid: displayPid } : {}), ...(displayPid ? { pid: displayPid } : {}),
...(runtimeModel ? { runtimeModel } : {}), ...(runtimeModel ? { runtimeModel } : {}),
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}), ...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
...(liveRuntimeMember?.livenessKind ...(liveRuntimeMember?.livenessKind
? { livenessKind: liveRuntimeMember.livenessKind } ? { livenessKind: liveRuntimeMember.livenessKind }
@ -9267,7 +9284,15 @@ export class TeamProvisioningService {
); );
} }
const memberSpec = this.buildConfiguredProvisioningMember(configuredMember); const [memberSpec] = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName,
baseCwd: run.request.cwd,
leadProviderId,
members: [this.buildConfiguredProvisioningMember(configuredMember)],
});
if (!memberSpec) {
throw new Error(`Member "${memberName}" could not be resolved for OpenCode lane reattach.`);
}
const nextLane = this.createMixedSecondaryLaneStateForMember(run, memberSpec); const nextLane = this.createMixedSecondaryLaneStateForMember(run, memberSpec);
const existingLaneIndex = run.mixedSecondaryLanes.findIndex( const existingLaneIndex = run.mixedSecondaryLanes.findIndex(
(lane) => lane.laneId === nextLane.laneId || lane.member.name.trim() === memberName (lane) => lane.laneId === nextLane.laneId || lane.member.name.trim() === memberName
@ -10537,6 +10562,95 @@ export class TeamProvisioningService {
return effectiveMembers; return effectiveMembers;
} }
private getOpenCodeRuntimeLaunchCwd(
fallbackCwd: string,
members: TeamCreateRequest['members']
): string {
if (members.length > 1 && members.some((member) => member.isolation === 'worktree')) {
throw new Error(
'OpenCode worktree isolation currently supports one isolated OpenCode member per runtime lane.'
);
}
const memberCwds = [
...new Set(
members.map((member) => member.cwd?.trim()).filter((cwd): cwd is string => Boolean(cwd))
),
];
if (memberCwds.length === 0) {
return fallbackCwd;
}
if (memberCwds.length === 1) {
return memberCwds[0];
}
throw new Error(
'OpenCode runtime lanes support exactly one project path in this release. Use mixed-team OpenCode side lanes for per-teammate worktree isolation.'
);
}
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
teamName: string;
baseCwd: string;
leadProviderId?: TeamProviderId;
members: TeamCreateRequest['members'];
}): Promise<TeamCreateRequest['members']> {
const isolatedOpenCodeMembers = params.members.filter((member) => {
const providerId = normalizeTeamMemberProviderId(member.providerId);
return providerId === 'opencode' && member.isolation === 'worktree';
});
if (isolatedOpenCodeMembers.length === 0) {
return params.members;
}
if (
isPureOpenCodeProvisioningRequest({
providerId: params.leadProviderId,
members: params.members,
}) &&
params.members.length > 1
) {
throw new Error(
'OpenCode worktree isolation currently supports mixed-team OpenCode side lanes or one-member OpenCode runtime lanes. Multiple OpenCode members in one lane cannot use separate worktrees yet.'
);
}
const nextMembers: TeamCreateRequest['members'] = [];
for (const member of params.members) {
const providerId = normalizeTeamMemberProviderId(member.providerId);
if (providerId !== 'opencode' || member.isolation !== 'worktree') {
nextMembers.push(member);
continue;
}
const existingCwd = member.cwd?.trim();
if (existingCwd) {
if (!path.isAbsolute(existingCwd)) {
throw new Error(
`OpenCode worktree path for "${member.name}" must be absolute: ${existingCwd}`
);
}
const existingCwdStat = await fs.promises.stat(existingCwd).catch(() => null);
if (existingCwdStat) {
if (!existingCwdStat.isDirectory()) {
throw new Error(
`OpenCode worktree path for "${member.name}" is not a directory: ${existingCwd}`
);
}
nextMembers.push({ ...member, cwd: existingCwd });
continue;
}
}
const resolution = await this.memberWorktreeManager.ensureMemberWorktree({
teamName: params.teamName,
memberName: member.name,
baseCwd: params.baseCwd,
});
nextMembers.push({ ...member, cwd: resolution.worktreePath });
}
return nextMembers;
}
private getFreshCachedProbeResult( private getFreshCachedProbeResult(
cwd: string, cwd: string,
providerId: TeamProviderId | undefined providerId: TeamProviderId | undefined
@ -11432,7 +11546,7 @@ export class TeamProvisioningService {
if (envWarning) { if (envWarning) {
throw new Error(envWarning); throw new Error(envWarning);
} }
const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
claudePath, claudePath,
cwd: request.cwd, cwd: request.cwd,
members: request.members, members: request.members,
@ -11445,6 +11559,12 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv, primaryEnv: provisioningEnv,
limitContext: request.limitContext, limitContext: request.limitContext,
}); });
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: request.teamName,
baseCwd: request.cwd,
leadProviderId: request.providerId,
members: materializedMemberSpecs,
});
const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs);
const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
@ -11805,10 +11925,15 @@ export class TeamProvisioningService {
} }
await ensureCwdExists(request.cwd); await ensureCwdExists(request.cwd);
const effectiveMembers = buildEffectiveTeamMemberSpecs(request.members, { const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
providerId: request.providerId, teamName: request.teamName,
model: request.model, baseCwd: request.cwd,
effort: request.effort, leadProviderId: request.providerId,
members: buildEffectiveTeamMemberSpecs(request.members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
}),
}); });
const teamDir = path.join(getTeamsBasePath(), request.teamName); const teamDir = path.join(getTeamsBasePath(), request.teamName);
const tasksDir = path.join(getTasksBasePath(), request.teamName); const tasksDir = path.join(getTasksBasePath(), request.teamName);
@ -11863,10 +11988,15 @@ export class TeamProvisioningService {
configRaw, configRaw,
request.providerId request.providerId
); );
const effectiveMembers = buildEffectiveTeamMemberSpecs(members, { const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
providerId: request.providerId, teamName: request.teamName,
model: request.model, baseCwd: request.cwd,
effort: request.effort, leadProviderId: request.providerId,
members: buildEffectiveTeamMemberSpecs(members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
}),
}); });
await this.updateConfigProjectPath(request.teamName, request.cwd); await this.updateConfigProjectPath(request.teamName, request.cwd);
@ -11957,11 +12087,12 @@ export class TeamProvisioningService {
laneId: 'primary', laneId: 'primary',
state: 'active', state: 'active',
}); });
const launchCwd = this.getOpenCodeRuntimeLaunchCwd(input.request.cwd, input.members);
const launchInput: TeamRuntimeLaunchInput = { const launchInput: TeamRuntimeLaunchInput = {
runId, runId,
laneId: 'primary', laneId: 'primary',
teamName: input.request.teamName, teamName: input.request.teamName,
cwd: input.request.cwd, cwd: launchCwd,
prompt: input.prompt, prompt: input.prompt,
providerId: 'opencode', providerId: 'opencode',
model: input.request.model, model: input.request.model,
@ -11975,7 +12106,7 @@ export class TeamProvisioningService {
providerId: 'opencode', providerId: 'opencode',
model: member.model ?? input.request.model, model: member.model ?? input.request.model,
effort: member.effort ?? input.request.effort, effort: member.effort ?? input.request.effort,
cwd: input.request.cwd, cwd: member.cwd?.trim() || launchCwd,
})), })),
previousLaunchState, previousLaunchState,
}; };
@ -12040,7 +12171,7 @@ export class TeamProvisioningService {
this.runtimeAdapterRunByTeam.set(input.request.teamName, { this.runtimeAdapterRunByTeam.set(input.request.teamName, {
runId, runId,
providerId: 'opencode', providerId: 'opencode',
cwd: input.request.cwd, cwd: launchCwd,
members: result.members, members: result.members,
}); });
this.aliveRunByTeam.set(input.request.teamName, runId); this.aliveRunByTeam.set(input.request.teamName, runId);
@ -12116,6 +12247,7 @@ export class TeamProvisioningService {
providerId: normalizeOptionalTeamProviderId(member.providerId), providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model, model: member.model,
effort: member.effort, effort: member.effort,
cwd: member.cwd?.trim() || undefined,
})), })),
], ],
}; };
@ -12155,6 +12287,7 @@ export class TeamProvisioningService {
providerBackendId: undefined, providerBackendId: undefined,
model: member.model?.trim() || undefined, model: member.model?.trim() || undefined,
effort: member.effort, effort: member.effort,
cwd: member.cwd?.trim() || undefined,
laneId: 'primary', laneId: 'primary',
laneKind: 'primary', laneKind: 'primary',
laneOwnerProviderId: 'opencode', laneOwnerProviderId: 'opencode',
@ -12435,7 +12568,7 @@ export class TeamProvisioningService {
throw new Error(envWarning); throw new Error(envWarning);
} }
const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({ const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
claudePath, claudePath,
cwd: request.cwd, cwd: request.cwd,
members: expectedMemberSpecs, members: expectedMemberSpecs,
@ -12448,6 +12581,12 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv, primaryEnv: provisioningEnv,
limitContext: request.limitContext, limitContext: request.limitContext,
}); });
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: request.teamName,
baseCwd: request.cwd,
leadProviderId: request.providerId,
members: materializedMemberSpecs,
});
const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs); const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs);
const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name)); const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) => const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
@ -13527,6 +13666,74 @@ export class TeamProvisioningService {
}) })
.catch(() => null); .catch(() => null);
if (existingRecord?.status === 'failed_terminal') { if (existingRecord?.status === 'failed_terminal') {
let recoveredRecord: OpenCodePromptDeliveryLedgerRecord | null = null;
let recoveredVisibleReply: OpenCodeVisibleReplyProof | null = null;
if (typeof promptLedger.applyDestinationProof === 'function') {
try {
const proof = await this.applyOpenCodeVisibleDestinationProof({
ledger: promptLedger,
ledgerRecord: existingRecord,
teamName,
replyRecipient: existingRecord.replyRecipient,
memberName: memberIdentity.canonicalMemberName,
});
recoveredRecord = proof.ledgerRecord;
recoveredVisibleReply = proof.visibleReply;
} catch {
recoveredRecord = null;
recoveredVisibleReply = null;
}
}
const recoveredReadAllowed =
recoveredRecord &&
this.isOpenCodeDeliveryResponseReadCommitAllowed({
responseState: recoveredRecord.responseState,
actionMode: recoveredRecord.actionMode ?? undefined,
taskRefs: recoveredRecord.taskRefs,
visibleReply: recoveredVisibleReply,
ledgerRecord: recoveredRecord,
});
if (recoveredRecord && recoveredReadAllowed) {
try {
await this.markInboxMessagesRead(teamName, memberName, [message]);
const committed = await promptLedger.markInboxReadCommitted({
id: recoveredRecord.id,
committedAt: nowIso(),
});
this.logOpenCodePromptDeliveryEvent(
'opencode_prompt_delivery_inbox_committed_read',
committed,
{ recoveredTerminal: true }
);
result.delivered += 1;
result.relayed += 1;
result.lastDelivery = {
delivered: true,
accepted: true,
responsePending: false,
responseState: committed.responseState,
ledgerStatus: committed.status,
ledgerRecordId: committed.id,
laneId: memberIdentity.laneId,
visibleReplyMessageId: committed.visibleReplyMessageId ?? undefined,
visibleReplyCorrelation: committed.visibleReplyCorrelation ?? undefined,
diagnostics: committed.diagnostics,
};
break;
} catch (error) {
const diagnostic = `opencode_inbox_mark_read_failed_after_terminal_recovery: ${getErrorMessage(
error
)}`;
result.failed += 1;
result.lastDelivery = {
delivered: false,
reason: 'opencode_inbox_mark_read_failed_after_terminal_recovery',
diagnostics: [diagnostic],
};
result.diagnostics = [...(result.diagnostics ?? []), diagnostic];
break;
}
}
const diagnostic = const diagnostic =
existingRecord.lastReason ?? existingRecord.lastReason ??
`opencode_prompt_delivery_failed_terminal: ${message.messageId}`; `opencode_prompt_delivery_failed_terminal: ${message.messageId}`;
@ -13547,7 +13754,6 @@ export class TeamProvisioningService {
} }
continue; continue;
} }
const fallbackReplyRecipient = const fallbackReplyRecipient =
typeof message.from === 'string' && typeof message.from === 'string' &&
message.from.trim() && message.from.trim() &&
@ -14770,6 +14976,7 @@ export class TeamProvisioningService {
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
fastMode?: TeamFastMode; fastMode?: TeamFastMode;
cwd?: string;
agentType?: string; agentType?: string;
removedAt?: number | string; removedAt?: number | string;
} | null { } | null {
@ -14819,6 +15026,7 @@ export class TeamProvisioningService {
: undefined; : undefined;
const agentType = const agentType =
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined; metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
const cwd = metaMember?.cwd?.trim() || configuredMember?.cwd?.trim() || undefined;
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt; const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
return { return {
@ -14831,6 +15039,7 @@ export class TeamProvisioningService {
...(model ? { model } : {}), ...(model ? { model } : {}),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}), ...(fastMode ? { fastMode } : {}),
...(cwd ? { cwd } : {}),
...(agentType ? { agentType } : {}), ...(agentType ? { agentType } : {}),
...(removedAt != null ? { removedAt } : {}), ...(removedAt != null ? { removedAt } : {}),
}; };
@ -15023,6 +15232,7 @@ export class TeamProvisioningService {
...(typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim() ...(typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim()
? { runtimeSessionId: member.runtimeSessionId.trim() } ? { runtimeSessionId: member.runtimeSessionId.trim() }
: {}), : {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
...(runtimeModel ? { model: runtimeModel } : {}), ...(runtimeModel ? { model: runtimeModel } : {}),
}); });
} }
@ -15060,6 +15270,7 @@ export class TeamProvisioningService {
...(normalizeOptionalTeamProviderId(member.providerId) ...(normalizeOptionalTeamProviderId(member.providerId)
? { providerId: normalizeOptionalTeamProviderId(member.providerId) } ? { providerId: normalizeOptionalTeamProviderId(member.providerId) }
: {}), : {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false) ...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false)
? { ? {
backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false), backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false),
@ -15089,6 +15300,7 @@ export class TeamProvisioningService {
...(typeof member.agentId === 'string' && member.agentId.trim() ...(typeof member.agentId === 'string' && member.agentId.trim()
? { agentId: member.agentId.trim() } ? { agentId: member.agentId.trim() }
: {}), : {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
}); });
} }
@ -15109,6 +15321,11 @@ export class TeamProvisioningService {
} }
const evidence = lane.result?.members[memberName]; const evidence = lane.result?.members[memberName];
const runtimeModel = lane.member.model?.trim() || undefined; const runtimeModel = lane.member.model?.trim() || undefined;
const laneMemberCwd =
typeof (lane.member as { cwd?: unknown }).cwd === 'string'
? (lane.member as { cwd?: string }).cwd?.trim()
: '';
const laneCwd = laneMemberCwd || run?.request.cwd;
upsertMetadata(memberName, { upsertMetadata(memberName, {
backendType: 'process', backendType: 'process',
providerId: 'opencode', providerId: 'opencode',
@ -15116,6 +15333,7 @@ export class TeamProvisioningService {
livenessKind: evidence?.livenessKind, livenessKind: evidence?.livenessKind,
pidSource: evidence?.pidSource, pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic, runtimeDiagnostic: evidence?.runtimeDiagnostic,
...(laneCwd ? { cwd: laneCwd } : {}),
...(runtimeModel ? { model: runtimeModel } : {}), ...(runtimeModel ? { model: runtimeModel } : {}),
...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0 ...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0
? { metricsPid: evidence.runtimePid } ? { metricsPid: evidence.runtimePid }
@ -15962,13 +16180,14 @@ export class TeamProvisioningService {
lane.runId = lane.runId ?? randomUUID(); lane.runId = lane.runId ?? randomUUID();
lane.warnings = []; lane.warnings = [];
lane.diagnostics = [...migration.diagnostics]; lane.diagnostics = [...migration.diagnostics];
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
this.setSecondaryRuntimeRun({ this.setSecondaryRuntimeRun({
teamName: run.teamName, teamName: run.teamName,
runId: lane.runId, runId: lane.runId,
providerId: 'opencode', providerId: 'opencode',
laneId: lane.laneId, laneId: lane.laneId,
memberName: lane.member.name, memberName: lane.member.name,
cwd: run.request.cwd, cwd: laneCwd,
}); });
await this.publishMixedSecondaryLaneStatusChange(run, lane); await this.publishMixedSecondaryLaneStatusChange(run, lane);
const previousLaunchState = await this.launchStateStore.read(run.teamName); const previousLaunchState = await this.launchStateStore.read(run.teamName);
@ -15978,7 +16197,7 @@ export class TeamProvisioningService {
runId: lane.runId, runId: lane.runId,
laneId: lane.laneId, laneId: lane.laneId,
teamName: run.teamName, teamName: run.teamName,
cwd: run.request.cwd, cwd: laneCwd,
prompt: run.request.prompt?.trim() ?? undefined, prompt: run.request.prompt?.trim() ?? undefined,
providerId: 'opencode', providerId: 'opencode',
model: lane.member.model, model: lane.member.model,
@ -15993,7 +16212,7 @@ export class TeamProvisioningService {
providerId: 'opencode', providerId: 'opencode',
model: lane.member.model, model: lane.member.model,
effort: lane.member.effort, effort: lane.member.effort,
cwd: run.request.cwd, cwd: laneCwd,
}, },
], ],
previousLaunchState, previousLaunchState,
@ -16079,7 +16298,7 @@ export class TeamProvisioningService {
runId: lane.runId, runId: lane.runId,
laneId: lane.laneId, laneId: lane.laneId,
teamName: run.teamName, teamName: run.teamName,
cwd: run.request.cwd, cwd: lane.member.cwd?.trim() || run.request.cwd,
providerId: 'opencode', providerId: 'opencode',
reason, reason,
previousLaunchState, previousLaunchState,
@ -16408,7 +16627,8 @@ export class TeamProvisioningService {
previousLaunchState: PersistedTeamLaunchSnapshot | null; previousLaunchState: PersistedTeamLaunchSnapshot | null;
}): Promise<TeamRuntimeMemberLaunchEvidence | null> { }): Promise<TeamRuntimeMemberLaunchEvidence | null> {
const adapter = this.getOpenCodeRuntimeAdapter(); const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter || !params.projectPath) { const runtimeProjectPath = params.member.cwd?.trim() || params.projectPath;
if (!adapter || !runtimeProjectPath) {
return null; return null;
} }
@ -16427,7 +16647,7 @@ export class TeamProvisioningService {
providerId: 'opencode', providerId: 'opencode',
model: params.member.model, model: params.member.model,
effort: params.member.effort, effort: params.member.effort,
cwd: params.projectPath, cwd: runtimeProjectPath,
}, },
], ],
previousLaunchState: params.previousLaunchState, previousLaunchState: params.previousLaunchState,
@ -21800,15 +22020,17 @@ export class TeamProvisioningService {
const model = const model =
typeof member.model === 'string' ? member.model.trim() || undefined : undefined; typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined; const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined;
const prev = byName.get(name); const prev = byName.get(name);
if (!prev) { if (!prev) {
byName.set(name, { name, role, workflow, isolation, providerId, model, effort }); byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
} else { } else {
byName.set(name, { byName.set(name, {
...prev, ...prev,
role: prev.role || role, role: prev.role || role,
workflow: prev.workflow || workflow, workflow: prev.workflow || workflow,
isolation: prev.isolation || isolation, isolation: prev.isolation || isolation,
cwd: prev.cwd || cwd,
providerId: prev.providerId || providerId, providerId: prev.providerId || providerId,
model: prev.model || model, model: prev.model || model,
effort: prev.effort || effort, effort: prev.effort || effort,
@ -21870,6 +22092,7 @@ export class TeamProvisioningService {
role: configMember?.role, role: configMember?.role,
workflow: configMember?.workflow, workflow: configMember?.workflow,
isolation: configMember?.isolation, isolation: configMember?.isolation,
cwd: configMember?.cwd,
providerId: configMember?.providerId, providerId: configMember?.providerId,
model: configMember?.model, model: configMember?.model,
effort: configMember?.effort, effort: configMember?.effort,
@ -21978,6 +22201,7 @@ export class TeamProvisioningService {
provider?: string; provider?: string;
model?: string; model?: string;
effort?: string; effort?: string;
cwd?: string;
}[]; }[];
}; };
if (!Array.isArray(parsed.members)) { if (!Array.isArray(parsed.members)) {
@ -21996,6 +22220,7 @@ export class TeamProvisioningService {
workflow: workflow:
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined, typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined, isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
cwd: typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined,
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider), providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined, model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,

View file

@ -124,6 +124,7 @@ export interface OpenCodeCleanupHostsCommandBody {
projectPath?: string; projectPath?: string;
staleAgeMs?: number | null; staleAgeMs?: number | null;
leaseStaleAgeMs?: number | null; leaseStaleAgeMs?: number | null;
preflightLeaseStaleAgeMs?: number | null;
} }
export interface OpenCodeCleanupHostsCommandData { export interface OpenCodeCleanupHostsCommandData {

View file

@ -120,7 +120,10 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
} }
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> { async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers); const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(
input.expectedMembers,
input.cwd
);
if (memberValidationDiagnostics.length > 0) { if (memberValidationDiagnostics.length > 0) {
return blockedLaunchResult( return blockedLaunchResult(
input, input,
@ -663,13 +666,14 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
} }
function validateOpenCodeRuntimeMembers( function validateOpenCodeRuntimeMembers(
members: TeamRuntimeLaunchInput['expectedMembers'] members: TeamRuntimeLaunchInput['expectedMembers'],
launchCwd?: string
): string[] { ): string[] {
if (members.length === 0) { if (members.length === 0) {
return ['OpenCode runtime adapter requires at least one expected OpenCode member.']; return ['OpenCode runtime adapter requires at least one expected OpenCode member.'];
} }
return members.flatMap((member, index) => { const diagnostics = members.flatMap((member, index) => {
const name = member.name.trim() || `<index ${index}>`; const name = member.name.trim() || `<index ${index}>`;
if (member.providerId === 'opencode') { if (member.providerId === 'opencode') {
return []; return [];
@ -678,6 +682,21 @@ function validateOpenCodeRuntimeMembers(
`OpenCode runtime adapter received non-OpenCode member "${name}" with provider "${member.providerId}".`, `OpenCode runtime adapter received non-OpenCode member "${name}" with provider "${member.providerId}".`,
]; ];
}); });
const memberCwds = [
...new Set(members.map((member) => member.cwd.trim()).filter((cwd) => cwd.length > 0)),
];
if (memberCwds.length > 1) {
diagnostics.push(
'OpenCode runtime adapter currently supports one project path per lane. Launch isolated OpenCode teammates as separate side lanes.'
);
}
const onlyMemberCwd = memberCwds.length === 1 ? memberCwds[0] : null;
if (launchCwd?.trim() && onlyMemberCwd && onlyMemberCwd !== launchCwd.trim()) {
diagnostics.push(
`OpenCode runtime lane cwd mismatch: launch cwd "${launchCwd.trim()}" differs from member cwd "${onlyMemberCwd}".`
);
}
return diagnostics;
} }
function formatOpenCodeBridgeDiagnostic(diagnostic: { function formatOpenCodeBridgeDiagnostic(diagnostic: {

View file

@ -180,6 +180,12 @@ export const MemberCard = ({
const { summary: runtimeSummaryText, memory: memoryLabel } = const { summary: runtimeSummaryText, memory: memoryLabel } =
splitRuntimeSummaryMemory(runtimeSummary); splitRuntimeSummaryMemory(runtimeSummary);
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry); const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
const isLead = isLeadMember(member);
const workspacePath = member.cwd?.trim();
const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree';
const workspaceBadgeTitle = workspacePath
? `Worktree isolation configured. Worktree path: ${workspacePath}`
: 'Worktree isolation is configured, but the runtime path is not available yet';
const activityTask = currentTask ?? reviewTask ?? null; const activityTask = currentTask ?? reviewTask ?? null;
const activityTitle = currentTask const activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}` ? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -345,6 +351,14 @@ export const MemberCard = ({
{member.gitBranch} {member.gitBranch}
</span> </span>
) : null} ) : null}
{showWorkspaceBadge ? (
<span
className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300"
title={workspaceBadgeTitle}
>
worktree
</span>
) : null}
{currentTask ? ( {currentTask ? (
<CurrentTaskIndicator <CurrentTaskIndicator
task={currentTask} task={currentTask}

View file

@ -11,17 +11,25 @@ import type { JSX } from 'react';
interface OpenCodeDeliveryWarningProps { interface OpenCodeDeliveryWarningProps {
warning: string | null; warning: string | null;
debugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null; debugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
pendingDelayMs?: number;
} }
export function OpenCodeDeliveryWarning({ export function OpenCodeDeliveryWarning({
warning, warning,
debugDetails, debugDetails,
pendingDelayMs = 10_000,
}: OpenCodeDeliveryWarningProps): JSX.Element | null { }: OpenCodeDeliveryWarningProps): JSX.Element | null {
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`; const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`;
const delayPendingWarning =
debugDetails?.responsePending === true && debugDetails.delivered !== false;
const [expandedKey, setExpandedKey] = useState<string | null>(null); const [expandedKey, setExpandedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copiedKey, setCopiedKey] = useState<string | null>(null);
const [pendingVisibleKey, setPendingVisibleKey] = useState<string | null>(() =>
delayPendingWarning ? null : detailsKey
);
const mountedRef = useRef(true); const mountedRef = useRef(true);
const copiedResetTimerRef = useRef<number | null>(null); const copiedResetTimerRef = useRef<number | null>(null);
const pendingTimerRef = useRef<number | null>(null);
const expanded = expandedKey === detailsKey; const expanded = expandedKey === detailsKey;
const copied = copiedKey === detailsKey; const copied = copiedKey === detailsKey;
const copyText = useMemo( const copyText = useMemo(
@ -36,10 +44,36 @@ export function OpenCodeDeliveryWarning({
if (copiedResetTimerRef.current !== null) { if (copiedResetTimerRef.current !== null) {
window.clearTimeout(copiedResetTimerRef.current); window.clearTimeout(copiedResetTimerRef.current);
} }
if (pendingTimerRef.current !== null) {
window.clearTimeout(pendingTimerRef.current);
}
}; };
}, []); }, []);
useEffect(() => {
if (pendingTimerRef.current !== null) {
window.clearTimeout(pendingTimerRef.current);
pendingTimerRef.current = null;
}
if (!warning) {
setPendingVisibleKey(null);
return;
}
if (!delayPendingWarning || pendingDelayMs <= 0) {
setPendingVisibleKey(detailsKey);
return;
}
setPendingVisibleKey(null);
pendingTimerRef.current = window.setTimeout(() => {
pendingTimerRef.current = null;
if (mountedRef.current) {
setPendingVisibleKey(detailsKey);
}
}, pendingDelayMs);
}, [delayPendingWarning, detailsKey, pendingDelayMs, warning]);
if (!warning) return null; if (!warning) return null;
if (delayPendingWarning && pendingVisibleKey !== detailsKey) return null;
const handleCopy = async (): Promise<void> => { const handleCopy = async (): Promise<void> => {
if (!copyText || !navigator.clipboard?.writeText) return; if (!copyText || !navigator.clipboard?.writeText) return;

View file

@ -981,6 +981,7 @@ export interface PersistedTeamLaunchMemberState {
providerBackendId?: TeamProviderBackendId; providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;
effort?: EffortLevel; effort?: EffortLevel;
cwd?: string;
selectedFastMode?: TeamFastMode; selectedFastMode?: TeamFastMode;
resolvedFastMode?: boolean; resolvedFastMode?: boolean;
laneId?: string; laneId?: string;
@ -1085,6 +1086,8 @@ export interface TeamAgentRuntimeEntry {
laneKind?: 'primary' | 'secondary'; laneKind?: 'primary' | 'secondary';
pid?: number; pid?: number;
runtimeModel?: string; runtimeModel?: string;
/** Runtime working directory, when known. */
cwd?: string;
rssBytes?: number; rssBytes?: number;
livenessKind?: TeamAgentRuntimeLivenessKind; livenessKind?: TeamAgentRuntimeLivenessKind;
pidSource?: TeamAgentRuntimePidSource; pidSource?: TeamAgentRuntimePidSource;
@ -1221,6 +1224,8 @@ export interface TeamProvisioningMemberInput {
workflow?: string; workflow?: string;
/** Opt-in: run this teammate in its own git worktree. */ /** Opt-in: run this teammate in its own git worktree. */
isolation?: 'worktree'; isolation?: 'worktree';
/** Resolved runtime working directory. Usually app-managed for isolated teammates. */
cwd?: string;
providerId?: TeamProviderId; providerId?: TeamProviderId;
providerBackendId?: TeamProviderBackendId; providerBackendId?: TeamProviderBackendId;
model?: string; model?: string;

View file

@ -558,6 +558,7 @@ async function runModelGauntlet(input: {
minimumAverageScore: input.minimumAverageScore, minimumAverageScore: input.minimumAverageScore,
minimumSuccessfulRuns: input.minimumSuccessfulRuns, minimumSuccessfulRuns: input.minimumSuccessfulRuns,
minimumConsistencyScore: input.minimumConsistencyScore, minimumConsistencyScore: input.minimumConsistencyScore,
consistencyScore: scoreStability.consistencyScore,
hardFailures, hardFailures,
providerInfraFailures, providerInfraFailures,
runtimeTransportFailures, runtimeTransportFailures,
@ -952,9 +953,10 @@ async function runGauntletOnce(input: {
diagnostics, diagnostics,
}; };
} finally { } finally {
if (harness) { const activeHarness = harness as Awaited<ReturnType<typeof createOpenCodeLiveHarness>> | null;
await harness.svc.stopTeam(teamName).catch(() => undefined); if (activeHarness) {
await harness.dispose().catch(() => undefined); await activeHarness.svc.stopTeam(teamName).catch(() => undefined);
await activeHarness.dispose().catch(() => undefined);
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined); await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
} }
setClaudeBasePathOverride(null); setClaudeBasePathOverride(null);

View file

@ -0,0 +1,109 @@
import { execFile } from 'child_process';
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const hoisted = vi.hoisted(() => ({
claudeRoot: '',
}));
vi.mock('@main/utils/pathDecoder', () => ({
getClaudeBasePath: () => hoisted.claudeRoot,
}));
import { TeamMemberWorktreeManager } from '../../../../src/main/services/team/TeamMemberWorktreeManager';
function execGit(args: string[], cwd: string): Promise<string> {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(new Error(String(stderr || error.message).trim()));
return;
}
resolve(String(stdout).trim());
});
});
}
function slugify(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 48) || 'member'
);
}
function shortHash(value: string): string {
return createHash('sha256').update(value).digest('hex').slice(0, 10);
}
async function createGitRepo(root: string): Promise<string> {
const repoPath = path.join(root, 'repo');
await fs.mkdir(repoPath, { recursive: true });
await execGit(['init'], repoPath);
await fs.writeFile(path.join(repoPath, 'README.md'), 'test repo\n', 'utf8');
await execGit(['add', 'README.md'], repoPath);
await execGit(['-c', 'user.email=test@example.com', '-c', 'user.name=Test', 'commit', '-m', 'init'], repoPath);
return await fs.realpath(repoPath);
}
describe('TeamMemberWorktreeManager', () => {
let tempRoot = '';
beforeEach(async () => {
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-member-worktree-'));
hoisted.claudeRoot = path.join(tempRoot, 'claude');
await fs.mkdir(hoisted.claudeRoot, { recursive: true });
});
afterEach(async () => {
await fs.rm(tempRoot, { recursive: true, force: true });
});
it('creates deterministic member worktrees on agent-teams branches', async () => {
const repoPath = await createGitRepo(tempRoot);
const manager = new TeamMemberWorktreeManager();
const resolution = await manager.ensureMemberWorktree({
teamName: 'Atlas HQ',
memberName: 'Bob',
baseCwd: repoPath,
});
expect(resolution.baseRepoPath).toBe(repoPath);
expect(resolution.branchName).toBe(`agent-teams/atlas-hq/bob-${shortHash(repoPath)}`);
expect(resolution.worktreePath).toBe(
path.join(hoisted.claudeRoot, 'team-worktrees', shortHash(repoPath), 'atlas-hq', 'bob')
);
await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe(
resolution.branchName
);
});
it('rejects an existing deterministic path checked out on the wrong branch', async () => {
const repoPath = await createGitRepo(tempRoot);
const wrongPath = path.join(
hoisted.claudeRoot,
'team-worktrees',
shortHash(repoPath),
slugify('Atlas HQ'),
slugify('Bob')
);
await fs.mkdir(path.dirname(wrongPath), { recursive: true });
await execGit(['worktree', 'add', '-b', 'some-other-branch', wrongPath, 'HEAD'], repoPath);
await expect(
new TeamMemberWorktreeManager().ensureMemberWorktree({
teamName: 'Atlas HQ',
memberName: 'Bob',
baseCwd: repoPath,
})
).rejects.toThrow('expected "agent-teams/atlas-hq/bob-');
});
});

View file

@ -3071,6 +3071,76 @@ describe('TeamProvisioningService', () => {
}); });
}); });
it('delivers OpenCode secondary-lane messages to the member worktree cwd after restart', 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',
diagnostics: [],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
])
);
(svc as any).getTrackedRunId = vi.fn(() => null);
(svc as any).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob');
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
(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',
isolation: 'worktree',
cwd: '/repo/.agent-team-worktrees/bob',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'hello bob',
messageId: 'msg-1',
})
).resolves.toMatchObject({ delivered: true });
expect(sendMessageToMember).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'opencode-run-bob',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo/.agent-team-worktrees/bob',
})
);
});
it('observes accepted OpenCode prompt delivery before sending the same inbox row again', async () => { it('observes accepted OpenCode prompt delivery before sending the same inbox row again', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({ const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
@ -3960,6 +4030,37 @@ describe('TeamProvisioningService', () => {
ledgerStatus: 'failed_terminal', ledgerStatus: 'failed_terminal',
reason: 'empty_assistant_turn', reason: 'empty_assistant_turn',
}); });
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
await fsPromises.mkdir(inboxDir, { recursive: true });
await fsPromises.writeFile(
path.join(inboxDir, 'user.json'),
`${JSON.stringify(
[
{
from: 'bob',
to: 'user',
text: 'Late but valid answer.',
timestamp: '2026-04-25T10:00:04.000Z',
read: false,
messageId: 'reply-after-terminal',
relayOfMessageId: 'msg-max-attempts',
source: 'runtime_delivery',
},
],
null,
2
)}\n`,
'utf8'
);
await expect(deliver()).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_visible_message',
ledgerStatus: 'responded',
visibleReplyMessageId: 'reply-after-terminal',
visibleReplyCorrelation: 'relayOfMessageId',
});
expect(sendMessageToMember).toHaveBeenCalledTimes(3); expect(sendMessageToMember).toHaveBeenCalledTimes(3);
expect(observeMessageDelivery).toHaveBeenCalledTimes(2); expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
}); });
@ -6485,7 +6586,9 @@ describe('TeamProvisioningService', () => {
}); });
describe('safe app launch matrix', () => { describe('safe app launch matrix', () => {
function createSafeLaunchService() { function createSafeLaunchService(options?: {
memberWorktreeManager?: { ensureMemberWorktree: ReturnType<typeof vi.fn> };
}) {
const mcpConfigBuilder = { const mcpConfigBuilder = {
writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')), writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')),
removeConfigFile: vi.fn(async () => {}), removeConfigFile: vi.fn(async () => {}),
@ -6506,7 +6609,10 @@ describe('TeamProvisioningService', () => {
membersMetaStore as any, membersMetaStore as any,
undefined, undefined,
mcpConfigBuilder as any, mcpConfigBuilder as any,
teamMetaStore as any teamMetaStore as any,
undefined,
undefined,
options?.memberWorktreeManager as any
); );
(svc as any).buildProvisioningEnv = vi.fn(async () => ({ (svc as any).buildProvisioningEnv = vi.fn(async () => ({
@ -6974,6 +7080,167 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId); await svc.cancelProvisioning(runId);
}); });
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {
allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
const bobWorktree = path.join(tempClaudeRoot, 'worktrees', 'bob');
const worktreeManager = {
ensureMemberWorktree: vi.fn(async () => ({
baseRepoPath: tempClaudeRoot,
worktreePath: bobWorktree,
branchName: 'agent-teams/test/bob',
})),
};
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 { svc, membersMetaStore } = createSafeLaunchService({ memberWorktreeManager: worktreeManager });
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
const { runId } = await svc.createTeam(
{
teamName: 'safe-mixed-opencode-worktree-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',
},
{
name: 'bob',
role: 'Developer',
providerId: 'opencode',
model: 'minimax-m2.5-free',
isolation: 'worktree',
},
],
},
() => {}
);
expect(worktreeManager.ensureMemberWorktree).toHaveBeenCalledWith({
teamName: 'safe-mixed-opencode-worktree-launch',
memberName: 'bob',
baseCwd: tempClaudeRoot,
});
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
'safe-mixed-opencode-worktree-launch',
expect.arrayContaining([
expect.objectContaining({
name: 'bob',
providerId: 'opencode',
isolation: 'worktree',
cwd: bobWorktree,
}),
]),
expect.objectContaining({ providerBackendId: 'codex-native' })
);
const run = (svc as any).runs.get(runId);
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(1));
expect(adapterLaunch).toHaveBeenCalledWith(
expect.objectContaining({
laneId: 'secondary:opencode:bob',
cwd: bobWorktree,
expectedMembers: [
expect.objectContaining({
name: 'bob',
providerId: 'opencode',
isolation: 'worktree',
cwd: bobWorktree,
}),
],
})
);
await svc.cancelProvisioning(runId);
});
it('rejects multi-member pure OpenCode worktree isolation instead of sharing one projectPath', async () => {
allowConsoleLogs();
const adapterLaunch = vi.fn();
const { svc } = createSafeLaunchService();
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: adapterLaunch,
reconcile: vi.fn(),
stop: vi.fn(),
} as any,
])
);
await expect(
svc.createTeam(
{
teamName: 'blocked-opencode-multi-worktree',
cwd: tempClaudeRoot,
providerId: 'opencode',
providerBackendId: 'adapter',
model: 'big-pickle',
members: [
{
name: 'bob',
providerId: 'opencode',
model: 'minimax-m2.5-free',
isolation: 'worktree',
},
{
name: 'tom',
providerId: 'opencode',
model: 'nemotron-3-super-free',
},
],
},
() => {}
)
).rejects.toThrow('Multiple OpenCode members in one lane cannot use separate worktrees yet');
expect(adapterLaunch).not.toHaveBeenCalled();
});
}); });
it('removes generated MCP config when launchTeam spawn fails synchronously', async () => { it('removes generated MCP config when launchTeam spawn fails synchronously', async () => {

View file

@ -495,6 +495,98 @@ describe('MemberCard starting-state visuals', () => {
}); });
}); });
it('shows a worktree badge only for teammates configured with worktree isolation', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
isolation: 'worktree',
cwd: '/tmp/project-alice-worktree',
},
memberColor: 'blue',
runtimeSummary: 'kimi · via OpenCode',
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('worktree');
expect(
host.querySelector(
'[title="Worktree isolation configured. Worktree path: /tmp/project-alice-worktree"]'
)
).not.toBeNull();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
isolation: 'worktree',
},
memberColor: 'blue',
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'opencode',
cwd: '/tmp/project',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeSummary: 'kimi · via OpenCode',
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('worktree');
expect(
host.querySelector(
'[title="Worktree isolation is configured, but the runtime path is not available yet"]'
)
).not.toBeNull();
expect(host.querySelector('[title="Worktree isolation configured. Runtime cwd: /tmp/project"]'))
.toBeNull();
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
cwd: '/tmp/project',
},
memberColor: 'blue',
runtimeSummary: 'kimi · via OpenCode',
isTeamAlive: true,
isTeamProvisioning: false,
})
);
await Promise.resolve();
});
expect(host.textContent).not.toContain('worktree');
expect(host.textContent).not.toContain('shared');
expect(host.querySelector('[title^="Shared workspace"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('copies bounded launch diagnostics only for launch errors', async () => { it('copies bounded launch diagnostics only for launch errors', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const writeText = vi.fn().mockResolvedValue(undefined); const writeText = vi.fn().mockResolvedValue(undefined);

View file

@ -28,7 +28,12 @@ function renderWarning(props: Partial<React.ComponentProps<typeof OpenCodeDelive
act(() => { act(() => {
root.render( root.render(
<OpenCodeDeliveryWarning warning={warning} debugDetails={debugDetails} {...props} /> <OpenCodeDeliveryWarning
warning={warning}
debugDetails={debugDetails}
pendingDelayMs={0}
{...props}
/>
); );
}); });
@ -47,6 +52,7 @@ function findButton(host: HTMLElement, text: string): HTMLButtonElement {
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
vi.useRealTimers();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -115,6 +121,58 @@ describe('OpenCodeDeliveryWarning', () => {
}); });
}); });
it('delays pending runtime delivery warnings by default', async () => {
vi.useFakeTimers();
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
act(() => {
root.render(<OpenCodeDeliveryWarning warning={warning} debugDetails={debugDetails} />);
});
expect(host.textContent).not.toContain(warning);
act(() => {
vi.advanceTimersByTime(9_999);
});
expect(host.textContent).not.toContain(warning);
act(() => {
vi.advanceTimersByTime(1);
});
expect(host.textContent).toContain(warning);
await act(async () => {
root.unmount();
});
});
it('shows failed runtime delivery warnings immediately', async () => {
const failedWarning =
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
const { host, root } = renderWarning({
warning: failedWarning,
debugDetails: {
...debugDetails,
delivered: false,
responsePending: false,
responseState: 'failed',
ledgerStatus: 'failed_terminal',
reason: 'tool_error',
diagnostics: ['tool_error'],
},
});
expect(host.textContent).toContain(failedWarning);
await act(async () => {
root.unmount();
});
});
it('hides details again when a different runtime delivery payload arrives', async () => { it('hides details again when a different runtime delivery payload arrives', async () => {
const { host, root } = renderWarning(); const { host, root } = renderWarning();
@ -127,6 +185,7 @@ describe('OpenCodeDeliveryWarning', () => {
root.render( root.render(
<OpenCodeDeliveryWarning <OpenCodeDeliveryWarning
warning={warning} warning={warning}
pendingDelayMs={0}
debugDetails={{ debugDetails={{
...debugDetails, ...debugDetails,
messageId: 'm-opencode-2', messageId: 'm-opencode-2',