fix(team): support OpenCode lead runtime sessions
This commit is contained in:
parent
9af0a0df2b
commit
961770b7a7
8 changed files with 709 additions and 29 deletions
|
|
@ -9660,6 +9660,28 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async hasDeliverableOpenCodeRuntimeSessionForRecipient(
|
||||||
|
teamName: string,
|
||||||
|
memberName: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const identity = await this.resolveOpenCodeMemberDeliveryIdentity(teamName, memberName).catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
if (!identity?.ok) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const runId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, identity.laneId);
|
||||||
|
if (!runId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({
|
||||||
|
teamName,
|
||||||
|
runId,
|
||||||
|
laneId: identity.laneId,
|
||||||
|
memberName: identity.canonicalMemberName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveOpenCodeMembersForRuntimeLane(
|
private async resolveOpenCodeMembersForRuntimeLane(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
laneId: string
|
laneId: string
|
||||||
|
|
@ -14300,7 +14322,27 @@ export class TeamProvisioningService {
|
||||||
if (!memberName) continue;
|
if (!memberName) continue;
|
||||||
|
|
||||||
const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
|
const isLead = isLeadMember({ name: memberName, agentType: member.agentType });
|
||||||
if (isLead) {
|
const leadRuntimeMember = isLead ? getLiveRuntimeMember(memberName) : undefined;
|
||||||
|
const leadLaunchMember = isLead ? launchSnapshot?.members[memberName] : undefined;
|
||||||
|
const leadActiveRunMember = isLead ? activeRunMemberByName.get(memberName) : undefined;
|
||||||
|
const leadRuntimeModel =
|
||||||
|
leadRuntimeMember?.model?.trim() ||
|
||||||
|
leadActiveRunMember?.model?.trim() ||
|
||||||
|
leadLaunchMember?.model?.trim() ||
|
||||||
|
member.model?.trim() ||
|
||||||
|
undefined;
|
||||||
|
const leadProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(leadActiveRunMember?.providerId) ??
|
||||||
|
normalizeOptionalTeamProviderId(leadRuntimeMember?.providerId) ??
|
||||||
|
normalizeOptionalTeamProviderId(leadLaunchMember?.providerId) ??
|
||||||
|
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||||
|
inferTeamProviderIdFromModel(leadRuntimeModel) ??
|
||||||
|
(currentRuntimeAdapterRun?.providerId === 'opencode' &&
|
||||||
|
currentRuntimeAdapterRun.members?.[memberName]
|
||||||
|
? 'opencode'
|
||||||
|
: undefined);
|
||||||
|
const useRuntimeSnapshotForLead = isLead && leadProviderId === 'opencode';
|
||||||
|
if (isLead && !useRuntimeSnapshotForLead) {
|
||||||
const pid = run?.child?.pid;
|
const pid = run?.child?.pid;
|
||||||
const usageStats = pid
|
const usageStats = pid
|
||||||
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
|
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
|
||||||
|
|
@ -15986,6 +16028,7 @@ export class TeamProvisioningService {
|
||||||
extraCliArgs: teamMeta?.extraCliArgs,
|
extraCliArgs: teamMeta?.extraCliArgs,
|
||||||
},
|
},
|
||||||
members: effectiveMembers,
|
members: effectiveMembers,
|
||||||
|
leadName: leadMember?.name?.trim() || 'team-lead',
|
||||||
prompt: [
|
prompt: [
|
||||||
`Restarting OpenCode teammate "${targetRuntimeMember.name}" by user request.`,
|
`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.',
|
'This is an app-managed OpenCode-only runtime refresh. Re-establish the team sessions and continue from persisted team context.',
|
||||||
|
|
@ -18806,6 +18849,31 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildOpenCodePrimaryRuntimeMembers(input: {
|
||||||
|
request: TeamCreateRequest | TeamLaunchRequest;
|
||||||
|
members: TeamCreateRequest['members'];
|
||||||
|
launchCwd: string;
|
||||||
|
leadName?: string;
|
||||||
|
}): TeamCreateRequest['members'] {
|
||||||
|
const leadName = input.leadName?.trim() || 'team-lead';
|
||||||
|
const normalizedLeadName = leadName.toLowerCase();
|
||||||
|
const runtimeMembers = input.members.filter(
|
||||||
|
(member) => member.name.trim().toLowerCase() !== normalizedLeadName
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: leadName,
|
||||||
|
role: 'Team Lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: input.request.model,
|
||||||
|
effort: input.request.effort,
|
||||||
|
cwd: input.launchCwd,
|
||||||
|
},
|
||||||
|
...runtimeMembers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private async materializeOpenCodeRuntimeAdapterDefaults<
|
private async materializeOpenCodeRuntimeAdapterDefaults<
|
||||||
TRequest extends TeamCreateRequest | TeamLaunchRequest,
|
TRequest extends TeamCreateRequest | TeamLaunchRequest,
|
||||||
>(params: {
|
>(params: {
|
||||||
|
|
@ -20582,6 +20650,7 @@ export class TeamProvisioningService {
|
||||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||||
request: launchRequest,
|
request: launchRequest,
|
||||||
members: effectiveMembers,
|
members: effectiveMembers,
|
||||||
|
leadName: 'team-lead',
|
||||||
prompt: launchRequest.prompt?.trim() ?? '',
|
prompt: launchRequest.prompt?.trim() ?? '',
|
||||||
sourceWarning: undefined,
|
sourceWarning: undefined,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|
@ -20637,6 +20706,7 @@ export class TeamProvisioningService {
|
||||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||||
request: launchRequest,
|
request: launchRequest,
|
||||||
members: effectiveMembers,
|
members: effectiveMembers,
|
||||||
|
leadName: this.extractLeadNameFromConfigRaw(configRaw) ?? 'team-lead',
|
||||||
prompt,
|
prompt,
|
||||||
sourceWarning: warning,
|
sourceWarning: warning,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|
@ -20646,6 +20716,7 @@ export class TeamProvisioningService {
|
||||||
private async runOpenCodeTeamRuntimeAdapterLaunch(input: {
|
private async runOpenCodeTeamRuntimeAdapterLaunch(input: {
|
||||||
request: TeamCreateRequest | TeamLaunchRequest;
|
request: TeamCreateRequest | TeamLaunchRequest;
|
||||||
members: TeamCreateRequest['members'];
|
members: TeamCreateRequest['members'];
|
||||||
|
leadName?: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
sourceWarning?: string;
|
sourceWarning?: string;
|
||||||
onProgress: (progress: TeamProvisioningProgress) => void;
|
onProgress: (progress: TeamProvisioningProgress) => void;
|
||||||
|
|
@ -20707,6 +20778,12 @@ export class TeamProvisioningService {
|
||||||
state: 'active',
|
state: 'active',
|
||||||
});
|
});
|
||||||
const launchCwd = this.getOpenCodeRuntimeLaunchCwd(input.request.cwd, input.members);
|
const launchCwd = this.getOpenCodeRuntimeLaunchCwd(input.request.cwd, input.members);
|
||||||
|
const runtimeMembers = this.buildOpenCodePrimaryRuntimeMembers({
|
||||||
|
request: input.request,
|
||||||
|
members: input.members,
|
||||||
|
launchCwd,
|
||||||
|
leadName: input.leadName,
|
||||||
|
});
|
||||||
const launchInput: TeamRuntimeLaunchInput = {
|
const launchInput: TeamRuntimeLaunchInput = {
|
||||||
runId,
|
runId,
|
||||||
laneId: 'primary',
|
laneId: 'primary',
|
||||||
|
|
@ -20717,7 +20794,7 @@ export class TeamProvisioningService {
|
||||||
model: input.request.model,
|
model: input.request.model,
|
||||||
effort: input.request.effort,
|
effort: input.request.effort,
|
||||||
skipPermissions: input.request.skipPermissions !== false,
|
skipPermissions: input.request.skipPermissions !== false,
|
||||||
expectedMembers: input.members.map((member) => ({
|
expectedMembers: runtimeMembers.map((member) => ({
|
||||||
name: member.name,
|
name: member.name,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
workflow: member.workflow,
|
workflow: member.workflow,
|
||||||
|
|
@ -20912,7 +20989,10 @@ export class TeamProvisioningService {
|
||||||
result,
|
result,
|
||||||
});
|
});
|
||||||
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
const members: Record<string, PersistedTeamLaunchMemberState> = {};
|
||||||
for (const member of input.expectedMembers) {
|
const persistedExpectedMembers = input.expectedMembers.filter(
|
||||||
|
(member) => !isLeadMember({ name: member.name })
|
||||||
|
);
|
||||||
|
for (const member of persistedExpectedMembers) {
|
||||||
const evidence = committedResult.members[member.name];
|
const evidence = committedResult.members[member.name];
|
||||||
members[member.name] = this.toOpenCodePersistedLaunchMember(
|
members[member.name] = this.toOpenCodePersistedLaunchMember(
|
||||||
member,
|
member,
|
||||||
|
|
@ -20922,8 +21002,8 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const snapshot = createPersistedLaunchSnapshot({
|
const snapshot = createPersistedLaunchSnapshot({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
expectedMembers: input.expectedMembers.map((member) => member.name),
|
expectedMembers: persistedExpectedMembers.map((member) => member.name),
|
||||||
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
|
bootstrapExpectedMembers: persistedExpectedMembers.map((member) => member.name),
|
||||||
leadSessionId: result.leadSessionId,
|
leadSessionId: result.leadSessionId,
|
||||||
launchPhase: committedResult.launchPhase,
|
launchPhase: committedResult.launchPhase,
|
||||||
members,
|
members,
|
||||||
|
|
@ -22457,19 +22537,26 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
||||||
if (isOpenCodeRecipient) {
|
if (isOpenCodeRecipient) {
|
||||||
const diagnostic =
|
const hasLeadSession = await this.hasDeliverableOpenCodeRuntimeSessionForRecipient(
|
||||||
'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.';
|
teamName,
|
||||||
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
|
inboxName
|
||||||
|
);
|
||||||
|
if (!hasLeadSession) {
|
||||||
|
const diagnostic =
|
||||||
|
'opencode_lead_runtime_session_missing: OpenCode lead runtime session is not available; leaving inbox unread for durable retry/diagnostics.';
|
||||||
|
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
|
||||||
|
return {
|
||||||
|
kind: 'opencode_lead_unsupported',
|
||||||
|
relayed: 0,
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
return {
|
return {
|
||||||
kind: 'opencode_lead_unsupported',
|
kind: 'native_lead',
|
||||||
relayed: 0,
|
relayed: this.isTeamAlive(teamName) ? await this.relayLeadInboxMessages(teamName) : 0,
|
||||||
diagnostics: [diagnostic],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
kind: 'native_lead',
|
|
||||||
relayed: this.isTeamAlive(teamName) ? await this.relayLeadInboxMessages(teamName) : 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOpenCodeRecipient) {
|
if (isOpenCodeRecipient) {
|
||||||
|
|
@ -25313,6 +25400,20 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
const activeRuntimeRunId =
|
const activeRuntimeRunId =
|
||||||
run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || '';
|
run?.runId?.trim() || currentRuntimeAdapterRun?.runId?.trim() || runId?.trim() || '';
|
||||||
|
const getCurrentRuntimeAdapterEvidence = (
|
||||||
|
memberName: string
|
||||||
|
): TeamRuntimeMemberLaunchEvidence | undefined => {
|
||||||
|
const direct = currentRuntimeAdapterRun?.members?.[memberName];
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
return Object.entries(currentRuntimeAdapterRun?.members ?? {}).find(
|
||||||
|
([candidateName, evidence]) =>
|
||||||
|
matchesTeamMemberIdentity(candidateName, memberName) ||
|
||||||
|
matchesTeamMemberIdentity(evidence.memberName ?? '', memberName)
|
||||||
|
)?.[1];
|
||||||
|
};
|
||||||
|
const committedPrimarySessionStatusByMember = new Map<string, MemberSpawnStatusEntry>();
|
||||||
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
||||||
const memberName = persistedMember.name?.trim() ?? '';
|
const memberName = persistedMember.name?.trim() ?? '';
|
||||||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||||
|
|
@ -25320,12 +25421,12 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
const activeRunMember = this.findEffectiveRunMember(run, memberName);
|
const activeRunMember = this.findEffectiveRunMember(run, memberName);
|
||||||
const activeRunModel = activeRunMember?.model?.trim();
|
const activeRunModel = activeRunMember?.model?.trim();
|
||||||
const evidenceModel = currentRuntimeAdapterRun?.members?.[memberName]?.model?.trim();
|
const currentRuntimeAdapterEvidence = getCurrentRuntimeAdapterEvidence(memberName);
|
||||||
|
const evidenceModel = currentRuntimeAdapterEvidence?.model?.trim();
|
||||||
const activeRunProviderId =
|
const activeRunProviderId =
|
||||||
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
||||||
inferTeamProviderIdFromModel(activeRunModel ?? evidenceModel);
|
inferTeamProviderIdFromModel(activeRunModel ?? evidenceModel);
|
||||||
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
|
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
|
||||||
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
|
||||||
upsertMetadata(memberName, {
|
upsertMetadata(memberName, {
|
||||||
backendType:
|
backendType:
|
||||||
effectiveProviderId === 'opencode'
|
effectiveProviderId === 'opencode'
|
||||||
|
|
@ -25363,6 +25464,128 @@ export class TeamProvisioningService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const [rawMemberName, evidence] of Object.entries(
|
||||||
|
currentRuntimeAdapterRun?.members ?? {}
|
||||||
|
)) {
|
||||||
|
const memberName = evidence.memberName?.trim() || rawMemberName.trim();
|
||||||
|
if (!memberName || memberName.toLowerCase() === 'user') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const evidenceProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(evidence.providerId) ??
|
||||||
|
currentRuntimeAdapterRun?.providerId;
|
||||||
|
const runtimeModel =
|
||||||
|
evidence.model?.trim() ||
|
||||||
|
this.findEffectiveRunMemberModel(run, memberName) ||
|
||||||
|
this.findConfiguredMemberModel(configuredMembers, memberName) ||
|
||||||
|
this.findMetaMemberModel(metaMembers, memberName);
|
||||||
|
upsertMetadata(memberName, {
|
||||||
|
backendType:
|
||||||
|
evidence.backendType ??
|
||||||
|
(evidenceProviderId === 'opencode'
|
||||||
|
? 'process'
|
||||||
|
: metadataByMember.get(memberName)?.backendType),
|
||||||
|
providerId: evidenceProviderId,
|
||||||
|
alive: false,
|
||||||
|
livenessKind: evidence.livenessKind,
|
||||||
|
pidSource: evidence.pidSource,
|
||||||
|
runtimeDiagnostic: evidence.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: evidence.runtimeDiagnosticSeverity,
|
||||||
|
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||||
|
...(typeof evidence.runtimePid === 'number' && evidence.runtimePid > 0
|
||||||
|
? { metricsPid: evidence.runtimePid }
|
||||||
|
: {}),
|
||||||
|
...(evidence.sessionId ? { runtimeSessionId: evidence.sessionId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredLeadForRuntime = configuredMembers.find((member) => isLeadMember(member));
|
||||||
|
const primaryLeadProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(configuredLeadForRuntime?.providerId) ??
|
||||||
|
inferTeamProviderIdFromModel(configuredLeadForRuntime?.model);
|
||||||
|
const shouldReadPrimaryOpenCodeCommittedSessions =
|
||||||
|
currentRuntimeAdapterRun?.providerId === 'opencode' || primaryLeadProviderId === 'opencode';
|
||||||
|
if (shouldReadPrimaryOpenCodeCommittedSessions) {
|
||||||
|
const primaryRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, 'primary').catch(
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
const committedEvidence = primaryRunId
|
||||||
|
? await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||||
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
teamName,
|
||||||
|
laneId: 'primary',
|
||||||
|
}).catch(() => null)
|
||||||
|
: null;
|
||||||
|
const committedActiveRunId = committedEvidence?.activeRunId?.trim() || null;
|
||||||
|
if (committedEvidence?.committed === true && committedActiveRunId === primaryRunId) {
|
||||||
|
for (const session of committedEvidence.sessions) {
|
||||||
|
if (session.runId !== primaryRunId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const memberName = session.memberName.trim();
|
||||||
|
if (
|
||||||
|
!memberName ||
|
||||||
|
memberName.toLowerCase() === 'user' ||
|
||||||
|
this.isMemberRemovedInMeta(metaMembers, memberName) ||
|
||||||
|
getCurrentRuntimeAdapterEvidence(memberName)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const configuredMember = configuredMembers.find((member) =>
|
||||||
|
matchesTeamMemberIdentity(member.name ?? '', memberName)
|
||||||
|
);
|
||||||
|
const metaMember = metaMembers.find((member) =>
|
||||||
|
matchesTeamMemberIdentity(member.name ?? '', memberName)
|
||||||
|
);
|
||||||
|
const runtimeModel =
|
||||||
|
this.findEffectiveRunMemberModel(run, memberName) ||
|
||||||
|
metaMember?.model?.trim() ||
|
||||||
|
configuredMember?.model?.trim() ||
|
||||||
|
undefined;
|
||||||
|
const memberProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(metaMember?.providerId) ??
|
||||||
|
normalizeOptionalTeamProviderId(configuredMember?.providerId) ??
|
||||||
|
inferTeamProviderIdFromModel(runtimeModel) ??
|
||||||
|
(isLeadMember({
|
||||||
|
name: memberName,
|
||||||
|
agentType: metaMember?.agentType ?? configuredMember?.agentType,
|
||||||
|
}) && primaryLeadProviderId === 'opencode'
|
||||||
|
? 'opencode'
|
||||||
|
: undefined);
|
||||||
|
if (memberProviderId !== 'opencode') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const observedAt = session.observedAt ?? nowIso();
|
||||||
|
committedPrimarySessionStatusByMember.set(memberName, {
|
||||||
|
status: 'online',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
updatedAt: observedAt,
|
||||||
|
firstSpawnAcceptedAt: observedAt,
|
||||||
|
lastHeartbeatAt: observedAt,
|
||||||
|
});
|
||||||
|
upsertMetadata(memberName, {
|
||||||
|
backendType: 'process',
|
||||||
|
providerId: 'opencode',
|
||||||
|
alive: true,
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
pidSource: 'runtime_bootstrap',
|
||||||
|
runtimeDiagnostic: 'bootstrap confirmed',
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
|
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||||
|
runtimeSessionId: session.id,
|
||||||
|
runtimeLastSeenAt: observedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const paneIds = [...metadataByMember.values()]
|
const paneIds = [...metadataByMember.values()]
|
||||||
.filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined)
|
.filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined)
|
||||||
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
.map((metadata) => metadata.tmuxPaneId?.trim() ?? '')
|
||||||
|
|
@ -25425,7 +25648,7 @@ export class TeamProvisioningService {
|
||||||
for (const [memberName, metadata] of metadataByMember.entries()) {
|
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||||
const launchMember = persistedLaunchSnapshot?.members[memberName];
|
const launchMember = persistedLaunchSnapshot?.members[memberName];
|
||||||
const adapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
const adapterEvidence = getCurrentRuntimeAdapterEvidence(memberName);
|
||||||
const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence
|
const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence
|
||||||
? {
|
? {
|
||||||
status: adapterEvidence.hardFailure
|
status: adapterEvidence.hardFailure
|
||||||
|
|
@ -25474,11 +25697,12 @@ export class TeamProvisioningService {
|
||||||
launchMember
|
launchMember
|
||||||
? this.buildLaunchMemberSpawnStatus(launchMember, metadata.model)
|
? this.buildLaunchMemberSpawnStatus(launchMember, metadata.model)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const committedPrimarySessionStatus = committedPrimarySessionStatusByMember.get(memberName);
|
||||||
const status = this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, launchStatus)
|
const status = this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, launchStatus)
|
||||||
? launchStatus
|
? launchStatus
|
||||||
: this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, adapterStatus)
|
: this.shouldPreferCurrentLaunchMemberStatus(trackedStatus, adapterStatus)
|
||||||
? adapterStatus
|
? adapterStatus
|
||||||
: (trackedStatus ?? adapterStatus ?? launchStatus);
|
: (trackedStatus ?? adapterStatus ?? launchStatus ?? committedPrimarySessionStatus);
|
||||||
const resolved = resolveTeamMemberRuntimeLiveness({
|
const resolved = resolveTeamMemberRuntimeLiveness({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -37298,6 +37522,19 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractLeadNameFromConfigRaw(configRaw: string): string | null {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(configRaw) as { members?: TeamConfig['members'] };
|
||||||
|
if (!Array.isArray(parsed.members)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const lead = parsed.members.find((member) => isLeadMember(member));
|
||||||
|
return lead?.name?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Two-stage preflight check:
|
* Two-stage preflight check:
|
||||||
* 1. `claude --version` verifies the binary is executable.
|
* 1. `claude --version` verifies the binary is executable.
|
||||||
|
|
|
||||||
|
|
@ -1116,10 +1116,19 @@ function buildMemberBootstrapPrompt(
|
||||||
const teamPrompt = input.prompt?.trim();
|
const teamPrompt = input.prompt?.trim();
|
||||||
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
const role = member.role?.trim() || member.workflow?.trim() || 'teammate';
|
||||||
const workflow = member.workflow?.trim();
|
const workflow = member.workflow?.trim();
|
||||||
|
const isTeamLead =
|
||||||
|
member.name.trim().toLowerCase() === 'team-lead' || role.trim().toLowerCase() === 'team lead';
|
||||||
|
const identityLine = isTeamLead
|
||||||
|
? `You are ${member.name}, the team lead for team "${input.teamName}".`
|
||||||
|
: `You are ${member.name}, a ${role} on team "${input.teamName}".`;
|
||||||
|
const messageTargets = isTeamLead
|
||||||
|
? 'the human user or a teammate'
|
||||||
|
: 'the human user, team lead, or another teammate';
|
||||||
|
const senderRole = isTeamLead ? 'team lead' : 'OpenCode teammate';
|
||||||
return [
|
return [
|
||||||
'<agent_teams_app_managed_bootstrap_briefing>',
|
'<agent_teams_app_managed_bootstrap_briefing>',
|
||||||
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
'AGENT_TEAMS_APP_MANAGED_BOOTSTRAP_V1',
|
||||||
`You are ${member.name}, a ${role} on team "${input.teamName}".`,
|
identityLine,
|
||||||
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
teamPrompt ? `Team launch context:\n${teamPrompt}` : null,
|
||||||
workflow ? `Workflow:\n${workflow}` : null,
|
workflow ? `Workflow:\n${workflow}` : null,
|
||||||
'',
|
'',
|
||||||
|
|
@ -1131,8 +1140,8 @@ function buildMemberBootstrapPrompt(
|
||||||
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
'Do not call task_briefing, message_send, or cross_team_send just to announce readiness, say understood, report no tasks, or ask for work.',
|
||||||
'If the briefing says there are no actionable tasks, stay idle silently.',
|
'If the briefing says there are no actionable tasks, stay idle silently.',
|
||||||
'',
|
'',
|
||||||
'When you need to message the human user, team lead, or another teammate, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.',
|
`When you need to message ${messageTargets}, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send) with teamName, to, from, text, and optional summary.`,
|
||||||
`Always set from="${member.name}" when sending a team message from this OpenCode teammate.`,
|
`Always set from="${member.name}" when sending a team message from this ${senderRole}.`,
|
||||||
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
|
'Do not answer team/app messages only as plain assistant text when agent-teams_message_send is available.',
|
||||||
'</agent_teams_app_managed_bootstrap_briefing>',
|
'</agent_teams_app_managed_bootstrap_briefing>',
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
||||||
const launchInput = captureAdapter.launchInputs[0];
|
const launchInput = captureAdapter.launchInputs[0];
|
||||||
expect(launchInput).toBeDefined();
|
expect(launchInput).toBeDefined();
|
||||||
expect(launchInput?.prompt ?? '').toContain('production desktop app');
|
expect(launchInput?.prompt ?? '').toContain('production desktop app');
|
||||||
expect(launchInput?.expectedMembers.map((member) => member.name)).toEqual(['bob', 'jack']);
|
expect(launchInput?.expectedMembers.map((member) => member.name)).toEqual([
|
||||||
|
'team-lead',
|
||||||
|
'bob',
|
||||||
|
'jack',
|
||||||
|
]);
|
||||||
expect(launchInput?.prompt?.length ?? 0).toBeGreaterThan(1_500);
|
expect(launchInput?.prompt?.length ?? 0).toBeGreaterThan(1_500);
|
||||||
|
|
||||||
const bridgeCapture = createCapturingOpenCodeBridge(selectedModel);
|
const bridgeCapture = createCapturingOpenCodeBridge(selectedModel);
|
||||||
|
|
@ -78,7 +82,11 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
|
||||||
expect(launchCommand?.leadPrompt).toContain('OpenCode members bootstrap silently');
|
expect(launchCommand?.leadPrompt).toContain('OpenCode members bootstrap silently');
|
||||||
expect(launchCommand?.leadPrompt.length ?? 0).toBeGreaterThan(1_500);
|
expect(launchCommand?.leadPrompt.length ?? 0).toBeGreaterThan(1_500);
|
||||||
expect(launchCommand?.leadPrompt.length ?? 0).toBeLessThan(80_000);
|
expect(launchCommand?.leadPrompt.length ?? 0).toBeLessThan(80_000);
|
||||||
expect(launchCommand?.members.map((member) => member.name)).toEqual(['bob', 'jack']);
|
expect(launchCommand?.members.map((member) => member.name)).toEqual([
|
||||||
|
'team-lead',
|
||||||
|
'bob',
|
||||||
|
'jack',
|
||||||
|
]);
|
||||||
|
|
||||||
for (const member of launchCommand?.members ?? []) {
|
for (const member of launchCommand?.members ?? []) {
|
||||||
expect(member.prompt).toContain(`You are ${member.name}`);
|
expect(member.prompt).toContain(`You are ${member.name}`);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
import { readOpenCodeRuntimeLaneIndex } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||||
|
import { TeamInboxWriter } from '../../../../src/main/services/team/TeamInboxWriter';
|
||||||
import { getTeamsBasePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
import { getTeamsBasePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createOpenCodeLiveHarness,
|
createOpenCodeLiveHarness,
|
||||||
getRuntimeTranscript,
|
getRuntimeTranscript,
|
||||||
|
|
@ -162,6 +163,140 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
||||||
300_000
|
300_000
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it(
|
||||||
|
'relays a desktop inbox message to the OpenCode lead session and records the lead reply',
|
||||||
|
async () => {
|
||||||
|
const { bridgeClient, selectedModel, svc, dispose } = await createOpenCodeLiveHarness({
|
||||||
|
tempDir,
|
||||||
|
selectedModel: process.env.OPENCODE_E2E_MODEL?.trim() || DEFAULT_MODEL,
|
||||||
|
projectPath: PROJECT_PATH,
|
||||||
|
});
|
||||||
|
|
||||||
|
const teamName = `opencode-lead-message-${Date.now()}`;
|
||||||
|
const leadName = 'team-lead';
|
||||||
|
const memberName = 'bob';
|
||||||
|
const expectedReply = `opencode-lead-message-e2e-${Date.now()}`;
|
||||||
|
const progressEvents: TeamProvisioningProgress[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runId } = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: PROJECT_PATH,
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: selectedModel,
|
||||||
|
skipPermissions: true,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: memberName,
|
||||||
|
role: 'Developer',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: selectedModel,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
progressEvents.push(progress);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(runId).toBeTruthy();
|
||||||
|
const progressDump = progressEvents
|
||||||
|
.map((progress) =>
|
||||||
|
[
|
||||||
|
progress.state,
|
||||||
|
progress.message,
|
||||||
|
progress.messageSeverity,
|
||||||
|
progress.error,
|
||||||
|
progress.cliLogsTail,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ')
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
expect(
|
||||||
|
progressEvents.some((progress) =>
|
||||||
|
progress.message.includes('OpenCode team launch is ready')
|
||||||
|
),
|
||||||
|
progressDump
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
|
expect(runtimeSnapshot.members[leadName]).toMatchObject({
|
||||||
|
alive: true,
|
||||||
|
runtimeModel: selectedModel,
|
||||||
|
});
|
||||||
|
|
||||||
|
const written = await new TeamInboxWriter().sendMessage(teamName, {
|
||||||
|
member: leadName,
|
||||||
|
from: 'user',
|
||||||
|
to: leadName,
|
||||||
|
source: 'user_sent',
|
||||||
|
text: [
|
||||||
|
`Reply to the app Messages UI with exactly: ${expectedReply}`,
|
||||||
|
`Use agent-teams_message_send with to="user" and from="${leadName}".`,
|
||||||
|
'Do not answer only as plain assistant text.',
|
||||||
|
].join('\n'),
|
||||||
|
});
|
||||||
|
|
||||||
|
let lastRelay: Awaited<ReturnType<typeof svc.relayInboxFileToLiveRecipient>> | null = null;
|
||||||
|
const deadline = Date.now() + 90_000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
lastRelay = await svc.relayInboxFileToLiveRecipient(teamName, leadName, {
|
||||||
|
onlyMessageId: written.messageId,
|
||||||
|
source: 'ui-send',
|
||||||
|
deliveryMetadata: { replyRecipient: 'user' },
|
||||||
|
});
|
||||||
|
if (lastRelay.relayed >= 1) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lastRelay.kind === 'opencode_lead_unsupported' ||
|
||||||
|
(lastRelay.lastDelivery?.delivered === false &&
|
||||||
|
lastRelay.lastDelivery.responsePending !== true)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 3_000));
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lastRelay).toMatchObject({
|
||||||
|
kind: 'opencode_member',
|
||||||
|
relayed: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
let reply: InboxMessage;
|
||||||
|
try {
|
||||||
|
reply = await waitForUserInboxReply(teamName, leadName, expectedReply, 90_000);
|
||||||
|
} catch (error) {
|
||||||
|
const transcript = await getRuntimeTranscript({
|
||||||
|
bridgeClient,
|
||||||
|
teamName,
|
||||||
|
memberName: leadName,
|
||||||
|
projectPath: PROJECT_PATH,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`${error instanceof Error ? error.message : String(error)}\nLast relay: ${JSON.stringify(
|
||||||
|
lastRelay,
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\nTranscript: ${JSON.stringify(transcript, null, 2)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(reply).toMatchObject({
|
||||||
|
from: leadName,
|
||||||
|
to: 'user',
|
||||||
|
});
|
||||||
|
expect(reply.text).toContain(expectedReply);
|
||||||
|
} finally {
|
||||||
|
await svc.stopTeam(teamName).catch(() => undefined);
|
||||||
|
await dispose();
|
||||||
|
await waitForOpenCodeLanesStopped(teamName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
300_000
|
||||||
|
);
|
||||||
|
|
||||||
it(
|
it(
|
||||||
'relays an OpenCode teammate message into another OpenCode member runtime and records the reply',
|
'relays an OpenCode teammate message into another OpenCode member runtime and records the reply',
|
||||||
async () => {
|
async () => {
|
||||||
|
|
|
||||||
|
|
@ -202,6 +202,75 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds a lead-specific OpenCode bootstrap prompt for team-lead sessions', async () => {
|
||||||
|
const launchOpenCodeTeam = vi.fn<
|
||||||
|
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
||||||
|
>(async () => ({
|
||||||
|
runId: 'run-1',
|
||||||
|
teamLaunchState: 'ready',
|
||||||
|
members: {
|
||||||
|
'team-lead': {
|
||||||
|
sessionId: 'oc-lead-session',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimePid: 123,
|
||||||
|
model: 'openai/gpt-5.4-mini',
|
||||||
|
evidence: [
|
||||||
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
alice: {
|
||||||
|
sessionId: 'oc-alice-session',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimePid: 124,
|
||||||
|
model: 'openai/gpt-5.4-mini',
|
||||||
|
evidence: [
|
||||||
|
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: [],
|
||||||
|
diagnostics: [],
|
||||||
|
}));
|
||||||
|
const bridge = bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||||
|
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot('cap-lead')),
|
||||||
|
launchOpenCodeTeam,
|
||||||
|
});
|
||||||
|
const adapter = new OpenCodeTeamRuntimeAdapter(bridge);
|
||||||
|
|
||||||
|
await adapter.launch(
|
||||||
|
launchInput({
|
||||||
|
expectedMembers: [
|
||||||
|
{
|
||||||
|
name: 'team-lead',
|
||||||
|
role: 'Team Lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openai/gpt-5.4-mini',
|
||||||
|
cwd: '/repo',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openai/gpt-5.4-mini',
|
||||||
|
cwd: '/repo',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const command = launchOpenCodeTeam.mock.calls[0]?.[0];
|
||||||
|
const leadPrompt = command?.members.find((member) => member.name === 'team-lead')?.prompt;
|
||||||
|
expect(leadPrompt).toContain('You are team-lead, the team lead');
|
||||||
|
expect(leadPrompt).toContain('message the human user or a teammate');
|
||||||
|
expect(leadPrompt).toContain('Always set from="team-lead"');
|
||||||
|
expect(leadPrompt).not.toContain('human user, team lead, or another teammate');
|
||||||
|
});
|
||||||
|
|
||||||
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
|
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
|
||||||
const firstReadiness = readiness({
|
const firstReadiness = readiness({
|
||||||
state: 'mcp_unavailable',
|
state: 'mcp_unavailable',
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||||
expect(adapter.launchInputs).toHaveLength(1);
|
expect(adapter.launchInputs).toHaveLength(1);
|
||||||
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
||||||
|
'team-lead',
|
||||||
'alice',
|
'alice',
|
||||||
'bob',
|
'bob',
|
||||||
]);
|
]);
|
||||||
|
|
@ -164,11 +165,28 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
runtimeModel: 'opencode/big-pickle',
|
runtimeModel: 'opencode/big-pickle',
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
const launchState = JSON.parse(
|
||||||
fs.readFile(path.join(getTeamsBasePath(), 'pure-opencode-safe-e2e', 'launch-state.json'), {
|
await fs.readFile(path.join(getTeamsBasePath(), 'pure-opencode-safe-e2e', 'launch-state.json'), {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
})
|
})
|
||||||
).resolves.toContain('"teamLaunchState": "clean_success"');
|
) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string };
|
||||||
|
expect(launchState.teamLaunchState).toBe('clean_success');
|
||||||
|
expect(launchState.expectedMembers).toEqual(['alice', 'bob']);
|
||||||
|
expect(Object.keys(launchState.members)).toEqual(['alice', 'bob']);
|
||||||
|
await expect(
|
||||||
|
readCommittedOpenCodeBootstrapSessionEvidence({
|
||||||
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
teamName: 'pure-opencode-safe-e2e',
|
||||||
|
laneId: 'primary',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
committed: true,
|
||||||
|
sessions: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ memberName: 'team-lead' }),
|
||||||
|
expect.objectContaining({ memberName: 'alice' }),
|
||||||
|
expect.objectContaining({ memberName: 'bob' }),
|
||||||
|
]),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts pure OpenCode runtime bootstrap check-ins during adapter launch', async () => {
|
it('accepts pure OpenCode runtime bootstrap check-ins during adapter launch', async () => {
|
||||||
|
|
@ -191,7 +209,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||||
expect(adapter.bootstrapCheckins).toEqual([
|
expect(adapter.bootstrapCheckins).toEqual([
|
||||||
{
|
{
|
||||||
memberName: 'alice',
|
memberName: 'team-lead',
|
||||||
runId,
|
runId,
|
||||||
state: 'accepted',
|
state: 'accepted',
|
||||||
},
|
},
|
||||||
|
|
@ -263,6 +281,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
|
|
||||||
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
expect(runId).toBe(adapter.launchInputs[0]?.runId);
|
||||||
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
expect(adapter.launchInputs[0]?.expectedMembers.map((member) => member.name)).toEqual([
|
||||||
|
'team-lead',
|
||||||
'alice',
|
'alice',
|
||||||
'bob',
|
'bob',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -4848,6 +4848,128 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports a runtime-backed OpenCode lead as a process member', async () => {
|
||||||
|
const teamName = 'pure-opencode-runtime-lead-team';
|
||||||
|
const projectPath = '/Users/test/project';
|
||||||
|
const runId = 'opencode-runtime-run';
|
||||||
|
writeLaunchConfig(teamName, projectPath, 'lead-session', []);
|
||||||
|
writeLaunchState(teamName, 'lead-session', {
|
||||||
|
'team-lead': {
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
agentToolAccepted: true,
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
hardFailureReason: undefined,
|
||||||
|
runtimePid: 333,
|
||||||
|
runtimeRunId: runId,
|
||||||
|
runtimeSessionId: 'session-team-lead',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
vi.mocked(listRuntimeProcessTableForCurrentPlatform).mockResolvedValue([
|
||||||
|
{
|
||||||
|
pid: 333,
|
||||||
|
ppid: 1,
|
||||||
|
command: 'node /tmp/opencode-bridge.js --team-name pure-opencode-runtime-lead-team',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||||
|
'333': createPidusageStat(333, 456_000_000),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
(svc as any).runtimeAdapterRunByTeam.set(teamName, {
|
||||||
|
runId,
|
||||||
|
providerId: 'opencode',
|
||||||
|
cwd: projectPath,
|
||||||
|
members: {
|
||||||
|
'team-lead': {
|
||||||
|
memberName: 'team-lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
launchState: 'confirmed_alive',
|
||||||
|
runtimeAlive: true,
|
||||||
|
bootstrapConfirmed: true,
|
||||||
|
hardFailure: false,
|
||||||
|
runtimePid: 333,
|
||||||
|
sessionId: 'session-team-lead',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(svc as any).aliveRunByTeam.set(teamName, runId);
|
||||||
|
|
||||||
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
|
|
||||||
|
expect(snapshot.members['team-lead']).toMatchObject({
|
||||||
|
alive: true,
|
||||||
|
backendType: 'process',
|
||||||
|
providerId: 'opencode',
|
||||||
|
runtimeModel: 'opencode/big-pickle',
|
||||||
|
runtimeSessionId: 'session-team-lead',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores OpenCode lead runtime liveness from committed primary session evidence', async () => {
|
||||||
|
const teamName = 'pure-opencode-runtime-lead-restart-team';
|
||||||
|
const projectPath = '/Users/test/project';
|
||||||
|
const runId = 'opencode-runtime-run-after-restart';
|
||||||
|
const teamDir = path.join(tempTeamsBase, teamName);
|
||||||
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
projectPath,
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'team-lead',
|
||||||
|
role: 'Team Lead',
|
||||||
|
agentType: 'team-lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await upsertOpenCodeRuntimeLaneIndexEntry({
|
||||||
|
teamsBasePath: tempTeamsBase,
|
||||||
|
teamName,
|
||||||
|
laneId: 'primary',
|
||||||
|
state: 'active',
|
||||||
|
});
|
||||||
|
await writeCommittedOpenCodeSessionStore({
|
||||||
|
teamName,
|
||||||
|
laneId: 'primary',
|
||||||
|
runId,
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: 'session-team-lead-after-restart',
|
||||||
|
teamName,
|
||||||
|
memberName: 'team-lead',
|
||||||
|
laneId: 'primary',
|
||||||
|
runId,
|
||||||
|
observedAt: '2026-04-22T12:00:00.000Z',
|
||||||
|
source: 'runtime_bootstrap_checkin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
|
|
||||||
|
expect(snapshot.members['team-lead']).toMatchObject({
|
||||||
|
alive: true,
|
||||||
|
backendType: 'process',
|
||||||
|
providerId: 'opencode',
|
||||||
|
runtimeModel: 'opencode/big-pickle',
|
||||||
|
runtimeSessionId: 'session-team-lead-after-restart',
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reconciles persisted launch state before building runtime snapshot metadata', async () => {
|
it('reconciles persisted launch state before building runtime snapshot metadata', async () => {
|
||||||
const teamName = 'zz-runtime-snapshot-reconciles-before-live-metadata';
|
const teamName = 'zz-runtime-snapshot-reconciles-before-live-metadata';
|
||||||
const leadSessionId = 'lead-session';
|
const leadSessionId = 'lead-session';
|
||||||
|
|
@ -14848,6 +14970,24 @@ describe('TeamProvisioningService', () => {
|
||||||
})
|
})
|
||||||
).rejects.toThrow('launch boom');
|
).rejects.toThrow('launch boom');
|
||||||
|
|
||||||
|
expect(adapterLaunch).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
expectedMembers: [
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'team-lead',
|
||||||
|
role: 'Team Lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax-m2.5-free',
|
||||||
|
cwd: '/tmp/opencode-team',
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'minimax-m2.5-free',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({
|
||||||
lanes: {},
|
lanes: {},
|
||||||
});
|
});
|
||||||
|
|
@ -16798,6 +16938,12 @@ describe('TeamProvisioningService', () => {
|
||||||
effort: 'medium',
|
effort: 'medium',
|
||||||
cwd: tempClaudeRoot,
|
cwd: tempClaudeRoot,
|
||||||
expectedMembers: [
|
expectedMembers: [
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'team-lead',
|
||||||
|
role: 'Team Lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'big-pickle',
|
||||||
|
}),
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'bob',
|
name: 'bob',
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
|
||||||
|
|
@ -3905,6 +3905,63 @@ Messages:
|
||||||
expect(rows[0].read).toBe(false);
|
expect(rows[0].read).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes OpenCode lead inbox rows through runtime relay when a lead session exists', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
hoisted.files.set(
|
||||||
|
`/mock/teams/${teamName}/config.json`,
|
||||||
|
JSON.stringify({
|
||||||
|
name: teamName,
|
||||||
|
projectPath: '/tmp/my-team',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'team-lead',
|
||||||
|
agentType: 'team-lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'openrouter/test',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
seedLeadInbox(teamName, [
|
||||||
|
{
|
||||||
|
from: 'user',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'Please coordinate.',
|
||||||
|
timestamp: '2026-02-23T17:06:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'opencode-lead-runtime-1',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.spyOn(service as any, 'hasDeliverableOpenCodeRuntimeSessionForRecipient').mockResolvedValue(
|
||||||
|
true
|
||||||
|
);
|
||||||
|
vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||||
|
delivered: true,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead');
|
||||||
|
|
||||||
|
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
|
||||||
|
expect((service as any).hasDeliverableOpenCodeRuntimeSessionForRecipient).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
'team-lead'
|
||||||
|
);
|
||||||
|
expect(service.deliverOpenCodeMemberMessage).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
expect.objectContaining({
|
||||||
|
memberName: 'team-lead',
|
||||||
|
messageId: 'opencode-lead-runtime-1',
|
||||||
|
replyRecipient: 'user',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const rows = JSON.parse(
|
||||||
|
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
||||||
|
);
|
||||||
|
expect(rows[0].read).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {
|
it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue