diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index 546fd3ff..bb429c5d 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -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 diff --git a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts index ebca2891..46f63a9b 100644 --- a/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts +++ b/src/features/team-runtime-lanes/core/domain/planTeamRuntimeLanes.ts @@ -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, diff --git a/src/main/index.ts b/src/main/index.ts index 09fc04e8..79717f9d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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( diff --git a/src/main/services/team/TeamMemberWorktreeManager.ts b/src/main/services/team/TeamMemberWorktreeManager.ts new file mode 100644 index 00000000..849a0d2e --- /dev/null +++ b/src/main/services/team/TeamMemberWorktreeManager.ts @@ -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 { + 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 { + try { + return await fs.promises.realpath(candidate); + } catch { + return null; + } +} + +async function resolveGitPath(cwd: string, raw: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 + ); + } +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3f027eba..038e6b24 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { + 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 { 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, diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index c5e99b39..3c6e22b2 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -124,6 +124,7 @@ export interface OpenCodeCleanupHostsCommandBody { projectPath?: string; staleAgeMs?: number | null; leaseStaleAgeMs?: number | null; + preflightLeaseStaleAgeMs?: number | null; } export interface OpenCodeCleanupHostsCommandData { diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 2944e80c..1f7750ee 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -120,7 +120,10 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter { } async launch(input: TeamRuntimeLaunchInput): Promise { - 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() || ``; 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: { diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index fc4b4621..d8271414 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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} ) : null} + {showWorkspaceBadge ? ( + + worktree + + ) : null} {currentTask ? ( (null); const [copiedKey, setCopiedKey] = useState(null); + const [pendingVisibleKey, setPendingVisibleKey] = useState(() => + delayPendingWarning ? null : detailsKey + ); const mountedRef = useRef(true); const copiedResetTimerRef = useRef(null); + const pendingTimerRef = useRef(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 => { if (!copyText || !navigator.clipboard?.writeText) return; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index baf641cc..640fb8c0 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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; diff --git a/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts b/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts index 59566fd9..e79d04e5 100644 --- a/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts +++ b/test/main/services/team/OpenCodeSemanticModelGauntlet.live.test.ts @@ -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> | null; + if (activeHarness) { + await activeHarness.svc.stopTeam(teamName).catch(() => undefined); + await activeHarness.dispose().catch(() => undefined); await waitForOpenCodeLanesStopped(teamName).catch(() => undefined); } setClaudeBasePathOverride(null); diff --git a/test/main/services/team/TeamMemberWorktreeManager.test.ts b/test/main/services/team/TeamMemberWorktreeManager.test.ts new file mode 100644 index 00000000..45f8538f --- /dev/null +++ b/test/main/services/team/TeamMemberWorktreeManager.test.ts @@ -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 { + 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 { + 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-'); + }); +}); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index e753e7a3..65be4d7a 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -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) => ({ + 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) => ({ @@ -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 }; + }) { 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) => { + 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 () => { diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index 4e964b65..e6577b9a 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -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); diff --git a/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx b/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx index e4fd38d0..e363a0dd 100644 --- a/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx +++ b/test/renderer/components/team/messages/OpenCodeDeliveryWarning.test.tsx @@ -28,7 +28,12 @@ function renderWarning(props: Partial { root.render( - + ); }); @@ -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(); + }); + + 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(