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),
|
: undefined),
|
||||||
model: params.member.model?.trim() || undefined,
|
model: params.member.model?.trim() || undefined,
|
||||||
effort: params.member.effort,
|
effort: params.member.effort,
|
||||||
|
cwd: params.member.cwd?.trim() || undefined,
|
||||||
selectedFastMode:
|
selectedFastMode:
|
||||||
normalizeFastMode(params.member.fastMode) ??
|
normalizeFastMode(params.member.fastMode) ??
|
||||||
(providerId === params.leadDefaults.providerId
|
(providerId === params.leadDefaults.providerId
|
||||||
|
|
@ -231,6 +232,7 @@ function createSecondaryLaneMemberState(
|
||||||
: undefined),
|
: undefined),
|
||||||
model: params.member.model?.trim() || undefined,
|
model: params.member.model?.trim() || undefined,
|
||||||
effort: params.member.effort,
|
effort: params.member.effort,
|
||||||
|
cwd: params.member.cwd?.trim() || undefined,
|
||||||
selectedFastMode:
|
selectedFastMode:
|
||||||
normalizeFastMode(params.member.fastMode) ??
|
normalizeFastMode(params.member.fastMode) ??
|
||||||
(providerId === params.leadDefaults.providerId
|
(providerId === params.leadDefaults.providerId
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import type {
|
||||||
|
|
||||||
export interface RuntimeLanePlannerMemberInput {
|
export interface RuntimeLanePlannerMemberInput {
|
||||||
name: string;
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
workflow?: string;
|
||||||
|
isolation?: 'worktree';
|
||||||
|
cwd?: string;
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
providerBackendId?: TeamProviderBackendId;
|
providerBackendId?: TeamProviderBackendId;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
@ -185,6 +189,10 @@ export function fromProvisioningMembers(
|
||||||
leadProviderId,
|
leadProviderId,
|
||||||
members: members.map((member) => ({
|
members: members.map((member) => ({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
|
role: member.role,
|
||||||
|
workflow: member.workflow,
|
||||||
|
isolation: member.isolation,
|
||||||
|
cwd: member.cwd,
|
||||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
providerBackendId: member.providerBackendId,
|
providerBackendId: member.providerBackendId,
|
||||||
model: member.model,
|
model: member.model,
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,7 @@ async function cleanupOpenCodeHostsForLifecycle(reason: 'startup' | 'shutdown'):
|
||||||
mode: reason === 'shutdown' ? 'force' : 'stale',
|
mode: reason === 'shutdown' ? 'force' : 'stale',
|
||||||
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
|
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
|
||||||
leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
|
leaseStaleAgeMs: reason === 'startup' ? 24 * 60 * 60_000 : null,
|
||||||
|
preflightLeaseStaleAgeMs: reason === 'startup' ? 2 * 60_000 : null,
|
||||||
});
|
});
|
||||||
if (result.cleaned > 0) {
|
if (result.cleaned > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
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 { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
|
import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
import {
|
import {
|
||||||
|
|
@ -254,6 +255,7 @@ interface PersistedRuntimeMemberLike {
|
||||||
tmuxPaneId?: string;
|
tmuxPaneId?: string;
|
||||||
backendType?: string;
|
backendType?: string;
|
||||||
providerId?: string;
|
providerId?: string;
|
||||||
|
cwd?: string;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
runtimeSessionId?: string;
|
runtimeSessionId?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1515,6 +1517,7 @@ interface LiveTeamAgentRuntimeMetadata {
|
||||||
backendType?: TeamAgentRuntimeBackendType;
|
backendType?: TeamAgentRuntimeBackendType;
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
cwd?: string;
|
||||||
pid?: number;
|
pid?: number;
|
||||||
metricsPid?: number;
|
metricsPid?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
@ -4000,7 +4003,8 @@ export class TeamProvisioningService {
|
||||||
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
|
private readonly mcpConfigBuilder: TeamMcpConfigBuilder = new TeamMcpConfigBuilder(),
|
||||||
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore(),
|
||||||
private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
|
private readonly inboxWriter: TeamInboxWriter = new TeamInboxWriter(),
|
||||||
private readonly openCodeTaskLogAttributionStore: OpenCodeTaskLogAttributionStore = new OpenCodeTaskLogAttributionStore()
|
private readonly openCodeTaskLogAttributionStore: OpenCodeTaskLogAttributionStore = new OpenCodeTaskLogAttributionStore(),
|
||||||
|
private readonly memberWorktreeManager: TeamMemberWorktreeManager = new TeamMemberWorktreeManager()
|
||||||
) {
|
) {
|
||||||
this.memberLogsFinder = new TeamMemberLogsFinder(
|
this.memberLogsFinder = new TeamMemberLogsFinder(
|
||||||
this.configReader,
|
this.configReader,
|
||||||
|
|
@ -5251,11 +5255,15 @@ export class TeamProvisioningService {
|
||||||
) {
|
) {
|
||||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
}
|
}
|
||||||
|
const memberRuntimeCwd = metaMember?.cwd?.trim() || configMember?.cwd?.trim();
|
||||||
const cwd =
|
const cwd =
|
||||||
config?.projectPath?.trim() ||
|
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
metaMember?.cwd?.trim() ||
|
? memberRuntimeCwd ||
|
||||||
configMember?.cwd?.trim() ||
|
config?.projectPath?.trim() ||
|
||||||
this.readPersistedTeamProjectPath(teamName);
|
this.readPersistedTeamProjectPath(teamName)
|
||||||
|
: config?.projectPath?.trim() ||
|
||||||
|
memberRuntimeCwd ||
|
||||||
|
this.readPersistedTeamProjectPath(teamName);
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
return { delivered: false, reason: 'opencode_project_path_unavailable' };
|
return { delivered: false, reason: 'opencode_project_path_unavailable' };
|
||||||
}
|
}
|
||||||
|
|
@ -5419,23 +5427,6 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ledgerRecord && ledger && messageId) {
|
if (ledgerRecord && ledger && messageId) {
|
||||||
if (ledgerRecord.status === 'failed_terminal') {
|
|
||||||
this.logOpenCodePromptDeliveryEvent(
|
|
||||||
'opencode_prompt_delivery_terminal_failure',
|
|
||||||
ledgerRecord
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
delivered: false,
|
|
||||||
accepted: false,
|
|
||||||
responsePending: false,
|
|
||||||
responseState: ledgerRecord.responseState,
|
|
||||||
ledgerStatus: ledgerRecord.status,
|
|
||||||
ledgerRecordId: ledgerRecord.id,
|
|
||||||
laneId: laneIdentity.laneId,
|
|
||||||
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
|
|
||||||
diagnostics: ledgerRecord.diagnostics,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
let proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||||
ledger,
|
ledger,
|
||||||
ledgerRecord,
|
ledgerRecord,
|
||||||
|
|
@ -5471,6 +5462,24 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ledgerRecord.status === 'failed_terminal') {
|
||||||
|
this.logOpenCodePromptDeliveryEvent(
|
||||||
|
'opencode_prompt_delivery_terminal_failure',
|
||||||
|
ledgerRecord
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
delivered: false,
|
||||||
|
accepted: false,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: ledgerRecord.responseState,
|
||||||
|
ledgerStatus: ledgerRecord.status,
|
||||||
|
ledgerRecordId: ledgerRecord.id,
|
||||||
|
laneId: laneIdentity.laneId,
|
||||||
|
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
|
||||||
|
diagnostics: ledgerRecord.diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const attemptDue = isOpenCodePromptDeliveryAttemptDue(ledgerRecord);
|
const attemptDue = isOpenCodePromptDeliveryAttemptDue(ledgerRecord);
|
||||||
if (ledgerRecord.status !== 'pending' && !attemptDue) {
|
if (ledgerRecord.status !== 'pending' && !attemptDue) {
|
||||||
const nextAttemptMs = ledgerRecord.nextAttemptAt
|
const nextAttemptMs = ledgerRecord.nextAttemptAt
|
||||||
|
|
@ -6191,6 +6200,7 @@ export class TeamProvisioningService {
|
||||||
...(configuredMember.role ? { role: configuredMember.role } : {}),
|
...(configuredMember.role ? { role: configuredMember.role } : {}),
|
||||||
...(configuredMember.workflow ? { workflow: configuredMember.workflow } : {}),
|
...(configuredMember.workflow ? { workflow: configuredMember.workflow } : {}),
|
||||||
...(configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
...(configuredMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||||
|
...(configuredMember.cwd ? { cwd: configuredMember.cwd } : {}),
|
||||||
...(configuredMember.providerId ? { providerId: configuredMember.providerId } : {}),
|
...(configuredMember.providerId ? { providerId: configuredMember.providerId } : {}),
|
||||||
...(configuredMember.providerBackendId
|
...(configuredMember.providerBackendId
|
||||||
? { providerBackendId: configuredMember.providerBackendId }
|
? { providerBackendId: configuredMember.providerBackendId }
|
||||||
|
|
@ -6208,6 +6218,7 @@ export class TeamProvisioningService {
|
||||||
role: member.role?.trim() || undefined,
|
role: member.role?.trim() || undefined,
|
||||||
workflow: member.workflow?.trim() || undefined,
|
workflow: member.workflow?.trim() || undefined,
|
||||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||||
|
cwd: member.cwd?.trim() || undefined,
|
||||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
providerBackendId: migrateProviderBackendId(member.providerId, member.providerBackendId),
|
||||||
model: member.model?.trim() || undefined,
|
model: member.model?.trim() || undefined,
|
||||||
|
|
@ -8591,6 +8602,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
const updatedAt = nowIso();
|
const updatedAt = nowIso();
|
||||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||||
|
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||||
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
||||||
|
|
||||||
let configuredMembers: TeamConfig['members'] = [];
|
let configuredMembers: TeamConfig['members'] = [];
|
||||||
|
|
@ -8718,6 +8730,10 @@ export class TeamProvisioningService {
|
||||||
inferTeamProviderIdFromModel(launchMember?.model) ??
|
inferTeamProviderIdFromModel(launchMember?.model) ??
|
||||||
inferTeamProviderIdFromModel(member.model);
|
inferTeamProviderIdFromModel(member.model);
|
||||||
const isOpenCodeMember = memberProviderId === 'opencode';
|
const isOpenCodeMember = memberProviderId === 'opencode';
|
||||||
|
const configuredCwd = typeof member.cwd === 'string' ? member.cwd.trim() : '';
|
||||||
|
const runtimeCwd =
|
||||||
|
liveRuntimeMember?.cwd ??
|
||||||
|
(configuredCwd || (isOpenCodeMember ? currentRuntimeAdapterRun?.cwd : undefined));
|
||||||
const metricsPid = liveRuntimeMember?.metricsPid;
|
const metricsPid = liveRuntimeMember?.metricsPid;
|
||||||
const isSharedOpenCodeHost =
|
const isSharedOpenCodeHost =
|
||||||
isOpenCodeMember &&
|
isOpenCodeMember &&
|
||||||
|
|
@ -8760,6 +8776,7 @@ export class TeamProvisioningService {
|
||||||
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
|
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
|
||||||
...(displayPid ? { pid: displayPid } : {}),
|
...(displayPid ? { pid: displayPid } : {}),
|
||||||
...(runtimeModel ? { runtimeModel } : {}),
|
...(runtimeModel ? { runtimeModel } : {}),
|
||||||
|
...(runtimeCwd ? { cwd: runtimeCwd } : {}),
|
||||||
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
|
...(typeof rssBytes === 'number' && rssBytes >= 0 ? { rssBytes } : {}),
|
||||||
...(liveRuntimeMember?.livenessKind
|
...(liveRuntimeMember?.livenessKind
|
||||||
? { livenessKind: liveRuntimeMember.livenessKind }
|
? { livenessKind: liveRuntimeMember.livenessKind }
|
||||||
|
|
@ -9267,7 +9284,15 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const memberSpec = this.buildConfiguredProvisioningMember(configuredMember);
|
const [memberSpec] = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
teamName,
|
||||||
|
baseCwd: run.request.cwd,
|
||||||
|
leadProviderId,
|
||||||
|
members: [this.buildConfiguredProvisioningMember(configuredMember)],
|
||||||
|
});
|
||||||
|
if (!memberSpec) {
|
||||||
|
throw new Error(`Member "${memberName}" could not be resolved for OpenCode lane reattach.`);
|
||||||
|
}
|
||||||
const nextLane = this.createMixedSecondaryLaneStateForMember(run, memberSpec);
|
const nextLane = this.createMixedSecondaryLaneStateForMember(run, memberSpec);
|
||||||
const existingLaneIndex = run.mixedSecondaryLanes.findIndex(
|
const existingLaneIndex = run.mixedSecondaryLanes.findIndex(
|
||||||
(lane) => lane.laneId === nextLane.laneId || lane.member.name.trim() === memberName
|
(lane) => lane.laneId === nextLane.laneId || lane.member.name.trim() === memberName
|
||||||
|
|
@ -10537,6 +10562,95 @@ export class TeamProvisioningService {
|
||||||
return effectiveMembers;
|
return effectiveMembers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getOpenCodeRuntimeLaunchCwd(
|
||||||
|
fallbackCwd: string,
|
||||||
|
members: TeamCreateRequest['members']
|
||||||
|
): string {
|
||||||
|
if (members.length > 1 && members.some((member) => member.isolation === 'worktree')) {
|
||||||
|
throw new Error(
|
||||||
|
'OpenCode worktree isolation currently supports one isolated OpenCode member per runtime lane.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const memberCwds = [
|
||||||
|
...new Set(
|
||||||
|
members.map((member) => member.cwd?.trim()).filter((cwd): cwd is string => Boolean(cwd))
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (memberCwds.length === 0) {
|
||||||
|
return fallbackCwd;
|
||||||
|
}
|
||||||
|
if (memberCwds.length === 1) {
|
||||||
|
return memberCwds[0];
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'OpenCode runtime lanes support exactly one project path in this release. Use mixed-team OpenCode side lanes for per-teammate worktree isolation.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
|
||||||
|
teamName: string;
|
||||||
|
baseCwd: string;
|
||||||
|
leadProviderId?: TeamProviderId;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
}): Promise<TeamCreateRequest['members']> {
|
||||||
|
const isolatedOpenCodeMembers = params.members.filter((member) => {
|
||||||
|
const providerId = normalizeTeamMemberProviderId(member.providerId);
|
||||||
|
return providerId === 'opencode' && member.isolation === 'worktree';
|
||||||
|
});
|
||||||
|
if (isolatedOpenCodeMembers.length === 0) {
|
||||||
|
return params.members;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPureOpenCodeProvisioningRequest({
|
||||||
|
providerId: params.leadProviderId,
|
||||||
|
members: params.members,
|
||||||
|
}) &&
|
||||||
|
params.members.length > 1
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'OpenCode worktree isolation currently supports mixed-team OpenCode side lanes or one-member OpenCode runtime lanes. Multiple OpenCode members in one lane cannot use separate worktrees yet.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMembers: TeamCreateRequest['members'] = [];
|
||||||
|
for (const member of params.members) {
|
||||||
|
const providerId = normalizeTeamMemberProviderId(member.providerId);
|
||||||
|
if (providerId !== 'opencode' || member.isolation !== 'worktree') {
|
||||||
|
nextMembers.push(member);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCwd = member.cwd?.trim();
|
||||||
|
if (existingCwd) {
|
||||||
|
if (!path.isAbsolute(existingCwd)) {
|
||||||
|
throw new Error(
|
||||||
|
`OpenCode worktree path for "${member.name}" must be absolute: ${existingCwd}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingCwdStat = await fs.promises.stat(existingCwd).catch(() => null);
|
||||||
|
if (existingCwdStat) {
|
||||||
|
if (!existingCwdStat.isDirectory()) {
|
||||||
|
throw new Error(
|
||||||
|
`OpenCode worktree path for "${member.name}" is not a directory: ${existingCwd}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
nextMembers.push({ ...member, cwd: existingCwd });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolution = await this.memberWorktreeManager.ensureMemberWorktree({
|
||||||
|
teamName: params.teamName,
|
||||||
|
memberName: member.name,
|
||||||
|
baseCwd: params.baseCwd,
|
||||||
|
});
|
||||||
|
nextMembers.push({ ...member, cwd: resolution.worktreePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextMembers;
|
||||||
|
}
|
||||||
|
|
||||||
private getFreshCachedProbeResult(
|
private getFreshCachedProbeResult(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
providerId: TeamProviderId | undefined
|
providerId: TeamProviderId | undefined
|
||||||
|
|
@ -11432,7 +11546,7 @@ export class TeamProvisioningService {
|
||||||
if (envWarning) {
|
if (envWarning) {
|
||||||
throw new Error(envWarning);
|
throw new Error(envWarning);
|
||||||
}
|
}
|
||||||
const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
||||||
claudePath,
|
claudePath,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
members: request.members,
|
members: request.members,
|
||||||
|
|
@ -11445,6 +11559,12 @@ export class TeamProvisioningService {
|
||||||
primaryEnv: provisioningEnv,
|
primaryEnv: provisioningEnv,
|
||||||
limitContext: request.limitContext,
|
limitContext: request.limitContext,
|
||||||
});
|
});
|
||||||
|
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
teamName: request.teamName,
|
||||||
|
baseCwd: request.cwd,
|
||||||
|
leadProviderId: request.providerId,
|
||||||
|
members: materializedMemberSpecs,
|
||||||
|
});
|
||||||
const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs);
|
const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs);
|
||||||
const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
|
const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
|
||||||
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
|
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
|
||||||
|
|
@ -11805,10 +11925,15 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureCwdExists(request.cwd);
|
await ensureCwdExists(request.cwd);
|
||||||
const effectiveMembers = buildEffectiveTeamMemberSpecs(request.members, {
|
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
providerId: request.providerId,
|
teamName: request.teamName,
|
||||||
model: request.model,
|
baseCwd: request.cwd,
|
||||||
effort: request.effort,
|
leadProviderId: request.providerId,
|
||||||
|
members: buildEffectiveTeamMemberSpecs(request.members, {
|
||||||
|
providerId: request.providerId,
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
const teamDir = path.join(getTeamsBasePath(), request.teamName);
|
||||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||||
|
|
@ -11863,10 +11988,15 @@ export class TeamProvisioningService {
|
||||||
configRaw,
|
configRaw,
|
||||||
request.providerId
|
request.providerId
|
||||||
);
|
);
|
||||||
const effectiveMembers = buildEffectiveTeamMemberSpecs(members, {
|
const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
providerId: request.providerId,
|
teamName: request.teamName,
|
||||||
model: request.model,
|
baseCwd: request.cwd,
|
||||||
effort: request.effort,
|
leadProviderId: request.providerId,
|
||||||
|
members: buildEffectiveTeamMemberSpecs(members, {
|
||||||
|
providerId: request.providerId,
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
||||||
|
|
||||||
|
|
@ -11957,11 +12087,12 @@ export class TeamProvisioningService {
|
||||||
laneId: 'primary',
|
laneId: 'primary',
|
||||||
state: 'active',
|
state: 'active',
|
||||||
});
|
});
|
||||||
|
const launchCwd = this.getOpenCodeRuntimeLaunchCwd(input.request.cwd, input.members);
|
||||||
const launchInput: TeamRuntimeLaunchInput = {
|
const launchInput: TeamRuntimeLaunchInput = {
|
||||||
runId,
|
runId,
|
||||||
laneId: 'primary',
|
laneId: 'primary',
|
||||||
teamName: input.request.teamName,
|
teamName: input.request.teamName,
|
||||||
cwd: input.request.cwd,
|
cwd: launchCwd,
|
||||||
prompt: input.prompt,
|
prompt: input.prompt,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
model: input.request.model,
|
model: input.request.model,
|
||||||
|
|
@ -11975,7 +12106,7 @@ export class TeamProvisioningService {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
model: member.model ?? input.request.model,
|
model: member.model ?? input.request.model,
|
||||||
effort: member.effort ?? input.request.effort,
|
effort: member.effort ?? input.request.effort,
|
||||||
cwd: input.request.cwd,
|
cwd: member.cwd?.trim() || launchCwd,
|
||||||
})),
|
})),
|
||||||
previousLaunchState,
|
previousLaunchState,
|
||||||
};
|
};
|
||||||
|
|
@ -12040,7 +12171,7 @@ export class TeamProvisioningService {
|
||||||
this.runtimeAdapterRunByTeam.set(input.request.teamName, {
|
this.runtimeAdapterRunByTeam.set(input.request.teamName, {
|
||||||
runId,
|
runId,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
cwd: input.request.cwd,
|
cwd: launchCwd,
|
||||||
members: result.members,
|
members: result.members,
|
||||||
});
|
});
|
||||||
this.aliveRunByTeam.set(input.request.teamName, runId);
|
this.aliveRunByTeam.set(input.request.teamName, runId);
|
||||||
|
|
@ -12116,6 +12247,7 @@ export class TeamProvisioningService {
|
||||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||||
model: member.model,
|
model: member.model,
|
||||||
effort: member.effort,
|
effort: member.effort,
|
||||||
|
cwd: member.cwd?.trim() || undefined,
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
@ -12155,6 +12287,7 @@ export class TeamProvisioningService {
|
||||||
providerBackendId: undefined,
|
providerBackendId: undefined,
|
||||||
model: member.model?.trim() || undefined,
|
model: member.model?.trim() || undefined,
|
||||||
effort: member.effort,
|
effort: member.effort,
|
||||||
|
cwd: member.cwd?.trim() || undefined,
|
||||||
laneId: 'primary',
|
laneId: 'primary',
|
||||||
laneKind: 'primary',
|
laneKind: 'primary',
|
||||||
laneOwnerProviderId: 'opencode',
|
laneOwnerProviderId: 'opencode',
|
||||||
|
|
@ -12435,7 +12568,7 @@ export class TeamProvisioningService {
|
||||||
throw new Error(envWarning);
|
throw new Error(envWarning);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allEffectiveMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
const materializedMemberSpecs = await this.materializeEffectiveTeamMemberSpecs({
|
||||||
claudePath,
|
claudePath,
|
||||||
cwd: request.cwd,
|
cwd: request.cwd,
|
||||||
members: expectedMemberSpecs,
|
members: expectedMemberSpecs,
|
||||||
|
|
@ -12448,6 +12581,12 @@ export class TeamProvisioningService {
|
||||||
primaryEnv: provisioningEnv,
|
primaryEnv: provisioningEnv,
|
||||||
limitContext: request.limitContext,
|
limitContext: request.limitContext,
|
||||||
});
|
});
|
||||||
|
const allEffectiveMemberSpecs = await this.resolveOpenCodeMemberWorkspacesForRuntime({
|
||||||
|
teamName: request.teamName,
|
||||||
|
baseCwd: request.cwd,
|
||||||
|
leadProviderId: request.providerId,
|
||||||
|
members: materializedMemberSpecs,
|
||||||
|
});
|
||||||
const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs);
|
const lanePlan = this.planRuntimeLanesOrThrow(request.providerId, allEffectiveMemberSpecs);
|
||||||
const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
|
const primaryMemberNames = new Set(lanePlan.primaryMembers.map((member) => member.name));
|
||||||
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
|
const effectiveMemberSpecs = allEffectiveMemberSpecs.filter((member) =>
|
||||||
|
|
@ -13527,6 +13666,74 @@ export class TeamProvisioningService {
|
||||||
})
|
})
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
if (existingRecord?.status === 'failed_terminal') {
|
if (existingRecord?.status === 'failed_terminal') {
|
||||||
|
let recoveredRecord: OpenCodePromptDeliveryLedgerRecord | null = null;
|
||||||
|
let recoveredVisibleReply: OpenCodeVisibleReplyProof | null = null;
|
||||||
|
if (typeof promptLedger.applyDestinationProof === 'function') {
|
||||||
|
try {
|
||||||
|
const proof = await this.applyOpenCodeVisibleDestinationProof({
|
||||||
|
ledger: promptLedger,
|
||||||
|
ledgerRecord: existingRecord,
|
||||||
|
teamName,
|
||||||
|
replyRecipient: existingRecord.replyRecipient,
|
||||||
|
memberName: memberIdentity.canonicalMemberName,
|
||||||
|
});
|
||||||
|
recoveredRecord = proof.ledgerRecord;
|
||||||
|
recoveredVisibleReply = proof.visibleReply;
|
||||||
|
} catch {
|
||||||
|
recoveredRecord = null;
|
||||||
|
recoveredVisibleReply = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const recoveredReadAllowed =
|
||||||
|
recoveredRecord &&
|
||||||
|
this.isOpenCodeDeliveryResponseReadCommitAllowed({
|
||||||
|
responseState: recoveredRecord.responseState,
|
||||||
|
actionMode: recoveredRecord.actionMode ?? undefined,
|
||||||
|
taskRefs: recoveredRecord.taskRefs,
|
||||||
|
visibleReply: recoveredVisibleReply,
|
||||||
|
ledgerRecord: recoveredRecord,
|
||||||
|
});
|
||||||
|
if (recoveredRecord && recoveredReadAllowed) {
|
||||||
|
try {
|
||||||
|
await this.markInboxMessagesRead(teamName, memberName, [message]);
|
||||||
|
const committed = await promptLedger.markInboxReadCommitted({
|
||||||
|
id: recoveredRecord.id,
|
||||||
|
committedAt: nowIso(),
|
||||||
|
});
|
||||||
|
this.logOpenCodePromptDeliveryEvent(
|
||||||
|
'opencode_prompt_delivery_inbox_committed_read',
|
||||||
|
committed,
|
||||||
|
{ recoveredTerminal: true }
|
||||||
|
);
|
||||||
|
result.delivered += 1;
|
||||||
|
result.relayed += 1;
|
||||||
|
result.lastDelivery = {
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: committed.responseState,
|
||||||
|
ledgerStatus: committed.status,
|
||||||
|
ledgerRecordId: committed.id,
|
||||||
|
laneId: memberIdentity.laneId,
|
||||||
|
visibleReplyMessageId: committed.visibleReplyMessageId ?? undefined,
|
||||||
|
visibleReplyCorrelation: committed.visibleReplyCorrelation ?? undefined,
|
||||||
|
diagnostics: committed.diagnostics,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
const diagnostic = `opencode_inbox_mark_read_failed_after_terminal_recovery: ${getErrorMessage(
|
||||||
|
error
|
||||||
|
)}`;
|
||||||
|
result.failed += 1;
|
||||||
|
result.lastDelivery = {
|
||||||
|
delivered: false,
|
||||||
|
reason: 'opencode_inbox_mark_read_failed_after_terminal_recovery',
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
};
|
||||||
|
result.diagnostics = [...(result.diagnostics ?? []), diagnostic];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
const diagnostic =
|
const diagnostic =
|
||||||
existingRecord.lastReason ??
|
existingRecord.lastReason ??
|
||||||
`opencode_prompt_delivery_failed_terminal: ${message.messageId}`;
|
`opencode_prompt_delivery_failed_terminal: ${message.messageId}`;
|
||||||
|
|
@ -13547,7 +13754,6 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fallbackReplyRecipient =
|
const fallbackReplyRecipient =
|
||||||
typeof message.from === 'string' &&
|
typeof message.from === 'string' &&
|
||||||
message.from.trim() &&
|
message.from.trim() &&
|
||||||
|
|
@ -14770,6 +14976,7 @@ export class TeamProvisioningService {
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
fastMode?: TeamFastMode;
|
fastMode?: TeamFastMode;
|
||||||
|
cwd?: string;
|
||||||
agentType?: string;
|
agentType?: string;
|
||||||
removedAt?: number | string;
|
removedAt?: number | string;
|
||||||
} | null {
|
} | null {
|
||||||
|
|
@ -14819,6 +15026,7 @@ export class TeamProvisioningService {
|
||||||
: undefined;
|
: undefined;
|
||||||
const agentType =
|
const agentType =
|
||||||
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
|
metaMember?.agentType?.trim() || configuredMember?.agentType?.trim() || undefined;
|
||||||
|
const cwd = metaMember?.cwd?.trim() || configuredMember?.cwd?.trim() || undefined;
|
||||||
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
|
const removedAt = metaMember?.removedAt ?? configuredMember?.removedAt;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -14831,6 +15039,7 @@ export class TeamProvisioningService {
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(effort ? { effort } : {}),
|
...(effort ? { effort } : {}),
|
||||||
...(fastMode ? { fastMode } : {}),
|
...(fastMode ? { fastMode } : {}),
|
||||||
|
...(cwd ? { cwd } : {}),
|
||||||
...(agentType ? { agentType } : {}),
|
...(agentType ? { agentType } : {}),
|
||||||
...(removedAt != null ? { removedAt } : {}),
|
...(removedAt != null ? { removedAt } : {}),
|
||||||
};
|
};
|
||||||
|
|
@ -15023,6 +15232,7 @@ export class TeamProvisioningService {
|
||||||
...(typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim()
|
...(typeof member.runtimeSessionId === 'string' && member.runtimeSessionId.trim()
|
||||||
? { runtimeSessionId: member.runtimeSessionId.trim() }
|
? { runtimeSessionId: member.runtimeSessionId.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||||
...(runtimeModel ? { model: runtimeModel } : {}),
|
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -15060,6 +15270,7 @@ export class TeamProvisioningService {
|
||||||
...(normalizeOptionalTeamProviderId(member.providerId)
|
...(normalizeOptionalTeamProviderId(member.providerId)
|
||||||
? { providerId: normalizeOptionalTeamProviderId(member.providerId) }
|
? { providerId: normalizeOptionalTeamProviderId(member.providerId) }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||||
...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false)
|
...(normalizeTeamAgentRuntimeBackendType(configuredBackendType, false)
|
||||||
? {
|
? {
|
||||||
backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false),
|
backendType: normalizeTeamAgentRuntimeBackendType(configuredBackendType, false),
|
||||||
|
|
@ -15089,6 +15300,7 @@ export class TeamProvisioningService {
|
||||||
...(typeof member.agentId === 'string' && member.agentId.trim()
|
...(typeof member.agentId === 'string' && member.agentId.trim()
|
||||||
? { agentId: member.agentId.trim() }
|
? { agentId: member.agentId.trim() }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -15109,6 +15321,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const evidence = lane.result?.members[memberName];
|
const evidence = lane.result?.members[memberName];
|
||||||
const runtimeModel = lane.member.model?.trim() || undefined;
|
const runtimeModel = lane.member.model?.trim() || undefined;
|
||||||
|
const laneMemberCwd =
|
||||||
|
typeof (lane.member as { cwd?: unknown }).cwd === 'string'
|
||||||
|
? (lane.member as { cwd?: string }).cwd?.trim()
|
||||||
|
: '';
|
||||||
|
const laneCwd = laneMemberCwd || run?.request.cwd;
|
||||||
upsertMetadata(memberName, {
|
upsertMetadata(memberName, {
|
||||||
backendType: 'process',
|
backendType: 'process',
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -15116,6 +15333,7 @@ export class TeamProvisioningService {
|
||||||
livenessKind: evidence?.livenessKind,
|
livenessKind: evidence?.livenessKind,
|
||||||
pidSource: evidence?.pidSource,
|
pidSource: evidence?.pidSource,
|
||||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||||
|
...(laneCwd ? { cwd: laneCwd } : {}),
|
||||||
...(runtimeModel ? { model: runtimeModel } : {}),
|
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||||
...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0
|
...(typeof evidence?.runtimePid === 'number' && evidence.runtimePid > 0
|
||||||
? { metricsPid: evidence.runtimePid }
|
? { metricsPid: evidence.runtimePid }
|
||||||
|
|
@ -15962,13 +16180,14 @@ export class TeamProvisioningService {
|
||||||
lane.runId = lane.runId ?? randomUUID();
|
lane.runId = lane.runId ?? randomUUID();
|
||||||
lane.warnings = [];
|
lane.warnings = [];
|
||||||
lane.diagnostics = [...migration.diagnostics];
|
lane.diagnostics = [...migration.diagnostics];
|
||||||
|
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
|
||||||
this.setSecondaryRuntimeRun({
|
this.setSecondaryRuntimeRun({
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
runId: lane.runId,
|
runId: lane.runId,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
memberName: lane.member.name,
|
memberName: lane.member.name,
|
||||||
cwd: run.request.cwd,
|
cwd: laneCwd,
|
||||||
});
|
});
|
||||||
await this.publishMixedSecondaryLaneStatusChange(run, lane);
|
await this.publishMixedSecondaryLaneStatusChange(run, lane);
|
||||||
const previousLaunchState = await this.launchStateStore.read(run.teamName);
|
const previousLaunchState = await this.launchStateStore.read(run.teamName);
|
||||||
|
|
@ -15978,7 +16197,7 @@ export class TeamProvisioningService {
|
||||||
runId: lane.runId,
|
runId: lane.runId,
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
cwd: run.request.cwd,
|
cwd: laneCwd,
|
||||||
prompt: run.request.prompt?.trim() ?? undefined,
|
prompt: run.request.prompt?.trim() ?? undefined,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
model: lane.member.model,
|
model: lane.member.model,
|
||||||
|
|
@ -15993,7 +16212,7 @@ export class TeamProvisioningService {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
model: lane.member.model,
|
model: lane.member.model,
|
||||||
effort: lane.member.effort,
|
effort: lane.member.effort,
|
||||||
cwd: run.request.cwd,
|
cwd: laneCwd,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
previousLaunchState,
|
previousLaunchState,
|
||||||
|
|
@ -16079,7 +16298,7 @@ export class TeamProvisioningService {
|
||||||
runId: lane.runId,
|
runId: lane.runId,
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
cwd: run.request.cwd,
|
cwd: lane.member.cwd?.trim() || run.request.cwd,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
reason,
|
reason,
|
||||||
previousLaunchState,
|
previousLaunchState,
|
||||||
|
|
@ -16408,7 +16627,8 @@ export class TeamProvisioningService {
|
||||||
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
previousLaunchState: PersistedTeamLaunchSnapshot | null;
|
||||||
}): Promise<TeamRuntimeMemberLaunchEvidence | null> {
|
}): Promise<TeamRuntimeMemberLaunchEvidence | null> {
|
||||||
const adapter = this.getOpenCodeRuntimeAdapter();
|
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||||
if (!adapter || !params.projectPath) {
|
const runtimeProjectPath = params.member.cwd?.trim() || params.projectPath;
|
||||||
|
if (!adapter || !runtimeProjectPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -16427,7 +16647,7 @@ export class TeamProvisioningService {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
model: params.member.model,
|
model: params.member.model,
|
||||||
effort: params.member.effort,
|
effort: params.member.effort,
|
||||||
cwd: params.projectPath,
|
cwd: runtimeProjectPath,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
previousLaunchState: params.previousLaunchState,
|
previousLaunchState: params.previousLaunchState,
|
||||||
|
|
@ -21800,15 +22020,17 @@ export class TeamProvisioningService {
|
||||||
const model =
|
const model =
|
||||||
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
typeof member.model === 'string' ? member.model.trim() || undefined : undefined;
|
||||||
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
const effort = isTeamEffortLevel(member.effort) ? member.effort : undefined;
|
||||||
|
const cwd = typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined;
|
||||||
const prev = byName.get(name);
|
const prev = byName.get(name);
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
byName.set(name, { name, role, workflow, isolation, providerId, model, effort });
|
byName.set(name, { name, role, workflow, isolation, cwd, providerId, model, effort });
|
||||||
} else {
|
} else {
|
||||||
byName.set(name, {
|
byName.set(name, {
|
||||||
...prev,
|
...prev,
|
||||||
role: prev.role || role,
|
role: prev.role || role,
|
||||||
workflow: prev.workflow || workflow,
|
workflow: prev.workflow || workflow,
|
||||||
isolation: prev.isolation || isolation,
|
isolation: prev.isolation || isolation,
|
||||||
|
cwd: prev.cwd || cwd,
|
||||||
providerId: prev.providerId || providerId,
|
providerId: prev.providerId || providerId,
|
||||||
model: prev.model || model,
|
model: prev.model || model,
|
||||||
effort: prev.effort || effort,
|
effort: prev.effort || effort,
|
||||||
|
|
@ -21870,6 +22092,7 @@ export class TeamProvisioningService {
|
||||||
role: configMember?.role,
|
role: configMember?.role,
|
||||||
workflow: configMember?.workflow,
|
workflow: configMember?.workflow,
|
||||||
isolation: configMember?.isolation,
|
isolation: configMember?.isolation,
|
||||||
|
cwd: configMember?.cwd,
|
||||||
providerId: configMember?.providerId,
|
providerId: configMember?.providerId,
|
||||||
model: configMember?.model,
|
model: configMember?.model,
|
||||||
effort: configMember?.effort,
|
effort: configMember?.effort,
|
||||||
|
|
@ -21978,6 +22201,7 @@ export class TeamProvisioningService {
|
||||||
provider?: string;
|
provider?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
|
cwd?: string;
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
if (!Array.isArray(parsed.members)) {
|
if (!Array.isArray(parsed.members)) {
|
||||||
|
|
@ -21996,6 +22220,7 @@ export class TeamProvisioningService {
|
||||||
workflow:
|
workflow:
|
||||||
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
typeof member.workflow === 'string' ? member.workflow.trim() || undefined : undefined,
|
||||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||||
|
cwd: typeof member.cwd === 'string' ? member.cwd.trim() || undefined : undefined,
|
||||||
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
|
providerId: normalizeTeamMemberProviderId(member.providerId ?? member.provider),
|
||||||
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
model: typeof member.model === 'string' ? member.model.trim() || undefined : undefined,
|
||||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ export interface OpenCodeCleanupHostsCommandBody {
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
staleAgeMs?: number | null;
|
staleAgeMs?: number | null;
|
||||||
leaseStaleAgeMs?: number | null;
|
leaseStaleAgeMs?: number | null;
|
||||||
|
preflightLeaseStaleAgeMs?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeCleanupHostsCommandData {
|
export interface OpenCodeCleanupHostsCommandData {
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,10 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
||||||
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(input.expectedMembers);
|
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(
|
||||||
|
input.expectedMembers,
|
||||||
|
input.cwd
|
||||||
|
);
|
||||||
if (memberValidationDiagnostics.length > 0) {
|
if (memberValidationDiagnostics.length > 0) {
|
||||||
return blockedLaunchResult(
|
return blockedLaunchResult(
|
||||||
input,
|
input,
|
||||||
|
|
@ -663,13 +666,14 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateOpenCodeRuntimeMembers(
|
function validateOpenCodeRuntimeMembers(
|
||||||
members: TeamRuntimeLaunchInput['expectedMembers']
|
members: TeamRuntimeLaunchInput['expectedMembers'],
|
||||||
|
launchCwd?: string
|
||||||
): string[] {
|
): string[] {
|
||||||
if (members.length === 0) {
|
if (members.length === 0) {
|
||||||
return ['OpenCode runtime adapter requires at least one expected OpenCode member.'];
|
return ['OpenCode runtime adapter requires at least one expected OpenCode member.'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return members.flatMap((member, index) => {
|
const diagnostics = members.flatMap((member, index) => {
|
||||||
const name = member.name.trim() || `<index ${index}>`;
|
const name = member.name.trim() || `<index ${index}>`;
|
||||||
if (member.providerId === 'opencode') {
|
if (member.providerId === 'opencode') {
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -678,6 +682,21 @@ function validateOpenCodeRuntimeMembers(
|
||||||
`OpenCode runtime adapter received non-OpenCode member "${name}" with provider "${member.providerId}".`,
|
`OpenCode runtime adapter received non-OpenCode member "${name}" with provider "${member.providerId}".`,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
const memberCwds = [
|
||||||
|
...new Set(members.map((member) => member.cwd.trim()).filter((cwd) => cwd.length > 0)),
|
||||||
|
];
|
||||||
|
if (memberCwds.length > 1) {
|
||||||
|
diagnostics.push(
|
||||||
|
'OpenCode runtime adapter currently supports one project path per lane. Launch isolated OpenCode teammates as separate side lanes.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const onlyMemberCwd = memberCwds.length === 1 ? memberCwds[0] : null;
|
||||||
|
if (launchCwd?.trim() && onlyMemberCwd && onlyMemberCwd !== launchCwd.trim()) {
|
||||||
|
diagnostics.push(
|
||||||
|
`OpenCode runtime lane cwd mismatch: launch cwd "${launchCwd.trim()}" differs from member cwd "${onlyMemberCwd}".`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return diagnostics;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOpenCodeBridgeDiagnostic(diagnostic: {
|
function formatOpenCodeBridgeDiagnostic(diagnostic: {
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,12 @@ export const MemberCard = ({
|
||||||
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||||
splitRuntimeSummaryMemory(runtimeSummary);
|
splitRuntimeSummaryMemory(runtimeSummary);
|
||||||
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||||
|
const isLead = isLeadMember(member);
|
||||||
|
const workspacePath = member.cwd?.trim();
|
||||||
|
const showWorkspaceBadge = !isLead && !isRemoved && member.isolation === 'worktree';
|
||||||
|
const workspaceBadgeTitle = workspacePath
|
||||||
|
? `Worktree isolation configured. Worktree path: ${workspacePath}`
|
||||||
|
: 'Worktree isolation is configured, but the runtime path is not available yet';
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -345,6 +351,14 @@ export const MemberCard = ({
|
||||||
{member.gitBranch}
|
{member.gitBranch}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{showWorkspaceBadge ? (
|
||||||
|
<span
|
||||||
|
className="shrink-0 rounded border border-emerald-400/35 bg-emerald-400/10 px-1 py-0.5 text-[9px] font-semibold uppercase leading-none text-emerald-300"
|
||||||
|
title={workspaceBadgeTitle}
|
||||||
|
>
|
||||||
|
worktree
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{currentTask ? (
|
{currentTask ? (
|
||||||
<CurrentTaskIndicator
|
<CurrentTaskIndicator
|
||||||
task={currentTask}
|
task={currentTask}
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,25 @@ import type { JSX } from 'react';
|
||||||
interface OpenCodeDeliveryWarningProps {
|
interface OpenCodeDeliveryWarningProps {
|
||||||
warning: string | null;
|
warning: string | null;
|
||||||
debugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
debugDetails?: OpenCodeRuntimeDeliveryDebugDetails | null;
|
||||||
|
pendingDelayMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpenCodeDeliveryWarning({
|
export function OpenCodeDeliveryWarning({
|
||||||
warning,
|
warning,
|
||||||
debugDetails,
|
debugDetails,
|
||||||
|
pendingDelayMs = 10_000,
|
||||||
}: OpenCodeDeliveryWarningProps): JSX.Element | null {
|
}: OpenCodeDeliveryWarningProps): JSX.Element | null {
|
||||||
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`;
|
const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`;
|
||||||
|
const delayPendingWarning =
|
||||||
|
debugDetails?.responsePending === true && debugDetails.delivered !== false;
|
||||||
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||||
|
const [pendingVisibleKey, setPendingVisibleKey] = useState<string | null>(() =>
|
||||||
|
delayPendingWarning ? null : detailsKey
|
||||||
|
);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const copiedResetTimerRef = useRef<number | null>(null);
|
const copiedResetTimerRef = useRef<number | null>(null);
|
||||||
|
const pendingTimerRef = useRef<number | null>(null);
|
||||||
const expanded = expandedKey === detailsKey;
|
const expanded = expandedKey === detailsKey;
|
||||||
const copied = copiedKey === detailsKey;
|
const copied = copiedKey === detailsKey;
|
||||||
const copyText = useMemo(
|
const copyText = useMemo(
|
||||||
|
|
@ -36,10 +44,36 @@ export function OpenCodeDeliveryWarning({
|
||||||
if (copiedResetTimerRef.current !== null) {
|
if (copiedResetTimerRef.current !== null) {
|
||||||
window.clearTimeout(copiedResetTimerRef.current);
|
window.clearTimeout(copiedResetTimerRef.current);
|
||||||
}
|
}
|
||||||
|
if (pendingTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingTimerRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(pendingTimerRef.current);
|
||||||
|
pendingTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (!warning) {
|
||||||
|
setPendingVisibleKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!delayPendingWarning || pendingDelayMs <= 0) {
|
||||||
|
setPendingVisibleKey(detailsKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingVisibleKey(null);
|
||||||
|
pendingTimerRef.current = window.setTimeout(() => {
|
||||||
|
pendingTimerRef.current = null;
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setPendingVisibleKey(detailsKey);
|
||||||
|
}
|
||||||
|
}, pendingDelayMs);
|
||||||
|
}, [delayPendingWarning, detailsKey, pendingDelayMs, warning]);
|
||||||
|
|
||||||
if (!warning) return null;
|
if (!warning) return null;
|
||||||
|
if (delayPendingWarning && pendingVisibleKey !== detailsKey) return null;
|
||||||
|
|
||||||
const handleCopy = async (): Promise<void> => {
|
const handleCopy = async (): Promise<void> => {
|
||||||
if (!copyText || !navigator.clipboard?.writeText) return;
|
if (!copyText || !navigator.clipboard?.writeText) return;
|
||||||
|
|
|
||||||
|
|
@ -981,6 +981,7 @@ export interface PersistedTeamLaunchMemberState {
|
||||||
providerBackendId?: TeamProviderBackendId;
|
providerBackendId?: TeamProviderBackendId;
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
|
cwd?: string;
|
||||||
selectedFastMode?: TeamFastMode;
|
selectedFastMode?: TeamFastMode;
|
||||||
resolvedFastMode?: boolean;
|
resolvedFastMode?: boolean;
|
||||||
laneId?: string;
|
laneId?: string;
|
||||||
|
|
@ -1085,6 +1086,8 @@ export interface TeamAgentRuntimeEntry {
|
||||||
laneKind?: 'primary' | 'secondary';
|
laneKind?: 'primary' | 'secondary';
|
||||||
pid?: number;
|
pid?: number;
|
||||||
runtimeModel?: string;
|
runtimeModel?: string;
|
||||||
|
/** Runtime working directory, when known. */
|
||||||
|
cwd?: string;
|
||||||
rssBytes?: number;
|
rssBytes?: number;
|
||||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
pidSource?: TeamAgentRuntimePidSource;
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
|
@ -1221,6 +1224,8 @@ export interface TeamProvisioningMemberInput {
|
||||||
workflow?: string;
|
workflow?: string;
|
||||||
/** Opt-in: run this teammate in its own git worktree. */
|
/** Opt-in: run this teammate in its own git worktree. */
|
||||||
isolation?: 'worktree';
|
isolation?: 'worktree';
|
||||||
|
/** Resolved runtime working directory. Usually app-managed for isolated teammates. */
|
||||||
|
cwd?: string;
|
||||||
providerId?: TeamProviderId;
|
providerId?: TeamProviderId;
|
||||||
providerBackendId?: TeamProviderBackendId;
|
providerBackendId?: TeamProviderBackendId;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
|
|
||||||
|
|
@ -558,6 +558,7 @@ async function runModelGauntlet(input: {
|
||||||
minimumAverageScore: input.minimumAverageScore,
|
minimumAverageScore: input.minimumAverageScore,
|
||||||
minimumSuccessfulRuns: input.minimumSuccessfulRuns,
|
minimumSuccessfulRuns: input.minimumSuccessfulRuns,
|
||||||
minimumConsistencyScore: input.minimumConsistencyScore,
|
minimumConsistencyScore: input.minimumConsistencyScore,
|
||||||
|
consistencyScore: scoreStability.consistencyScore,
|
||||||
hardFailures,
|
hardFailures,
|
||||||
providerInfraFailures,
|
providerInfraFailures,
|
||||||
runtimeTransportFailures,
|
runtimeTransportFailures,
|
||||||
|
|
@ -952,9 +953,10 @@ async function runGauntletOnce(input: {
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (harness) {
|
const activeHarness = harness as Awaited<ReturnType<typeof createOpenCodeLiveHarness>> | null;
|
||||||
await harness.svc.stopTeam(teamName).catch(() => undefined);
|
if (activeHarness) {
|
||||||
await harness.dispose().catch(() => undefined);
|
await activeHarness.svc.stopTeam(teamName).catch(() => undefined);
|
||||||
|
await activeHarness.dispose().catch(() => undefined);
|
||||||
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
|
await waitForOpenCodeLanesStopped(teamName).catch(() => undefined);
|
||||||
}
|
}
|
||||||
setClaudeBasePathOverride(null);
|
setClaudeBasePathOverride(null);
|
||||||
|
|
|
||||||
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 () => {
|
it('observes accepted OpenCode prompt delivery before sending the same inbox row again', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||||
|
|
@ -3960,6 +4030,37 @@ describe('TeamProvisioningService', () => {
|
||||||
ledgerStatus: 'failed_terminal',
|
ledgerStatus: 'failed_terminal',
|
||||||
reason: 'empty_assistant_turn',
|
reason: 'empty_assistant_turn',
|
||||||
});
|
});
|
||||||
|
const inboxDir = path.join(tempTeamsBase, 'team-a', 'inboxes');
|
||||||
|
await fsPromises.mkdir(inboxDir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
path.join(inboxDir, 'user.json'),
|
||||||
|
`${JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
from: 'bob',
|
||||||
|
to: 'user',
|
||||||
|
text: 'Late but valid answer.',
|
||||||
|
timestamp: '2026-04-25T10:00:04.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'reply-after-terminal',
|
||||||
|
relayOfMessageId: 'msg-max-attempts',
|
||||||
|
source: 'runtime_delivery',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await expect(deliver()).resolves.toMatchObject({
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: 'responded_visible_message',
|
||||||
|
ledgerStatus: 'responded',
|
||||||
|
visibleReplyMessageId: 'reply-after-terminal',
|
||||||
|
visibleReplyCorrelation: 'relayOfMessageId',
|
||||||
|
});
|
||||||
expect(sendMessageToMember).toHaveBeenCalledTimes(3);
|
expect(sendMessageToMember).toHaveBeenCalledTimes(3);
|
||||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
|
expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
@ -6485,7 +6586,9 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('safe app launch matrix', () => {
|
describe('safe app launch matrix', () => {
|
||||||
function createSafeLaunchService() {
|
function createSafeLaunchService(options?: {
|
||||||
|
memberWorktreeManager?: { ensureMemberWorktree: ReturnType<typeof vi.fn> };
|
||||||
|
}) {
|
||||||
const mcpConfigBuilder = {
|
const mcpConfigBuilder = {
|
||||||
writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')),
|
writeConfigFile: vi.fn(async () => path.join(tempClaudeRoot, 'mcp-config.json')),
|
||||||
removeConfigFile: vi.fn(async () => {}),
|
removeConfigFile: vi.fn(async () => {}),
|
||||||
|
|
@ -6506,7 +6609,10 @@ describe('TeamProvisioningService', () => {
|
||||||
membersMetaStore as any,
|
membersMetaStore as any,
|
||||||
undefined,
|
undefined,
|
||||||
mcpConfigBuilder as any,
|
mcpConfigBuilder as any,
|
||||||
teamMetaStore as any
|
teamMetaStore as any,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
options?.memberWorktreeManager as any
|
||||||
);
|
);
|
||||||
|
|
||||||
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
||||||
|
|
@ -6974,6 +7080,167 @@ describe('TeamProvisioningService', () => {
|
||||||
|
|
||||||
await svc.cancelProvisioning(runId);
|
await svc.cancelProvisioning(runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||||
|
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||||
|
|
||||||
|
const bobWorktree = path.join(tempClaudeRoot, 'worktrees', 'bob');
|
||||||
|
const worktreeManager = {
|
||||||
|
ensureMemberWorktree: vi.fn(async () => ({
|
||||||
|
baseRepoPath: tempClaudeRoot,
|
||||||
|
worktreePath: bobWorktree,
|
||||||
|
branchName: 'agent-teams/test/bob',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||||
|
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
|
||||||
|
const memberName = expectedMembers[0]?.name ?? 'unknown';
|
||||||
|
return {
|
||||||
|
runId: String(input.runId),
|
||||||
|
teamName: String(input.teamName),
|
||||||
|
launchPhase: 'finished',
|
||||||
|
teamLaunchState: 'clean_success',
|
||||||
|
members: {
|
||||||
|
[memberName]: {
|
||||||
|
memberName,
|
||||||
|
providerId: 'opencode',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const { svc, membersMetaStore } = createSafeLaunchService({ memberWorktreeManager: worktreeManager });
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: adapterLaunch,
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName: 'safe-mixed-opencode-worktree-launch',
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'codex',
|
||||||
|
providerBackendId: 'codex-native',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
effort: 'medium',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'codex',
|
||||||
|
model: 'gpt-5.4-mini',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax-m2.5-free',
|
||||||
|
isolation: 'worktree',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(worktreeManager.ensureMemberWorktree).toHaveBeenCalledWith({
|
||||||
|
teamName: 'safe-mixed-opencode-worktree-launch',
|
||||||
|
memberName: 'bob',
|
||||||
|
baseCwd: tempClaudeRoot,
|
||||||
|
});
|
||||||
|
expect(membersMetaStore.writeMembers).toHaveBeenCalledWith(
|
||||||
|
'safe-mixed-opencode-worktree-launch',
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
isolation: 'worktree',
|
||||||
|
cwd: bobWorktree,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
expect.objectContaining({ providerBackendId: 'codex-native' })
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = (svc as any).runs.get(runId);
|
||||||
|
await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
|
||||||
|
await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(adapterLaunch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
cwd: bobWorktree,
|
||||||
|
expectedMembers: [
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
isolation: 'worktree',
|
||||||
|
cwd: bobWorktree,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await svc.cancelProvisioning(runId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects multi-member pure OpenCode worktree isolation instead of sharing one projectPath', async () => {
|
||||||
|
allowConsoleLogs();
|
||||||
|
const adapterLaunch = vi.fn();
|
||||||
|
const { svc } = createSafeLaunchService();
|
||||||
|
svc.setRuntimeAdapterRegistry(
|
||||||
|
new TeamRuntimeAdapterRegistry([
|
||||||
|
{
|
||||||
|
providerId: 'opencode',
|
||||||
|
prepare: vi.fn(),
|
||||||
|
launch: adapterLaunch,
|
||||||
|
reconcile: vi.fn(),
|
||||||
|
stop: vi.fn(),
|
||||||
|
} as any,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName: 'blocked-opencode-multi-worktree',
|
||||||
|
cwd: tempClaudeRoot,
|
||||||
|
providerId: 'opencode',
|
||||||
|
providerBackendId: 'adapter',
|
||||||
|
model: 'big-pickle',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'bob',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax-m2.5-free',
|
||||||
|
isolation: 'worktree',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tom',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'nemotron-3-super-free',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
() => {}
|
||||||
|
)
|
||||||
|
).rejects.toThrow('Multiple OpenCode members in one lane cannot use separate worktrees yet');
|
||||||
|
expect(adapterLaunch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes generated MCP config when launchTeam spawn fails synchronously', async () => {
|
it('removes generated MCP config when launchTeam spawn fails synchronously', async () => {
|
||||||
|
|
|
||||||
|
|
@ -495,6 +495,98 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows a worktree badge only for teammates configured with worktree isolation', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(MemberCard, {
|
||||||
|
member: {
|
||||||
|
...member,
|
||||||
|
providerId: 'opencode',
|
||||||
|
isolation: 'worktree',
|
||||||
|
cwd: '/tmp/project-alice-worktree',
|
||||||
|
},
|
||||||
|
memberColor: 'blue',
|
||||||
|
runtimeSummary: 'kimi · via OpenCode',
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('worktree');
|
||||||
|
expect(
|
||||||
|
host.querySelector(
|
||||||
|
'[title="Worktree isolation configured. Worktree path: /tmp/project-alice-worktree"]'
|
||||||
|
)
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(MemberCard, {
|
||||||
|
member: {
|
||||||
|
...member,
|
||||||
|
providerId: 'opencode',
|
||||||
|
isolation: 'worktree',
|
||||||
|
},
|
||||||
|
memberColor: 'blue',
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: true,
|
||||||
|
restartable: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
runtimeSummary: 'kimi · via OpenCode',
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain('worktree');
|
||||||
|
expect(
|
||||||
|
host.querySelector(
|
||||||
|
'[title="Worktree isolation is configured, but the runtime path is not available yet"]'
|
||||||
|
)
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(host.querySelector('[title="Worktree isolation configured. Runtime cwd: /tmp/project"]'))
|
||||||
|
.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(MemberCard, {
|
||||||
|
member: {
|
||||||
|
...member,
|
||||||
|
providerId: 'opencode',
|
||||||
|
cwd: '/tmp/project',
|
||||||
|
},
|
||||||
|
memberColor: 'blue',
|
||||||
|
runtimeSummary: 'kimi · via OpenCode',
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).not.toContain('worktree');
|
||||||
|
expect(host.textContent).not.toContain('shared');
|
||||||
|
expect(host.querySelector('[title^="Shared workspace"]')).toBeNull();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('copies bounded launch diagnostics only for launch errors', async () => {
|
it('copies bounded launch diagnostics only for launch errors', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,12 @@ function renderWarning(props: Partial<React.ComponentProps<typeof OpenCodeDelive
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
root.render(
|
root.render(
|
||||||
<OpenCodeDeliveryWarning warning={warning} debugDetails={debugDetails} {...props} />
|
<OpenCodeDeliveryWarning
|
||||||
|
warning={warning}
|
||||||
|
debugDetails={debugDetails}
|
||||||
|
pendingDelayMs={0}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -47,6 +52,7 @@ function findButton(host: HTMLElement, text: string): HTMLButtonElement {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -115,6 +121,58 @@ describe('OpenCodeDeliveryWarning', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delays pending runtime delivery warnings by default', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<OpenCodeDeliveryWarning warning={warning} debugDetails={debugDetails} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).not.toContain(warning);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(9_999);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).not.toContain(warning);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain(warning);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows failed runtime delivery warnings immediately', async () => {
|
||||||
|
const failedWarning =
|
||||||
|
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete.';
|
||||||
|
const { host, root } = renderWarning({
|
||||||
|
warning: failedWarning,
|
||||||
|
debugDetails: {
|
||||||
|
...debugDetails,
|
||||||
|
delivered: false,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: 'failed',
|
||||||
|
ledgerStatus: 'failed_terminal',
|
||||||
|
reason: 'tool_error',
|
||||||
|
diagnostics: ['tool_error'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain(failedWarning);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('hides details again when a different runtime delivery payload arrives', async () => {
|
it('hides details again when a different runtime delivery payload arrives', async () => {
|
||||||
const { host, root } = renderWarning();
|
const { host, root } = renderWarning();
|
||||||
|
|
||||||
|
|
@ -127,6 +185,7 @@ describe('OpenCodeDeliveryWarning', () => {
|
||||||
root.render(
|
root.render(
|
||||||
<OpenCodeDeliveryWarning
|
<OpenCodeDeliveryWarning
|
||||||
warning={warning}
|
warning={warning}
|
||||||
|
pendingDelayMs={0}
|
||||||
debugDetails={{
|
debugDetails={{
|
||||||
...debugDetails,
|
...debugDetails,
|
||||||
messageId: 'm-opencode-2',
|
messageId: 'm-opencode-2',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue