fix(team): support pure opencode restart

This commit is contained in:
777genius 2026-05-09 07:41:14 +03:00
parent 6dab896aee
commit 0cb30b6ce3
2 changed files with 270 additions and 0 deletions

View file

@ -14573,10 +14573,16 @@ export class TeamProvisioningService {
private async restartMemberUnlocked(teamName: string, memberName: string): Promise<void> {
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<boolean> {
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<string>();
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<TeamProvisioningService['resolveEffectiveConfiguredMember']>
> => {
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<RetryFailedOpenCodeSecondaryLanesResult> {

View file

@ -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<Record<string, unknown>>).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({