fix(team): route opencode lead through runtime
This commit is contained in:
parent
0551c9c363
commit
2f5454ab3c
7 changed files with 303 additions and 55 deletions
|
|
@ -1827,9 +1827,9 @@ OpenCode lead rule:
|
||||||
|
|
||||||
- Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path.
|
- Mixed team with native Codex/Claude/Gemini lead: keep the existing `relayLeadInboxMessages()` path.
|
||||||
- OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`.
|
- OpenCode teammate or secondary lane: use `relayOpenCodeMemberInboxMessages()`.
|
||||||
- Pure OpenCode lead inbox in v1: do not mark messages read and do not report delivery success unless a real stored OpenCode `team-lead` session exists. Return a diagnostic like `opencode_lead_runtime_session_missing`.
|
- Pure OpenCode lead inbox: launch and store a real OpenCode `team-lead` runtime session, then relay through `relayOpenCodeMemberInboxMessages()`. Do not mark messages read unless that delivery is accepted.
|
||||||
- Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them.
|
- Do not fake lead delivery by sending to a random teammate session. That would make messages appear delivered while the actual recipient never saw them.
|
||||||
- A future explicit OpenCode lead lane can reuse this selector by teaching the bridge to create/store a `team-lead` session and by passing `agent: "team-lead"` where the bridge supports it. That is not part of this v1 seam.
|
- If the stored `team-lead` session is missing, keep the row retryable instead of falling back to another teammate.
|
||||||
|
|
||||||
FileWatcher change:
|
FileWatcher change:
|
||||||
|
|
||||||
|
|
@ -2967,13 +2967,12 @@ it('routes native lead inbox relay through the legacy stdin path', async () => {
|
||||||
```
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
it('does not silently consume pure OpenCode lead inbox when no lead session exists', async () => {
|
it('relays pure OpenCode lead inbox through the stored lead session', async () => {
|
||||||
// Configure a pure OpenCode runtime-adapter team where isTeamAlive() is true via runtimeAdapterRunByTeam.
|
// Configure a pure OpenCode runtime-adapter team with a stored team-lead session.
|
||||||
// Ensure there is no stored OpenCode session record for the canonical lead name.
|
|
||||||
// Seed inboxes/<lead>.json with one unread message.
|
// Seed inboxes/<lead>.json with one unread message.
|
||||||
// Call relayInboxFileToLiveRecipient(teamName, leadName).
|
// Call relayInboxFileToLiveRecipient(teamName, leadName).
|
||||||
// Assert diagnostics include opencode_lead_runtime_session_missing.
|
// Assert the relay kind is opencode_member and the prompt targets team-lead.
|
||||||
// Assert the inbox row remains unread and no teammate session received the prompt.
|
// Assert the inbox row is marked read only after accepted runtime delivery.
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -3464,8 +3463,8 @@ Avoid heavy E2E until targeted tests pass.
|
||||||
- UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure.
|
- UI direct sends to live OpenCode teammates either confirm runtime delivery or show a visible warning; there is no log-only post-send delivery failure.
|
||||||
- Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior.
|
- Persisted inbox messages addressed to OpenCode teammates are live-relayed to their runtime lanes, while native teammates keep file-watch behavior and lead keeps lead relay behavior.
|
||||||
- OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding.
|
- OpenCode inbox relay is direct-to-runtime and does not reuse native `relayMemberInboxMessages()` / `SendMessage` forwarding.
|
||||||
- Pure OpenCode lead inbox delivery is not silently consumed: without a real OpenCode lead session, rows remain unread and diagnostics say `opencode_lead_runtime_session_missing` or equivalent.
|
- Pure OpenCode lead inbox delivery uses the stored `team-lead` runtime session and does not silently fall back to another teammate.
|
||||||
- Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths.
|
- Renderer send-message actions return `SendMessageResult` on success and reject on real send failure, so pending-reply cleanup is not dependent on dead `.catch()` paths.
|
||||||
- `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender.
|
- `message_send` cannot create `from: "user", to: "user"` rows; user-directed MCP replies require a configured teammate sender.
|
||||||
- OpenCode replies appear in Messages UI without frontend fake state.
|
- OpenCode replies appear in Messages UI without frontend fake state.
|
||||||
- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, unsupported OpenCode lead diagnostics, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape.
|
- Tests cover native default, OpenCode override, assignment protocol, tool alias canonicalization, tool proof, taskRefs persistence, user-directed sender guard, local recipient canonicalization, direct-message runtime delivery result visibility, OpenCode reply feed projection, OpenCode-targeted inbox relay/dedupe, pure OpenCode lead relay, launch identity injection, lane-scoped manifest activeRunId recovery, and runtime delivery team-change event shape.
|
||||||
|
|
|
||||||
|
|
@ -3028,9 +3028,10 @@ async function handleSendMessage(
|
||||||
: leadName !== null && memberName === leadName;
|
: leadName !== null && memberName === leadName;
|
||||||
const actionMode = payload.actionMode;
|
const actionMode = payload.actionMode;
|
||||||
|
|
||||||
const recipientProviderId = !isLeadRecipient
|
const recipientProviderId = await provisioning.resolveRuntimeRecipientProviderId(
|
||||||
? await provisioning.resolveRuntimeRecipientProviderId(tn, memberName)
|
tn,
|
||||||
: undefined;
|
memberName
|
||||||
|
);
|
||||||
const isOpenCodeRecipient = recipientProviderId === 'opencode';
|
const isOpenCodeRecipient = recipientProviderId === 'opencode';
|
||||||
|
|
||||||
// Attachments are routed through explicit provider transports only.
|
// Attachments are routed through explicit provider transports only.
|
||||||
|
|
@ -3051,7 +3052,7 @@ async function handleSendMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smart routing: lead + alive → stdin direct, else → inbox
|
// Smart routing: lead + alive → stdin direct, else → inbox
|
||||||
if (isLeadRecipient && isAlive) {
|
if (isLeadRecipient && isAlive && !isOpenCodeRecipient) {
|
||||||
const resolvedLeadName = leadName ?? memberName;
|
const resolvedLeadName = leadName ?? memberName;
|
||||||
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
const teammateRoster = await getDurableLeadTeammateRoster(tn, resolvedLeadName);
|
||||||
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
const rosterContextBlock = buildLeadRosterContextBlock(tn, resolvedLeadName, teammateRoster);
|
||||||
|
|
|
||||||
|
|
@ -712,24 +712,23 @@ export function createPersistedLaunchSnapshot(params: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
expectedMembers: readonly string[];
|
expectedMembers: readonly string[];
|
||||||
bootstrapExpectedMembers?: readonly string[];
|
bootstrapExpectedMembers?: readonly string[];
|
||||||
|
includeLeadMembers?: boolean;
|
||||||
leadSessionId?: string;
|
leadSessionId?: string;
|
||||||
launchPhase?: PersistedTeamLaunchPhase;
|
launchPhase?: PersistedTeamLaunchPhase;
|
||||||
members?: Record<string, PersistedTeamLaunchMemberState>;
|
members?: Record<string, PersistedTeamLaunchMemberState>;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}): PersistedTeamLaunchSnapshot {
|
}): PersistedTeamLaunchSnapshot {
|
||||||
const updatedAt = params.updatedAt ?? new Date().toISOString();
|
const updatedAt = params.updatedAt ?? new Date().toISOString();
|
||||||
|
const shouldKeepExpectedMemberName = (name: string): boolean =>
|
||||||
|
name.length > 0 && name !== 'user' && (params.includeLeadMembers || !isLeadMember({ name }));
|
||||||
const expectedMembers = Array.from(
|
const expectedMembers = Array.from(
|
||||||
new Set(
|
new Set(params.expectedMembers.map(normalizeMemberName).filter(shouldKeepExpectedMemberName))
|
||||||
params.expectedMembers
|
|
||||||
.map(normalizeMemberName)
|
|
||||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const bootstrapExpectedMembers = Array.from(
|
const bootstrapExpectedMembers = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
(params.bootstrapExpectedMembers ?? expectedMembers)
|
(params.bootstrapExpectedMembers ?? expectedMembers)
|
||||||
.map(normalizeMemberName)
|
.map(normalizeMemberName)
|
||||||
.filter((name) => name.length > 0 && name !== 'user' && !isLeadMember({ name }))
|
.filter(shouldKeepExpectedMemberName)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const members = params.members ?? {};
|
const members = params.members ?? {};
|
||||||
|
|
|
||||||
|
|
@ -3344,12 +3344,7 @@ interface OpenCodeMemberInboxRelayResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiveInboxRelayResult {
|
interface LiveInboxRelayResult {
|
||||||
kind:
|
kind: 'ignored' | 'native_lead' | 'native_member_noop' | 'opencode_member';
|
||||||
| 'ignored'
|
|
||||||
| 'native_lead'
|
|
||||||
| 'native_member_noop'
|
|
||||||
| 'opencode_member'
|
|
||||||
| 'opencode_lead_unsupported';
|
|
||||||
relayed: number;
|
relayed: number;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
lastDelivery?: OpenCodeMemberInboxDelivery;
|
lastDelivery?: OpenCodeMemberInboxDelivery;
|
||||||
|
|
@ -14859,7 +14854,13 @@ 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 candidateLaunchMember = launchSnapshot?.members[memberName];
|
||||||
|
const candidateRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||||
|
const leadRuntimeProviderId =
|
||||||
|
normalizeOptionalTeamProviderId(candidateRuntimeAdapterEvidence?.providerId) ??
|
||||||
|
normalizeOptionalTeamProviderId(candidateLaunchMember?.providerId) ??
|
||||||
|
normalizeOptionalTeamProviderId(member.providerId);
|
||||||
|
if (isLead && leadRuntimeProviderId !== 'opencode') {
|
||||||
const pid = run?.child?.pid;
|
const pid = run?.child?.pid;
|
||||||
const usageStats = pid
|
const usageStats = pid
|
||||||
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
|
? this.buildRuntimeProcessLoadStatsSafely(teamName, memberName, {
|
||||||
|
|
@ -14929,6 +14930,7 @@ export class TeamProvisioningService {
|
||||||
const liveRuntimeMember = getLiveRuntimeMember(memberName);
|
const liveRuntimeMember = getLiveRuntimeMember(memberName);
|
||||||
const spawnStatusMember = getSpawnStatusMember(memberName);
|
const spawnStatusMember = getSpawnStatusMember(memberName);
|
||||||
const launchMember = launchSnapshot?.members[memberName];
|
const launchMember = launchSnapshot?.members[memberName];
|
||||||
|
const runtimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||||
const activeRunMember = activeRunMemberByName.get(memberName);
|
const activeRunMember = activeRunMemberByName.get(memberName);
|
||||||
const activeRunModel = activeRunMember?.model?.trim();
|
const activeRunModel = activeRunMember?.model?.trim();
|
||||||
const activeRunProviderId =
|
const activeRunProviderId =
|
||||||
|
|
@ -14969,6 +14971,16 @@ export class TeamProvisioningService {
|
||||||
member.providerBackendId
|
member.providerBackendId
|
||||||
);
|
);
|
||||||
const isOpenCodeMember = memberProviderId === 'opencode';
|
const isOpenCodeMember = memberProviderId === 'opencode';
|
||||||
|
const runtimeAdapterSessionId =
|
||||||
|
typeof runtimeAdapterEvidence?.sessionId === 'string'
|
||||||
|
? runtimeAdapterEvidence.sessionId.trim()
|
||||||
|
: '';
|
||||||
|
const runtimeAdapterPid =
|
||||||
|
typeof runtimeAdapterEvidence?.runtimePid === 'number' &&
|
||||||
|
Number.isFinite(runtimeAdapterEvidence.runtimePid) &&
|
||||||
|
runtimeAdapterEvidence.runtimePid > 0
|
||||||
|
? runtimeAdapterEvidence.runtimePid
|
||||||
|
: undefined;
|
||||||
const configuredCwd =
|
const configuredCwd =
|
||||||
typeof activeRunMember?.cwd === 'string'
|
typeof activeRunMember?.cwd === 'string'
|
||||||
? activeRunMember.cwd.trim()
|
? activeRunMember.cwd.trim()
|
||||||
|
|
@ -14985,7 +14997,9 @@ export class TeamProvisioningService {
|
||||||
metricsPid > 0 &&
|
metricsPid > 0 &&
|
||||||
liveRuntimeMember?.pidSource !== 'agent_process_table';
|
liveRuntimeMember?.pidSource !== 'agent_process_table';
|
||||||
const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid);
|
const rssPid = isSharedOpenCodeHost ? metricsPid : (liveRuntimeMember?.pid ?? metricsPid);
|
||||||
const displayPid = isSharedOpenCodeHost ? rssPid : liveRuntimeMember?.pid;
|
const displayPid = isSharedOpenCodeHost
|
||||||
|
? rssPid
|
||||||
|
: (liveRuntimeMember?.pid ?? runtimeAdapterPid);
|
||||||
const restartable = isOpenCodeMember
|
const restartable = isOpenCodeMember
|
||||||
? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid)
|
? !isSharedOpenCodeHost && Boolean(liveRuntimeMember?.pid)
|
||||||
: isSharedOpenCodeHost
|
: isSharedOpenCodeHost
|
||||||
|
|
@ -14994,6 +15008,8 @@ export class TeamProvisioningService {
|
||||||
const historicalBootstrapConfirmed =
|
const historicalBootstrapConfirmed =
|
||||||
launchMember?.bootstrapConfirmed === true ||
|
launchMember?.bootstrapConfirmed === true ||
|
||||||
launchMember?.launchState === 'confirmed_alive' ||
|
launchMember?.launchState === 'confirmed_alive' ||
|
||||||
|
runtimeAdapterEvidence?.bootstrapConfirmed === true ||
|
||||||
|
runtimeAdapterEvidence?.launchState === 'confirmed_alive' ||
|
||||||
spawnStatusMember?.bootstrapConfirmed === true ||
|
spawnStatusMember?.bootstrapConfirmed === true ||
|
||||||
spawnStatusMember?.launchState === 'confirmed_alive';
|
spawnStatusMember?.launchState === 'confirmed_alive';
|
||||||
const spawnStatusConfirmsBootstrap =
|
const spawnStatusConfirmsBootstrap =
|
||||||
|
|
@ -15003,7 +15019,9 @@ export class TeamProvisioningService {
|
||||||
isOpenCodeMember &&
|
isOpenCodeMember &&
|
||||||
(typeof liveRuntimeMember?.pid === 'number' ||
|
(typeof liveRuntimeMember?.pid === 'number' ||
|
||||||
typeof liveRuntimeMember?.metricsPid === 'number' ||
|
typeof liveRuntimeMember?.metricsPid === 'number' ||
|
||||||
typeof liveRuntimeMember?.runtimeSessionId === 'string');
|
typeof liveRuntimeMember?.runtimeSessionId === 'string' ||
|
||||||
|
typeof runtimeAdapterPid === 'number' ||
|
||||||
|
runtimeAdapterSessionId.length > 0);
|
||||||
const confirmedOpenCodeRuntimeAlive =
|
const confirmedOpenCodeRuntimeAlive =
|
||||||
isOpenCodeMember &&
|
isOpenCodeMember &&
|
||||||
canUseLiveSpawnStatusRuntimeTruth &&
|
canUseLiveSpawnStatusRuntimeTruth &&
|
||||||
|
|
@ -15012,6 +15030,12 @@ export class TeamProvisioningService {
|
||||||
spawnStatusMember?.hardFailure !== true &&
|
spawnStatusMember?.hardFailure !== true &&
|
||||||
spawnStatusMember?.launchState !== 'failed_to_start' &&
|
spawnStatusMember?.launchState !== 'failed_to_start' &&
|
||||||
spawnStatusMember?.launchState !== 'runtime_pending_permission';
|
spawnStatusMember?.launchState !== 'runtime_pending_permission';
|
||||||
|
const confirmedOpenCodeRuntimeAdapterAlive =
|
||||||
|
isOpenCodeMember &&
|
||||||
|
runtimeAdapterEvidence?.bootstrapConfirmed === true &&
|
||||||
|
runtimeAdapterEvidence.runtimeAlive === true &&
|
||||||
|
runtimeAdapterEvidence.hardFailure !== true &&
|
||||||
|
hasOpenCodeRuntimeHandle;
|
||||||
const confirmedSpawnRuntimeFallback =
|
const confirmedSpawnRuntimeFallback =
|
||||||
!isOpenCodeMember &&
|
!isOpenCodeMember &&
|
||||||
spawnStatusConfirmsBootstrap &&
|
spawnStatusConfirmsBootstrap &&
|
||||||
|
|
@ -15026,6 +15050,7 @@ export class TeamProvisioningService {
|
||||||
const effectiveAlive =
|
const effectiveAlive =
|
||||||
liveRuntimeMember?.alive === true ||
|
liveRuntimeMember?.alive === true ||
|
||||||
confirmedOpenCodeRuntimeAlive ||
|
confirmedOpenCodeRuntimeAlive ||
|
||||||
|
confirmedOpenCodeRuntimeAdapterAlive ||
|
||||||
confirmedSpawnRuntimeFallback;
|
confirmedSpawnRuntimeFallback;
|
||||||
const effectiveLivenessKind =
|
const effectiveLivenessKind =
|
||||||
confirmedOpenCodeRuntimeAlive &&
|
confirmedOpenCodeRuntimeAlive &&
|
||||||
|
|
@ -15154,7 +15179,9 @@ export class TeamProvisioningService {
|
||||||
...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}),
|
...(liveRuntimeMember?.metricsPid ? { runtimePid: liveRuntimeMember.metricsPid } : {}),
|
||||||
...(liveRuntimeMember?.runtimeSessionId
|
...(liveRuntimeMember?.runtimeSessionId
|
||||||
? { runtimeSessionId: liveRuntimeMember.runtimeSessionId }
|
? { runtimeSessionId: liveRuntimeMember.runtimeSessionId }
|
||||||
: {}),
|
: runtimeAdapterSessionId
|
||||||
|
? { runtimeSessionId: runtimeAdapterSessionId }
|
||||||
|
: {}),
|
||||||
...(liveRuntimeMember?.runtimeLastSeenAt
|
...(liveRuntimeMember?.runtimeLastSeenAt
|
||||||
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -19888,6 +19915,29 @@ export class TeamProvisioningService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildOpenCodeRuntimeAdapterLaunchMembers(
|
||||||
|
request: TeamCreateRequest | TeamLaunchRequest,
|
||||||
|
members: TeamCreateRequest['members']
|
||||||
|
): TeamCreateRequest['members'] {
|
||||||
|
if (resolveTeamProviderId(request.providerId) !== 'opencode') {
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
if (members.some((member) => isLeadMember(member))) {
|
||||||
|
return members;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'team-lead',
|
||||||
|
role: 'Team Lead',
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: request.model,
|
||||||
|
effort: request.effort,
|
||||||
|
},
|
||||||
|
...members,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
|
private async resolveOpenCodeMemberWorkspacesForRuntime(params: {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
baseCwd: string;
|
baseCwd: string;
|
||||||
|
|
@ -21530,6 +21580,10 @@ export class TeamProvisioningService {
|
||||||
leadProviderId: launchRequest.providerId,
|
leadProviderId: launchRequest.providerId,
|
||||||
members: materialized.members,
|
members: materialized.members,
|
||||||
});
|
});
|
||||||
|
const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers(
|
||||||
|
launchRequest,
|
||||||
|
effectiveMembers
|
||||||
|
);
|
||||||
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
|
const teamDir = path.join(getTeamsBasePath(), launchRequest.teamName);
|
||||||
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
|
const tasksDir = path.join(getTasksBasePath(), launchRequest.teamName);
|
||||||
await fs.promises.mkdir(teamDir, { recursive: true });
|
await fs.promises.mkdir(teamDir, { recursive: true });
|
||||||
|
|
@ -21558,7 +21612,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||||
request: launchRequest,
|
request: launchRequest,
|
||||||
members: effectiveMembers,
|
members: runtimeLaunchMembers,
|
||||||
prompt: launchRequest.prompt?.trim() ?? '',
|
prompt: launchRequest.prompt?.trim() ?? '',
|
||||||
sourceWarning: undefined,
|
sourceWarning: undefined,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|
@ -21594,6 +21648,10 @@ export class TeamProvisioningService {
|
||||||
leadProviderId: launchRequest.providerId,
|
leadProviderId: launchRequest.providerId,
|
||||||
members: materialized.members,
|
members: materialized.members,
|
||||||
});
|
});
|
||||||
|
const runtimeLaunchMembers = this.buildOpenCodeRuntimeAdapterLaunchMembers(
|
||||||
|
launchRequest,
|
||||||
|
effectiveMembers
|
||||||
|
);
|
||||||
await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd);
|
await this.updateConfigProjectPath(launchRequest.teamName, launchRequest.cwd);
|
||||||
|
|
||||||
let existingTasks: TeamTask[] = [];
|
let existingTasks: TeamTask[] = [];
|
||||||
|
|
@ -21613,7 +21671,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
return this.runOpenCodeTeamRuntimeAdapterLaunch({
|
||||||
request: launchRequest,
|
request: launchRequest,
|
||||||
members: effectiveMembers,
|
members: runtimeLaunchMembers,
|
||||||
prompt,
|
prompt,
|
||||||
sourceWarning: warning,
|
sourceWarning: warning,
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|
@ -21901,6 +21959,7 @@ export class TeamProvisioningService {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
expectedMembers: input.expectedMembers.map((member) => member.name),
|
expectedMembers: input.expectedMembers.map((member) => member.name),
|
||||||
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
|
bootstrapExpectedMembers: input.expectedMembers.map((member) => member.name),
|
||||||
|
includeLeadMembers: true,
|
||||||
leadSessionId: result.leadSessionId,
|
leadSessionId: result.leadSessionId,
|
||||||
launchPhase: committedResult.launchPhase,
|
launchPhase: committedResult.launchPhase,
|
||||||
members,
|
members,
|
||||||
|
|
@ -23462,13 +23521,21 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) {
|
||||||
if (isOpenCodeRecipient) {
|
if (isOpenCodeRecipient) {
|
||||||
const diagnostic =
|
const relayOptions: OpenCodeMemberInboxRelayOptions = {
|
||||||
'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.';
|
source: options.source ?? 'watcher',
|
||||||
logger.warn(`[${teamName}] ${diagnostic} inbox=${inboxName}`);
|
...(options.onlyMessageId ? { onlyMessageId: options.onlyMessageId } : {}),
|
||||||
|
...(options.deliveryMetadata ? { deliveryMetadata: options.deliveryMetadata } : {}),
|
||||||
|
};
|
||||||
|
const relay = await this.relayOpenCodeMemberInboxMessages(
|
||||||
|
teamName,
|
||||||
|
inboxName,
|
||||||
|
relayOptions
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
kind: 'opencode_lead_unsupported',
|
kind: 'opencode_member',
|
||||||
relayed: 0,
|
relayed: relay.relayed,
|
||||||
diagnostics: [diagnostic],
|
diagnostics: relay.diagnostics,
|
||||||
|
lastDelivery: relay.lastDelivery,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -251,9 +251,8 @@ liveDescribe('OpenCode semantic messaging live e2e', () => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
lastRelay.kind === 'opencode_lead_unsupported' ||
|
lastRelay.lastDelivery?.delivered === false &&
|
||||||
(lastRelay.lastDelivery?.delivered === false &&
|
lastRelay.lastDelivery.responsePending !== true
|
||||||
lastRelay.lastDelivery.responsePending !== true)
|
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,11 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('pure-opencode-safe-e2e');
|
const runtimeSnapshot = await svc.getTeamAgentRuntimeSnapshot('pure-opencode-safe-e2e');
|
||||||
|
expect(runtimeSnapshot.members['team-lead']).toMatchObject({
|
||||||
|
alive: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
runtimeModel: 'opencode/big-pickle',
|
||||||
|
});
|
||||||
expect(runtimeSnapshot.members.alice).toMatchObject({
|
expect(runtimeSnapshot.members.alice).toMatchObject({
|
||||||
alive: true,
|
alive: true,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -240,8 +245,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
})
|
})
|
||||||
) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string };
|
) as { expectedMembers: string[]; members: Record<string, unknown>; teamLaunchState: string };
|
||||||
expect(launchState.teamLaunchState).toBe('clean_success');
|
expect(launchState.teamLaunchState).toBe('clean_success');
|
||||||
expect(launchState.expectedMembers).toEqual(['alice', 'bob']);
|
expect(launchState.expectedMembers).toEqual(['team-lead', 'alice', 'bob']);
|
||||||
expect(Object.keys(launchState.members)).toEqual(['alice', 'bob']);
|
expect(Object.keys(launchState.members)).toEqual(['team-lead', 'alice', 'bob']);
|
||||||
await expect(
|
await expect(
|
||||||
readCommittedOpenCodeBootstrapSessionEvidence({
|
readCommittedOpenCodeBootstrapSessionEvidence({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
|
@ -278,7 +283,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: 'team-lead',
|
memberName: 'alice',
|
||||||
runId,
|
runId,
|
||||||
state: 'accepted',
|
state: 'accepted',
|
||||||
},
|
},
|
||||||
|
|
@ -350,7 +355,6 @@ 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',
|
||||||
]);
|
]);
|
||||||
|
|
@ -11164,6 +11168,94 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId);
|
expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('delivers pure OpenCode lead inbox messages through the primary runtime lane end-to-end', async () => {
|
||||||
|
const teamName = 'pure-opencode-lead-inbox-delivery-safe-e2e';
|
||||||
|
const adapter = new VisibleReplyOpenCodeRuntimeAdapter({
|
||||||
|
replySource: 'runtime_delivery',
|
||||||
|
});
|
||||||
|
const svc = new TeamProvisioningService();
|
||||||
|
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||||
|
const launch = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: projectPath,
|
||||||
|
providerId: 'opencode',
|
||||||
|
model: 'opencode/big-pickle',
|
||||||
|
skipPermissions: true,
|
||||||
|
members: [{ name: 'alice', role: 'Developer', providerId: 'opencode' }],
|
||||||
|
},
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
const messageId = 'msg-pure-opencode-lead-inbox';
|
||||||
|
const leadInboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', 'team-lead.json');
|
||||||
|
await fs.mkdir(path.dirname(leadInboxPath), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
leadInboxPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
from: 'user',
|
||||||
|
to: 'team-lead',
|
||||||
|
text: 'coordinate this pure opencode team',
|
||||||
|
timestamp: '2026-05-08T10:05:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
svc.relayInboxFileToLiveRecipient(teamName, 'team-lead', {
|
||||||
|
onlyMessageId: messageId,
|
||||||
|
source: 'ui-send',
|
||||||
|
deliveryMetadata: {
|
||||||
|
replyRecipient: 'user',
|
||||||
|
actionMode: 'do',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
kind: 'opencode_member',
|
||||||
|
relayed: 1,
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: false,
|
||||||
|
responseState: 'responded_visible_message',
|
||||||
|
visibleReplyMessageId: `reply-${messageId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(adapter.messageInputs).toHaveLength(1);
|
||||||
|
expect(adapter.messageInputs[0]).toMatchObject({
|
||||||
|
runId: launch.runId,
|
||||||
|
teamName,
|
||||||
|
laneId: 'primary',
|
||||||
|
memberName: 'team-lead',
|
||||||
|
text: 'coordinate this pure opencode team',
|
||||||
|
messageId,
|
||||||
|
replyRecipient: 'user',
|
||||||
|
actionMode: 'do',
|
||||||
|
});
|
||||||
|
|
||||||
|
const leadInbox = await readInboxRows(teamName, 'team-lead');
|
||||||
|
expect(leadInbox[0]).toMatchObject({
|
||||||
|
messageId,
|
||||||
|
read: true,
|
||||||
|
});
|
||||||
|
const userInbox = await readInboxRows(teamName, 'user');
|
||||||
|
expect(userInbox[0]).toMatchObject({
|
||||||
|
from: 'team-lead',
|
||||||
|
to: 'user',
|
||||||
|
source: 'runtime_delivery',
|
||||||
|
messageId: `reply-${messageId}`,
|
||||||
|
relayOfMessageId: messageId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('surfaces pure OpenCode delivery permission blocks as the shared tool approval dialog', async () => {
|
it('surfaces pure OpenCode delivery permission blocks as the shared tool approval dialog', async () => {
|
||||||
const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e';
|
const teamName = 'pure-opencode-delivery-permission-approval-safe-e2e';
|
||||||
const adapter = new PermissionBlockedOpenCodeRuntimeAdapter();
|
const adapter = new PermissionBlockedOpenCodeRuntimeAdapter();
|
||||||
|
|
|
||||||
|
|
@ -1664,6 +1664,66 @@ Messages:
|
||||||
expect(payload).not.toContain('MessageId: m-ordinary-11');
|
expect(payload).not.toContain('MessageId: m-ordinary-11');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps native member work-sync rows unread without accepted report proof', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
service.setMemberWorkSyncAcceptedReportChecker(async () => false);
|
||||||
|
seedMemberInbox(teamName, 'alice', [
|
||||||
|
{
|
||||||
|
from: 'system',
|
||||||
|
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-work-sync-unproved',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
const firstRelayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
const rowsAfterFirst = JSON.parse(
|
||||||
|
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
|
||||||
|
) as Array<{ read?: boolean }>;
|
||||||
|
|
||||||
|
expect(firstRelayed).toBe(1);
|
||||||
|
expect(rowsAfterFirst[0]?.read).toBe(false);
|
||||||
|
|
||||||
|
const secondRelayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
|
||||||
|
expect(secondRelayed).toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('read-commits native member work-sync rows after accepted report proof', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
service.setMemberWorkSyncAcceptedReportChecker(async () => true);
|
||||||
|
seedMemberInbox(teamName, 'alice', [
|
||||||
|
{
|
||||||
|
from: 'system',
|
||||||
|
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-work-sync-proved',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
attachAliveRun(service, teamName);
|
||||||
|
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
const rows = JSON.parse(
|
||||||
|
hoisted.files.get(`/mock/teams/${teamName}/inboxes/alice.json`) ?? '[]'
|
||||||
|
) as Array<{ read?: boolean }>;
|
||||||
|
|
||||||
|
expect(relayed).toBe(1);
|
||||||
|
expect(rows[0]?.read).toBe(true);
|
||||||
|
await expect(service.relayMemberInboxMessages(teamName, 'alice')).resolves.toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('retries a work-sync nudge after member relay times out before stdin write completes', async () => {
|
it('retries a work-sync nudge after member relay times out before stdin write completes', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
|
|
@ -4170,7 +4230,7 @@ Messages:
|
||||||
expect(rows[0].read).toBe(true);
|
expect(rows[0].read).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves OpenCode lead inbox rows unread with an explicit unsupported diagnostic', async () => {
|
it('routes OpenCode lead inbox rows through OpenCode member relay', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
hoisted.files.set(
|
hoisted.files.set(
|
||||||
|
|
@ -4198,19 +4258,50 @@ Messages:
|
||||||
messageId: 'opencode-lead-unread-1',
|
messageId: 'opencode-lead-unread-1',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
const relaySpy = vi.spyOn(service, 'relayOpenCodeMemberInboxMessages').mockResolvedValue({
|
||||||
|
relayed: 1,
|
||||||
|
attempted: 1,
|
||||||
|
delivered: 1,
|
||||||
|
failed: 0,
|
||||||
|
diagnostics: ['fake OpenCode lead relay ready'],
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead');
|
const relay = await service.relayInboxFileToLiveRecipient(teamName, 'team-lead', {
|
||||||
|
onlyMessageId: 'opencode-lead-unread-1',
|
||||||
|
source: 'ui-send',
|
||||||
|
deliveryMetadata: {
|
||||||
|
replyRecipient: 'user',
|
||||||
|
actionMode: 'do',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
expect(relay).toMatchObject({ kind: 'opencode_lead_unsupported', relayed: 0 });
|
expect(relay).toMatchObject({
|
||||||
expect(relay.diagnostics?.join('\n')).toContain('opencode_lead_runtime_session_missing');
|
kind: 'opencode_member',
|
||||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
relayed: 1,
|
||||||
'opencode_lead_runtime_session_missing'
|
diagnostics: ['fake OpenCode lead relay ready'],
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: true,
|
||||||
|
accepted: true,
|
||||||
|
responsePending: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(relaySpy).toHaveBeenCalledWith(
|
||||||
|
teamName,
|
||||||
|
'team-lead',
|
||||||
|
expect.objectContaining({
|
||||||
|
onlyMessageId: 'opencode-lead-unread-1',
|
||||||
|
source: 'ui-send',
|
||||||
|
deliveryMetadata: expect.objectContaining({
|
||||||
|
replyRecipient: 'user',
|
||||||
|
actionMode: 'do',
|
||||||
|
}),
|
||||||
|
})
|
||||||
);
|
);
|
||||||
vi.mocked(console.warn).mockClear();
|
|
||||||
const rows = JSON.parse(
|
|
||||||
hoisted.files.get(`/mock/teams/${teamName}/inboxes/team-lead.json`) ?? '[]'
|
|
||||||
);
|
|
||||||
expect(rows[0].read).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {
|
it('keeps failed OpenCode member inbox relay rows unread for retry', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue