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),
model: params.member.model?.trim() || undefined,
effort: params.member.effort,
cwd: params.member.cwd?.trim() || undefined,
selectedFastMode:
normalizeFastMode(params.member.fastMode) ??
(providerId === params.leadDefaults.providerId
@ -231,6 +232,7 @@ function createSecondaryLaneMemberState(
: undefined),
model: params.member.model?.trim() || undefined,
effort: params.member.effort,
cwd: params.member.cwd?.trim() || undefined,
selectedFastMode:
normalizeFastMode(params.member.fastMode) ??
(providerId === params.leadDefaults.providerId

View file

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

View file

@ -296,6 +296,7 @@ async function cleanupOpenCodeHostsForLifecycle(reason: 'startup' | 'shutdown'):
mode: reason === 'shutdown' ? 'force' : 'stale',
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
preflightLeaseStaleAgeMs: reason === 'startup' ? 2 * 60_000 : null,
});
if (result.cleaned > 0) {
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 { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import { TeamMetaStore } from './TeamMetaStore';
import {
@ -254,6 +255,7 @@ interface PersistedRuntimeMemberLike {
tmuxPaneId?: string;
backendType?: string;
providerId?: string;
cwd?: string;
runtimePid?: number;
runtimeSessionId?: string;
}
@ -1515,6 +1517,7 @@ interface LiveTeamAgentRuntimeMetadata {
backendType?: TeamAgentRuntimeBackendType;
providerId?: TeamProviderId;
agentId?: string;
cwd?: string;
pid?: number;
metricsPid?: number;
model?: string;
@ -4000,7 +4003,8 @@ export class TeamProvisioningService {
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
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.configReader,
@ -5251,11 +5255,15 @@ export class TeamProvisioningService {
) {
return { delivered: false, reason: 'opencode_runtime_not_active' };
}
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
const cwd =
config?.projectPath?.trim() ||
metaMember?.cwd?.trim() ||
configMember?.cwd?.trim() ||
this.readPersistedTeamProjectPath(teamName);
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
? memberRuntimeCwd ||
config?.projectPath?.trim() ||
this.readPersistedTeamProjectPath(teamName)
: config?.projectPath?.trim() ||
memberRuntimeCwd ||
this.readPersistedTeamProjectPath(teamName);
if (!cwd) {
return { delivered: false, reason: 'opencode_project_path_unavailable' };
}
@ -5419,23 +5427,6 @@ export class TeamProvisioningService {
}
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({
ledger,
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);
if (ledgerRecord.status !== 'pending' && !attemptDue) {
const nextAttemptMs = ledgerRecord.nextAttemptAt
@ -6191,6 +6200,7 @@ export class TeamProvisioningService {
...(configuredMember.role ? { role: configuredMember.role } : {}),
...(configuredMember.workflow ? { workflow: configuredMember.workflow } : {}),
...(configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
...(configuredMember.cwd ? { cwd: configuredMember.cwd } : {}),
...(configuredMember.providerId ? { providerId: configuredMember.providerId } : {}),
...(configuredMember.providerBackendId
? { providerBackendId: configuredMember.providerBackendId }
@ -6208,6 +6218,7 @@ export class TeamProvisioningService {
role: member.role?.trim() || undefined,
workflow: member.workflow?.trim() || undefined,
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
cwd: member.cwd?.trim() || undefined,
providerId: normalizeOptionalTeamProviderId(member.providerId),
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
model: member.model?.trim() || undefined,
@ -8591,6 +8602,7 @@ export class TeamProvisioningService {
const updatedAt = nowIso();
const run = runId ? (this.runs.get(runId) ?? null) : null;
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
let configuredMembers: TeamConfig['members'] = [];
@ -8718,6 +8730,10 @@ export class TeamProvisioningService {
inferTeamProviderIdFromModel(launchMember?.model) ??
inferTeamProviderIdFromModel(member.model);
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 isSharedOpenCodeHost =
isOpenCodeMember &&
@ -8760,6 +8776,7 @@ export class TeamProvisioningService {
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
...(displayPid ? { pid: displayPid } : {}),
...(runtimeModel ? { runtimeModel } : {}),
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
...(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 existingLaneIndex = run.mixedSecondaryLanes.findIndex(
(lane) => lane.laneId === nextLane.laneId || lane.member.name.trim() === memberName
@ -10537,6 +10562,95 @@ export class TeamProvisioningService {
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(
cwd: string,
providerId: TeamProviderId | undefined
@ -11432,7 +11546,7 @@ export class TeamProvisioningService {
if (envWarning) {
throw new Error(envWarning);
}
const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
claudePath,
cwd: request.cwd,
members: request.members,
@ -11445,6 +11559,12 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv,
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 primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
@ -11805,10 +11925,15 @@ export class TeamProvisioningService {
}
await ensureCwdExists(request.cwd);
const effectiveMembers = buildEffectiveTeamMemberSpecs(request.members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: request.teamName,
baseCwd: request.cwd,
leadProviderId: request.providerId,
members: buildEffectiveTeamMemberSpecs(request.members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
}),
});
const teamDir = path.join(getTeamsBasePath(), request.teamName);
const tasksDir = path.join(getTasksBasePath(), request.teamName);
@ -11863,10 +11988,15 @@ export class TeamProvisioningService {
configRaw,
request.providerId
);
const effectiveMembers = buildEffectiveTeamMemberSpecs(members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
teamName: request.teamName,
baseCwd: request.cwd,
leadProviderId: request.providerId,
members: buildEffectiveTeamMemberSpecs(members, {
providerId: request.providerId,
model: request.model,
effort: request.effort,
}),
});
await this.updateConfigProjectPath(request.teamName, request.cwd);
@ -11957,11 +12087,12 @@ export class TeamProvisioningService {
laneId: 'primary',
state: 'active',
});
const launchCwd = this.getOpenCodeRuntimeLaunchCwd(input.request.cwd, input.members);
const launchInput: TeamRuntimeLaunchInput = {
runId,
laneId: 'primary',
teamName: input.request.teamName,
cwd: input.request.cwd,
cwd: launchCwd,
prompt: input.prompt,
providerId: 'opencode',
model: input.request.model,
@ -11975,7 +12106,7 @@ export class TeamProvisioningService {
providerId: 'opencode',
model: member.model ?? input.request.model,
effort: member.effort ?? input.request.effort,
cwd: input.request.cwd,
cwd: member.cwd?.trim() || launchCwd,
})),
previousLaunchState,
};
@ -12040,7 +12171,7 @@ export class TeamProvisioningService {
this.runtimeAdapterRunByTeam.set(input.request.teamName, {
runId,
providerId: 'opencode',
cwd: input.request.cwd,
cwd: launchCwd,
members: result.members,
});
this.aliveRunByTeam.set(input.request.teamName, runId);
@ -12116,6 +12247,7 @@ export class TeamProvisioningService {
providerId: normalizeOptionalTeamProviderId(member.providerId),
model: member.model,
effort: member.effort,
cwd: member.cwd?.trim() || undefined,
})),
],
};
@ -12155,6 +12287,7 @@ export class TeamProvisioningService {
providerBackendId: undefined,
model: member.model?.trim() || undefined,
effort: member.effort,
cwd: member.cwd?.trim() || undefined,
laneId: 'primary',
laneKind: 'primary',
laneOwnerProviderId: 'opencode',
@ -12435,7 +12568,7 @@ export class TeamProvisioningService {
throw new Error(envWarning);
}
const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
claudePath,
cwd: request.cwd,
members: expectedMemberSpecs,
@ -12448,6 +12581,12 @@ export class TeamProvisioningService {
primaryEnv: provisioningEnv,
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 primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
@ -13527,6 +13666,74 @@ export class TeamProvisioningService {
})
.catch(() => null);
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 =
existingRecord.lastReason ??
`opencode_prompt_delivery_failed_terminal: ${message.messageId}`;
@ -13547,7 +13754,6 @@ export class TeamProvisioningService {
}
continue;
}
const fallbackReplyRecipient =
typeof message.from === 'string' &&
message.from.trim() &&
@ -14770,6 +14976,7 @@ export class TeamProvisioningService {
model?: string;
effort?: EffortLevel;
fastMode?: TeamFastMode;
cwd?: string;
agentType?: string;
removedAt?: number | string;
} | null {
@ -14819,6 +15026,7 @@ export class TeamProvisioningService {
: undefined;
const agentType =
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
const cwd = metaMember?.cwd?.trim() || configuredMember?.cwd?.trim() || undefined;
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
return {
@ -14831,6 +15039,7 @@ export class TeamProvisioningService {
...(model ? { model } : {}),
...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}),
...(cwd ? { cwd } : {}),
...(agentType ? { agentType } : {}),
...(removedAt != null ? { removedAt } : {}),
};
@ -15023,6 +15232,7 @@ export class TeamProvisioningService {
...(typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim()
? { runtimeSessionId: member.runtimeSessionId.trim() }
: {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
...(runtimeModel ? { model: runtimeModel } : {}),
});
}
@ -15060,6 +15270,7 @@ export class TeamProvisioningService {
...(normalizeOptionalTeamProviderId(member.providerId)
? { providerId: normalizeOptionalTeamProviderId(member.providerId) }
: {}),
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false)
? {
backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false),
@ -15089,6 +15300,7 @@ export class TeamProvisioningService {
...(typeof member.agentId === 'string' && 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 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, {
backendType: 'process',
providerId: 'opencode',
@ -15116,6 +15333,7 @@ export class TeamProvisioningService {
livenessKind: evidence?.livenessKind,
pidSource: evidence?.pidSource,
runtimeDiagnostic: evidence?.runtimeDiagnostic,
...(laneCwd ? { cwd: laneCwd } : {}),
...(runtimeModel ? { model: runtimeModel } : {}),
...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0
? { metricsPid: evidence.runtimePid }
@ -15962,13 +16180,14 @@ export class TeamProvisioningService {
lane.runId = lane.runId ?? randomUUID();
lane.warnings = [];
lane.diagnostics = [...migration.diagnostics];
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
this.setSecondaryRuntimeRun({
teamName: run.teamName,
runId: lane.runId,
providerId: 'opencode',
laneId: lane.laneId,
memberName: lane.member.name,
cwd: run.request.cwd,
cwd: laneCwd,
});
await this.publishMixedSecondaryLaneStatusChange(run, lane);
const previousLaunchState = await this.launchStateStore.read(run.teamName);
@ -15978,7 +16197,7 @@ export class TeamProvisioningService {
runId: lane.runId,
laneId: lane.laneId,
teamName: run.teamName,
cwd: run.request.cwd,
cwd: laneCwd,
prompt: run.request.prompt?.trim() ?? undefined,
providerId: 'opencode',
model: lane.member.model,
@ -15993,7 +16212,7 @@ export class TeamProvisioningService {
providerId: 'opencode',
model: lane.member.model,
effort: lane.member.effort,
cwd: run.request.cwd,
cwd: laneCwd,
},
],
previousLaunchState,
@ -16079,7 +16298,7 @@ export class TeamProvisioningService {
runId: lane.runId,
laneId: lane.laneId,
teamName: run.teamName,
cwd: run.request.cwd,
cwd: lane.member.cwd?.trim() || run.request.cwd,
providerId: 'opencode',
reason,
previousLaunchState,
@ -16408,7 +16627,8 @@ export class TeamProvisioningService {
previousLaunchState: PersistedTeamLaunchSnapshot | null;
}): Promise<TeamRuntimeMemberLaunchEvidence | null> {
const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter || !params.projectPath) {
const runtimeProjectPath = params.member.cwd?.trim() || params.projectPath;
if (!adapter || !runtimeProjectPath) {
return null;
}
@ -16427,7 +16647,7 @@ export class TeamProvisioningService {
providerId: 'opencode',
model: params.member.model,
effort: params.member.effort,
cwd: params.projectPath,
cwd: runtimeProjectPath,
},
],
previousLaunchState: params.previousLaunchState,
@ -21800,15 +22020,17 @@ export class TeamProvisioningService {
const model =
typeof member.model === 'string' ? member.model.trim() || undefined : 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);
if (!prev) {
byName.set(name, { name, role, workflow, isolation, providerId, model, effort });
byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
} else {
byName.set(name, {
...prev,
role: prev.role || role,
workflow: prev.workflow || workflow,
isolation: prev.isolation || isolation,
cwd: prev.cwd || cwd,
providerId: prev.providerId || providerId,
model: prev.model || model,
effort: prev.effort || effort,
@ -21870,6 +22092,7 @@ export class TeamProvisioningService {
role: configMember?.role,
workflow: configMember?.workflow,
isolation: configMember?.isolation,
cwd: configMember?.cwd,
providerId: configMember?.providerId,
model: configMember?.model,
effort: configMember?.effort,
@ -21978,6 +22201,7 @@ export class TeamProvisioningService {
provider?: string;
model?: string;
effort?: string;
cwd?: string;
}[];
};
if (!Array.isArray(parsed.members)) {
@ -21996,6 +22220,7 @@ export class TeamProvisioningService {
workflow:
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : 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),
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,

View file

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

View file

@ -120,7 +120,10 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
}
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers);
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(
input.expectedMembers,
input.cwd
);
if (memberValidationDiagnostics.length > 0) {
return blockedLaunchResult(
input,
@ -663,13 +666,14 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
}
function validateOpenCodeRuntimeMembers(
members: TeamRuntimeLaunchInput['expectedMembers']
members: TeamRuntimeLaunchInput['expectedMembers'],
launchCwd?: string
): string[] {
if (members.length === 0) {
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}>`;
if (member.providerId === 'opencode') {
return [];
@ -678,6 +682,21 @@ function validateOpenCodeRuntimeMembers(
`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: {

View file

@ -180,6 +180,12 @@ export const MemberCard = ({
const { summary: runtimeSummaryText, memory: memoryLabel } =
splitRuntimeSummaryMemory(runtimeSummary);
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 activityTitle = currentTask
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
@ -345,6 +351,14 @@ export const MemberCard = ({
{member.gitBranch}
</span>
) : 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 ? (
<CurrentTaskIndicator
task={currentTask}

View file

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

View file

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

View file

@ -558,6 +558,7 @@ async function runModelGauntlet(input: {
minimumAverageScore: input.minimumAverageScore,
minimumSuccessfulRuns: input.minimumSuccessfulRuns,
minimumConsistencyScore: input.minimumConsistencyScore,
consistencyScore: scoreStability.consistencyScore,
hardFailures,
providerInfraFailures,
runtimeTransportFailures,
@ -952,9 +953,10 @@ async function runGauntletOnce(input: {
diagnostics,
};
} finally {
if (harness) {
await harness.svc.stopTeam(teamName).catch(() => undefined);
await harness.dispose().catch(() => undefined);
const activeHarness = harness as Awaited<ReturnType<typeof createOpenCodeLiveHarness>> | null;
if (activeHarness) {
await activeHarness.svc.stopTeam(teamName).catch(() => undefined);
await activeHarness.dispose().catch(() => undefined);
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
}
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 () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
@ -3960,6 +4030,37 @@ describe('TeamProvisioningService', () => {
ledgerStatus: 'failed_terminal',
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(observeMessageDelivery).toHaveBeenCalledTimes(2);
});
@ -6485,7 +6586,9 @@ describe('TeamProvisioningService', () => {
});
describe('safe app launch matrix', () => {
function createSafeLaunchService() {
function createSafeLaunchService(options?: {
memberWorktreeManager?: { ensureMemberWorktree: ReturnType<typeof vi.fn> };
}) {
const mcpConfigBuilder = {
writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')),
removeConfigFile: vi.fn(async () => {}),
@ -6506,7 +6609,10 @@ describe('TeamProvisioningService', () => {
membersMetaStore as any,
undefined,
mcpConfigBuilder as any,
teamMetaStore as any
teamMetaStore as any,
undefined,
undefined,
options?.memberWorktreeManager as any
);
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
@ -6974,6 +7080,167 @@ describe('TeamProvisioningService', () => {
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 () => {

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 () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const writeText = vi.fn().mockResolvedValue(undefined);

View file

@ -28,7 +28,12 @@ function renderWarning(props: Partial<React.ComponentProps<typeof OpenCodeDelive
act(() => {
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(() => {
document.body.innerHTML = '';
vi.useRealTimers();
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 () => {
const { host, root } = renderWarning();
@ -127,6 +185,7 @@ describe('OpenCodeDeliveryWarning', () => {
root.render(
<OpenCodeDeliveryWarning
warning={warning}
pendingDelayMs={0}
debugDetails={{
...debugDetails,
messageId: 'm-opencode-2',