feat(team): support controlled opencode teammate relaunch
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
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,
|
||||
} 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}>
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||