feat(team): support controlled opencode teammate relaunch

This commit is contained in:
777genius 2026-04-28 15:32:21 +03:00
parent 95da573081
commit 28d0ab20c0
20 changed files with 289 additions and 16 deletions

View file

@ -654,7 +654,7 @@ function drawAvatar(
isLead: boolean,
avatarUrl?: string
): void {
const avatarR = r * 0.6;
const avatarR = r * AGENT_DRAW.avatarRadiusScale;
// Try to draw avatar image
if (avatarUrl) {

View file

@ -58,9 +58,9 @@ export const FORCE = {
export const NODE = {
/** Lead agent radius */
radiusLead: 32,
radiusLead: 38,
/** Team member radius */
radiusMember: 24,
radiusMember: 30,
/** Process node radius */
radiusProcess: 14,
/** Cross-team ghost node radius */
@ -110,6 +110,7 @@ export const AGENT_DRAW = {
sparkScale: 0.45,
sparkViewBox: 256,
subIconScale: 0.45,
avatarRadiusScale: 0.74,
} as const;
// ─── Context ring (lead node only) ─────────────────────────────────────────

View file

@ -466,8 +466,8 @@ export class TeamGraphAdapter {
launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined,
contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined,
avatarUrl: leadMember
? resolveMemberAvatarUrl(leadMember, avatarMap, 64)
: agentAvatarUrl(leadName, 64),
? resolveMemberAvatarUrl(leadMember, avatarMap, 96)
: agentAvatarUrl(leadName, 96),
pendingApproval,
activeTool: activeTool
? {
@ -567,7 +567,7 @@ export class TeamGraphAdapter {
launchStatusLabel: isTeamVisualOnline
? (launchPresentation.launchStatusLabel ?? undefined)
: undefined,
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64),
avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96),
currentTaskId: member.currentTaskId ?? undefined,
currentTaskSubject: member.currentTaskId
? data.tasks.find((t) => t.id === member.currentTaskId)?.subject

View file

@ -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(
member: PersistedTeamLaunchMemberState | undefined | null
): boolean {
@ -9618,6 +9637,20 @@ export class TeamProvisioningService {
);
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) {
await this.stopSingleMixedSecondaryRuntimeLane(run, existingLane, 'relaunch');
}
@ -9629,7 +9662,10 @@ export class TeamProvisioningService {
laneState.state = 'queued';
laneState.result = null;
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) {
run.mixedSecondaryLanes[existingLaneIndex] = laneState;
@ -9647,6 +9683,45 @@ export class TeamProvisioningService {
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> {
const run = this.getMutableAliveRunOrThrow(teamName);
const laneIndex = run.mixedSecondaryLanes.findIndex((lane) =>
@ -16512,6 +16587,7 @@ export class TeamProvisioningService {
run: ProvisioningRun,
lane: MixedSecondaryRuntimeLaneState
): Promise<void> {
const requestedDiagnostics = [...lane.diagnostics];
const adapter = this.getOpenCodeRuntimeAdapter();
if (!adapter) {
const message = 'OpenCode runtime adapter is not registered for mixed team launch.';
@ -16535,10 +16611,10 @@ export class TeamProvisioningService {
},
},
warnings: [],
diagnostics: [message],
diagnostics: [...requestedDiagnostics, message],
};
lane.warnings = [];
lane.diagnostics = [message];
lane.diagnostics = [...requestedDiagnostics, message];
await this.publishMixedSecondaryLaneStatusChange(run, lane);
lane.state = 'finished';
return;
@ -16560,7 +16636,7 @@ export class TeamProvisioningService {
lane.state = 'launching';
lane.runId = lane.runId ?? randomUUID();
lane.warnings = [];
lane.diagnostics = [...migration.diagnostics];
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics];
const laneCwd = lane.member.cwd?.trim() || run.request.cwd;
this.setSecondaryRuntimeRun({
teamName: run.teamName,
@ -16610,10 +16686,11 @@ export class TeamProvisioningService {
}
lane.result = result;
lane.warnings = [...result.warnings];
lane.diagnostics = [...migration.diagnostics, ...result.diagnostics];
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, ...result.diagnostics];
if (isDefinitiveOpenCodePreLaunchFailure(result, lane.member.name)) {
const diagnostics = [
...requestedDiagnostics,
...migration.diagnostics,
...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name),
];
@ -16656,7 +16733,7 @@ export class TeamProvisioningService {
diagnostics: [message],
};
lane.warnings = [];
lane.diagnostics = [...migration.diagnostics, message];
lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, message];
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName: run.teamName,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View file

@ -46,6 +46,37 @@ import type {
TeamTaskWithKanban,
} 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 {
open: boolean;
member: ResolvedTeamMember | null;
@ -165,6 +196,13 @@ export const MemberDetailDialog = ({
const launchErrorMessage = launchDiagnosticsPayload
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
: 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(() => {
if (!open || !member) {
@ -284,10 +322,10 @@ export const MemberDetailDialog = ({
<DialogFooter>
{restartError ? (
<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">
<span className="min-w-0 truncate" title={launchErrorMessage}>
{launchErrorMessage}
<span className="min-w-0 truncate" title={effectiveLaunchErrorMessage}>
{effectiveLaunchErrorMessage}
</span>
{launchDiagnosticsPayload && showCopyDiagnostics ? (
<MemberLaunchDiagnosticsButton
@ -339,7 +377,7 @@ export const MemberDetailDialog = ({
) : (
<RotateCcw size={14} />
)}
Restart
{restartButtonLabel}
</Button>
)}
<Button variant="outline" size="sm" className="gap-1.5" onClick={onSendMessage}>

View file

@ -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 () => {
const teamName = 'mixed-opencode-update-member-reattach-safe-e2e';
await writeMixedTeamConfig({ teamName, projectPath });

View file

@ -336,4 +336,80 @@ describe('MemberDetailDialog activity count', () => {
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();
});
});
});