fix: stabilize opencode mcp transport refresh
Keep OpenCode app MCP transport evidence durable, refresh stale sessions without consuming normal delivery attempts, and keep recoverable runtime diagnostics out of member card errors. Cover stable MCP restart/fallback, forced session refresh, resolved_behavior_changed recovery, and renderer diagnostics with regression and safe e2e tests.
This commit is contained in:
parent
5e0d552cb9
commit
6e4f8ff8c4
25 changed files with 2351 additions and 70 deletions
|
|
@ -459,6 +459,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash;
|
||||
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -487,11 +488,15 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
try {
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash;
|
||||
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH = mcpHttpServer.urlHash;
|
||||
await ensureOpenCodeLocalMcpLaunchEnv(nextEnv);
|
||||
} catch (error) {
|
||||
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
|
||||
delete bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH;
|
||||
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL;
|
||||
delete nextEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL_HASH;
|
||||
await ensureOpenCodeLocalMcpLaunchEnv(nextEnv);
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge MCP HTTP server refresh failed: ${
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
|||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type ChildProcess } from 'child_process';
|
||||
import { createHash } from 'crypto';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
|
|
@ -14,11 +15,32 @@ const MCP_HTTP_ENDPOINT = '/mcp';
|
|||
const MCP_HTTP_READY_TIMEOUT_MS = 5_000;
|
||||
const MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS = 3_000;
|
||||
const MCP_HTTP_READY_POLL_MS = 100;
|
||||
const MCP_HTTP_PORT_RELEASE_TIMEOUT_MS = 3_000;
|
||||
const MCP_HTTP_STABLE_PORT_BASE = 43_100;
|
||||
const MCP_HTTP_STABLE_PORT_SPAN = 700;
|
||||
const MCP_HTTP_STABLE_PORT_SCAN_LIMIT = 20;
|
||||
const MCP_HTTP_PORT_ENV = 'CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT';
|
||||
|
||||
export interface AgentTeamsMcpHttpTransportEvidence {
|
||||
schemaVersion: 1;
|
||||
transport: 'httpStream';
|
||||
host: string;
|
||||
port: number;
|
||||
endpoint: string;
|
||||
url: string;
|
||||
urlHash: string;
|
||||
generation: number;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface AgentTeamsMcpHttpServerHandle {
|
||||
url: string;
|
||||
port: number;
|
||||
pid: number | null;
|
||||
generation: number;
|
||||
urlHash: string;
|
||||
transportEvidence: AgentTeamsMcpHttpTransportEvidence;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface AgentTeamsMcpHttpServerDeps {
|
||||
|
|
@ -50,6 +72,18 @@ async function allocateLoopbackPort(): Promise<number> {
|
|||
});
|
||||
}
|
||||
|
||||
async function canListenOnLoopbackPort(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
server.listen(port, host, () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -91,6 +125,21 @@ async function waitForLoopbackPort(host: string, port: number, timeoutMs: number
|
|||
);
|
||||
}
|
||||
|
||||
async function waitForLoopbackPortAvailable(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs: number
|
||||
): Promise<boolean> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await canListenOnLoopbackPort(host, port)) {
|
||||
return true;
|
||||
}
|
||||
await sleep(MCP_HTTP_READY_POLL_MS);
|
||||
}
|
||||
return await canListenOnLoopbackPort(host, port);
|
||||
}
|
||||
|
||||
function defaultSpawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
|
|
@ -117,10 +166,56 @@ function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[]
|
|||
];
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function parseConfiguredStablePort(value: string | undefined): number | null {
|
||||
if (!value?.trim()) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value.trim());
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65_535) {
|
||||
logger.warn(`Ignoring invalid ${MCP_HTTP_PORT_ENV} value: ${value}`);
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function resolveDefaultStablePort(): number {
|
||||
const configured = parseConfiguredStablePort(process.env[MCP_HTTP_PORT_ENV]);
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
const basis = `${getClaudeBasePath()}|agent-teams-opencode-mcp-http`;
|
||||
const hashPrefix = sha256Hex(basis).slice(0, 8);
|
||||
const offset = Number.parseInt(hashPrefix, 16) % MCP_HTTP_STABLE_PORT_SPAN;
|
||||
return MCP_HTTP_STABLE_PORT_BASE + offset;
|
||||
}
|
||||
|
||||
function buildTransportEvidence(
|
||||
port: number,
|
||||
generation: number
|
||||
): AgentTeamsMcpHttpTransportEvidence {
|
||||
const url = `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`;
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: MCP_HTTP_HOST,
|
||||
port,
|
||||
endpoint: MCP_HTTP_ENDPOINT,
|
||||
url,
|
||||
urlHash: sha256Hex(url),
|
||||
generation,
|
||||
observedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentTeamsMcpHttpServer {
|
||||
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
|
||||
private child: ChildProcess | null = null;
|
||||
private handle: AgentTeamsMcpHttpServerHandle | null = null;
|
||||
private generation = 0;
|
||||
private readonly expectedStopChildren = new WeakSet<ChildProcess>();
|
||||
|
||||
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
||||
|
|
@ -140,12 +235,24 @@ export class AgentTeamsMcpHttpServer {
|
|||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child;
|
||||
const releasePort = this.handle?.port ?? null;
|
||||
this.child = null;
|
||||
this.handle = null;
|
||||
if (child) {
|
||||
this.expectedStopChildren.add(child);
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
}
|
||||
if (releasePort) {
|
||||
await waitForLoopbackPortAvailable(
|
||||
MCP_HTTP_HOST,
|
||||
releasePort,
|
||||
MCP_HTTP_PORT_RELEASE_TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentHandle(): AgentTeamsMcpHttpServerHandle | null {
|
||||
return this.handle;
|
||||
}
|
||||
|
||||
private async reuseOrRestartExistingHandle(
|
||||
|
|
@ -164,20 +271,72 @@ export class AgentTeamsMcpHttpServer {
|
|||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
const restartPort = handle.port;
|
||||
const previousUrlHash = handle.urlHash;
|
||||
await this.stop();
|
||||
return this.startOnce({
|
||||
preferredPort: restartPort,
|
||||
previousUrlHash,
|
||||
reason: 'health_reuse_failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.startOnce();
|
||||
}
|
||||
|
||||
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
|
||||
private async resolveStartPort(preferredPort?: number | null): Promise<{
|
||||
port: number;
|
||||
diagnostics: string[];
|
||||
}> {
|
||||
const diagnostics: string[] = [];
|
||||
if (preferredPort && (await canListenOnLoopbackPort(MCP_HTTP_HOST, preferredPort))) {
|
||||
return { port: preferredPort, diagnostics };
|
||||
}
|
||||
if (preferredPort) {
|
||||
diagnostics.push(`opencode_app_mcp_preferred_port_unavailable:${preferredPort}`);
|
||||
}
|
||||
|
||||
if (this.deps.allocatePort && (!preferredPort || diagnostics.length > 0)) {
|
||||
return { port: await this.deps.allocatePort(), diagnostics };
|
||||
}
|
||||
|
||||
const stablePort = resolveDefaultStablePort();
|
||||
for (let offset = 0; offset < MCP_HTTP_STABLE_PORT_SCAN_LIMIT; offset += 1) {
|
||||
const candidate = stablePort + offset;
|
||||
if (candidate > 65_535) {
|
||||
break;
|
||||
}
|
||||
if (preferredPort === candidate) {
|
||||
continue;
|
||||
}
|
||||
if (await canListenOnLoopbackPort(MCP_HTTP_HOST, candidate)) {
|
||||
if (candidate !== stablePort) {
|
||||
diagnostics.push(`opencode_app_mcp_preferred_port_unavailable:${stablePort}`);
|
||||
}
|
||||
return { port: candidate, diagnostics };
|
||||
}
|
||||
}
|
||||
|
||||
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
|
||||
const port = await allocatePort();
|
||||
diagnostics.push('opencode_app_mcp_stable_port_range_unavailable');
|
||||
return { port, diagnostics };
|
||||
}
|
||||
|
||||
private async startOnce(
|
||||
input: {
|
||||
preferredPort?: number | null;
|
||||
previousUrlHash?: string | null;
|
||||
reason?: string;
|
||||
} = {}
|
||||
): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
|
||||
const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess;
|
||||
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
|
||||
const launchSpec = await resolveLaunchSpec();
|
||||
const port = await allocatePort();
|
||||
const selectedPort = await this.resolveStartPort(input.preferredPort ?? null);
|
||||
const port = selectedPort.port;
|
||||
const args = buildHttpServerArgs(launchSpec, port);
|
||||
const childEnv = applyAgentTeamsIdentityEnv({
|
||||
...process.env,
|
||||
|
|
@ -254,14 +413,35 @@ export class AgentTeamsMcpHttpServer {
|
|||
}
|
||||
|
||||
startupSettled = true;
|
||||
const generation = this.generation + 1;
|
||||
const transportEvidence = buildTransportEvidence(port, generation);
|
||||
this.generation = generation;
|
||||
const diagnostics = [...selectedPort.diagnostics];
|
||||
if (input.previousUrlHash && input.previousUrlHash !== transportEvidence.urlHash) {
|
||||
diagnostics.push('opencode_app_mcp_public_url_changed');
|
||||
}
|
||||
if (input.reason) {
|
||||
diagnostics.push(`opencode_app_mcp_restart_reason:${input.reason}`);
|
||||
}
|
||||
this.handle = {
|
||||
url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`,
|
||||
url: transportEvidence.url,
|
||||
port,
|
||||
pid: child.pid ?? null,
|
||||
generation,
|
||||
urlHash: transportEvidence.urlHash,
|
||||
transportEvidence,
|
||||
diagnostics,
|
||||
};
|
||||
logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`);
|
||||
for (const diagnostic of diagnostics) {
|
||||
logger.warn(`Agent Teams MCP HTTP diagnostic: ${diagnostic}`);
|
||||
}
|
||||
return this.handle;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer();
|
||||
|
||||
export function getCurrentAgentTeamsMcpHttpTransportEvidence(): AgentTeamsMcpHttpTransportEvidence | null {
|
||||
return agentTeamsMcpHttpServer.getCurrentHandle()?.transportEvidence ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,16 +190,20 @@ import {
|
|||
parseBootstrapRuntimeProofDetail,
|
||||
validateBootstrapRuntimeProofEnvelope,
|
||||
} from './bootstrap/BootstrapProofValidation';
|
||||
import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer';
|
||||
import {
|
||||
buildNativeAppManagedBootstrapSpecs,
|
||||
buildNativeAppManagedBootstrapSpecsWithDiagnostics,
|
||||
type NativeAppManagedBootstrapSpec,
|
||||
} from './bootstrap/NativeAppManagedBootstrapContextBuilder';
|
||||
import {
|
||||
buildOpenCodePromptDeliveryAttemptId,
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
hashOpenCodePromptDeliveryPayload,
|
||||
isOpenCodePromptDeliveryAttemptDue,
|
||||
isOpenCodePromptResponseStateResponded,
|
||||
isOpenCodeSessionRefreshResponseState,
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS,
|
||||
type OpenCodePromptDeliveryLedgerRecord,
|
||||
type OpenCodePromptDeliveryLedgerStore,
|
||||
type OpenCodePromptDeliveryStatus,
|
||||
|
|
@ -8210,6 +8214,80 @@ export class TeamProvisioningService {
|
|||
);
|
||||
}
|
||||
|
||||
private isOpenCodeNoAssistantDeliveryFailure(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): boolean {
|
||||
if (record.inboxReadCommittedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const noAssistantState =
|
||||
record.responseState === 'empty_assistant_turn' ||
|
||||
record.responseState === 'prompt_delivered_no_assistant_message';
|
||||
const reasonText = [record.responseState, record.lastReason, ...record.diagnostics]
|
||||
.filter((reason): reason is string => typeof reason === 'string' && reason.trim().length > 0)
|
||||
.join('\n')
|
||||
.toLowerCase();
|
||||
if (
|
||||
!noAssistantState &&
|
||||
!reasonText.includes('empty_assistant_turn') &&
|
||||
!reasonText.includes('prompt_delivered_no_assistant_message') &&
|
||||
!reasonText.includes('accepted the prompt, but no assistant turn was recorded')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ![record.lastReason, ...record.diagnostics].some((reason) => {
|
||||
const reasonCode = classifyOpenCodeRuntimeDeliveryReasonCode(reason ?? undefined);
|
||||
return (
|
||||
reasonCode === 'quota_exhausted' ||
|
||||
reasonCode === 'auth_error' ||
|
||||
reasonCode === 'filesystem_error'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private isOpenCodeNoAssistantTerminalDeliveryFailure(
|
||||
record: OpenCodePromptDeliveryLedgerRecord
|
||||
): boolean {
|
||||
return (
|
||||
record.status === 'failed_terminal' &&
|
||||
record.attempts <= record.maxAttempts &&
|
||||
this.isOpenCodeNoAssistantDeliveryFailure(record)
|
||||
);
|
||||
}
|
||||
|
||||
private async requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded(input: {
|
||||
ledger: OpenCodePromptDeliveryLedgerStore;
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
if (!this.isOpenCodeNoAssistantTerminalDeliveryFailure(input.ledgerRecord)) {
|
||||
return input.ledgerRecord;
|
||||
}
|
||||
|
||||
const scheduledAt = nowIso();
|
||||
const requeued = await input.ledger.markNextAttemptScheduled({
|
||||
id: input.ledgerRecord.id,
|
||||
status: 'retry_scheduled',
|
||||
nextAttemptAt: scheduledAt,
|
||||
reason: 'opencode_prompt_delivery_requeued_after_terminal_no_assistant_response',
|
||||
scheduledAt,
|
||||
});
|
||||
logger.info(
|
||||
'opencode_prompt_delivery_requeued_after_terminal_no_assistant_response',
|
||||
JSON.stringify({
|
||||
teamName: requeued.teamName,
|
||||
memberName: requeued.memberName,
|
||||
laneId: requeued.laneId,
|
||||
runId: requeued.runId,
|
||||
inboxMessageId: requeued.inboxMessageId,
|
||||
attempts: requeued.attempts,
|
||||
maxAttempts: requeued.maxAttempts,
|
||||
})
|
||||
);
|
||||
return requeued;
|
||||
}
|
||||
|
||||
private async requeueOpenCodeRuntimeManifestWatermarkDeliveryIfNeeded(input: {
|
||||
ledger: OpenCodePromptDeliveryLedgerStore;
|
||||
ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
|
||||
|
|
@ -9259,7 +9337,69 @@ export class TeamProvisioningService {
|
|||
reason: string;
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
const now = nowIso();
|
||||
if (input.retry && input.ledgerRecord.attempts >= input.ledgerRecord.maxAttempts) {
|
||||
const sessionRefreshRetry =
|
||||
input.retry &&
|
||||
isOpenCodeSessionRefreshResponseState({
|
||||
responseState: input.ledgerRecord.responseState,
|
||||
reason: input.reason,
|
||||
diagnostics: input.ledgerRecord.diagnostics,
|
||||
});
|
||||
if (sessionRefreshRetry) {
|
||||
const maxSessionRefreshAttempts =
|
||||
input.ledgerRecord.maxSessionRefreshAttempts ??
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
|
||||
if ((input.ledgerRecord.sessionRefreshAttempts ?? 0) >= maxSessionRefreshAttempts) {
|
||||
return await input.ledger.markFailedTerminal({
|
||||
id: input.ledgerRecord.id,
|
||||
reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
|
||||
diagnostics: [
|
||||
input.reason,
|
||||
`OpenCode session stayed stale after ${maxSessionRefreshAttempts} session refresh attempt(s).`,
|
||||
],
|
||||
failedAt: now,
|
||||
});
|
||||
}
|
||||
const delayMs = this.getOpenCodeDeliveryNextDelayMs({
|
||||
responseState: input.ledgerRecord.responseState,
|
||||
retry: input.retry,
|
||||
ledgerRecord: input.ledgerRecord,
|
||||
});
|
||||
const nextAttemptAt = new Date(Date.now() + delayMs).toISOString();
|
||||
const ledgerRecord = await input.ledger.markSessionRefreshScheduled({
|
||||
id: input.ledgerRecord.id,
|
||||
nextAttemptAt,
|
||||
reason: input.reason,
|
||||
scheduledAt: now,
|
||||
maxSessionRefreshAttempts,
|
||||
diagnostics: ['opencode_session_refresh_scheduled_after_resolved_behavior_changed'],
|
||||
});
|
||||
this.logOpenCodePromptDeliveryEvent(
|
||||
'opencode_prompt_delivery_session_refresh_scheduled',
|
||||
ledgerRecord,
|
||||
{
|
||||
retry: true,
|
||||
reason: input.reason,
|
||||
sessionRefreshAttempts: ledgerRecord.sessionRefreshAttempts ?? 0,
|
||||
maxSessionRefreshAttempts,
|
||||
}
|
||||
);
|
||||
this.scheduleOpenCodePromptDeliveryWatchdog({
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
messageId: input.ledgerRecord.inboxMessageId,
|
||||
delayMs,
|
||||
});
|
||||
return ledgerRecord;
|
||||
}
|
||||
const canScheduleNoAssistantRecoveryRetry =
|
||||
input.retry &&
|
||||
input.ledgerRecord.attempts === input.ledgerRecord.maxAttempts &&
|
||||
this.isOpenCodeNoAssistantDeliveryFailure(input.ledgerRecord);
|
||||
if (
|
||||
input.retry &&
|
||||
input.ledgerRecord.attempts >= input.ledgerRecord.maxAttempts &&
|
||||
!canScheduleNoAssistantRecoveryRetry
|
||||
) {
|
||||
return await input.ledger.markFailedTerminal({
|
||||
id: input.ledgerRecord.id,
|
||||
reason: input.reason,
|
||||
|
|
@ -10108,21 +10248,41 @@ export class TeamProvisioningService {
|
|||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||
}
|
||||
|
||||
if (laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode') {
|
||||
const bootstrapReady = await this.hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence({
|
||||
let legacyOpenCodeBootstrapSessionToStamp: OpenCodeCommittedBootstrapSessionRecord | null =
|
||||
null;
|
||||
let refreshedOpenCodeBootstrapSessionToStamp: OpenCodeCommittedBootstrapSessionRecord | null =
|
||||
null;
|
||||
let forceOpenCodeSessionRefreshReason: string | undefined;
|
||||
if (laneIdentity.laneOwnerProviderId === 'opencode') {
|
||||
const bootstrapSession = await this.findDeliverableOpenCodeRuntimeBootstrapSessionEvidence({
|
||||
teamName,
|
||||
runId: runtimeRunId,
|
||||
laneId: laneIdentity.laneId,
|
||||
memberName: canonicalMemberName,
|
||||
});
|
||||
if (!bootstrapReady) {
|
||||
return {
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
diagnostics: [
|
||||
`OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`,
|
||||
],
|
||||
};
|
||||
if (!bootstrapSession) {
|
||||
if (laneIdentity.laneKind === 'secondary') {
|
||||
return {
|
||||
delivered: false,
|
||||
reason: 'opencode_runtime_not_active',
|
||||
diagnostics: [
|
||||
`OpenCode runtime bootstrap is not confirmed for ${canonicalMemberName}. Message was saved and will be retried after runtime check-in.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
if (!bootstrapSession.appMcpTransportHash?.trim()) {
|
||||
legacyOpenCodeBootstrapSessionToStamp = bootstrapSession;
|
||||
}
|
||||
const appMcpTransportMismatch =
|
||||
this.getOpenCodeAppMcpTransportMismatchDiagnostic(bootstrapSession);
|
||||
if (appMcpTransportMismatch) {
|
||||
refreshedOpenCodeBootstrapSessionToStamp = bootstrapSession;
|
||||
forceOpenCodeSessionRefreshReason = appMcpTransportMismatch;
|
||||
logger.info(
|
||||
`[${teamName}] OpenCode delivery detected stale app MCP transport for ${canonicalMemberName}; requesting bridge session refresh before send. ${appMcpTransportMismatch}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -10178,6 +10338,7 @@ export class TeamProvisioningService {
|
|||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
controlUrl: controlUrl ?? undefined,
|
||||
taskRefs: input.taskRefs,
|
||||
forceSessionRefreshReason: forceOpenCodeSessionRefreshReason,
|
||||
});
|
||||
await this.rememberOpenCodeRuntimePidFromBridge({
|
||||
teamName,
|
||||
|
|
@ -10188,6 +10349,20 @@ export class TeamProvisioningService {
|
|||
runtimePid: result.runtimePid,
|
||||
reason: 'opencode_delivery_runtime_pid_observed',
|
||||
});
|
||||
if (result.ok && legacyOpenCodeBootstrapSessionToStamp) {
|
||||
await this.stampOpenCodeAppMcpTransportEvidenceIfMissing(
|
||||
legacyOpenCodeBootstrapSessionToStamp
|
||||
);
|
||||
}
|
||||
if (result.ok && result.sessionId && refreshedOpenCodeBootstrapSessionToStamp) {
|
||||
await this.stampOpenCodeAppMcpTransportEvidenceIfMissing(
|
||||
refreshedOpenCodeBootstrapSessionToStamp,
|
||||
{
|
||||
overwriteExistingHash: true,
|
||||
runtimeSessionId: result.sessionId,
|
||||
}
|
||||
);
|
||||
}
|
||||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
result.responseObservation
|
||||
);
|
||||
|
|
@ -10301,9 +10476,7 @@ export class TeamProvisioningService {
|
|||
this.logOpenCodePromptDeliveryEvent('opencode_prompt_delivery_ledger_created', ledgerRecord);
|
||||
}
|
||||
const deliveryAttemptId = ledgerRecord
|
||||
? [ledgerRecord.id, ledgerRecord.attempts + 1, ledgerRecord.payloadHash.slice(0, 12)].join(
|
||||
':'
|
||||
)
|
||||
? buildOpenCodePromptDeliveryAttemptId(ledgerRecord)
|
||||
: undefined;
|
||||
|
||||
if (ledgerRecord && ledger && messageId) {
|
||||
|
|
@ -10422,7 +10595,19 @@ export class TeamProvisioningService {
|
|||
attemptDue,
|
||||
ledgerRecord,
|
||||
});
|
||||
if (ledgerRecord.status !== 'pending' && adapter.observeMessageDelivery) {
|
||||
const retryShouldRefreshSessionBeforeObserve =
|
||||
retryDueBeforeObserve &&
|
||||
ledgerRecord.status === 'retry_scheduled' &&
|
||||
isOpenCodeSessionRefreshResponseState({
|
||||
responseState: ledgerRecord.responseState,
|
||||
reason: ledgerRecord.lastReason ?? undefined,
|
||||
diagnostics: ledgerRecord.diagnostics,
|
||||
});
|
||||
if (
|
||||
ledgerRecord.status !== 'pending' &&
|
||||
adapter.observeMessageDelivery &&
|
||||
!retryShouldRefreshSessionBeforeObserve
|
||||
) {
|
||||
const observed = await adapter.observeMessageDelivery({
|
||||
...(runtimeRunId ? { runId: runtimeRunId } : {}),
|
||||
teamName,
|
||||
|
|
@ -10530,6 +10715,54 @@ export class TeamProvisioningService {
|
|||
readAllowed,
|
||||
});
|
||||
const retryDue = retryDueBeforeObserve;
|
||||
if (
|
||||
retryDue &&
|
||||
retryable &&
|
||||
isOpenCodeSessionRefreshResponseState({
|
||||
responseState: ledgerRecord.responseState,
|
||||
reason: pendingReason,
|
||||
diagnostics: ledgerRecord.diagnostics,
|
||||
})
|
||||
) {
|
||||
ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({
|
||||
ledger,
|
||||
ledgerRecord,
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
retry: true,
|
||||
reason: pendingReason,
|
||||
});
|
||||
if (ledgerRecord.status === 'failed_terminal') {
|
||||
return {
|
||||
delivered: false,
|
||||
accepted: true,
|
||||
responsePending: false,
|
||||
responseState: ledgerRecord.responseState,
|
||||
ledgerStatus: ledgerRecord.status,
|
||||
ledgerRecordId: ledgerRecord.id,
|
||||
laneId: laneIdentity.laneId,
|
||||
visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined,
|
||||
visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined,
|
||||
reason: ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal',
|
||||
diagnostics: ledgerRecord.diagnostics.length
|
||||
? ledgerRecord.diagnostics
|
||||
: [ledgerRecord.lastReason ?? 'opencode_prompt_delivery_failed_terminal'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: ledgerRecord.responseState,
|
||||
ledgerStatus: ledgerRecord.status,
|
||||
ledgerRecordId: ledgerRecord.id,
|
||||
laneId: laneIdentity.laneId,
|
||||
visibleReplyMessageId: ledgerRecord.visibleReplyMessageId ?? undefined,
|
||||
visibleReplyCorrelation: ledgerRecord.visibleReplyCorrelation ?? undefined,
|
||||
reason: ledgerRecord.lastReason ?? 'opencode_delivery_response_pending',
|
||||
diagnostics: ledgerRecord.diagnostics,
|
||||
};
|
||||
}
|
||||
if (!retryDue || !retryable) {
|
||||
ledgerRecord = await this.scheduleOpenCodePromptLedgerFollowUp({
|
||||
ledger,
|
||||
|
|
@ -10604,6 +10837,7 @@ export class TeamProvisioningService {
|
|||
workSyncReviewRequestEventIds: input.workSyncReviewRequestEventIds,
|
||||
controlUrl: controlUrl ?? undefined,
|
||||
taskRefs: input.taskRefs,
|
||||
forceSessionRefreshReason: forceOpenCodeSessionRefreshReason,
|
||||
});
|
||||
} catch (error) {
|
||||
const diagnostic = `opencode_message_delivery_exception: ${getErrorMessage(error)}`;
|
||||
|
|
@ -10670,6 +10904,20 @@ export class TeamProvisioningService {
|
|||
runtimePid: result.runtimePid,
|
||||
reason: 'opencode_delivery_runtime_pid_observed',
|
||||
});
|
||||
if (result.ok && legacyOpenCodeBootstrapSessionToStamp) {
|
||||
await this.stampOpenCodeAppMcpTransportEvidenceIfMissing(
|
||||
legacyOpenCodeBootstrapSessionToStamp
|
||||
);
|
||||
}
|
||||
if (result.ok && result.sessionId && refreshedOpenCodeBootstrapSessionToStamp) {
|
||||
await this.stampOpenCodeAppMcpTransportEvidenceIfMissing(
|
||||
refreshedOpenCodeBootstrapSessionToStamp,
|
||||
{
|
||||
overwriteExistingHash: true,
|
||||
runtimeSessionId: result.sessionId,
|
||||
}
|
||||
);
|
||||
}
|
||||
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
|
||||
result.responseObservation
|
||||
);
|
||||
|
|
@ -12843,6 +13091,8 @@ export class TeamProvisioningService {
|
|||
const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath);
|
||||
const existingSessions = await this.readOpenCodeRuntimeSessionStore(sessionStorePath);
|
||||
const source = input.source ?? 'runtime_bootstrap_checkin';
|
||||
const appMcpTransportEvidence =
|
||||
source === 'app_managed_bootstrap' ? getCurrentAgentTeamsMcpHttpTransportEvidence() : null;
|
||||
const session = {
|
||||
id: input.runtimeSessionId,
|
||||
teamName: input.teamName,
|
||||
|
|
@ -12855,6 +13105,12 @@ export class TeamProvisioningService {
|
|||
...(source === 'app_managed_bootstrap' && input.appManagedBootstrapCandidate
|
||||
? { appManagedBootstrapCandidate: input.appManagedBootstrapCandidate }
|
||||
: {}),
|
||||
...(appMcpTransportEvidence
|
||||
? {
|
||||
appMcpTransportHash: appMcpTransportEvidence.urlHash,
|
||||
appMcpTransportEvidence,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const sessions = this.mergeOpenCodeRuntimeSessionRecords(existingSessions, session);
|
||||
const manifestStore = createRuntimeStoreManifestStore({
|
||||
|
|
@ -12942,31 +13198,161 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||
private getOpenCodeAppMcpTransportMismatchDiagnostic(
|
||||
session: OpenCodeCommittedBootstrapSessionRecord
|
||||
): string | null {
|
||||
const committedHash = session.appMcpTransportHash?.trim();
|
||||
const currentHash = getCurrentAgentTeamsMcpHttpTransportEvidence()?.urlHash?.trim();
|
||||
if (!committedHash || !currentHash || committedHash === currentHash) {
|
||||
return null;
|
||||
}
|
||||
return `opencode_app_mcp_transport_changed:${committedHash}->${currentHash}`;
|
||||
}
|
||||
|
||||
private async stampOpenCodeAppMcpTransportEvidenceIfMissing(
|
||||
session: OpenCodeCommittedBootstrapSessionRecord,
|
||||
options: {
|
||||
overwriteExistingHash?: boolean;
|
||||
runtimeSessionId?: string | null;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const overwriteExistingHash = options.overwriteExistingHash === true;
|
||||
const runtimeSessionId = options.runtimeSessionId?.trim() || null;
|
||||
if (session.appMcpTransportHash?.trim() && !overwriteExistingHash) {
|
||||
return;
|
||||
}
|
||||
const appMcpTransportEvidence = getCurrentAgentTeamsMcpHttpTransportEvidence();
|
||||
if (!appMcpTransportEvidence) {
|
||||
return;
|
||||
}
|
||||
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||
(candidate) => candidate.schemaName === 'opencode.sessionStore'
|
||||
);
|
||||
if (!descriptor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const manifestPath = getOpenCodeRuntimeManifestPath(
|
||||
getTeamsBasePath(),
|
||||
session.teamName,
|
||||
session.laneId
|
||||
);
|
||||
const runtimeDirectory = path.dirname(manifestPath);
|
||||
const sessionStorePath = path.join(runtimeDirectory, descriptor.relativePath);
|
||||
const existingSessions = await this.readOpenCodeRuntimeSessionStore(sessionStorePath);
|
||||
let changed = false;
|
||||
const sessions = existingSessions
|
||||
.filter((record) => {
|
||||
if (!runtimeSessionId || runtimeSessionId === session.id) {
|
||||
return true;
|
||||
}
|
||||
const recordId = typeof record.id === 'string' ? record.id : '';
|
||||
const recordRunId = typeof record.runId === 'string' ? record.runId : null;
|
||||
const recordLaneId = typeof record.laneId === 'string' ? record.laneId : '';
|
||||
const recordMemberName = typeof record.memberName === 'string' ? record.memberName : '';
|
||||
return !(
|
||||
recordId === runtimeSessionId &&
|
||||
recordRunId === session.runId &&
|
||||
recordLaneId === session.laneId &&
|
||||
namesMatchCaseInsensitive(recordMemberName, session.memberName)
|
||||
);
|
||||
})
|
||||
.map((record) => {
|
||||
const recordId = typeof record.id === 'string' ? record.id : '';
|
||||
const recordRunId = typeof record.runId === 'string' ? record.runId : null;
|
||||
const recordLaneId = typeof record.laneId === 'string' ? record.laneId : '';
|
||||
const recordMemberName = typeof record.memberName === 'string' ? record.memberName : '';
|
||||
const hasTransportHash =
|
||||
typeof record.appMcpTransportHash === 'string' &&
|
||||
record.appMcpTransportHash.trim().length > 0;
|
||||
if (
|
||||
recordId !== session.id ||
|
||||
recordRunId !== session.runId ||
|
||||
recordLaneId !== session.laneId ||
|
||||
!namesMatchCaseInsensitive(recordMemberName, session.memberName) ||
|
||||
(hasTransportHash && !overwriteExistingHash)
|
||||
) {
|
||||
return record;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...record,
|
||||
...(runtimeSessionId ? { id: runtimeSessionId } : {}),
|
||||
appMcpTransportHash: appMcpTransportEvidence.urlHash,
|
||||
appMcpTransportEvidence,
|
||||
};
|
||||
});
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const manifestStore = createRuntimeStoreManifestStore({
|
||||
filePath: manifestPath,
|
||||
teamName: session.teamName,
|
||||
lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS,
|
||||
});
|
||||
const receiptStore = createRuntimeStoreReceiptStore({
|
||||
filePath: path.join(runtimeDirectory, 'opencode-runtime-receipts.json'),
|
||||
lockOptions: OPENCODE_BOOTSTRAP_EVIDENCE_LOCK_OPTIONS,
|
||||
});
|
||||
const writer = new RuntimeStoreBatchWriter(runtimeDirectory, manifestStore, receiptStore);
|
||||
await writer.writeBatch({
|
||||
teamName: session.teamName,
|
||||
runId: session.runId,
|
||||
capabilitySnapshotId: null,
|
||||
behaviorFingerprint: null,
|
||||
reason: 'delivery_commit',
|
||||
writes: [
|
||||
{
|
||||
descriptor,
|
||||
data: { sessions },
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[${session.teamName}] Failed to stamp OpenCode app MCP transport evidence for ${session.memberName}: ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
laneId: string;
|
||||
memberName: string;
|
||||
}): Promise<boolean> {
|
||||
}): Promise<OpenCodeCommittedBootstrapSessionRecord | null> {
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName: input.teamName,
|
||||
laneId: input.laneId,
|
||||
}).catch(() => null);
|
||||
if (!evidence?.committed) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
const activeRunId = evidence.activeRunId?.trim() || null;
|
||||
if (activeRunId !== input.runId) {
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
return evidence.sessions.some(
|
||||
(session) =>
|
||||
session.runId === input.runId &&
|
||||
namesMatchCaseInsensitive(session.memberName, input.memberName)
|
||||
return (
|
||||
evidence.sessions.find(
|
||||
(session) =>
|
||||
session.runId === input.runId &&
|
||||
namesMatchCaseInsensitive(session.memberName, input.memberName)
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private async hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input: {
|
||||
teamName: string;
|
||||
runId: string | null;
|
||||
laneId: string;
|
||||
memberName: string;
|
||||
}): Promise<boolean> {
|
||||
return (await this.findDeliverableOpenCodeRuntimeBootstrapSessionEvidence(input)) != null;
|
||||
}
|
||||
|
||||
private async readOpenCodeRuntimeSessionStore(
|
||||
filePath: string
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
|
|
@ -23378,6 +23764,15 @@ export class TeamProvisioningService {
|
|||
existingRecord = requeuedRecord;
|
||||
}
|
||||
}
|
||||
if (existingRecord?.status === 'failed_terminal') {
|
||||
const requeuedRecord = await this.requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded({
|
||||
ledger: promptLedger,
|
||||
ledgerRecord: existingRecord,
|
||||
});
|
||||
if (requeuedRecord.status !== 'failed_terminal') {
|
||||
existingRecord = requeuedRecord;
|
||||
}
|
||||
}
|
||||
if (existingRecord?.status === 'failed_terminal') {
|
||||
let recoveredRecord: OpenCodePromptDeliveryLedgerRecord | null = null;
|
||||
let recoveredVisibleReply: OpenCodeVisibleReplyProof | null = null;
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type {
|
|||
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
|
||||
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
|
||||
export const OPEN_CODE_APP_MANAGED_BOOTSTRAP_CONTRACT_VERSION = 1 as const;
|
||||
export const OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION = 1 as const;
|
||||
export const OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION = 2 as const;
|
||||
|
||||
export type OpenCodeBridgeCommandName =
|
||||
| 'opencode.handshake'
|
||||
|
|
@ -172,6 +172,7 @@ export interface OpenCodeSendMessageCommandBody {
|
|||
text: string;
|
||||
messageId?: string;
|
||||
deliveryAttemptId?: string;
|
||||
forceSessionRefreshReason?: string;
|
||||
payloadHash?: string;
|
||||
settlementMode?: 'observed' | 'acceptance';
|
||||
fileParts?: {
|
||||
|
|
@ -690,8 +691,7 @@ export function validateOpenCodeBridgeHandshake(input: {
|
|||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1. Falling back to observed delivery mode is required.',
|
||||
reason: `OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version ${OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION}. Falling back to observed delivery mode is required.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { randomUUID } from 'crypto';
|
||||
|
||||
import { stableHash } from './OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION,
|
||||
stableHash,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
|
||||
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||
import type {
|
||||
|
|
@ -296,6 +299,9 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
) {
|
||||
throw error;
|
||||
}
|
||||
if (body.forceSessionRefreshReason?.trim()) {
|
||||
return buildOpenCodeForceSessionRefreshUnsupportedData(body, error);
|
||||
}
|
||||
activeRequestId = `${commandRequestId}-observed`;
|
||||
activeBody = {
|
||||
...body,
|
||||
|
|
@ -312,6 +318,9 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
activeBody.settlementMode === 'acceptance' &&
|
||||
isOpenCodeAcceptanceContractMissingError(result.error.message)
|
||||
) {
|
||||
if (body.forceSessionRefreshReason?.trim()) {
|
||||
return buildOpenCodeForceSessionRefreshUnsupportedData(body, result.error.message);
|
||||
}
|
||||
activeRequestId = `${commandRequestId}-observed`;
|
||||
activeBody = {
|
||||
...body,
|
||||
|
|
@ -644,6 +653,36 @@ function isOpenCodeAcceptanceContractMissingError(error: unknown): boolean {
|
|||
return message.includes('OpenCode delivery acceptance mode is required');
|
||||
}
|
||||
|
||||
function buildOpenCodeForceSessionRefreshUnsupportedData(
|
||||
body: OpenCodeSendMessageCommandBody,
|
||||
error: unknown
|
||||
): OpenCodeSendMessageCommandData {
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
const reason = `OpenCode forced session refresh requires delivery acceptance contract version ${OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION}. Update agent_teams_orchestrator and restart the app.`;
|
||||
return {
|
||||
accepted: false,
|
||||
memberName: body.memberName,
|
||||
responseObservation: {
|
||||
state: 'session_stale',
|
||||
deliveredUserMessageId: null,
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason,
|
||||
},
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'opencode_force_session_refresh_contract_missing',
|
||||
severity: 'error',
|
||||
message: `${reason} ${detail}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function isOpenCodeCompletedCommandRecoveryError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes(OPEN_CODE_COMPLETED_COMMAND_RECOVERY_MESSAGE);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import type { AgentActionMode, InboxMessage, InboxMessageKind, TaskRef } from '@
|
|||
export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1;
|
||||
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const OPENCODE_PROMPT_DELIVERY_FAILED_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
export const OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS = 5;
|
||||
|
||||
export type OpenCodePromptDeliveryStatus =
|
||||
| 'pending'
|
||||
|
|
@ -46,6 +47,9 @@ export interface OpenCodePromptDeliveryLedgerRecord {
|
|||
responseState: OpenCodeDeliveryResponseState;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
sessionRefreshAttempts?: number;
|
||||
maxSessionRefreshAttempts?: number;
|
||||
lastSessionRefreshReason?: string | null;
|
||||
acceptanceUnknown: boolean;
|
||||
nextAttemptAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
|
|
@ -233,6 +237,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
responseState: 'not_observed',
|
||||
attempts: 0,
|
||||
maxAttempts: input.maxAttempts ?? 3,
|
||||
sessionRefreshAttempts: 0,
|
||||
maxSessionRefreshAttempts: OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS,
|
||||
lastSessionRefreshReason: null,
|
||||
acceptanceUnknown: false,
|
||||
nextAttemptAt: null,
|
||||
lastAttemptAt: null,
|
||||
|
|
@ -310,6 +317,11 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
const observation = input.responseObservation;
|
||||
const responseState =
|
||||
observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
|
||||
const sessionRefreshState = isOpenCodeSessionRefreshResponseState({
|
||||
responseState,
|
||||
reason: input.reason ?? observation?.reason ?? record.lastReason,
|
||||
diagnostics: input.diagnostics,
|
||||
});
|
||||
const responded = isOpenCodePromptResponseStateResponded(responseState);
|
||||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState);
|
||||
const acceptedRuntimePromptMessageId =
|
||||
|
|
@ -336,7 +348,8 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
const shouldIncrementAttempts =
|
||||
(input.accepted || input.attempted === true) &&
|
||||
!acceptedAttemptAlreadyRecorded &&
|
||||
!acceptedPromptAlreadyRecorded;
|
||||
!acceptedPromptAlreadyRecorded &&
|
||||
!sessionRefreshState;
|
||||
const lastRuntimePromptMessageId =
|
||||
acceptedRuntimePromptMessageId ??
|
||||
record.lastRuntimePromptMessageId ??
|
||||
|
|
@ -381,6 +394,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
visibleReplyCorrelation:
|
||||
observation?.visibleReplyCorrelation ?? record.visibleReplyCorrelation,
|
||||
lastReason: input.reason ?? observation?.reason ?? record.lastReason,
|
||||
lastSessionRefreshReason: sessionRefreshState
|
||||
? (input.reason ?? observation?.reason ?? record.lastSessionRefreshReason ?? null)
|
||||
: (record.lastSessionRefreshReason ?? null),
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
||||
updatedAt: input.now,
|
||||
};
|
||||
|
|
@ -400,6 +416,11 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
|
||||
input.responseObservation.state
|
||||
);
|
||||
const sessionRefreshState = isOpenCodeSessionRefreshResponseState({
|
||||
responseState: input.responseObservation.state,
|
||||
reason: input.responseObservation.reason ?? record.lastReason,
|
||||
diagnostics: input.diagnostics,
|
||||
});
|
||||
const previousRuntimePromptMessageIds = getOpenCodeRuntimePromptMessageIds(record);
|
||||
const deliveredRuntimePromptMessageId =
|
||||
input.responseObservation.deliveredUserMessageId?.trim() || null;
|
||||
|
|
@ -457,6 +478,9 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
visibleReplyCorrelation:
|
||||
input.responseObservation.visibleReplyCorrelation ?? record.visibleReplyCorrelation,
|
||||
lastReason: input.responseObservation.reason ?? record.lastReason,
|
||||
lastSessionRefreshReason: sessionRefreshState
|
||||
? (input.responseObservation.reason ?? record.lastSessionRefreshReason ?? null)
|
||||
: (record.lastSessionRefreshReason ?? null),
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, input.diagnostics ?? []),
|
||||
updatedAt: input.observedAt,
|
||||
};
|
||||
|
|
@ -527,6 +551,38 @@ export class OpenCodePromptDeliveryLedgerStore {
|
|||
}));
|
||||
}
|
||||
|
||||
async markSessionRefreshScheduled(input: {
|
||||
id: string;
|
||||
nextAttemptAt: string;
|
||||
reason: string;
|
||||
scheduledAt: string;
|
||||
maxSessionRefreshAttempts?: number;
|
||||
diagnostics?: string[];
|
||||
}): Promise<OpenCodePromptDeliveryLedgerRecord> {
|
||||
return await this.updateExisting(input.id, (record) => {
|
||||
const maxSessionRefreshAttempts =
|
||||
record.maxSessionRefreshAttempts ??
|
||||
input.maxSessionRefreshAttempts ??
|
||||
OPENCODE_PROMPT_DELIVERY_SESSION_REFRESH_MAX_ATTEMPTS;
|
||||
const sessionRefreshAttempts = (record.sessionRefreshAttempts ?? 0) + 1;
|
||||
return {
|
||||
...record,
|
||||
status: 'retry_scheduled',
|
||||
responseState: 'session_stale',
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
sessionRefreshAttempts,
|
||||
maxSessionRefreshAttempts,
|
||||
lastSessionRefreshReason: input.reason,
|
||||
lastReason: input.reason,
|
||||
diagnostics: mergeDiagnostics(record.diagnostics, [
|
||||
input.reason,
|
||||
...(input.diagnostics ?? []),
|
||||
]),
|
||||
updatedAt: input.scheduledAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async markRetryAttempted(input: {
|
||||
id: string;
|
||||
attemptedAt: string;
|
||||
|
|
@ -770,6 +826,20 @@ export function getLatestOpenCodeRuntimePromptMessageId(
|
|||
return ids[ids.length - 1] ?? null;
|
||||
}
|
||||
|
||||
export function buildOpenCodePromptDeliveryAttemptId(
|
||||
record: Pick<
|
||||
OpenCodePromptDeliveryLedgerRecord,
|
||||
'id' | 'attempts' | 'payloadHash' | 'sessionRefreshAttempts'
|
||||
>
|
||||
): string {
|
||||
const base = [record.id, record.attempts + 1, record.payloadHash.slice(0, 12)];
|
||||
const sessionRefreshAttempts = record.sessionRefreshAttempts ?? 0;
|
||||
if (sessionRefreshAttempts > 0) {
|
||||
base.push(`refresh${sessionRefreshAttempts}`);
|
||||
}
|
||||
return base.join(':');
|
||||
}
|
||||
|
||||
export function isOpenCodePromptResponseStateResponded(
|
||||
state: OpenCodeDeliveryResponseState
|
||||
): boolean {
|
||||
|
|
@ -787,6 +857,39 @@ function isOpenCodePromptDeliveryUnansweredResponseState(
|
|||
return state === 'empty_assistant_turn' || state === 'prompt_delivered_no_assistant_message';
|
||||
}
|
||||
|
||||
export function isOpenCodeResolvedBehaviorChangedReason(
|
||||
reason: string | null | undefined
|
||||
): boolean {
|
||||
return /\bresolved_behavior_changed:[^\s]+->[^\s]+/i.test(reason?.trim() ?? '');
|
||||
}
|
||||
|
||||
export function isOpenCodeSessionTransportChangedReason(
|
||||
reason: string | null | undefined
|
||||
): boolean {
|
||||
return /\bopencode_app_mcp_transport_changed:[^\s]+->[^\s]+/i.test(reason?.trim() ?? '');
|
||||
}
|
||||
|
||||
export function isOpenCodeSessionRefreshResponseState(input: {
|
||||
responseState?: OpenCodeDeliveryResponseState;
|
||||
reason?: string | null;
|
||||
diagnostics?: readonly string[];
|
||||
}): boolean {
|
||||
if (input.responseState === 'session_stale') {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
isOpenCodeResolvedBehaviorChangedReason(input.reason) ||
|
||||
isOpenCodeSessionTransportChangedReason(input.reason)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (input.diagnostics ?? []).some(
|
||||
(diagnostic) =>
|
||||
isOpenCodeResolvedBehaviorChangedReason(diagnostic) ||
|
||||
isOpenCodeSessionTransportChangedReason(diagnostic)
|
||||
);
|
||||
}
|
||||
|
||||
export function isOpenCodePromptDeliveryAttemptDue(
|
||||
record: OpenCodePromptDeliveryLedgerRecord,
|
||||
nowMs: number = Date.now()
|
||||
|
|
@ -845,6 +948,9 @@ function isOpenCodePromptDeliveryLedgerRecord(
|
|||
isOpenCodeDeliveryResponseState(record.responseState) &&
|
||||
isNonNegativeInteger(record.attempts) &&
|
||||
isNonNegativeInteger(record.maxAttempts) &&
|
||||
isOptionalNonNegativeInteger(record.sessionRefreshAttempts) &&
|
||||
isOptionalNonNegativeInteger(record.maxSessionRefreshAttempts) &&
|
||||
isOptionalNullableString(record.lastSessionRefreshReason) &&
|
||||
typeof record.acceptanceUnknown === 'boolean' &&
|
||||
isOptionalNullableString(record.nextAttemptAt) &&
|
||||
isOptionalNullableString(record.lastAttemptAt) &&
|
||||
|
|
@ -947,6 +1053,10 @@ function isNonNegativeInteger(value: unknown): value is number {
|
|||
return Number.isInteger(value) && (value as number) >= 0;
|
||||
}
|
||||
|
||||
function isOptionalNonNegativeInteger(value: unknown): value is number | undefined {
|
||||
return value === undefined || isNonNegativeInteger(value);
|
||||
}
|
||||
|
||||
function isTaskRefArray(value: unknown): value is TaskRef[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
|
|
|
|||
|
|
@ -258,9 +258,6 @@ export function decideOpenCodePromptDeliveryRepair(
|
|||
if (input.status === 'failed_terminal') {
|
||||
return none('terminal_record');
|
||||
}
|
||||
if (input.attempts >= input.maxAttempts) {
|
||||
return none('max_attempts_reached');
|
||||
}
|
||||
if (input.hardFailureKind !== 'none') {
|
||||
return none(`hard_failure:${input.hardFailureKind}`);
|
||||
}
|
||||
|
|
@ -339,6 +336,10 @@ export function decideOpenCodePromptDeliveryRepair(
|
|||
);
|
||||
}
|
||||
|
||||
if (input.attempts >= input.maxAttempts) {
|
||||
return none('max_attempts_reached');
|
||||
}
|
||||
|
||||
if (
|
||||
(input.responseState === 'responded_non_visible_tool' ||
|
||||
input.responseState === 'responded_tool_call') &&
|
||||
|
|
|
|||
|
|
@ -44,7 +44,12 @@ function isInformationalOpenCodeRuntimeDeliveryDiagnostic(
|
|||
normalized ===
|
||||
'opencode prompt_async accepted; response observation will continue through durable app-side ledger reconciliation.' ||
|
||||
normalized === 'opencode session status busy' ||
|
||||
normalized === 'opencode_delivery_response_pending'
|
||||
normalized === 'opencode_delivery_response_pending' ||
|
||||
normalized === 'opencode_session_refresh_scheduled_after_resolved_behavior_changed' ||
|
||||
Boolean(
|
||||
normalized?.startsWith('resolved_behavior_changed:') ||
|
||||
normalized?.includes('opencode_app_mcp_transport_changed:')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +92,13 @@ function getOpenCodeRuntimeDeliveryStateFallback(
|
|||
) {
|
||||
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
|
||||
}
|
||||
if (
|
||||
state === 'session_stale' ||
|
||||
normalizedReason?.startsWith('resolved_behavior_changed:') ||
|
||||
normalizedReason?.startsWith('opencode_app_mcp_transport_changed:')
|
||||
) {
|
||||
return 'OpenCode session changed; refreshing the session before retry.';
|
||||
}
|
||||
if (
|
||||
normalizedReason === 'visible_reply_destination_not_found_yet' ||
|
||||
normalizedReason === 'visible_reply_missing_relayofmessageid'
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ export interface OpenCodeCommittedBootstrapSessionRecord {
|
|||
observedAt: string | null;
|
||||
source: OpenCodeBootstrapEvidenceSource;
|
||||
appManagedBootstrapCandidate?: OpenCodeAppManagedBootstrapCandidate;
|
||||
appMcpTransportHash?: string;
|
||||
appMcpTransportEvidence?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface OpenCodeCommittedBootstrapSessionEvidence {
|
||||
|
|
@ -320,6 +322,13 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
|||
source === 'app_managed_bootstrap'
|
||||
? normalizeAppManagedBootstrapCandidate(record.appManagedBootstrapCandidate)
|
||||
: undefined;
|
||||
const appMcpTransportHash = normalizeNonEmptyStoreString(record.appMcpTransportHash);
|
||||
const appMcpTransportEvidence =
|
||||
record.appMcpTransportEvidence &&
|
||||
typeof record.appMcpTransportEvidence === 'object' &&
|
||||
!Array.isArray(record.appMcpTransportEvidence)
|
||||
? (record.appMcpTransportEvidence as Record<string, unknown>)
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
teamName,
|
||||
|
|
@ -329,6 +338,8 @@ function normalizeOpenCodeBootstrapSessionRecord(
|
|||
observedAt,
|
||||
source,
|
||||
...(appManagedBootstrapCandidate ? { appManagedBootstrapCandidate } : {}),
|
||||
...(appMcpTransportHash ? { appMcpTransportHash } : {}),
|
||||
...(appMcpTransportEvidence ? { appMcpTransportEvidence } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface OpenCodeTeamRuntimeMessageInput {
|
|||
workSyncReviewRequestEventIds?: string[];
|
||||
controlUrl?: string;
|
||||
taskRefs?: TaskRef[];
|
||||
forceSessionRefreshReason?: string;
|
||||
bootstrapCheckinRetry?: {
|
||||
runtimeSessionId: string;
|
||||
reason?: string;
|
||||
|
|
@ -381,6 +382,9 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
text: buildOpenCodeRuntimeMessageText(input),
|
||||
messageId: input.messageId,
|
||||
...(input.deliveryAttemptId ? { deliveryAttemptId: input.deliveryAttemptId } : {}),
|
||||
...(input.forceSessionRefreshReason
|
||||
? { forceSessionRefreshReason: input.forceSessionRefreshReason }
|
||||
: {}),
|
||||
settlementMode: resolveOpenCodeRuntimeSettlementMode(input),
|
||||
fileParts: input.fileParts,
|
||||
actionMode: input.actionMode,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const DISK_FULL_MESSAGE =
|
|||
'Local disk is full (ENOSPC). Free disk space and retry OpenCode delivery.';
|
||||
const OPENCODE_BRIDGE_OUTCOME_UNKNOWN_AFTER_TIMEOUT_MESSAGE =
|
||||
'OpenCode bridge outcome unknown after timeout, retrying/observing.';
|
||||
const OPENCODE_SESSION_REFRESH_MESSAGE =
|
||||
'OpenCode session changed; refreshing the session before retry.';
|
||||
|
||||
const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
|
||||
{
|
||||
|
|
@ -80,6 +82,18 @@ const RUNTIME_DIAGNOSTIC_RULES: readonly RuntimeDiagnosticRule[] = [
|
|||
tokens: ['codex native exec timed out'],
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
reasonCode: 'backend_error',
|
||||
tokens: [
|
||||
'resolved_behavior_changed',
|
||||
'opencode_app_mcp_transport_changed',
|
||||
'opencode session refresh scheduled after resolved behavior changed',
|
||||
'opencode_prompt_delivery_session_refresh_scheduled',
|
||||
],
|
||||
priority: 20,
|
||||
generic: true,
|
||||
normalizeMessage: () => OPENCODE_SESSION_REFRESH_MESSAGE,
|
||||
},
|
||||
{
|
||||
reasonCode: 'backend_error',
|
||||
tokens: [
|
||||
|
|
|
|||
|
|
@ -128,6 +128,43 @@ function maybeString(value: string | undefined): string | undefined {
|
|||
return boundedString(value, 240);
|
||||
}
|
||||
|
||||
function isRuntimeDiagnosticCardError(params: {
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
launchState?: MemberLaunchState;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
hardFailure?: boolean;
|
||||
}): boolean {
|
||||
if (!params.runtimeDiagnostic) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
params.runtimeDiagnosticSeverity === 'error' ||
|
||||
params.launchState === 'failed_to_start' ||
|
||||
params.spawnStatus === 'error' ||
|
||||
params.hardFailure === true
|
||||
);
|
||||
}
|
||||
|
||||
function isRecoverableOpenCodeSessionRefreshText(value: string | undefined): boolean {
|
||||
const normalized = value?.trim().toLowerCase() ?? '';
|
||||
return (
|
||||
normalized.includes('resolved_behavior_changed:') ||
|
||||
normalized.includes('opencode_app_mcp_transport_changed:') ||
|
||||
normalized.includes('opencode session changed; refreshing the session before retry') ||
|
||||
normalized.includes('opencode_session_refresh_scheduled_after_resolved_behavior_changed')
|
||||
);
|
||||
}
|
||||
|
||||
function isRuntimeAdvisoryCardError(runtimeAdvisory: MemberRuntimeAdvisory | undefined): boolean {
|
||||
if (isRecoverableOpenCodeSessionRefreshText(runtimeAdvisory?.message)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
runtimeAdvisory?.kind === 'api_error' && runtimeAdvisory.reasonCode !== 'protocol_proof_missing'
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null {
|
||||
const normalized = value
|
||||
?.replace(/\s+/g, ' ')
|
||||
|
|
@ -257,7 +294,29 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
const runtimeAdvisoryLabel = boundedString(params.runtimeAdvisoryLabel ?? undefined);
|
||||
const runtimeAdvisoryMessage = boundedString(runtimeAdvisory?.message);
|
||||
const runtimeAdvisoryCardError =
|
||||
runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage;
|
||||
isRuntimeAdvisoryCardError(runtimeAdvisory) &&
|
||||
![runtimeAdvisoryTitle, runtimeAdvisoryLabel, runtimeAdvisoryMessage].some(
|
||||
isRecoverableOpenCodeSessionRefreshText
|
||||
)
|
||||
? (runtimeAdvisoryTitle ?? runtimeAdvisoryLabel ?? runtimeAdvisoryMessage)
|
||||
: undefined;
|
||||
const runtimeDiagnosticSeverity =
|
||||
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity;
|
||||
const spawnRuntimeDiagnosticCardError = isRuntimeDiagnosticCardError({
|
||||
runtimeDiagnostic: spawnEntry?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: spawnEntry?.runtimeDiagnosticSeverity,
|
||||
launchState: spawnEntry?.launchState,
|
||||
spawnStatus: spawnEntry?.status,
|
||||
hardFailure: spawnEntry?.hardFailure,
|
||||
})
|
||||
? spawnEntry?.runtimeDiagnostic
|
||||
: undefined;
|
||||
const runtimeEntryDiagnosticCardError = isRuntimeDiagnosticCardError({
|
||||
runtimeDiagnostic: runtimeEntry?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtimeEntry?.runtimeDiagnosticSeverity,
|
||||
})
|
||||
? runtimeEntry?.runtimeDiagnostic
|
||||
: undefined;
|
||||
const runtimeDiagnostic =
|
||||
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
||||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||
|
|
@ -269,8 +328,8 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
normalizeMemberLaunchFailureReason(
|
||||
spawnEntry?.error ??
|
||||
spawnEntry?.hardFailureReason ??
|
||||
spawnEntry?.runtimeDiagnostic ??
|
||||
runtimeEntry?.runtimeDiagnostic
|
||||
spawnRuntimeDiagnosticCardError ??
|
||||
runtimeEntryDiagnosticCardError
|
||||
) ?? undefined
|
||||
) ?? runtimeAdvisoryCardError;
|
||||
const diagnostics = uniqueDiagnostics(
|
||||
|
|
@ -384,10 +443,9 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
? { rssBytes: boundedNumber(runtimeEntry?.rssBytes) }
|
||||
: {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity)
|
||||
...(runtimeDiagnosticSeverity
|
||||
? {
|
||||
runtimeDiagnosticSeverity:
|
||||
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
|
||||
runtimeDiagnosticSeverity,
|
||||
}
|
||||
: {}),
|
||||
...(runtimeAdvisory?.kind ? { runtimeAdvisoryKind: runtimeAdvisory.kind } : {}),
|
||||
|
|
|
|||
|
|
@ -38,6 +38,15 @@ function readControl() {
|
|||
|
||||
function isUnhealthy() {
|
||||
const control = readControl();
|
||||
if (control === 'unhealthy-once:' + port) {
|
||||
try {
|
||||
fs.writeFileSync(controlFile, 'healthy');
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
if (control === 'unhealthy-pid:' + process.pid) {
|
||||
return true;
|
||||
}
|
||||
return control === 'unhealthy-all' || control === 'unhealthy-port:' + port;
|
||||
}
|
||||
|
||||
|
|
@ -304,11 +313,12 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
const server = createControlledServer({ scriptPath, controlFile });
|
||||
|
||||
const first = await server.ensureStarted();
|
||||
const warnCountAfterFirstStart = vi.mocked(console.warn).mock.calls.length;
|
||||
const second = await server.ensureStarted();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(await readHealthStatus(first.url)).toBe(200);
|
||||
expect(vi.mocked(console.warn).mock.calls).toEqual([]);
|
||||
expect(vi.mocked(console.warn).mock.calls.slice(warnCountAfterFirstStart)).toEqual([]);
|
||||
});
|
||||
|
||||
it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => {
|
||||
|
|
@ -323,12 +333,13 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
});
|
||||
|
||||
const first = await server.ensureStarted();
|
||||
await writeFile(controlFile, `unhealthy-port:${first.port}`, 'utf8');
|
||||
expect(await readHealthStatus(first.url)).toBe(503);
|
||||
expect(first.pid).toEqual(expect.any(Number));
|
||||
await writeFile(controlFile, `unhealthy-pid:${first.pid}`, 'utf8');
|
||||
|
||||
const second = await server.ensureStarted();
|
||||
|
||||
expect(second.port).not.toBe(first.port);
|
||||
expect(second.port).toBe(first.port);
|
||||
expect(second.url).toBe(first.url);
|
||||
expect(second.pid).not.toBe(first.pid);
|
||||
expect(await readHealthStatus(second.url)).toBe(200);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
|
||||
|
|
@ -358,7 +369,7 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
expect(await readHealthStatus(second.url)).toBe(200);
|
||||
});
|
||||
|
||||
it('passes a refreshed MCP URL into a real bridge child process after the cached URL goes stale', async () => {
|
||||
it('passes a refreshed healthy MCP URL into a real bridge child process after cached health goes stale', async () => {
|
||||
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
|
||||
const bridgeBinaryPath = await writeFakeOpenCodeBridgeBinary(tempDir!);
|
||||
const controlFile = path.join(tempDir!, 'health-control.txt');
|
||||
|
|
@ -404,12 +415,13 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
if (!firstResult.ok) {
|
||||
throw new Error(firstResult.error.message);
|
||||
}
|
||||
const firstHandle = server.getCurrentHandle();
|
||||
expect(firstHandle?.pid).toEqual(expect.any(Number));
|
||||
await writeFile(
|
||||
controlFile,
|
||||
`unhealthy-port:${new URL(firstResult.data.observedMcpUrl).port}`,
|
||||
`unhealthy-pid:${firstHandle?.pid}`,
|
||||
'utf8'
|
||||
);
|
||||
expect(await readHealthStatus(firstResult.data.observedMcpUrl)).toBe(503);
|
||||
|
||||
const secondResult = await client.execute<{ runId: string }, { observedMcpUrl: string }>(
|
||||
'opencode.launchTeam',
|
||||
|
|
@ -424,7 +436,7 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
if (!secondResult.ok) {
|
||||
throw new Error(secondResult.error.message);
|
||||
}
|
||||
expect(secondResult.data.observedMcpUrl).not.toBe(firstResult.data.observedMcpUrl);
|
||||
expect(secondResult.data.observedMcpUrl).toBe(firstResult.data.observedMcpUrl);
|
||||
expect(await readHealthStatus(secondResult.data.observedMcpUrl)).toBe(200);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -65,10 +65,23 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
|
||||
const handle = await server.ensureStarted();
|
||||
|
||||
expect(handle).toEqual({
|
||||
expect(handle).toMatchObject({
|
||||
url: 'http://127.0.0.1:41001/mcp',
|
||||
port: 41001,
|
||||
pid: 43123,
|
||||
generation: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
expect(handle.urlHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(handle.transportEvidence).toMatchObject({
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 41001,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:41001/mcp',
|
||||
urlHash: handle.urlHash,
|
||||
generation: 1,
|
||||
});
|
||||
expect(spawnProcess).toHaveBeenCalledWith(
|
||||
'node',
|
||||
|
|
@ -201,20 +214,94 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
const second = await server.ensureStarted();
|
||||
|
||||
expect(first.url).toBe('http://127.0.0.1:41007/mcp');
|
||||
expect(second).toEqual({
|
||||
url: 'http://127.0.0.1:41008/mcp',
|
||||
port: 41008,
|
||||
expect(second).toMatchObject({
|
||||
url: 'http://127.0.0.1:41007/mcp',
|
||||
port: 41007,
|
||||
pid: 43124,
|
||||
generation: 2,
|
||||
diagnostics: ['opencode_app_mcp_restart_reason:health_reuse_failed'],
|
||||
});
|
||||
expect(second.transportEvidence).toMatchObject({
|
||||
port: 41007,
|
||||
url: 'http://127.0.0.1:41007/mcp',
|
||||
urlHash: second.urlHash,
|
||||
generation: 2,
|
||||
});
|
||||
expect(spawnProcess).toHaveBeenCalledTimes(2);
|
||||
expect(allocatePort).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL');
|
||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000);
|
||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000);
|
||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41008, 5_000);
|
||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000);
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
|
||||
expect(vi.mocked(console.warn).mock.calls[1]?.join(' ')).toContain(
|
||||
'opencode_app_mcp_restart_reason:health_reuse_failed'
|
||||
);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
});
|
||||
|
||||
it('falls back without killing unknown processes when the preferred restart port stays occupied', async () => {
|
||||
const firstChild = new FakeChildProcess(43123);
|
||||
const secondChild = new FakeChildProcess(43124);
|
||||
const blocker = net.createServer();
|
||||
const blockedPort = await new Promise<number>((resolve, reject) => {
|
||||
blocker.once('error', reject);
|
||||
blocker.listen(0, '127.0.0.1', () => {
|
||||
const address = blocker.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
reject(new Error('Failed to allocate blocked test port'));
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
const fallbackPort = blockedPort === 65_535 ? blockedPort - 1 : blockedPort + 1;
|
||||
const spawnProcess = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstChild as any)
|
||||
.mockReturnValueOnce(secondChild as any);
|
||||
const allocatePort = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(blockedPort)
|
||||
.mockResolvedValueOnce(fallbackPort);
|
||||
const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => {
|
||||
if (port === blockedPort && timeoutMs === 3_000) {
|
||||
throw new Error('stale health check');
|
||||
}
|
||||
});
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort,
|
||||
spawnProcess,
|
||||
waitForPort,
|
||||
});
|
||||
|
||||
try {
|
||||
const first = await server.ensureStarted();
|
||||
const second = await server.ensureStarted();
|
||||
|
||||
expect(first.url).toBe(`http://127.0.0.1:${blockedPort}/mcp`);
|
||||
expect(second).toMatchObject({
|
||||
url: `http://127.0.0.1:${fallbackPort}/mcp`,
|
||||
port: fallbackPort,
|
||||
pid: 43124,
|
||||
generation: 2,
|
||||
});
|
||||
expect(second.diagnostics).toContain('opencode_app_mcp_public_url_changed');
|
||||
expect(second.diagnostics).toContain(
|
||||
`opencode_app_mcp_preferred_port_unavailable:${blockedPort}`
|
||||
);
|
||||
expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1);
|
||||
expect(allocatePort).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
vi.mocked(console.warn).mockClear();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails startup promptly when the child exits before readiness', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ describe('OpenCodeBridgeCommandContract', () => {
|
|||
).toEqual({
|
||||
ok: false,
|
||||
reason:
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1. Falling back to observed delivery mode is required.',
|
||||
`OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version ${OPEN_CODE_DELIVERY_ACCEPTANCE_CONTRACT_VERSION}. Falling back to observed delivery mode is required.`,
|
||||
});
|
||||
|
||||
server.bridgeProtocol.opencodeDeliveryAcceptanceContractVersion =
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import * as path from 'path';
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildOpenCodePromptDeliveryAttemptId,
|
||||
createOpenCodePromptDeliveryLedgerStore,
|
||||
hashOpenCodePromptDeliveryPayload,
|
||||
isOpenCodePromptDeliveryAttemptDue,
|
||||
isOpenCodeSessionRefreshResponseState,
|
||||
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
|
||||
|
||||
describe('OpenCodePromptDeliveryLedger', () => {
|
||||
|
|
@ -458,6 +460,67 @@ describe('OpenCodePromptDeliveryLedger', () => {
|
|||
expect(retryAccepted.lastRuntimePromptMessageId).toBe('msg_prompt_2');
|
||||
});
|
||||
|
||||
it('tracks session refresh retries without consuming normal delivery attempts', async () => {
|
||||
const store = createStore();
|
||||
const record = await store.ensurePending({
|
||||
teamName: 'team-a',
|
||||
memberName: 'jack',
|
||||
laneId: 'secondary:opencode:jack',
|
||||
inboxMessageId: 'msg-session-stale',
|
||||
inboxTimestamp: '2026-04-25T09:59:00.000Z',
|
||||
source: 'watcher',
|
||||
replyRecipient: 'user',
|
||||
payloadHash: 'sha256:session-stale',
|
||||
now: '2026-04-25T10:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(buildOpenCodePromptDeliveryAttemptId(record)).toBe(
|
||||
`${record.id}:1:${record.payloadHash.slice(0, 12)}`
|
||||
);
|
||||
|
||||
const stale = await store.applyDeliveryResult({
|
||||
id: record.id,
|
||||
accepted: false,
|
||||
attempted: true,
|
||||
responseObservation: {
|
||||
state: 'session_stale',
|
||||
deliveredUserMessageId: null,
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'resolved_behavior_changed:old->new',
|
||||
},
|
||||
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
|
||||
now: '2026-04-25T10:00:05.000Z',
|
||||
});
|
||||
|
||||
expect(stale.attempts).toBe(0);
|
||||
expect(stale.responseState).toBe('session_stale');
|
||||
expect(stale.lastSessionRefreshReason).toBe('resolved_behavior_changed:old->new');
|
||||
|
||||
const scheduled = await store.markSessionRefreshScheduled({
|
||||
id: record.id,
|
||||
nextAttemptAt: '2026-04-25T10:00:10.000Z',
|
||||
reason: 'resolved_behavior_changed:old->new',
|
||||
scheduledAt: '2026-04-25T10:00:06.000Z',
|
||||
});
|
||||
|
||||
expect(scheduled.status).toBe('retry_scheduled');
|
||||
expect(scheduled.attempts).toBe(0);
|
||||
expect(scheduled.sessionRefreshAttempts).toBe(1);
|
||||
expect(buildOpenCodePromptDeliveryAttemptId(scheduled)).toBe(
|
||||
`${record.id}:1:${record.payloadHash.slice(0, 12)}:refresh1`
|
||||
);
|
||||
expect(
|
||||
isOpenCodeSessionRefreshResponseState({
|
||||
reason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps schema-1 legacy prompt-id fields compatible and normalizes when touched', async () => {
|
||||
const store = createStore();
|
||||
const legacy = await store.ensurePending({
|
||||
|
|
|
|||
|
|
@ -40,6 +40,22 @@ describe('OpenCodePromptDeliveryRepairPolicy', () => {
|
|||
expect(decision.controlText).toContain('relayOfMessageId="msg-1"');
|
||||
});
|
||||
|
||||
it('keeps no-assistant response repair available for a bounded max-attempt recovery retry', () => {
|
||||
const decision = decideOpenCodePromptDeliveryRepair(
|
||||
base({
|
||||
status: 'retry_scheduled',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
responseState: 'prompt_delivered_no_assistant_message',
|
||||
pendingReason: 'prompt_delivered_no_assistant_message',
|
||||
})
|
||||
);
|
||||
|
||||
expect(decision.kind).toBe('no_assistant_response');
|
||||
expect(decision.retryable).toBe(true);
|
||||
expect(decision.controlText).toContain('You must not end this turn empty.');
|
||||
});
|
||||
|
||||
it('requires member work sync status and report for work-sync nudges', () => {
|
||||
const decision = decideOpenCodePromptDeliveryRepair(
|
||||
base({
|
||||
|
|
|
|||
|
|
@ -366,6 +366,106 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not fall back to observed mode when forced session refresh contract is missing', async () => {
|
||||
const execute = vi.fn().mockRejectedValueOnce(
|
||||
new Error(
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 2.'
|
||||
)
|
||||
);
|
||||
const executor = {
|
||||
execute: execute as unknown as OpenCodeReadinessBridgeCommandExecutor['execute'] &
|
||||
ReturnType<typeof vi.fn>,
|
||||
};
|
||||
const bridge = new OpenCodeReadinessBridge(executor);
|
||||
|
||||
await expect(
|
||||
bridge.sendOpenCodeTeamMessage({
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
projectPath: '/repo',
|
||||
memberName: 'bob',
|
||||
text: 'hello',
|
||||
messageId: 'message-1',
|
||||
deliveryAttemptId: 'ledger-1:1:payload',
|
||||
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
settlementMode: 'acceptance',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
accepted: false,
|
||||
memberName: 'bob',
|
||||
responseObservation: {
|
||||
state: 'session_stale',
|
||||
},
|
||||
diagnostics: [
|
||||
expect.objectContaining({
|
||||
code: 'opencode_force_session_refresh_contract_missing',
|
||||
severity: 'error',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(execute.mock.calls[0]?.[1]).toMatchObject({
|
||||
settlementMode: 'acceptance',
|
||||
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes forced session refresh reason in send payload hash', async () => {
|
||||
const executor = fakeSequenceExecutor([
|
||||
bridgeCommandSuccess({
|
||||
command: 'opencode.sendMessage',
|
||||
requestId: 'send-req-1',
|
||||
data: {
|
||||
accepted: true,
|
||||
memberName: 'bob',
|
||||
sessionId: 'session-bob-1',
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
bridgeCommandSuccess({
|
||||
command: 'opencode.sendMessage',
|
||||
requestId: 'send-req-2',
|
||||
data: {
|
||||
accepted: true,
|
||||
memberName: 'bob',
|
||||
sessionId: 'session-bob-2',
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const bridge = new OpenCodeReadinessBridge(executor);
|
||||
const base = {
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
projectPath: '/repo',
|
||||
memberName: 'bob',
|
||||
text: 'hello',
|
||||
messageId: 'message-1',
|
||||
deliveryAttemptId: 'ledger-1:1:payload',
|
||||
};
|
||||
|
||||
await bridge.sendOpenCodeTeamMessage(base);
|
||||
await bridge.sendOpenCodeTeamMessage({
|
||||
...base,
|
||||
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
});
|
||||
|
||||
const firstBody = executor.execute.mock.calls[0]?.[1] as { payloadHash?: string };
|
||||
const secondBody = executor.execute.mock.calls[1]?.[1] as {
|
||||
payloadHash?: string;
|
||||
forceSessionRefreshReason?: string;
|
||||
};
|
||||
expect(secondBody.forceSessionRefreshReason).toBe(
|
||||
'opencode_app_mcp_transport_changed:old->new'
|
||||
);
|
||||
expect(firstBody.payloadHash).toEqual(expect.any(String));
|
||||
expect(secondBody.payloadHash).toEqual(expect.any(String));
|
||||
expect(secondBody.payloadHash).not.toBe(firstBody.payloadHash);
|
||||
});
|
||||
|
||||
it('recovers accepted OpenCode sendMessage after bridge timeout through commandStatus by default', async () => {
|
||||
const executor = fakeSequenceExecutor([
|
||||
bridgeFailure('timeout', 'OpenCode bridge command timed out', []),
|
||||
|
|
@ -754,6 +854,54 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('does not use observed guarded fallback when forced session refresh contract is missing', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure('internal_error', 'direct observed bridge must not run', [])
|
||||
);
|
||||
const stateChangingExecute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
bridgeCommandFailure({
|
||||
command: 'opencode.sendMessage',
|
||||
requestId: 'guarded-send-acceptance',
|
||||
kind: 'internal_error',
|
||||
message:
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 2.',
|
||||
})
|
||||
);
|
||||
const bridge = new OpenCodeReadinessBridge(executor, {
|
||||
stateChangingCommands: { execute: stateChangingExecute },
|
||||
});
|
||||
|
||||
await expect(
|
||||
bridge.sendOpenCodeTeamMessage({
|
||||
runId: 'run-1',
|
||||
teamId: 'team-a',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
projectPath: '/repo',
|
||||
memberName: 'bob',
|
||||
text: 'hello',
|
||||
messageId: 'message-1',
|
||||
deliveryAttemptId: 'ledger-1:1:payload',
|
||||
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
settlementMode: 'acceptance',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
accepted: false,
|
||||
responseObservation: { state: 'session_stale' },
|
||||
diagnostics: [
|
||||
expect.objectContaining({
|
||||
code: 'opencode_force_session_refresh_contract_missing',
|
||||
severity: 'error',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(stateChangingExecute).toHaveBeenCalledTimes(1);
|
||||
expect(executor.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||
const executor = fakeExecutor(
|
||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||
|
|
|
|||
|
|
@ -66,6 +66,41 @@ describe('OpenCodeRuntimeDeliveryDiagnostics', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('formats resolved behavior changes as recoverable session refresh state', () => {
|
||||
const record = {
|
||||
diagnostics: [
|
||||
'OpenCode session reconcile skipped because the stored session is stale',
|
||||
'resolved_behavior_changed:old->new',
|
||||
],
|
||||
lastReason: 'resolved_behavior_changed:old->new',
|
||||
responseState: 'session_stale',
|
||||
status: 'retry_scheduled',
|
||||
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
|
||||
|
||||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
|
||||
'OpenCode session changed; refreshing the session before retry.'
|
||||
);
|
||||
expect(
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('formats app MCP transport changes as recoverable session refresh state', () => {
|
||||
const record = {
|
||||
diagnostics: ['opencode_app_mcp_transport_changed:old->new'],
|
||||
lastReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
responseState: 'session_stale',
|
||||
status: 'retry_scheduled',
|
||||
} as Parameters<typeof selectOpenCodeRuntimeDeliveryReason>[0];
|
||||
|
||||
expect(selectOpenCodeRuntimeDeliveryReason(record)).toBe(
|
||||
'OpenCode session changed; refreshing the session before retry.'
|
||||
);
|
||||
expect(
|
||||
isActionRequiredOpenCodeRuntimeDeliveryReason(selectOpenCodeRuntimeDeliveryReason(record))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('prioritizes local disk-full diagnostics over secondary aborted assistant errors', () => {
|
||||
const record = {
|
||||
diagnostics: [
|
||||
|
|
|
|||
|
|
@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
messageId: 'msg-1',
|
||||
replyRecipient: 'alice',
|
||||
actionMode: 'delegate',
|
||||
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
||||
})
|
||||
).resolves.toEqual({
|
||||
|
|
@ -553,6 +554,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
messageId: 'msg-1',
|
||||
settlementMode: 'acceptance',
|
||||
actionMode: 'delegate',
|
||||
forceSessionRefreshReason: 'opencode_app_mcp_transport_changed:old->new',
|
||||
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
|
||||
agent: 'teammate',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -144,4 +144,21 @@ describe('RuntimeDiagnosticClassifier', () => {
|
|||
generic: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies resolved OpenCode behavior changes as recoverable generic refresh state', () => {
|
||||
expect(classifyRuntimeDiagnostic('resolved_behavior_changed:old->new')).toMatchObject({
|
||||
reasonCode: 'backend_error',
|
||||
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
|
||||
generic: true,
|
||||
actionRequired: false,
|
||||
});
|
||||
expect(
|
||||
classifyRuntimeDiagnostic('opencode_app_mcp_transport_changed:old->new')
|
||||
).toMatchObject({
|
||||
reasonCode: 'backend_error',
|
||||
normalizedMessage: 'OpenCode session changed; refreshing the session before retry.',
|
||||
generic: true,
|
||||
actionRequired: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -39,9 +39,11 @@ import {
|
|||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||
import { agentTeamsMcpHttpServer } from '../../../../src/main/services/team/AgentTeamsMcpHttpServer';
|
||||
import {
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
getOpenCodeRuntimeLaneIndexPath,
|
||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
|
|
@ -10229,7 +10231,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
addGeminiPrimaryToMixedRun(currentRun);
|
||||
staleRun.runId = `run-${teamName}-stale`;
|
||||
currentRun.runId = `run-${teamName}-current`;
|
||||
markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||
await markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||
trackLiveRun(svc, staleRun);
|
||||
trackLiveRun(svc, currentRun);
|
||||
|
||||
|
|
@ -10257,6 +10259,89 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(adapter.messageInputs[0]?.runId).not.toBe(staleRun.runId);
|
||||
});
|
||||
|
||||
it('refreshes stale mixed OpenCode secondary session evidence before direct delivery when MCP transport changed', async () => {
|
||||
const teamName = 'mixed-opencode-secondary-transport-refresh-safe-e2e';
|
||||
await writeMixedTeamConfig({
|
||||
teamName,
|
||||
projectPath,
|
||||
includeGeminiPrimary: true,
|
||||
primaryProviderId: 'anthropic',
|
||||
});
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, {
|
||||
includeGeminiPrimary: true,
|
||||
primaryProviderId: 'anthropic',
|
||||
});
|
||||
const adapter = new FakeOpenCodeRuntimeAdapter();
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
run.runId = `run-${teamName}-current`;
|
||||
await markMixedOpenCodeLaneConfirmedForTest(run, 'bob', {
|
||||
sessionId: 'oc-session-bob-stale-mixed-transport',
|
||||
appMcpTransportHash: 'old-mixed-safe-e2e-transport-hash',
|
||||
});
|
||||
trackLiveRun(svc, run);
|
||||
|
||||
const transportSpy = vi
|
||||
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
|
||||
.mockReturnValue({
|
||||
url: 'http://127.0.0.1:43126/mcp',
|
||||
port: 43126,
|
||||
child: { pid: 43126 },
|
||||
generation: 5,
|
||||
urlHash: 'current-mixed-safe-e2e-transport-hash',
|
||||
transportEvidence: {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43126,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:43126/mcp',
|
||||
urlHash: 'current-mixed-safe-e2e-transport-hash',
|
||||
generation: 5,
|
||||
observedAt: '2026-04-23T10:00:00.000Z',
|
||||
},
|
||||
diagnostics: [],
|
||||
} as any);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName: 'bob',
|
||||
text: 'refresh stale mixed transport before opencode send',
|
||||
messageId: 'msg-mixed-transport-refresh-safe-e2e',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
} finally {
|
||||
transportSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
forceSessionRefreshReason:
|
||||
'opencode_app_mcp_transport_changed:old-mixed-safe-e2e-transport-hash->current-mixed-safe-e2e-transport-hash',
|
||||
});
|
||||
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
});
|
||||
expect(evidence.sessions[0]).toMatchObject({
|
||||
id: 'session-bob',
|
||||
appMcpTransportHash: 'current-mixed-safe-e2e-transport-hash',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes direct OpenCode member messages only to the targeted live mixed OpenCode lane', async () => {
|
||||
const firstTeamName = 'mixed-opencode-direct-message-live-team-a-safe-e2e';
|
||||
const secondTeamName = 'mixed-opencode-direct-message-live-team-b-safe-e2e';
|
||||
|
|
@ -10299,7 +10384,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
addGeminiPrimaryToMixedRun(secondRun);
|
||||
firstRun.child = { stdin: { writable: true } };
|
||||
secondRun.child = { stdin: { writable: true } };
|
||||
markMixedOpenCodeLaneConfirmedForTest(secondRun, 'bob');
|
||||
await markMixedOpenCodeLaneConfirmedForTest(secondRun, 'bob');
|
||||
trackLiveRun(svc, firstRun);
|
||||
trackLiveRun(svc, secondRun);
|
||||
|
||||
|
|
@ -10590,7 +10675,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const currentRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(currentRun);
|
||||
currentRun.runId = `run-${teamName}-current`;
|
||||
markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||
await markMixedOpenCodeLaneConfirmedForTest(currentRun, 'bob');
|
||||
trackLiveRun(svc, currentRun);
|
||||
injectStaleTerminalProvisioningRun(svc, teamName, `run-${teamName}-stale`);
|
||||
|
||||
|
|
@ -10669,6 +10754,90 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(adapter.messageInputs[0]?.runId).not.toBe(first.runId);
|
||||
});
|
||||
|
||||
it('refreshes stale OpenCode session evidence before direct delivery when MCP transport changed', async () => {
|
||||
const teamName = 'pure-opencode-direct-message-transport-refresh-safe-e2e';
|
||||
const adapter = new FakeOpenCodeRuntimeAdapter();
|
||||
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
|
||||
);
|
||||
await writeOpenCodeBootstrapSessionEvidenceForTest({
|
||||
teamName,
|
||||
laneId: 'primary',
|
||||
runId: launch.runId,
|
||||
memberName: 'alice',
|
||||
sessionId: 'oc-session-alice-stale-transport',
|
||||
appMcpTransportHash: 'old-safe-e2e-transport-hash',
|
||||
});
|
||||
|
||||
const transportSpy = vi
|
||||
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
|
||||
.mockReturnValue({
|
||||
url: 'http://127.0.0.1:43125/mcp',
|
||||
port: 43125,
|
||||
child: { pid: 43125 },
|
||||
generation: 4,
|
||||
urlHash: 'current-safe-e2e-transport-hash',
|
||||
transportEvidence: {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43125,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:43125/mcp',
|
||||
urlHash: 'current-safe-e2e-transport-hash',
|
||||
generation: 4,
|
||||
observedAt: '2026-04-23T10:00:00.000Z',
|
||||
},
|
||||
diagnostics: [],
|
||||
} as any);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage(teamName, {
|
||||
memberName: 'alice',
|
||||
text: 'refresh stale transport before pure opencode send',
|
||||
messageId: 'msg-transport-refresh-safe-e2e',
|
||||
})
|
||||
).resolves.toEqual({
|
||||
delivered: true,
|
||||
diagnostics: [],
|
||||
});
|
||||
} finally {
|
||||
transportSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(adapter.messageInputs).toHaveLength(1);
|
||||
expect(adapter.messageInputs[0]).toMatchObject({
|
||||
runId: launch.runId,
|
||||
teamName,
|
||||
laneId: 'primary',
|
||||
memberName: 'alice',
|
||||
forceSessionRefreshReason:
|
||||
'opencode_app_mcp_transport_changed:old-safe-e2e-transport-hash->current-safe-e2e-transport-hash',
|
||||
});
|
||||
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
teamName,
|
||||
laneId: 'primary',
|
||||
});
|
||||
expect(evidence.sessions[0]).toMatchObject({
|
||||
id: 'session-alice',
|
||||
appMcpTransportHash: 'current-safe-e2e-transport-hash',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes direct OpenCode member messages only to the targeted live pure OpenCode team', async () => {
|
||||
const firstTeamName = 'pure-opencode-direct-message-live-team-a-safe-e2e';
|
||||
const secondTeamName = 'pure-opencode-direct-message-live-team-b-safe-e2e';
|
||||
|
|
@ -13265,7 +13434,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
markMixedOpenCodeLaneConfirmedForTest(run, 'tom');
|
||||
await markMixedOpenCodeLaneConfirmedForTest(run, 'tom');
|
||||
trackLiveRun(svc, run);
|
||||
|
||||
await expect(
|
||||
|
|
@ -13321,7 +13490,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter]));
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
markMixedOpenCodeLaneConfirmedForTest(run, 'bob');
|
||||
await markMixedOpenCodeLaneConfirmedForTest(run, 'bob');
|
||||
trackLiveRun(svc, run);
|
||||
|
||||
await expect(
|
||||
|
|
@ -18457,6 +18626,7 @@ async function writeOpenCodeBootstrapSessionEvidenceForTest(input: {
|
|||
runId?: string | null;
|
||||
memberName?: string;
|
||||
sessionId?: string;
|
||||
appMcpTransportHash?: string;
|
||||
}): Promise<void> {
|
||||
const runId = input.runId ?? null;
|
||||
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||
|
|
@ -18507,6 +18677,9 @@ async function writeOpenCodeBootstrapSessionEvidenceForTest(input: {
|
|||
runId,
|
||||
observedAt: '2026-04-23T10:00:00.000Z',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
...(input.appMcpTransportHash
|
||||
? { appMcpTransportHash: input.appMcpTransportHash }
|
||||
: {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -18686,9 +18859,17 @@ function createMixedLiveRun(input: {
|
|||
};
|
||||
}
|
||||
|
||||
function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): void {
|
||||
async function markMixedOpenCodeLaneConfirmedForTest(
|
||||
run: any,
|
||||
memberName: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
appMcpTransportHash?: string;
|
||||
} = {}
|
||||
): Promise<void> {
|
||||
const now = '2026-04-23T10:00:00.000Z';
|
||||
const laneId = `secondary:opencode:${memberName}`;
|
||||
const sessionId = options.sessionId ?? `session-${memberName}`;
|
||||
const lane = run.mixedSecondaryLanes?.find((candidate: any) => candidate.laneId === laneId);
|
||||
if (!lane) {
|
||||
throw new Error(`Missing mixed OpenCode lane fixture for ${memberName}`);
|
||||
|
|
@ -18709,7 +18890,7 @@ function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): vo
|
|||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
sessionId: `session-${memberName}`,
|
||||
sessionId,
|
||||
runtimePid: 10_000,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'opencode_bridge',
|
||||
|
|
@ -18720,6 +18901,14 @@ function markMixedOpenCodeLaneConfirmedForTest(run: any, memberName: string): vo
|
|||
warnings: [],
|
||||
diagnostics: ['fake OpenCode launch ready'],
|
||||
};
|
||||
await writeOpenCodeBootstrapSessionEvidenceForTest({
|
||||
teamName: run.teamName,
|
||||
laneId,
|
||||
runId: run.runId,
|
||||
memberName,
|
||||
sessionId,
|
||||
appMcpTransportHash: options.appMcpTransportHash,
|
||||
});
|
||||
}
|
||||
|
||||
function removeMixedOpenCodeLaneForTest(run: any, memberName: string): void {
|
||||
|
|
|
|||
|
|
@ -132,6 +132,7 @@ import {
|
|||
getMixedLaunchFallbackRecoveryError,
|
||||
TeamProvisioningService,
|
||||
} from '@main/services/team/TeamProvisioningService';
|
||||
import { agentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||
import { TeamTaskActivityIntervalService } from '@main/services/team/TeamTaskActivityIntervalService';
|
||||
import {
|
||||
clearAutoResumeService,
|
||||
|
|
@ -148,10 +149,13 @@ import {
|
|||
getTeamLaunchSummaryPath,
|
||||
} from '@main/services/team/TeamLaunchStateStore';
|
||||
import { TeamConfigReader } from '@main/services/team/TeamConfigReader';
|
||||
import { OPEN_CODE_BRIDGE_SCHEMA_VERSION } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import { OpenCodeReadinessBridge } from '@main/services/team/opencode/bridge/OpenCodeReadinessBridge';
|
||||
import {
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
getOpenCodeRuntimeManifestPath,
|
||||
OpenCodeRuntimeManifestEvidenceReader,
|
||||
readCommittedOpenCodeBootstrapSessionEvidence,
|
||||
readOpenCodeRuntimeLaneIndex,
|
||||
setOpenCodeRuntimeActiveRunManifest,
|
||||
upsertOpenCodeRuntimeLaneIndexEntry,
|
||||
|
|
@ -164,6 +168,7 @@ import {
|
|||
RuntimeStoreBatchWriter,
|
||||
} from '@main/services/team/opencode/store/RuntimeStoreManifest';
|
||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||
import { OpenCodeTeamRuntimeAdapter } from '@main/services/team/runtime/OpenCodeTeamRuntimeAdapter';
|
||||
import { TeamRuntimeAdapterRegistry } from '@main/services/team/runtime/TeamRuntimeAdapter';
|
||||
import { spawnCli } from '@main/utils/childProcess';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
|
|
@ -448,6 +453,7 @@ async function writeCommittedOpenCodeSessionStore(input: {
|
|||
teamName: string;
|
||||
laneId: string;
|
||||
runId: string;
|
||||
batchKey?: string;
|
||||
sessions: unknown[];
|
||||
}): Promise<void> {
|
||||
const descriptor = OPENCODE_RUNTIME_STORE_DESCRIPTORS.find(
|
||||
|
|
@ -465,8 +471,9 @@ async function writeCommittedOpenCodeSessionStore(input: {
|
|||
}),
|
||||
{
|
||||
clock: () => new Date('2026-04-22T12:00:00.000Z'),
|
||||
batchIdFactory: () => `batch-${input.runId}`,
|
||||
receiptIdFactory: () => `receipt-${input.runId}`,
|
||||
batchIdFactory: () => `batch-${input.runId}${input.batchKey ? `-${input.batchKey}` : ''}`,
|
||||
receiptIdFactory: () =>
|
||||
`receipt-${input.runId}${input.batchKey ? `-${input.batchKey}` : ''}`,
|
||||
}
|
||||
);
|
||||
await writer.writeBatch({
|
||||
|
|
@ -766,6 +773,133 @@ describe('TeamProvisioningService', () => {
|
|||
})
|
||||
).toEqual({ state: 'none' });
|
||||
});
|
||||
|
||||
it('schedules one bounded recovery retry before terminalizing no-assistant OpenCode delivery', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).scheduleOpenCodePromptDeliveryWatchdog = vi.fn();
|
||||
const record = {
|
||||
id: 'opencode-prompt:test',
|
||||
teamName: 'team-a',
|
||||
memberName: 'atlas',
|
||||
laneId: 'secondary:opencode:atlas',
|
||||
runId: 'run-1',
|
||||
runtimeSessionId: 'ses-1',
|
||||
inboxMessageId: 'msg-1',
|
||||
inboxTimestamp: '2026-05-18T08:31:00.000Z',
|
||||
source: 'watcher',
|
||||
messageKind: null,
|
||||
replyRecipient: 'team-lead',
|
||||
actionMode: null,
|
||||
taskRefs: [],
|
||||
payloadHash: 'sha256:test',
|
||||
status: 'accepted',
|
||||
responseState: 'prompt_delivered_no_assistant_message',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
acceptanceUnknown: false,
|
||||
nextAttemptAt: null,
|
||||
lastAttemptAt: '2026-05-18T08:31:30.000Z',
|
||||
lastObservedAt: '2026-05-18T08:31:45.000Z',
|
||||
acceptedAt: '2026-05-18T08:31:30.000Z',
|
||||
respondedAt: null,
|
||||
failedAt: null,
|
||||
inboxReadCommittedAt: null,
|
||||
inboxReadCommitError: null,
|
||||
prePromptCursor: null,
|
||||
postPromptCursor: null,
|
||||
deliveredUserMessageId: 'delivered-1',
|
||||
observedAssistantMessageId: null,
|
||||
observedAssistantPreview: null,
|
||||
observedToolCallNames: [],
|
||||
observedVisibleMessageId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyInbox: null,
|
||||
visibleReplyCorrelation: null,
|
||||
lastReason: 'prompt_delivered_no_assistant_message',
|
||||
diagnostics: ['prompt_delivered_no_assistant_message'],
|
||||
createdAt: '2026-05-18T08:31:00.000Z',
|
||||
updatedAt: '2026-05-18T08:31:45.000Z',
|
||||
};
|
||||
const ledger = {
|
||||
markFailedTerminal: vi.fn(),
|
||||
markNextAttemptScheduled: vi.fn(async (input: any) => ({
|
||||
...record,
|
||||
status: input.status,
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
lastReason: input.reason,
|
||||
updatedAt: input.scheduledAt,
|
||||
})),
|
||||
};
|
||||
|
||||
const nextRecord = await (svc as any).scheduleOpenCodePromptLedgerFollowUp({
|
||||
ledger,
|
||||
ledgerRecord: record,
|
||||
teamName: 'team-a',
|
||||
memberName: 'atlas',
|
||||
retry: true,
|
||||
reason: 'prompt_delivered_no_assistant_message',
|
||||
});
|
||||
|
||||
expect(ledger.markFailedTerminal).not.toHaveBeenCalled();
|
||||
expect(ledger.markNextAttemptScheduled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: record.id,
|
||||
status: 'retry_scheduled',
|
||||
reason: 'prompt_delivered_no_assistant_message',
|
||||
})
|
||||
);
|
||||
expect(nextRecord.status).toBe('retry_scheduled');
|
||||
});
|
||||
|
||||
it('does not requeue terminal no-assistant delivery after the bounded recovery retry is exhausted', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const record = {
|
||||
status: 'failed_terminal',
|
||||
responseState: 'prompt_delivered_no_assistant_message',
|
||||
attempts: 4,
|
||||
maxAttempts: 3,
|
||||
inboxReadCommittedAt: null,
|
||||
lastReason: 'prompt_delivered_no_assistant_message',
|
||||
diagnostics: ['prompt_delivered_no_assistant_message'],
|
||||
};
|
||||
const ledger = {
|
||||
markNextAttemptScheduled: vi.fn(),
|
||||
};
|
||||
|
||||
const nextRecord = await (svc as any).requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded({
|
||||
ledger,
|
||||
ledgerRecord: record,
|
||||
});
|
||||
|
||||
expect(nextRecord).toBe(record);
|
||||
expect(ledger.markNextAttemptScheduled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not requeue terminal no-assistant delivery when diagnostics contain action-required provider errors', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const record = {
|
||||
status: 'failed_terminal',
|
||||
responseState: 'empty_assistant_turn',
|
||||
attempts: 3,
|
||||
maxAttempts: 3,
|
||||
inboxReadCommittedAt: null,
|
||||
lastReason: 'empty_assistant_turn',
|
||||
diagnostics: [
|
||||
'Insufficient credits. Add more using https://openrouter.ai/settings/credits',
|
||||
],
|
||||
};
|
||||
const ledger = {
|
||||
markNextAttemptScheduled: vi.fn(),
|
||||
};
|
||||
|
||||
const nextRecord = await (svc as any).requeueOpenCodeNoAssistantTerminalDeliveryIfNeeded({
|
||||
ledger,
|
||||
ledgerRecord: record,
|
||||
});
|
||||
|
||||
expect(nextRecord).toBe(record);
|
||||
expect(ledger.markNextAttemptScheduled).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('team launch notifications', () => {
|
||||
|
|
@ -7375,6 +7509,8 @@ describe('TeamProvisioningService', () => {
|
|||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'session_stale',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
});
|
||||
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
|
||||
|
|
@ -7385,6 +7521,38 @@ describe('TeamProvisioningService', () => {
|
|||
prePromptCursor: 'cursor-1',
|
||||
})
|
||||
);
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
|
||||
const scheduledEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||
data: Array<{
|
||||
nextAttemptAt: string | null;
|
||||
sessionRefreshAttempts?: number;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
}>;
|
||||
};
|
||||
expect(scheduledEnvelope.data[0]).toMatchObject({
|
||||
attempts: 1,
|
||||
maxAttempts: 3,
|
||||
sessionRefreshAttempts: 1,
|
||||
});
|
||||
scheduledEnvelope.data[0].nextAttemptAt = '2000-01-01T00:00:00.000Z';
|
||||
await fsPromises.writeFile(ledgerPath, JSON.stringify(scheduledEnvelope, null, 2), 'utf8');
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-stale-session',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
});
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageToMember.mock.calls[1]?.[0]).toMatchObject({
|
||||
runId: 'opencode-run-bob',
|
||||
|
|
@ -7393,6 +7561,581 @@ describe('TeamProvisioningService', () => {
|
|||
memberName: 'bob',
|
||||
cwd: '/repo',
|
||||
messageId: 'msg-stale-session',
|
||||
deliveryAttemptId: expect.stringContaining(':refresh1'),
|
||||
});
|
||||
});
|
||||
|
||||
it('forces an OpenCode session refresh before send when app MCP transport evidence changed', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob-refreshed',
|
||||
runtimePromptMessageId: 'msg_prompt_refreshed',
|
||||
prePromptCursor: 'cursor-refreshed',
|
||||
responseObservation: {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'oc-user-refreshed',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_response_pending',
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
batchKey: 'transport-refresh',
|
||||
sessions: [
|
||||
{
|
||||
id: 'oc-session-bob',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
source: 'app_managed_bootstrap',
|
||||
appMcpTransportHash: 'old-transport-hash',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const currentTransportEvidence = {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43123,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:43123/mcp',
|
||||
urlHash: 'current-transport-hash',
|
||||
generation: 2,
|
||||
observedAt: '2026-04-25T10:00:00.000Z',
|
||||
};
|
||||
const transportSpy = vi
|
||||
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
|
||||
.mockReturnValue({
|
||||
url: currentTransportEvidence.url,
|
||||
port: currentTransportEvidence.port,
|
||||
child: { pid: 43123 },
|
||||
generation: currentTransportEvidence.generation,
|
||||
urlHash: currentTransportEvidence.urlHash,
|
||||
transportEvidence: currentTransportEvidence,
|
||||
diagnostics: [],
|
||||
} as any);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello refreshed bob',
|
||||
messageId: 'msg-refresh-transport',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
});
|
||||
} finally {
|
||||
transportSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageToMember.mock.calls[0]?.[0]).toMatchObject({
|
||||
runId: 'opencode-run-bob',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
memberName: 'bob',
|
||||
forceSessionRefreshReason:
|
||||
'opencode_app_mcp_transport_changed:old-transport-hash->current-transport-hash',
|
||||
});
|
||||
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
});
|
||||
expect(evidence.sessions[0]).toMatchObject({
|
||||
id: 'oc-session-bob-refreshed',
|
||||
appMcpTransportHash: 'current-transport-hash',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes app MCP transport mismatch through the production OpenCode adapter and bridge command', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember: vi.fn() });
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
batchKey: 'production-adapter-refresh',
|
||||
sessions: [
|
||||
{
|
||||
id: 'oc-session-bob',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
source: 'app_managed_bootstrap',
|
||||
appMcpTransportHash: 'old-production-transport-hash',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const currentTransportEvidence = {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43124,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:43124/mcp',
|
||||
urlHash: 'current-production-transport-hash',
|
||||
generation: 3,
|
||||
observedAt: '2026-04-25T10:00:00.000Z',
|
||||
};
|
||||
const transportSpy = vi
|
||||
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
|
||||
.mockReturnValue({
|
||||
url: currentTransportEvidence.url,
|
||||
port: currentTransportEvidence.port,
|
||||
child: { pid: 43124 },
|
||||
generation: currentTransportEvidence.generation,
|
||||
urlHash: currentTransportEvidence.urlHash,
|
||||
transportEvidence: currentTransportEvidence,
|
||||
diagnostics: [],
|
||||
} as any);
|
||||
const directBridgeExecute = vi.fn(async () => {
|
||||
throw new Error('direct OpenCode bridge executor should not be used for acceptance send');
|
||||
});
|
||||
const stateChangingExecute = vi.fn(async (input: {
|
||||
command: string;
|
||||
body: Record<string, unknown>;
|
||||
}) => ({
|
||||
ok: true as const,
|
||||
schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION,
|
||||
requestId: 'send-refresh-command',
|
||||
command: input.command as any,
|
||||
completedAt: '2026-04-25T10:00:01.000Z',
|
||||
durationMs: 10,
|
||||
runtime: {
|
||||
providerId: 'opencode' as const,
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
binaryFingerprint: 'test-opencode-binary',
|
||||
version: '1.0.0',
|
||||
capabilitySnapshotId: 'test-capability-snapshot',
|
||||
},
|
||||
diagnostics: [],
|
||||
data: {
|
||||
accepted: true,
|
||||
memberName: 'bob',
|
||||
sessionId: 'oc-session-bob-production-refresh',
|
||||
runtimePid: 456,
|
||||
runtimePromptMessageId: 'msg_prompt_production_refresh',
|
||||
prePromptCursor: 'cursor-production-refresh',
|
||||
responseObservation: {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'oc-user-production-refresh',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_response_pending',
|
||||
},
|
||||
diagnostics: [],
|
||||
},
|
||||
}));
|
||||
const productionBridge = new OpenCodeReadinessBridge(
|
||||
{ execute: directBridgeExecute },
|
||||
{ stateChangingCommands: { execute: stateChangingExecute as any } }
|
||||
);
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(productionBridge)])
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello production bob',
|
||||
messageId: 'msg-production-refresh-transport',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
});
|
||||
} finally {
|
||||
transportSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(directBridgeExecute).not.toHaveBeenCalled();
|
||||
expect(stateChangingExecute).toHaveBeenCalledTimes(1);
|
||||
const commandInput = stateChangingExecute.mock.calls[0]?.[0] as {
|
||||
command: string;
|
||||
teamName: string;
|
||||
laneId: string;
|
||||
runId: string;
|
||||
cwd: string;
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
expect(commandInput).toMatchObject({
|
||||
command: 'opencode.sendMessage',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
cwd: '/repo',
|
||||
});
|
||||
expect(commandInput.body).toMatchObject({
|
||||
runId: 'opencode-run-bob',
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
projectPath: '/repo',
|
||||
memberName: 'bob',
|
||||
messageId: 'msg-production-refresh-transport',
|
||||
settlementMode: 'acceptance',
|
||||
forceSessionRefreshReason:
|
||||
'opencode_app_mcp_transport_changed:old-production-transport-hash->current-production-transport-hash',
|
||||
});
|
||||
expect(commandInput.body.payloadHash).toEqual(expect.any(String));
|
||||
expect(commandInput.body.deliveryAttemptId).toEqual(expect.any(String));
|
||||
expect(String(commandInput.body.text)).toContain('hello production bob');
|
||||
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
});
|
||||
expect(evidence.sessions[0]).toMatchObject({
|
||||
id: 'oc-session-bob-production-refresh',
|
||||
appMcpTransportHash: 'current-production-transport-hash',
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps current transport evidence when forced refresh succeeds with the same OpenCode session id', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_same_session_refresh',
|
||||
prePromptCursor: 'cursor-same-session-refresh',
|
||||
responseObservation: {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'oc-user-same-session-refresh',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_response_pending',
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
batchKey: 'same-session-transport-refresh',
|
||||
sessions: [
|
||||
{
|
||||
id: 'oc-session-bob',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
source: 'app_managed_bootstrap',
|
||||
appMcpTransportHash: 'old-same-session-transport-hash',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const currentTransportEvidence = {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43128,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:43128/mcp',
|
||||
urlHash: 'current-same-session-transport-hash',
|
||||
generation: 7,
|
||||
observedAt: '2026-04-25T10:00:00.000Z',
|
||||
};
|
||||
const transportSpy = vi
|
||||
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
|
||||
.mockReturnValue({
|
||||
url: currentTransportEvidence.url,
|
||||
port: currentTransportEvidence.port,
|
||||
child: { pid: 43128 },
|
||||
generation: currentTransportEvidence.generation,
|
||||
urlHash: currentTransportEvidence.urlHash,
|
||||
transportEvidence: currentTransportEvidence,
|
||||
diagnostics: [],
|
||||
} as any);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello same session refresh bob',
|
||||
messageId: 'msg-same-session-refresh-transport',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
});
|
||||
} finally {
|
||||
transportSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
forceSessionRefreshReason:
|
||||
'opencode_app_mcp_transport_changed:old-same-session-transport-hash->current-same-session-transport-hash',
|
||||
})
|
||||
);
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
});
|
||||
expect(evidence.sessions[0]).toMatchObject({
|
||||
id: 'oc-session-bob',
|
||||
appMcpTransportHash: 'current-same-session-transport-hash',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed through the delivery ledger when forced refresh reaches an old OpenCode bridge contract', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember: vi.fn() });
|
||||
await writeCommittedOpenCodeSessionStore({
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
batchKey: 'production-adapter-refresh-contract-missing',
|
||||
sessions: [
|
||||
{
|
||||
id: 'oc-session-bob',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
runId: 'opencode-run-bob',
|
||||
source: 'app_managed_bootstrap',
|
||||
appMcpTransportHash: 'old-contract-transport-hash',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const currentTransportEvidence = {
|
||||
schemaVersion: 1,
|
||||
transport: 'httpStream',
|
||||
host: '127.0.0.1',
|
||||
port: 43127,
|
||||
endpoint: '/mcp',
|
||||
url: 'http://127.0.0.1:43127/mcp',
|
||||
urlHash: 'current-contract-transport-hash',
|
||||
generation: 6,
|
||||
observedAt: '2026-04-25T10:00:00.000Z',
|
||||
};
|
||||
const transportSpy = vi
|
||||
.spyOn(agentTeamsMcpHttpServer, 'getCurrentHandle')
|
||||
.mockReturnValue({
|
||||
url: currentTransportEvidence.url,
|
||||
port: currentTransportEvidence.port,
|
||||
child: { pid: 43127 },
|
||||
generation: currentTransportEvidence.generation,
|
||||
urlHash: currentTransportEvidence.urlHash,
|
||||
transportEvidence: currentTransportEvidence,
|
||||
diagnostics: [],
|
||||
} as any);
|
||||
const directBridgeExecute = vi.fn(async () => {
|
||||
throw new Error('direct OpenCode bridge executor should not be used for acceptance send');
|
||||
});
|
||||
const stateChangingExecute = vi.fn(async (input: { command: string }) => ({
|
||||
ok: false as const,
|
||||
schemaVersion: OPEN_CODE_BRIDGE_SCHEMA_VERSION,
|
||||
requestId: 'send-refresh-contract-missing',
|
||||
command: input.command as any,
|
||||
completedAt: '2026-04-25T10:00:01.000Z',
|
||||
durationMs: 10,
|
||||
error: {
|
||||
kind: 'contract_violation' as const,
|
||||
message:
|
||||
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 2.',
|
||||
retryable: false,
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
const productionBridge = new OpenCodeReadinessBridge(
|
||||
{ execute: directBridgeExecute },
|
||||
{ stateChangingCommands: { execute: stateChangingExecute as any } }
|
||||
);
|
||||
svc.setRuntimeAdapterRegistry(
|
||||
new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(productionBridge)])
|
||||
);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello old contract bob',
|
||||
messageId: 'msg-production-refresh-contract-missing',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: false,
|
||||
accepted: false,
|
||||
responsePending: false,
|
||||
responseState: 'session_stale',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
diagnostics: expect.arrayContaining([
|
||||
expect.stringContaining(
|
||||
'OpenCode forced session refresh requires delivery acceptance contract version 2'
|
||||
),
|
||||
'opencode_session_refresh_scheduled_after_resolved_behavior_changed',
|
||||
]),
|
||||
});
|
||||
} finally {
|
||||
transportSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(directBridgeExecute).not.toHaveBeenCalled();
|
||||
expect(stateChangingExecute).toHaveBeenCalledTimes(1);
|
||||
const evidence = await readCommittedOpenCodeBootstrapSessionEvidence({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
});
|
||||
expect(evidence.sessions[0]).toMatchObject({
|
||||
id: 'oc-session-bob',
|
||||
appMcpTransportHash: 'old-contract-transport-hash',
|
||||
});
|
||||
});
|
||||
|
||||
it('stops repeated OpenCode session refresh loops at the refresh cap', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
runtimePromptMessageId: 'msg_prompt_refresh_cap',
|
||||
prePromptCursor: 'cursor-refresh-cap',
|
||||
responseObservation: {
|
||||
state: 'pending',
|
||||
deliveredUserMessageId: 'oc-user-refresh-cap',
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'assistant_response_pending',
|
||||
},
|
||||
diagnostics: [],
|
||||
}));
|
||||
const observeMessageDelivery = vi.fn(async (input: Record<string, unknown>) => ({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
memberName: String(input.memberName),
|
||||
sessionId: 'oc-session-bob',
|
||||
responseObservation: {
|
||||
state: 'session_stale',
|
||||
deliveredUserMessageId: null,
|
||||
assistantMessageId: null,
|
||||
toolCallNames: [],
|
||||
visibleMessageToolCallId: null,
|
||||
visibleReplyMessageId: null,
|
||||
visibleReplyCorrelation: null,
|
||||
latestAssistantPreview: null,
|
||||
reason: 'resolved_behavior_changed:old->new',
|
||||
},
|
||||
diagnostics: ['OpenCode session reconcile skipped because the stored session is stale'],
|
||||
}));
|
||||
await configureOpenCodeBobDeliveryService({
|
||||
svc,
|
||||
sendMessageToMember,
|
||||
observeMessageDelivery,
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-stale-session-cap',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'pending',
|
||||
});
|
||||
|
||||
const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({
|
||||
teamsBasePath: tempTeamsBase,
|
||||
teamName: 'team-a',
|
||||
laneId: 'secondary:opencode:bob',
|
||||
fileName: 'opencode-prompt-delivery-ledger.json',
|
||||
});
|
||||
const ledgerEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
Object.assign(ledgerEnvelope.data[0], {
|
||||
status: 'accepted',
|
||||
responseState: 'session_stale',
|
||||
nextAttemptAt: '2000-01-01T00:00:00.000Z',
|
||||
lastReason: 'resolved_behavior_changed:old->new',
|
||||
sessionRefreshAttempts: 5,
|
||||
maxSessionRefreshAttempts: 5,
|
||||
});
|
||||
await fsPromises.writeFile(ledgerPath, JSON.stringify(ledgerEnvelope, null, 2), 'utf8');
|
||||
|
||||
await expect(
|
||||
svc.deliverOpenCodeMemberMessage('team-a', {
|
||||
memberName: 'bob',
|
||||
text: 'hello bob',
|
||||
messageId: 'msg-stale-session-cap',
|
||||
source: 'watcher',
|
||||
inboxTimestamp: '2026-04-25T10:00:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
delivered: false,
|
||||
accepted: true,
|
||||
responsePending: false,
|
||||
responseState: 'session_stale',
|
||||
ledgerStatus: 'failed_terminal',
|
||||
reason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
|
||||
});
|
||||
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(1);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(1);
|
||||
const terminalEnvelope = JSON.parse(await fsPromises.readFile(ledgerPath, 'utf8')) as {
|
||||
data: Array<Record<string, unknown>>;
|
||||
};
|
||||
expect(terminalEnvelope.data[0]).toMatchObject({
|
||||
status: 'failed_terminal',
|
||||
attempts: 1,
|
||||
sessionRefreshAttempts: 5,
|
||||
maxSessionRefreshAttempts: 5,
|
||||
lastReason: 'opencode_session_refresh_loop_after_resolved_behavior_changed',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -9696,7 +10439,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => {
|
||||
it('marks OpenCode delivery terminal after bounded recovery instead of leaving it pending', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const emptyResponseObservation = {
|
||||
state: 'empty_assistant_turn' as const,
|
||||
|
|
@ -9811,6 +10554,15 @@ describe('TeamProvisioningService', () => {
|
|||
responseState: 'empty_assistant_turn',
|
||||
});
|
||||
await forceDue();
|
||||
await expect(deliver()).resolves.toMatchObject({
|
||||
delivered: true,
|
||||
accepted: true,
|
||||
responsePending: true,
|
||||
responseState: 'empty_assistant_turn',
|
||||
ledgerStatus: 'retry_scheduled',
|
||||
reason: 'empty_assistant_turn',
|
||||
});
|
||||
await forceDue();
|
||||
await expect(deliver()).resolves.toMatchObject({
|
||||
delivered: false,
|
||||
accepted: true,
|
||||
|
|
@ -9850,8 +10602,8 @@ describe('TeamProvisioningService', () => {
|
|||
visibleReplyMessageId: 'reply-after-terminal',
|
||||
visibleReplyCorrelation: 'relayOfMessageId',
|
||||
});
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(3);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(2);
|
||||
expect(sendMessageToMember).toHaveBeenCalledTimes(4);
|
||||
expect(observeMessageDelivery).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('queues newer OpenCode deliveries behind one active unresolved member delivery', async () => {
|
||||
|
|
|
|||
|
|
@ -2508,8 +2508,15 @@ Messages:
|
|||
vi.spyOn(service as any, 'getCurrentOpenCodeRuntimeRunId').mockReturnValue('opencode-run-1');
|
||||
vi.spyOn(
|
||||
service as any,
|
||||
'hasDeliverableOpenCodeRuntimeBootstrapSessionEvidence'
|
||||
).mockResolvedValue(true);
|
||||
'findDeliverableOpenCodeRuntimeBootstrapSessionEvidence'
|
||||
).mockResolvedValue({
|
||||
id: 'session-jack',
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
laneId,
|
||||
runId: 'opencode-run-1',
|
||||
source: 'runtime_bootstrap_checkin',
|
||||
});
|
||||
vi.spyOn(service as any, 'applyOpenCodeVisibleDestinationProof').mockImplementation(
|
||||
async (input: any) => ({
|
||||
ledgerRecord: input.ledgerRecord,
|
||||
|
|
|
|||
|
|
@ -114,4 +114,128 @@ describe('member launch diagnostics', () => {
|
|||
);
|
||||
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not turn healthy info liveness diagnostics into member card errors', () => {
|
||||
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||
teamName: 'atlas-hq-5',
|
||||
runId: '5a9ee2e5-a8cb-4559-b624-0dbf13ee4d11',
|
||||
memberName: 'atlas',
|
||||
spawnEntry: {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: true,
|
||||
livenessKind: 'runtime_process',
|
||||
livenessSource: 'heartbeat',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
updatedAt: '2026-05-18T08:13:23.902Z',
|
||||
},
|
||||
runtimeEntry: {
|
||||
memberName: 'atlas',
|
||||
providerId: 'opencode',
|
||||
alive: true,
|
||||
restartable: false,
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
diagnostics: [
|
||||
'OpenCode runtime process detected after bootstrap confirmation',
|
||||
'matched OpenCode runtime pid and process identity',
|
||||
'bootstrap confirmed',
|
||||
],
|
||||
updatedAt: '2026-05-18T08:34:47.845Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.memberCardError).toBeUndefined();
|
||||
expect(payload.runtimeDiagnostic).toBe(
|
||||
'OpenCode runtime process detected after bootstrap confirmation'
|
||||
);
|
||||
expect(payload.runtimeDiagnosticSeverity).toBe('info');
|
||||
expect(payload.diagnostics).toContain(
|
||||
'OpenCode runtime process detected after bootstrap confirmation'
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers advisory errors over healthy info liveness diagnostics', () => {
|
||||
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||
memberName: 'atlas',
|
||||
runtimeAdvisoryLabel: 'OpenCode delivery error',
|
||||
runtimeAdvisoryTitle:
|
||||
'OpenCode runtime delivery error. OpenCode accepted the prompt, but no assistant turn was recorded.',
|
||||
spawnEntry: {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
updatedAt: '2026-05-18T08:13:23.902Z',
|
||||
},
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-18T08:31:46.075Z',
|
||||
reasonCode: 'backend_error',
|
||||
message: 'OpenCode accepted the prompt, but no assistant turn was recorded.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.memberCardError).toBe(
|
||||
'OpenCode runtime delivery error. OpenCode accepted the prompt, but no assistant turn was recorded.'
|
||||
);
|
||||
expect(payload.memberCardError).not.toBe(
|
||||
'OpenCode runtime process detected after bootstrap confirmation'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not surface recoverable OpenCode session refresh advisory as card error', () => {
|
||||
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||
memberName: 'tom',
|
||||
runtimeAdvisoryLabel: 'OpenCode session refresh',
|
||||
runtimeAdvisoryTitle: 'OpenCode session changed; refreshing the session before retry.',
|
||||
spawnEntry: {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
updatedAt: '2026-05-18T08:13:23.902Z',
|
||||
},
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-18T08:31:46.075Z',
|
||||
reasonCode: 'backend_error',
|
||||
message: 'OpenCode session changed; refreshing the session before retry.',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.memberCardError).toBeUndefined();
|
||||
expect(payload.diagnostics).toContain(
|
||||
'OpenCode session changed; refreshing the session before retry.'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not surface recoverable OpenCode transport refresh advisory as card error', () => {
|
||||
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||
memberName: 'tom',
|
||||
runtimeAdvisoryLabel: 'OpenCode session refresh',
|
||||
runtimeAdvisoryTitle:
|
||||
'OpenCode session changed; refreshing the session before retry.',
|
||||
runtimeAdvisory: {
|
||||
kind: 'api_error',
|
||||
observedAt: '2026-05-18T08:31:46.075Z',
|
||||
reasonCode: 'backend_error',
|
||||
message: 'opencode_app_mcp_transport_changed:old->new',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.memberCardError).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue