feat(team): support controlled opencode teammate relaunch
|
|
@ -654,7 +654,7 @@ function drawAvatar(
|
||||||
isLead: boolean,
|
isLead: boolean,
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
): void {
|
): void {
|
||||||
const avatarR = r * 0.6;
|
const avatarR = r * AGENT_DRAW.avatarRadiusScale;
|
||||||
|
|
||||||
// Try to draw avatar image
|
// Try to draw avatar image
|
||||||
if (avatarUrl) {
|
if (avatarUrl) {
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,9 @@ export const FORCE = {
|
||||||
|
|
||||||
export const NODE = {
|
export const NODE = {
|
||||||
/** Lead agent radius */
|
/** Lead agent radius */
|
||||||
radiusLead: 32,
|
radiusLead: 38,
|
||||||
/** Team member radius */
|
/** Team member radius */
|
||||||
radiusMember: 24,
|
radiusMember: 30,
|
||||||
/** Process node radius */
|
/** Process node radius */
|
||||||
radiusProcess: 14,
|
radiusProcess: 14,
|
||||||
/** Cross-team ghost node radius */
|
/** Cross-team ghost node radius */
|
||||||
|
|
@ -110,6 +110,7 @@ export const AGENT_DRAW = {
|
||||||
sparkScale: 0.45,
|
sparkScale: 0.45,
|
||||||
sparkViewBox: 256,
|
sparkViewBox: 256,
|
||||||
subIconScale: 0.45,
|
subIconScale: 0.45,
|
||||||
|
avatarRadiusScale: 0.74,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// ─── Context ring (lead node only) ─────────────────────────────────────────
|
// ─── Context ring (lead node only) ─────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -466,8 +466,8 @@ export class TeamGraphAdapter {
|
||||||
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
|
||||||
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
|
||||||
avatarUrl: leadMember
|
avatarUrl: leadMember
|
||||||
? resolveMemberAvatarUrl(leadMember, avatarMap, 64)
|
? resolveMemberAvatarUrl(leadMember, avatarMap, 96)
|
||||||
: agentAvatarUrl(leadName, 64),
|
: agentAvatarUrl(leadName, 96),
|
||||||
pendingApproval,
|
pendingApproval,
|
||||||
activeTool: activeTool
|
activeTool: activeTool
|
||||||
? {
|
? {
|
||||||
|
|
@ -567,7 +567,7 @@ export class TeamGraphAdapter {
|
||||||
launchStatusLabel: isTeamVisualOnline
|
launchStatusLabel: isTeamVisualOnline
|
||||||
? (launchPresentation.launchStatusLabel ?? undefined)
|
? (launchPresentation.launchStatusLabel ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
|
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
|
||||||
currentTaskId: member.currentTaskId ?? undefined,
|
currentTaskId: member.currentTaskId ?? undefined,
|
||||||
currentTaskSubject: member.currentTaskId
|
currentTaskSubject: member.currentTaskId
|
||||||
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject
|
||||||
|
|
|
||||||
|
|
@ -1626,6 +1626,25 @@ function hasOpenCodeRuntimeLivenessMarker(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOpenCodeRuntimeEntryHandle(
|
||||||
|
value:
|
||||||
|
| Pick<TeamAgentRuntimeEntry, 'pid' | 'runtimePid' | 'runtimeSessionId' | 'livenessKind'>
|
||||||
|
| undefined
|
||||||
|
| null
|
||||||
|
): boolean {
|
||||||
|
if (!value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const pid = typeof value.pid === 'number' && Number.isFinite(value.pid) && value.pid > 0;
|
||||||
|
const runtimePid =
|
||||||
|
typeof value.runtimePid === 'number' &&
|
||||||
|
Number.isFinite(value.runtimePid) &&
|
||||||
|
value.runtimePid > 0;
|
||||||
|
const runtimeSessionId =
|
||||||
|
typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0;
|
||||||
|
return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value);
|
||||||
|
}
|
||||||
|
|
||||||
function isRecoverablePersistedOpenCodeRuntimeCandidate(
|
function isRecoverablePersistedOpenCodeRuntimeCandidate(
|
||||||
member: PersistedTeamLaunchMemberState | undefined | null
|
member: PersistedTeamLaunchMemberState | undefined | null
|
||||||
): boolean {
|
): boolean {
|
||||||
|
|
@ -9618,6 +9637,20 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
const existingLane = existingLaneIndex >= 0 ? run.mixedSecondaryLanes[existingLaneIndex] : null;
|
const existingLane = existingLaneIndex >= 0 ? run.mixedSecondaryLanes[existingLaneIndex] : null;
|
||||||
|
|
||||||
|
if (run.pendingMemberRestarts.has(memberName)) {
|
||||||
|
throw new Error(`Restart for teammate "${memberName}" is already in progress`);
|
||||||
|
}
|
||||||
|
if (existingLane?.state === 'queued' || existingLane?.state === 'launching') {
|
||||||
|
throw new Error(`Restart for teammate "${memberName}" is already in progress`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRuntimeEvidence = await this.hasOpenCodeMemberRuntimeEvidenceForControlledRelaunch({
|
||||||
|
teamName,
|
||||||
|
memberName: memberSpec.name,
|
||||||
|
laneId: nextLane.laneId,
|
||||||
|
existingLane,
|
||||||
|
});
|
||||||
|
|
||||||
if (existingLane) {
|
if (existingLane) {
|
||||||
await this.stopSingleMixedSecondaryRuntimeLane(run, existingLane, 'relaunch');
|
await this.stopSingleMixedSecondaryRuntimeLane(run, existingLane, 'relaunch');
|
||||||
}
|
}
|
||||||
|
|
@ -9629,7 +9662,10 @@ export class TeamProvisioningService {
|
||||||
laneState.state = 'queued';
|
laneState.state = 'queued';
|
||||||
laneState.result = null;
|
laneState.result = null;
|
||||||
laneState.warnings = [];
|
laneState.warnings = [];
|
||||||
laneState.diagnostics = options?.reason ? [`controlled_reattach:${options.reason}`] : [];
|
laneState.diagnostics = [
|
||||||
|
...(options?.reason ? [`controlled_reattach:${options.reason}`] : []),
|
||||||
|
...(!hasRuntimeEvidence ? ['fresh_relaunch:no_runtime_evidence'] : []),
|
||||||
|
];
|
||||||
|
|
||||||
if (existingLaneIndex >= 0) {
|
if (existingLaneIndex >= 0) {
|
||||||
run.mixedSecondaryLanes[existingLaneIndex] = laneState;
|
run.mixedSecondaryLanes[existingLaneIndex] = laneState;
|
||||||
|
|
@ -9647,6 +9683,45 @@ export class TeamProvisioningService {
|
||||||
await this.launchSingleMixedSecondaryLane(run, laneState);
|
await this.launchSingleMixedSecondaryLane(run, laneState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hasOpenCodeMemberRuntimeEvidenceForControlledRelaunch(params: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
laneId: string;
|
||||||
|
existingLane: MixedSecondaryRuntimeLaneState | null;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const laneResultMember =
|
||||||
|
params.existingLane?.result?.members[params.memberName] ??
|
||||||
|
Object.values(params.existingLane?.result?.members ?? {}).find(
|
||||||
|
(member) => member.memberName?.trim() === params.memberName
|
||||||
|
);
|
||||||
|
if (hasOpenCodeRuntimeHandle(laneResultMember)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persistedSnapshot = await this.launchStateStore.read(params.teamName).catch(() => null);
|
||||||
|
const persistedMember =
|
||||||
|
persistedSnapshot?.members[params.memberName] ??
|
||||||
|
Object.values(persistedSnapshot?.members ?? {}).find(
|
||||||
|
(member) => member.laneId === params.laneId
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hasOpenCodeRuntimeHandle(persistedMember) ||
|
||||||
|
hasOpenCodeRuntimeLivenessMarker(persistedMember)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(params.teamName).catch(
|
||||||
|
() => new Map<string, TeamAgentRuntimeEntry>()
|
||||||
|
);
|
||||||
|
const liveRuntimeMember =
|
||||||
|
liveRuntimeByMember.get(params.memberName) ??
|
||||||
|
[...liveRuntimeByMember.entries()].find(([candidateName]) =>
|
||||||
|
matchesObservedMemberNameForExpected(candidateName, params.memberName)
|
||||||
|
)?.[1];
|
||||||
|
return hasOpenCodeRuntimeEntryHandle(liveRuntimeMember);
|
||||||
|
}
|
||||||
|
|
||||||
async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise<void> {
|
async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise<void> {
|
||||||
const run = this.getMutableAliveRunOrThrow(teamName);
|
const run = this.getMutableAliveRunOrThrow(teamName);
|
||||||
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
|
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
|
||||||
|
|
@ -16512,6 +16587,7 @@ export class TeamProvisioningService {
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
lane: MixedSecondaryRuntimeLaneState
|
lane: MixedSecondaryRuntimeLaneState
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const requestedDiagnostics = [...lane.diagnostics];
|
||||||
const adapter = this.getOpenCodeRuntimeAdapter();
|
const adapter = this.getOpenCodeRuntimeAdapter();
|
||||||
if (!adapter) {
|
if (!adapter) {
|
||||||
const message = 'OpenCode runtime adapter is not registered for mixed team launch.';
|
const message = 'OpenCode runtime adapter is not registered for mixed team launch.';
|
||||||
|
|
@ -16535,10 +16611,10 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
warnings: [],
|
warnings: [],
|
||||||
diagnostics: [message],
|
diagnostics: [...requestedDiagnostics, message],
|
||||||
};
|
};
|
||||||
lane.warnings = [];
|
lane.warnings = [];
|
||||||
lane.diagnostics = [message];
|
lane.diagnostics = [...requestedDiagnostics, message];
|
||||||
await this.publishMixedSecondaryLaneStatusChange(run, lane);
|
await this.publishMixedSecondaryLaneStatusChange(run, lane);
|
||||||
lane.state = 'finished';
|
lane.state = 'finished';
|
||||||
return;
|
return;
|
||||||
|
|
@ -16560,7 +16636,7 @@ export class TeamProvisioningService {
|
||||||
lane.state = 'launching';
|
lane.state = 'launching';
|
||||||
lane.runId = lane.runId ?? randomUUID();
|
lane.runId = lane.runId ?? randomUUID();
|
||||||
lane.warnings = [];
|
lane.warnings = [];
|
||||||
lane.diagnostics = [...migration.diagnostics];
|
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
|
||||||
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
|
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
|
||||||
this.setSecondaryRuntimeRun({
|
this.setSecondaryRuntimeRun({
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
|
|
@ -16610,10 +16686,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
lane.result = result;
|
lane.result = result;
|
||||||
lane.warnings = [...result.warnings];
|
lane.warnings = [...result.warnings];
|
||||||
lane.diagnostics = [...migration.diagnostics, ...result.diagnostics];
|
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, ...result.diagnostics];
|
||||||
|
|
||||||
if (isDefinitiveOpenCodePreLaunchFailure(result, lane.member.name)) {
|
if (isDefinitiveOpenCodePreLaunchFailure(result, lane.member.name)) {
|
||||||
const diagnostics = [
|
const diagnostics = [
|
||||||
|
...requestedDiagnostics,
|
||||||
...migration.diagnostics,
|
...migration.diagnostics,
|
||||||
...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name),
|
...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name),
|
||||||
];
|
];
|
||||||
|
|
@ -16656,7 +16733,7 @@ export class TeamProvisioningService {
|
||||||
diagnostics: [message],
|
diagnostics: [message],
|
||||||
};
|
};
|
||||||
lane.warnings = [];
|
lane.warnings = [];
|
||||||
lane.diagnostics = [...migration.diagnostics, message];
|
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, message];
|
||||||
await upsertOpenCodeRuntimeLaneIndexEntry({
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 1.3 MiB |
|
|
@ -46,6 +46,37 @@ import type {
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
const OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE =
|
||||||
|
'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.';
|
||||||
|
|
||||||
|
function hasOpenCodeRuntimeEvidence(runtimeEntry: TeamAgentRuntimeEntry | undefined): boolean {
|
||||||
|
const hasPid =
|
||||||
|
typeof runtimeEntry?.pid === 'number' &&
|
||||||
|
Number.isFinite(runtimeEntry.pid) &&
|
||||||
|
runtimeEntry.pid > 0;
|
||||||
|
const hasRuntimePid =
|
||||||
|
typeof runtimeEntry?.runtimePid === 'number' &&
|
||||||
|
Number.isFinite(runtimeEntry.runtimePid) &&
|
||||||
|
runtimeEntry.runtimePid > 0;
|
||||||
|
const hasRuntimeSessionId =
|
||||||
|
typeof runtimeEntry?.runtimeSessionId === 'string' &&
|
||||||
|
runtimeEntry.runtimeSessionId.trim().length > 0;
|
||||||
|
const hasRuntimeLiveness =
|
||||||
|
runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||||
|
runtimeEntry?.livenessKind === 'runtime_process_candidate' ||
|
||||||
|
runtimeEntry?.livenessKind === 'permission_blocked';
|
||||||
|
return Boolean(hasPid || hasRuntimePid || hasRuntimeSessionId || hasRuntimeLiveness);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenCodeNoRuntimeEvidenceFailure(
|
||||||
|
member: ResolvedTeamMember,
|
||||||
|
spawnEntry: MemberSpawnStatusEntry | undefined,
|
||||||
|
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||||
|
): boolean {
|
||||||
|
const failed = spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error';
|
||||||
|
return member.providerId === 'opencode' && failed && !hasOpenCodeRuntimeEvidence(runtimeEntry);
|
||||||
|
}
|
||||||
|
|
||||||
interface MemberDetailDialogProps {
|
interface MemberDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
member: ResolvedTeamMember | null;
|
member: ResolvedTeamMember | null;
|
||||||
|
|
@ -165,6 +196,13 @@ export const MemberDetailDialog = ({
|
||||||
const launchErrorMessage = launchDiagnosticsPayload
|
const launchErrorMessage = launchDiagnosticsPayload
|
||||||
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
|
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const openCodeNoRuntimeEvidence = member
|
||||||
|
? isOpenCodeNoRuntimeEvidenceFailure(member, spawnEntry, runtimeEntry)
|
||||||
|
: false;
|
||||||
|
const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence
|
||||||
|
? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE
|
||||||
|
: launchErrorMessage;
|
||||||
|
const restartButtonLabel = openCodeNoRuntimeEvidence ? 'Relaunch OpenCode' : 'Restart';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
@ -284,10 +322,10 @@ export const MemberDetailDialog = ({
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{restartError ? (
|
{restartError ? (
|
||||||
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
||||||
) : launchErrorMessage ? (
|
) : effectiveLaunchErrorMessage ? (
|
||||||
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
|
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
|
||||||
<span className="min-w-0 truncate" title={launchErrorMessage}>
|
<span className="min-w-0 truncate" title={effectiveLaunchErrorMessage}>
|
||||||
{launchErrorMessage}
|
{effectiveLaunchErrorMessage}
|
||||||
</span>
|
</span>
|
||||||
{launchDiagnosticsPayload && showCopyDiagnostics ? (
|
{launchDiagnosticsPayload && showCopyDiagnostics ? (
|
||||||
<MemberLaunchDiagnosticsButton
|
<MemberLaunchDiagnosticsButton
|
||||||
|
|
@ -339,7 +377,7 @@ export const MemberDetailDialog = ({
|
||||||
) : (
|
) : (
|
||||||
<RotateCcw size={14} />
|
<RotateCcw size={14} />
|
||||||
)}
|
)}
|
||||||
Restart
|
{restartButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>
|
||||||
|
|
|
||||||
|
|
@ -14276,6 +14276,87 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fresh relaunches a failed mixed OpenCode teammate without runtime evidence', async () => {
|
||||||
|
const teamName = 'mixed-opencode-fresh-relaunch-no-runtime-evidence-safe-e2e';
|
||||||
|
await writeMixedTeamConfig({ teamName, projectPath });
|
||||||
|
await writeTeamMeta(teamName, projectPath);
|
||||||
|
await writeMembersMeta(teamName);
|
||||||
|
const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', {
|
||||||
|
bob: 'confirmed',
|
||||||
|
tom: 'confirmed',
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
const run = createMixedLiveRun({ teamName, projectPath });
|
||||||
|
run.mixedSecondaryLanes = run.mixedSecondaryLanes.filter(
|
||||||
|
(lane: { member: { name: string } }) => lane.member.name !== 'bob'
|
||||||
|
);
|
||||||
|
run.memberSpawnStatuses.set('bob', {
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'File lock timeout: lanes.json',
|
||||||
|
error: 'File lock timeout: lanes.json',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
} as never);
|
||||||
|
trackLiveRun(svc, run);
|
||||||
|
|
||||||
|
await svc.restartMember(teamName, 'bob');
|
||||||
|
|
||||||
|
await waitForCondition(() => adapter.launchInputs.length === 1);
|
||||||
|
expect(adapter.stopInputs).toHaveLength(0);
|
||||||
|
expect(adapter.launchInputs[0]).toMatchObject({
|
||||||
|
teamName,
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
expectedMembers: [expect.objectContaining({ name: 'bob', providerId: 'opencode' })],
|
||||||
|
});
|
||||||
|
const bobLane = run.mixedSecondaryLanes.find(
|
||||||
|
(lane: { member: { name: string } }) => lane.member.name === 'bob'
|
||||||
|
);
|
||||||
|
expect(bobLane).toMatchObject({
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
diagnostics: expect.arrayContaining([
|
||||||
|
'controlled_reattach:manual_restart',
|
||||||
|
'fresh_relaunch:no_runtime_evidence',
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
hardFailure: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate fresh relaunch while a mixed OpenCode lane is queued', async () => {
|
||||||
|
const teamName = 'mixed-opencode-fresh-relaunch-queued-reject-safe-e2e';
|
||||||
|
await writeMixedTeamConfig({ teamName, projectPath });
|
||||||
|
await writeTeamMeta(teamName, projectPath);
|
||||||
|
await writeMembersMeta(teamName);
|
||||||
|
const adapter = new FakeOpenCodeRuntimeAdapter();
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
const run = createMixedLiveRun({ teamName, projectPath });
|
||||||
|
trackLiveRun(svc, run);
|
||||||
|
|
||||||
|
await expect(svc.restartMember(teamName, 'bob')).rejects.toThrow(
|
||||||
|
'Restart for teammate "bob" is already in progress'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adapter.launchInputs).toHaveLength(0);
|
||||||
|
expect(adapter.stopInputs).toHaveLength(0);
|
||||||
|
expect(
|
||||||
|
run.mixedSecondaryLanes.find(
|
||||||
|
(lane: { member: { name: string } }) => lane.member.name === 'bob'
|
||||||
|
)
|
||||||
|
).toMatchObject({
|
||||||
|
state: 'queued',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reattaches an existing mixed OpenCode teammate after member update without changing siblings', async () => {
|
it('reattaches an existing mixed OpenCode teammate after member update without changing siblings', async () => {
|
||||||
const teamName = 'mixed-opencode-update-member-reattach-safe-e2e';
|
const teamName = 'mixed-opencode-update-member-reattach-safe-e2e';
|
||||||
await writeMixedTeamConfig({ teamName, projectPath });
|
await writeMixedTeamConfig({ teamName, projectPath });
|
||||||
|
|
|
||||||
|
|
@ -336,4 +336,80 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows Relaunch OpenCode copy for failed OpenCode teammates without runtime evidence', async () => {
|
||||||
|
const member: ResolvedTeamMember = {
|
||||||
|
name: 'jack',
|
||||||
|
status: 'active',
|
||||||
|
currentTaskId: null,
|
||||||
|
taskCount: 0,
|
||||||
|
lastActiveAt: null,
|
||||||
|
messageCount: 0,
|
||||||
|
providerId: 'opencode',
|
||||||
|
};
|
||||||
|
const onRestartMember = vi.fn(async () => undefined);
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(MemberDetailDialog, {
|
||||||
|
open: true,
|
||||||
|
member,
|
||||||
|
teamName: 'demo-team',
|
||||||
|
members: [member],
|
||||||
|
tasks: [],
|
||||||
|
isTeamAlive: true,
|
||||||
|
spawnEntry: {
|
||||||
|
status: 'error',
|
||||||
|
launchState: 'failed_to_start',
|
||||||
|
runtimeAlive: false,
|
||||||
|
bootstrapConfirmed: false,
|
||||||
|
hardFailure: true,
|
||||||
|
hardFailureReason: 'File lock timeout: lanes.json',
|
||||||
|
agentToolAccepted: false,
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'jack',
|
||||||
|
alive: false,
|
||||||
|
restartable: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
livenessKind: 'registered_only',
|
||||||
|
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
|
updatedAt: '2026-04-24T12:00:01.000Z',
|
||||||
|
},
|
||||||
|
onClose: () => undefined,
|
||||||
|
onSendMessage: () => undefined,
|
||||||
|
onAssignTask: () => undefined,
|
||||||
|
onTaskClick: () => undefined,
|
||||||
|
onRestartMember,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.textContent).toContain(
|
||||||
|
'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.'
|
||||||
|
);
|
||||||
|
const relaunchButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('Relaunch OpenCode')
|
||||||
|
);
|
||||||
|
expect(relaunchButton).not.toBeUndefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
relaunchButton?.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onRestartMember).toHaveBeenCalledWith('jack');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||