feat(opencode): persist teammate worktree context
This commit is contained in:
parent
49982a1db8
commit
610bfc561d
15 changed files with 1093 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
202
src/main/services/team/TeamMemberWorktreeManager.ts
Normal file
202
src/main/services/team/TeamMemberWorktreeManager.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ export interface OpenCodeCleanupHostsCommandBody {
|
|||
projectPath?: string;
|
||||
staleAgeMs?: number | null;
|
||||
leaseStaleAgeMs?: number | null;
|
||||
preflightLeaseStaleAgeMs?: number | null;
|
||||
}
|
||||
|
||||
export interface OpenCodeCleanupHostsCommandData {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
109
test/main/services/team/TeamMemberWorktreeManager.test.ts
Normal file
109
test/main/services/team/TeamMemberWorktreeManager.test.ts
Normal 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-');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue