From 0cb30b6ce3e5af3a1f43b3254159551a9947b27e Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 9 May 2026 07:41:14 +0300 Subject: [PATCH] fix(team): support pure opencode restart --- .../services/team/TeamProvisioningService.ts | 160 ++++++++++++++++++ .../team/TeamProvisioningService.test.ts | 110 ++++++++++++ 2 files changed, 270 insertions(+) diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index d59f81d6..f8e10cc0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -14573,10 +14573,16 @@ export class TeamProvisioningService { private async restartMemberUnlocked(teamName: string, memberName: string): Promise { const runId = this.getAliveRunId(teamName); if (!runId) { + if (await this.restartPureOpenCodePrimaryMemberWithoutTrackedRun(teamName, memberName)) { + return; + } throw new Error(`Team "${teamName}" is not currently running`); } const run = this.runs.get(runId); if (!run || run.processKilled || run.cancelRequested) { + if (await this.restartPureOpenCodePrimaryMemberWithoutTrackedRun(teamName, memberName)) { + return; + } throw new Error(`Team "${teamName}" is not currently running`); } @@ -14926,6 +14932,160 @@ export class TeamProvisioningService { } } + private async restartPureOpenCodePrimaryMemberWithoutTrackedRun( + teamName: string, + memberName: string + ): Promise { + const runtimeRun = this.runtimeAdapterRunByTeam.get(teamName); + if (runtimeRun?.providerId !== 'opencode') { + return false; + } + + const adapter = this.getOpenCodeRuntimeAdapter(); + if (!adapter) { + throw new Error('OpenCode runtime adapter is not available for member restart.'); + } + + const config = await this.readConfigForStrictDecision(teamName); + if (!config) { + return false; + } + + const [teamMeta, metaMembers] = await Promise.all([ + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const configuredMember = this.resolveEffectiveConfiguredMember( + config.members ?? [], + metaMembers, + memberName + ); + if (!configuredMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + if (configuredMember.removedAt) { + throw new Error(`Member "${memberName}" has been removed`); + } + if (isLeadMember({ name: configuredMember.name, agentType: configuredMember.agentType })) { + throw new Error('Lead restart is not supported from member controls'); + } + + const leadMember = config.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + if (leadProviderId !== 'opencode') { + return false; + } + + const configuredNames = new Set(); + for (const member of config.members ?? []) { + const name = member.name?.trim(); + if (name) { + configuredNames.add(name); + } + } + for (const member of metaMembers) { + const name = member.name?.trim(); + if (name) { + configuredNames.add(name); + } + } + + const activeMembers = [...configuredNames] + .map((name) => this.resolveEffectiveConfiguredMember(config.members ?? [], metaMembers, name)) + .filter( + ( + member + ): member is NonNullable< + ReturnType + > => { + if (!member || member.removedAt) { + return false; + } + return !isLeadMember({ name: member.name, agentType: member.agentType }); + } + ) + .sort((left, right) => left.name.localeCompare(right.name)); + + const targetMember = activeMembers.find((member) => + matchesExactTeamMemberName(member.name, configuredMember.name) + ); + if (!targetMember) { + throw new Error(`Member "${memberName}" is not configured in team "${teamName}"`); + } + + const nonOpenCodeMember = activeMembers.find((member) => { + const providerId = normalizeOptionalTeamProviderId(member.providerId) ?? leadProviderId; + return providerId !== 'opencode'; + }); + if (nonOpenCodeMember) { + return false; + } + + const projectPath = + targetMember.cwd?.trim() || + config.projectPath?.trim() || + teamMeta?.cwd?.trim() || + runtimeRun.cwd?.trim() || + this.readPersistedTeamProjectPath(teamName); + if (!projectPath) { + throw new Error(`Team "${teamName}" project path is not available for OpenCode restart`); + } + + const effectiveMembers = await this.resolveOpenCodeMemberWorkspacesForRuntime({ + teamName, + baseCwd: projectPath, + leadProviderId: 'opencode', + members: activeMembers.map((member) => this.buildConfiguredProvisioningMember(member)), + }); + const targetRuntimeMember = effectiveMembers.find((member) => + matchesExactTeamMemberName(member.name, targetMember.name) + ); + if (!targetRuntimeMember) { + throw new Error(`Member "${memberName}" could not be resolved for OpenCode restart`); + } + + this.invalidateRuntimeSnapshotCaches(teamName); + this.persistOpenCodeMemberRestartSystemMessage({ + teamName, + leadName: leadMember?.name?.trim() || 'team-lead', + leadSessionId: runtimeRun.runId, + displayName: config.description?.trim() || config.name, + member: targetRuntimeMember, + reason: 'manual_restart', + }); + + await this.runOpenCodeTeamRuntimeAdapterLaunch({ + request: { + teamName, + cwd: projectPath, + prompt: teamMeta?.prompt?.trim() || '', + providerId: 'opencode', + providerBackendId: migrateProviderBackendId('opencode', teamMeta?.providerBackendId), + model: targetRuntimeMember.model?.trim() || teamMeta?.model, + effort: + targetRuntimeMember.effort ?? + (isTeamEffortLevel(teamMeta?.effort) ? teamMeta.effort : undefined), + fastMode: teamMeta?.fastMode, + limitContext: teamMeta?.limitContext, + skipPermissions: teamMeta?.skipPermissions, + worktree: teamMeta?.worktree, + extraCliArgs: teamMeta?.extraCliArgs, + }, + members: effectiveMembers, + prompt: [ + `Restarting OpenCode teammate "${targetRuntimeMember.name}" by user request.`, + 'This is an app-managed OpenCode-only runtime refresh. Re-establish the team sessions and continue from persisted team context.', + ].join('\n'), + sourceWarning: + 'OpenCode-only member restart refreshes the primary OpenCode runtime lane because pure OpenCode teams do not keep a native lead run.', + onProgress: () => undefined, + }); + this.invalidateRuntimeSnapshotCaches(teamName); + return true; + } + async retryFailedOpenCodeSecondaryLanes( teamName: string ): Promise { diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index c7eb7c45..79ea5f71 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -3991,6 +3991,116 @@ describe('TeamProvisioningService', () => { ); }); + it('restarts a pure OpenCode member through the app-owned runtime adapter without a tracked lead run', async () => { + const svc = new TeamProvisioningService(); + const adapterLaunch = vi.fn(); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: adapterLaunch, + reconcile: vi.fn(), + stop: vi.fn(), + } as any, + ]) + ); + + (svc as any).runtimeAdapterRunByTeam.set('pure-opencode-team', { + runId: 'opencode-run-1', + providerId: 'opencode', + cwd: '/repo', + members: {}, + }); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Pure OpenCode Team', + projectPath: '/repo', + members: [ + { name: 'team-lead', agentType: 'team-lead', providerId: 'opencode' }, + { + name: 'alice', + role: 'Reviewer', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + agentType: 'general-purpose', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + agentType: 'general-purpose', + }, + ], + })), + }; + (svc as any).teamMetaStore = { + getMeta: vi.fn(async () => ({ + version: 1, + cwd: '/repo', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + createdAt: Date.now(), + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + agentType: 'general-purpose', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + agentType: 'general-purpose', + }, + ]), + }; + vi.spyOn(svc as any, 'resolveOpenCodeMemberWorkspacesForRuntime').mockImplementation( + async (input: any) => + (input.members as Array>).map((member) => ({ + ...member, + cwd: '/repo', + })) + ); + const persistRestartMessage = vi + .spyOn(svc as any, 'persistOpenCodeMemberRestartSystemMessage') + .mockImplementation(() => undefined); + const runtimeRelaunch = vi + .spyOn(svc as any, 'runOpenCodeTeamRuntimeAdapterLaunch') + .mockResolvedValue({ runId: 'opencode-run-2' }); + + await svc.restartMember('pure-opencode-team', 'alice'); + + expect(runtimeRelaunch).toHaveBeenCalledTimes(1); + const relaunchInput = runtimeRelaunch.mock.calls[0]?.[0] as any; + expect(relaunchInput.request).toMatchObject({ + teamName: 'pure-opencode-team', + cwd: '/repo', + providerId: 'opencode', + model: 'openai/gpt-5.4-mini', + }); + expect(relaunchInput.members.map((member: { name: string }) => member.name).sort()).toEqual([ + 'alice', + 'bob', + ]); + expect(relaunchInput.sourceWarning).toContain('OpenCode-only member restart refreshes'); + expect(persistRestartMessage).toHaveBeenCalledWith( + expect.objectContaining({ + teamName: 'pure-opencode-team', + leadName: 'team-lead', + member: expect.objectContaining({ name: 'alice' }), + reason: 'manual_restart', + }) + ); + }); + it('still allows restarting a primary-lane teammate when another mixed secondary lane exists', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({