fix: harden opencode runtime recovery

This commit is contained in:
777genius 2026-05-19 01:27:34 +03:00
parent 9cd5144e1a
commit 85b767e247
10 changed files with 1075 additions and 40 deletions

View file

@ -670,7 +670,7 @@ jobs:
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
steps:
- name: Upload stable-named assets for /latest/download links
- name: Upload compatibility aliases for older updater clients
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@ -681,7 +681,7 @@ jobs:
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
declare -A FILES=(
declare -A COMPATIBILITY_ALIASES=(
["Claude-Agent-Teams-UI-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.dmg"
["Claude-Agent-Teams-UI-Setup.exe"]="Agent.Teams.AI.Setup.${VERSION}.exe"
@ -691,17 +691,16 @@ jobs:
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
)
# Download versioned files and re-upload with stable names
for STABLE_NAME in "${!FILES[@]}"; do
VERSIONED_NAME="${FILES[$STABLE_NAME]}"
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do
VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}"
echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
gh release download "${TAG}" \
--repo "$REPO" \
--pattern "${VERSIONED_NAME}" \
--dir "$TMP_DIR" \
--clobber
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}"
gh release upload "${TAG}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}"
gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber
done
- name: Publish canonical updater metadata
@ -734,31 +733,33 @@ jobs:
}
# Canonical Windows feed
download_asset "Claude-Agent-Teams-UI-Setup.exe"
WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")"
WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")"
WIN_ASSET="Agent.Teams.AI.Setup.${VERSION}.exe"
download_asset "${WIN_ASSET}"
WIN_SHA="$(sha512_base64 "${WIN_ASSET}")"
WIN_SIZE="$(file_size "${WIN_ASSET}")"
cat > latest.yml <<EOF
version: ${VERSION}
files:
- url: Claude-Agent-Teams-UI-Setup.exe
- url: ${WIN_ASSET}
sha512: ${WIN_SHA}
size: ${WIN_SIZE}
path: Claude-Agent-Teams-UI-Setup.exe
path: ${WIN_ASSET}
sha512: ${WIN_SHA}
releaseDate: '${RELEASE_DATE}'
EOF
# Canonical Linux feed
download_asset "Claude-Agent-Teams-UI.AppImage"
LINUX_SHA="$(sha512_base64 "Claude-Agent-Teams-UI.AppImage")"
LINUX_SIZE="$(file_size "Claude-Agent-Teams-UI.AppImage")"
LINUX_ASSET="Agent.Teams.AI-${VERSION}.AppImage"
download_asset "${LINUX_ASSET}"
LINUX_SHA="$(sha512_base64 "${LINUX_ASSET}")"
LINUX_SIZE="$(file_size "${LINUX_ASSET}")"
cat > latest-linux.yml <<EOF
version: ${VERSION}
files:
- url: Claude-Agent-Teams-UI.AppImage
- url: ${LINUX_ASSET}
sha512: ${LINUX_SHA}
size: ${LINUX_SIZE}
path: Claude-Agent-Teams-UI.AppImage
path: ${LINUX_ASSET}
sha512: ${LINUX_SHA}
releaseDate: '${RELEASE_DATE}'
EOF

View file

@ -190,7 +190,6 @@ import {
parseBootstrapRuntimeProofDetail,
validateBootstrapRuntimeProofEnvelope,
} from './bootstrap/BootstrapProofValidation';
import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer';
import {
buildNativeAppManagedBootstrapSpecs,
buildNativeAppManagedBootstrapSpecsWithDiagnostics,
@ -245,6 +244,7 @@ import {
openCodeTaskRefsIncludeAll as openCodeTaskRefsIncludeAllValue,
} from './opencode/delivery/OpenCodeRuntimeDeliveryProofMatching';
import { OpenCodeRuntimeDeliveryProofReader } from './opencode/delivery/OpenCodeRuntimeDeliveryProofReader';
import { inferOpenCodeTaskRefsFromInboxMessage } from './opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference';
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
import {
type RuntimeDeliveryDestinationPort,
@ -282,6 +282,7 @@ import {
} from './opencode/store/RuntimeStoreManifest';
import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
import { buildActionModeProtocol } from './actionModeInstructions';
import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer';
import { isAgentTeamsToolUse } from './agentTeamsToolNames';
import { atomicWriteAsync } from './atomicWrite';
import { peekAutoResumeService } from './AutoResumeService';
@ -23795,6 +23796,27 @@ export class TeamProvisioningService {
: null;
}
private async inferOpenCodeInboxMessageTaskRefs(
teamName: string,
message: InboxMessage,
readTasks?: () => Promise<readonly TeamTask[]>
): Promise<TaskRef[]> {
if (Array.isArray(message.taskRefs) && message.taskRefs.length > 0) {
return message.taskRefs;
}
const tasks = await (readTasks?.() ?? new TeamTaskReader().getTasks(teamName).catch(() => []));
if (tasks.length === 0) {
return [];
}
return inferOpenCodeTaskRefsFromInboxMessage({
teamName,
message,
tasks,
});
}
async relayOpenCodeMemberInboxMessages(
teamName: string,
memberName: string,
@ -23936,6 +23958,12 @@ export class TeamProvisioningService {
})
.slice(0, 10);
let taskRefInferenceTasks: Promise<readonly TeamTask[]> | null = null;
const readTaskRefInferenceTasks = (): Promise<readonly TeamTask[]> => {
taskRefInferenceTasks ??= new TeamTaskReader().getTasks(teamName).catch(() => []);
return taskRefInferenceTasks;
};
for (const message of unread) {
let existingRecord = await promptLedger
.getByInboxMessage({
@ -24069,8 +24097,23 @@ export class TeamProvisioningService {
options.deliveryMetadata?.actionMode ??
message.actionMode ??
null;
const existingTaskRefs = existingRecord?.taskRefs?.length
? existingRecord.taskRefs
: undefined;
const metadataTaskRefs = options.deliveryMetadata?.taskRefs?.length
? options.deliveryMetadata.taskRefs
: undefined;
const messageTaskRefs = message.taskRefs?.length ? message.taskRefs : undefined;
const inferredTaskRefs =
existingTaskRefs || metadataTaskRefs || messageTaskRefs
? []
: await this.inferOpenCodeInboxMessageTaskRefs(
teamName,
message,
readTaskRefInferenceTasks
);
const effectiveTaskRefs =
existingRecord?.taskRefs ?? options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [];
existingTaskRefs ?? metadataTaskRefs ?? messageTaskRefs ?? inferredTaskRefs;
const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher';
result.attempted += 1;
const attachmentPayloads = await this.resolveOpenCodeInboxAttachmentPayloads({

View file

@ -7,6 +7,7 @@ import { TeamTaskReader } from '../../TeamTaskReader';
import {
getOpenCodeRuntimeDeliveryPromptTimeMs,
getOpenCodeRuntimeDeliveryRecordTimeMs,
isPotentialOpenCodeRuntimeDeliveryError,
isTerminalSuccessfulOpenCodeDeliveryRecord,
type OpenCodeRuntimeDeliveryProofSnapshot,
} from './OpenCodeRuntimeDeliveryAdvisoryPolicy';
@ -17,9 +18,10 @@ import {
normalizeOpenCodeRuntimeDeliveryToken,
openCodeTaskRefsIncludeAll,
} from './OpenCodeRuntimeDeliveryProofMatching';
import { inferOpenCodeTaskRefsFromInboxMessage } from './OpenCodeRuntimeDeliveryTaskRefInference';
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
import type { InboxMessage, TeamConfig, TeamTask } from '@shared/types';
import type { InboxMessage, TaskRef, TeamConfig, TeamTask } from '@shared/types';
const PROOF_READ_CONCURRENCY = 4;
@ -100,7 +102,8 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe
constructor(
private readonly latestSuccessTimesByMember: ReadonlyMap<string, number>,
private readonly visibleRepliesByMember: ReadonlyMap<string, readonly IndexedVisibleReply[]>,
private readonly taskProgressTimes: ReadonlyMap<string, number>
private readonly taskProgressTimes: ReadonlyMap<string, number>,
private readonly inferredTaskRefsByRecordId: ReadonlyMap<string, readonly TaskRef[]>
) {}
getSnapshot(
@ -149,13 +152,25 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe
}) ?? null;
}
if (!visibleReply && record.taskRefs.length > 0) {
const taskRefs =
record.taskRefs.length > 0
? record.taskRefs
: (this.inferredTaskRefsByRecordId.get(record.id) ?? []);
if (!visibleReply && taskRefs.length > 0) {
const recordWithTaskRefs =
taskRefs === record.taskRefs
? record
: {
...record,
taskRefs: [...taskRefs],
};
visibleReply =
visibleReplies
.filter((candidate) =>
isOpenCodeRecoveredVisibleReplyCandidate({
message: candidate.message,
ledgerRecord: record,
ledgerRecord: recordWithTaskRefs,
from: memberName,
requireTaskRefs: true,
})
@ -164,7 +179,7 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe
}
let taskProgressAt = 0;
for (const taskRef of record.taskRefs) {
for (const taskRef of taskRefs) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
continue;
@ -197,20 +212,17 @@ export class OpenCodeRuntimeDeliveryProofReader {
async readProofIndex(
input: OpenCodeRuntimeDeliveryProofReaderInput
): Promise<OpenCodeRuntimeDeliveryProofIndex> {
const [configuredLeadName, visibleRepliesByMember, taskProgressTimes] = await Promise.all([
const [configuredLeadName, visibleRepliesByMember, taskProgressProof] = await Promise.all([
this.readConfiguredLeadName(input.teamName),
this.readVisibleRepliesByMember(input),
this.readTaskProgressProofTimes(
input.teamName,
input.activeMemberKeys,
input.recordsByMember
),
this.readTaskProgressProof(input.teamName, input.activeMemberKeys, input.recordsByMember),
]);
return new MaterializedOpenCodeRuntimeDeliveryProofIndex(
this.readLatestSuccessTimesByMember(input.activeMemberKeys, input.recordsByMember),
await visibleRepliesByMember(configuredLeadName),
taskProgressTimes
taskProgressProof.taskProgressTimes,
taskProgressProof.inferredTaskRefsByRecordId
);
}
@ -307,17 +319,24 @@ export class OpenCodeRuntimeDeliveryProofReader {
return result;
}
private async readTaskProgressProofTimes(
private async readTaskProgressProof(
teamName: string,
activeMemberKeys: ReadonlySet<string>,
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
): Promise<Map<string, number>> {
): Promise<{
taskProgressTimes: Map<string, number>;
inferredTaskRefsByRecordId: Map<string, TaskRef[]>;
}> {
const taskIdsByMember = new Map<string, Set<string>>();
const recordsMissingTaskRefs: OpenCodePromptDeliveryLedgerRecord[] = [];
for (const [memberKey, records] of recordsByMember) {
if (!activeMemberKeys.has(memberKey)) {
continue;
}
for (const record of records) {
if (record.taskRefs.length === 0 && isPotentialOpenCodeRuntimeDeliveryError(record)) {
recordsMissingTaskRefs.push(record);
}
for (const taskRef of record.taskRefs) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
@ -329,16 +348,42 @@ export class OpenCodeRuntimeDeliveryProofReader {
}
}
}
if (taskIdsByMember.size === 0) {
return new Map();
if (taskIdsByMember.size === 0 && recordsMissingTaskRefs.length === 0) {
return { taskProgressTimes: new Map(), inferredTaskRefsByRecordId: new Map() };
}
const tasks = await this.taskReader.getTasks(teamName).catch(() => []);
if (tasks.length === 0) {
return new Map();
return { taskProgressTimes: new Map(), inferredTaskRefsByRecordId: new Map() };
}
const result = new Map<string, number>();
const inferredTaskRefsByRecordId = await this.inferTaskRefsForRecordsMissingTaskRefs({
teamName,
records: recordsMissingTaskRefs,
tasks,
});
for (const record of recordsMissingTaskRefs) {
const memberKey = normalizeOpenCodeRuntimeDeliveryToken(record.memberName);
if (!activeMemberKeys.has(memberKey)) {
continue;
}
for (const taskRef of inferredTaskRefsByRecordId.get(record.id) ?? []) {
const taskId = taskRef.taskId?.trim();
if (!taskId) {
continue;
}
const taskIds = taskIdsByMember.get(memberKey) ?? new Set<string>();
taskIds.add(taskId);
taskIdsByMember.set(memberKey, taskIds);
}
}
if (taskIdsByMember.size === 0) {
return { taskProgressTimes: new Map(), inferredTaskRefsByRecordId };
}
const taskProgressTimes = new Map<string, number>();
for (const task of tasks) {
const taskId = task.id?.trim();
if (!taskId) {
@ -353,9 +398,56 @@ export class OpenCodeRuntimeDeliveryProofReader {
continue;
}
const key = getOpenCodeTaskProgressProofKey(memberKey, taskId);
result.set(key, Math.max(result.get(key) ?? 0, proofAt));
taskProgressTimes.set(key, Math.max(taskProgressTimes.get(key) ?? 0, proofAt));
}
}
return { taskProgressTimes, inferredTaskRefsByRecordId };
}
private async inferTaskRefsForRecordsMissingTaskRefs(input: {
teamName: string;
records: readonly OpenCodePromptDeliveryLedgerRecord[];
tasks: readonly TeamTask[];
}): Promise<Map<string, TaskRef[]>> {
const result = new Map<string, TaskRef[]>();
if (input.records.length === 0) {
return result;
}
const inboxMessagesByMember = new Map<string, Promise<InboxMessage[]>>();
const getInboxMessages = (memberName: string): Promise<InboxMessage[]> => {
const memberKey = normalizeOpenCodeRuntimeDeliveryToken(memberName);
const existing = inboxMessagesByMember.get(memberKey);
if (existing) {
return existing;
}
const request = this.inboxReader
.getMessagesFor(input.teamName, memberName)
.catch(() => [] as InboxMessage[]);
inboxMessagesByMember.set(memberKey, request);
return request;
};
await mapLimit(input.records, PROOF_READ_CONCURRENCY, async (record) => {
const inboxMessageId = record.inboxMessageId?.trim();
if (!inboxMessageId) {
return;
}
const messages = await getInboxMessages(record.memberName);
const message = messages.find((candidate) => candidate.messageId?.trim() === inboxMessageId);
if (!message) {
return;
}
const inferred = inferOpenCodeTaskRefsFromInboxMessage({
teamName: input.teamName,
message,
tasks: input.tasks,
});
if (inferred.length > 0) {
result.set(record.id, inferred);
}
});
return result;
}
}

View file

@ -0,0 +1,119 @@
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import type { InboxMessage, TaskRef, TeamTask } from '@shared/types';
function normalizeTaskRefs(taskRefs: readonly TaskRef[] | undefined): TaskRef[] {
if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
return [];
}
const normalized: TaskRef[] = [];
for (const rawTaskRef of taskRefs as readonly unknown[]) {
if (!rawTaskRef || typeof rawTaskRef !== 'object') {
continue;
}
const taskRef = rawTaskRef as Record<string, unknown>;
const teamName = typeof taskRef.teamName === 'string' ? taskRef.teamName.trim() : '';
const taskId = typeof taskRef.taskId === 'string' ? taskRef.taskId.trim() : '';
const displayId = typeof taskRef.displayId === 'string' ? taskRef.displayId.trim() : '';
if (teamName && taskId && displayId) {
normalized.push({ teamName, taskId, displayId });
}
}
return normalized;
}
function extractTaskReferenceTokens(text: string): Set<string> {
const tokens = new Set<string>();
for (const match of text.matchAll(/#([A-Za-z0-9][A-Za-z0-9_-]*)/g)) {
const token = match[1]?.trim().toLowerCase();
if (token) {
tokens.add(token);
}
}
for (const match of text.matchAll(
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
)) {
const token = match[0]?.trim().toLowerCase();
if (token) {
tokens.add(token);
}
}
return tokens;
}
function taskRefForTask(teamName: string, task: Pick<TeamTask, 'id' | 'displayId'>): TaskRef {
return {
teamName,
taskId: task.id.trim(),
displayId: getTaskDisplayId(task),
};
}
function findUniqueTaskRefInText(input: {
teamName: string;
text: string;
tasks: readonly Pick<TeamTask, 'id' | 'displayId'>[];
}): TaskRef[] {
const tokens = extractTaskReferenceTokens(input.text);
if (tokens.size === 0) {
return [];
}
const matches = new Map<string, TaskRef>();
for (const task of input.tasks) {
const taskId = task.id?.trim();
if (!taskId) {
continue;
}
const displayId = getTaskDisplayId(task);
if (tokens.has(taskId.toLowerCase()) || tokens.has(displayId.toLowerCase())) {
matches.set(taskId, taskRefForTask(input.teamName, task));
}
}
return matches.size === 1 ? Array.from(matches.values()) : [];
}
function getCommentHeadingText(text: string): string {
const firstLine = text
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) {
return '';
}
return /^\**Comment on(?: task)? #/i.test(firstLine) ? firstLine : '';
}
export function inferOpenCodeTaskRefsFromInboxMessage(input: {
teamName: string;
message: Pick<InboxMessage, 'commentId' | 'messageId' | 'summary' | 'taskRefs' | 'text'>;
tasks: readonly Pick<TeamTask, 'id' | 'displayId'>[];
}): TaskRef[] {
const structured = normalizeTaskRefs(input.message.taskRefs);
if (structured.length > 0 || input.tasks.length === 0) {
return structured;
}
const summary = input.message.summary?.trim() ?? '';
const heading = getCommentHeadingText(input.message.text ?? '');
const messageId = input.message.messageId?.trim() ?? '';
const commentId = input.message.commentId?.trim() ?? '';
const text = input.message.text?.trim() ?? '';
for (const candidate of [summary, heading, messageId, commentId, text]) {
if (!candidate) {
continue;
}
const inferred = findUniqueTaskRefInText({
teamName: input.teamName,
text: candidate,
tasks: input.tasks,
});
if (inferred.length > 0) {
return inferred;
}
}
return [];
}

View file

@ -107,6 +107,66 @@ const OPEN_CODE_CAPABILITY_SNAPSHOT_PRELAUNCH_MISMATCH_MARKERS = [
'Bridge server capability snapshot mismatch',
'OpenCode bridge capability snapshot precondition mismatch',
];
const OPEN_CODE_READINESS_RETRY_DELAYS_MS = [750, 2_000] as const;
type OpenCodeTeamLaunchReadinessInput = Parameters<
OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']
>[0];
function getOpenCodeReadinessDiagnosticText(readiness: OpenCodeTeamLaunchReadiness): string {
return [...readiness.diagnostics, ...readiness.missing].join('\n');
}
function isTransientOpenCodeReadinessTransportFailure(
readiness: OpenCodeTeamLaunchReadiness
): boolean {
if (readiness.launchAllowed) {
return false;
}
if (readiness.state !== 'mcp_unavailable' && readiness.state !== 'unknown_error') {
return false;
}
const diagnosticText = getOpenCodeReadinessDiagnosticText(readiness).toLowerCase();
if (!diagnosticText) {
return false;
}
const hasHardFailureMarker =
/\b(?:401|403)\b/.test(diagnosticText) ||
diagnosticText.includes('unauthorized') ||
diagnosticText.includes('forbidden') ||
diagnosticText.includes('missing canonical app mcp tool id') ||
diagnosticText.includes('observed alias') ||
diagnosticText.includes('app mcp tool missing') ||
diagnosticText.includes('tool is absent') ||
diagnosticText.includes('missing required field') ||
diagnosticText.includes('runtime store') ||
diagnosticText.includes('capability snapshot') ||
diagnosticText.includes('contract') ||
diagnosticText.includes('schema') ||
diagnosticText.includes('invalid input') ||
/\b(?:404|405)\b/.test(diagnosticText) ||
diagnosticText.includes('not found');
if (hasHardFailureMarker) {
return false;
}
return (
diagnosticText.includes('unable to connect') ||
diagnosticText.includes('socket connection was closed') ||
diagnosticText.includes('fetch failed') ||
diagnosticText.includes('econnreset') ||
diagnosticText.includes('econnrefused') ||
diagnosticText.includes('socket hang up') ||
diagnosticText.includes('networkerror') ||
diagnosticText.includes('/experimental/tool/ids unavailable')
);
}
function sleepOpenCodeReadinessRetry(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}
function resolveOpenCodeRuntimeSettlementMode(
input: Pick<OpenCodeTeamRuntimeMessageInput, 'messageKind'>
@ -123,7 +183,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
const runtimeOnly = input.runtimeOnly === true;
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
const readiness = await this.checkOpenCodeReadinessWithTransientRetry({
projectPath: input.cwd,
selectedModel: input.model ?? null,
requireExecutionProbe: !runtimeOnly,
@ -154,6 +214,20 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
return this.lastReadinessByProjectPath.get(projectPath) ?? null;
}
private async checkOpenCodeReadinessWithTransientRetry(
input: OpenCodeTeamLaunchReadinessInput
): Promise<OpenCodeTeamLaunchReadiness> {
let readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness(input);
for (const delayMs of OPEN_CODE_READINESS_RETRY_DELAYS_MS) {
if (!isTransientOpenCodeReadinessTransportFailure(readiness)) {
return readiness;
}
await sleepOpenCodeReadinessRetry(delayMs);
readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness(input);
}
return readiness;
}
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(
input.expectedMembers,

View file

@ -0,0 +1,202 @@
import { describe, expect, it, vi } from 'vitest';
import { OpenCodeRuntimeDeliveryProofReader } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryProofReader';
import type { OpenCodePromptDeliveryLedgerRecord } from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import type { InboxMessage, TaskRef, TeamTask } from '../../../../src/shared/types/team';
const TEAM_NAME = 'relay-works-69';
const TARGET_TASK_ID = 'a7fd5f34-ff82-4ead-8089-34064454a623';
const OTHER_TASK_ID = '8dc34135-1111-4111-8111-8dc341350000';
const TARGET_TASK_REF: TaskRef = {
teamName: TEAM_NAME,
taskId: TARGET_TASK_ID,
displayId: 'a7fd5f34',
};
const OTHER_TASK_REF: TaskRef = {
teamName: TEAM_NAME,
taskId: OTHER_TASK_ID,
displayId: '8dc34135',
};
function createLedgerRecord(): OpenCodePromptDeliveryLedgerRecord {
return {
id: 'opencode-prompt:dependency-comment',
teamName: TEAM_NAME,
memberName: 'tom',
laneId: 'secondary:opencode:tom',
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'dependency-comment-1',
inboxTimestamp: '2026-05-18T21:25:05.428Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'session_stale',
attempts: 1,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-18T21:25:27.592Z',
lastObservedAt: '2026-05-18T21:27:58.582Z',
acceptedAt: '2026-05-18T21:25:27.592Z',
respondedAt: null,
failedAt: '2026-05-18T21:27:58.582Z',
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: 'opencode_session_stale_observe_loop_after_accepted_prompt',
diagnostics: [
'OpenCode API error',
'OpenCode session stayed stale while observing an accepted prompt after 5 attempt(s).',
],
createdAt: '2026-05-18T21:25:05.428Z',
updatedAt: '2026-05-18T21:27:58.582Z',
};
}
function createDependencyInboxMessage(): InboxMessage {
return {
from: 'team-lead',
to: 'tom',
text: [
'**Comment on task #a7fd5f34** _Calculator styles_',
'',
'> **Dependency resolved** - task #8dc34135 completed.',
'> All blockers for #a7fd5f34 are resolved - this task is ready to start.',
].join('\n'),
timestamp: '2026-05-18T21:25:05.428Z',
read: false,
summary: 'Comment on #a7fd5f34',
messageId: 'dependency-comment-1',
source: 'system_notification',
};
}
function createRuntimeReply(taskRefs: TaskRef[]): InboxMessage {
return {
from: 'tom',
to: 'team-lead',
text: 'Done and verified.',
timestamp: '2026-05-18T21:25:45.000Z',
read: false,
summary: 'Done',
messageId: `reply-${taskRefs[0]?.displayId ?? 'none'}`,
source: 'runtime_delivery',
taskRefs,
};
}
function createProofReader(leadInboxMessages: InboxMessage[]): OpenCodeRuntimeDeliveryProofReader {
const inboxReader = {
getMessagesFor: vi.fn((_teamName: string, inboxName: string) => {
if (inboxName === 'tom') {
return Promise.resolve([createDependencyInboxMessage()]);
}
if (inboxName === 'team-lead') {
return Promise.resolve(leadInboxMessages);
}
return Promise.resolve([]);
}),
};
const taskReader = {
getTasks: vi.fn(() =>
Promise.resolve([
{ id: TARGET_TASK_ID, displayId: 'a7fd5f34' },
{ id: OTHER_TASK_ID, displayId: '8dc34135' },
] as TeamTask[])
),
};
const configReader = {
getConfigSnapshot: vi.fn(() =>
Promise.resolve({
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})
),
};
return new OpenCodeRuntimeDeliveryProofReader(
inboxReader as never,
taskReader as never,
configReader as never
);
}
describe('OpenCodeRuntimeDeliveryProofReader', () => {
it('matches visible replies using task refs inferred from the original inbox message', async () => {
const record = createLedgerRecord();
const proofIndex = await createProofReader([
createRuntimeReply([TARGET_TASK_REF]),
]).readProofIndex({
teamName: TEAM_NAME,
activeMemberKeys: new Set(['tom']),
recordsByMember: new Map([['tom', [record]]]),
});
expect(proofIndex.getSnapshot('tom', record).visibleReplyMessageId).toBe('reply-a7fd5f34');
});
it('does not treat a reply for another task as proof for an inferred task ref', async () => {
const record = createLedgerRecord();
const proofIndex = await createProofReader([
createRuntimeReply([OTHER_TASK_REF]),
]).readProofIndex({
teamName: TEAM_NAME,
activeMemberKeys: new Set(['tom']),
recordsByMember: new Map([['tom', [record]]]),
});
expect(proofIndex.getSnapshot('tom', record).visibleReplyMessageId).toBeUndefined();
});
it('does not infer task refs for non-error records', async () => {
const record: OpenCodePromptDeliveryLedgerRecord = {
...createLedgerRecord(),
status: 'pending',
responseState: 'pending',
failedAt: null,
lastReason: null,
diagnostics: [],
};
const inboxReader = {
getMessagesFor: vi.fn(() => Promise.resolve([] as InboxMessage[])),
};
const taskReader = {
getTasks: vi.fn(() => Promise.resolve([] as TeamTask[])),
};
const configReader = {
getConfigSnapshot: vi.fn(() =>
Promise.resolve({
members: [{ name: 'team-lead', agentType: 'team-lead' }],
})
),
};
const proofIndex = await new OpenCodeRuntimeDeliveryProofReader(
inboxReader as never,
taskReader as never,
configReader as never
).readProofIndex({
teamName: TEAM_NAME,
activeMemberKeys: new Set(['tom']),
recordsByMember: new Map([['tom', [record]]]),
});
expect(proofIndex.getSnapshot('tom', record).taskProgressAt).toBeUndefined();
expect(taskReader.getTasks).not.toHaveBeenCalled();
expect(inboxReader.getMessagesFor).not.toHaveBeenCalledWith(TEAM_NAME, 'tom');
});
});

View file

@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { inferOpenCodeTaskRefsFromInboxMessage } from '../../../../src/main/services/team/opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference';
const TEAM_NAME = 'relay-works-69';
const TASKS = [
{ id: 'a7fd5f34-ff82-4ead-8089-34064454a623', displayId: 'a7fd5f34' },
{ id: '8dc34135-1111-4111-8111-8dc341350000', displayId: '8dc34135' },
{ id: '1', displayId: '1' },
];
describe('inferOpenCodeTaskRefsFromInboxMessage', () => {
it('preserves structured task refs', () => {
const structured = [{ teamName: TEAM_NAME, taskId: 'task-1', displayId: 'abcd1234' }];
expect(
inferOpenCodeTaskRefsFromInboxMessage({
teamName: TEAM_NAME,
message: {
text: 'Ignore text #a7fd5f34.',
taskRefs: structured,
},
tasks: TASKS,
})
).toEqual(structured);
});
it('uses the summary before ambiguous full text', () => {
expect(
inferOpenCodeTaskRefsFromInboxMessage({
teamName: TEAM_NAME,
message: {
text: [
'**Comment on task #a7fd5f34** _Calculator styles_',
'',
'> **Dependency resolved** - task #8dc34135 completed.',
'> All blockers for #a7fd5f34 are resolved - this task is ready to start.',
].join('\n'),
summary: 'Comment on #a7fd5f34',
},
tasks: TASKS,
})
).toEqual([{ teamName: TEAM_NAME, taskId: TASKS[0].id, displayId: 'a7fd5f34' }]);
});
it('uses a comment heading when the summary is missing', () => {
expect(
inferOpenCodeTaskRefsFromInboxMessage({
teamName: TEAM_NAME,
message: {
text: [
'**Comment on #a7fd5f34** _Calculator styles_',
'',
'Dependency #8dc34135 is resolved.',
].join('\n'),
},
tasks: TASKS,
})
).toEqual([{ teamName: TEAM_NAME, taskId: TASKS[0].id, displayId: 'a7fd5f34' }]);
});
it('does not infer from ambiguous text without a unique candidate field', () => {
expect(
inferOpenCodeTaskRefsFromInboxMessage({
teamName: TEAM_NAME,
message: {
text: 'Dependency resolved: #8dc34135 unblocks #a7fd5f34.',
},
tasks: TASKS,
})
).toEqual([]);
});
it('supports short display ids', () => {
expect(
inferOpenCodeTaskRefsFromInboxMessage({
teamName: TEAM_NAME,
message: {
summary: 'Comment on #1',
text: 'Ready.',
},
tasks: TASKS,
})
).toEqual([{ teamName: TEAM_NAME, taskId: '1', displayId: '1' }]);
});
});

View file

@ -36,6 +36,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
selectedModel: 'openai/gpt-5.4-mini',
requireExecutionProbe: true,
});
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledTimes(1);
});
it('uses runtime-only readiness for model-less preflight checks', async () => {
@ -156,6 +157,225 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
);
});
it('retries transient MCP readiness transport failures before prepare succeeds', async () => {
const firstReadiness = readiness({
state: 'mcp_unavailable',
launchAllowed: false,
diagnostics: [
'OpenCode /experimental/tool/ids unavailable - Unable to connect. Is the computer able to access the url?',
],
missing: ['runtime_deliver_message'],
});
const finalReadiness = readiness({
state: 'ready',
launchAllowed: true,
diagnostics: ['OpenCode readiness recovered'],
});
const checkReadiness = vi
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
.mockResolvedValueOnce(firstReadiness)
.mockResolvedValueOnce(finalReadiness);
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: checkReadiness,
});
vi.useFakeTimers();
try {
const resultPromise = adapter.prepare(launchInput());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(750);
await expect(resultPromise).resolves.toEqual({
ok: true,
providerId: 'opencode',
modelId: 'openai/gpt-5.4-mini',
diagnostics: ['OpenCode readiness recovered'],
warnings: [],
});
} finally {
vi.useRealTimers();
}
expect(checkReadiness).toHaveBeenCalledTimes(2);
expect(adapter.getLastOpenCodeTeamLaunchReadiness('/repo')).toBe(finalReadiness);
});
it('retries unknown readiness failures only when diagnostics show transport failure', async () => {
const checkReadiness = vi
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
.mockResolvedValueOnce(
readiness({
state: 'unknown_error',
launchAllowed: false,
diagnostics: ['OpenCode readiness bridge failed: fetch failed'],
})
)
.mockResolvedValueOnce(readiness({ state: 'ready', launchAllowed: true }));
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: checkReadiness,
});
vi.useFakeTimers();
try {
const resultPromise = adapter.prepare(launchInput());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(750);
await expect(resultPromise).resolves.toMatchObject({
ok: true,
providerId: 'opencode',
});
} finally {
vi.useRealTimers();
}
expect(checkReadiness).toHaveBeenCalledTimes(2);
});
it('returns the final readiness failure after transient retries are exhausted', async () => {
const finalReadiness = readiness({
state: 'mcp_unavailable',
launchAllowed: false,
diagnostics: ['Final OpenCode /experimental/tool/ids unavailable - ECONNRESET'],
missing: ['final transport missing'],
});
const checkReadiness = vi
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
.mockResolvedValueOnce(
readiness({
state: 'mcp_unavailable',
launchAllowed: false,
diagnostics: ['First OpenCode /experimental/tool/ids unavailable - Unable to connect'],
})
)
.mockResolvedValueOnce(
readiness({
state: 'unknown_error',
launchAllowed: false,
diagnostics: ['Second OpenCode readiness bridge failed: socket hang up'],
})
)
.mockResolvedValueOnce(finalReadiness);
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: checkReadiness,
});
vi.useFakeTimers();
try {
const resultPromise = adapter.prepare(launchInput());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(750);
await vi.advanceTimersByTimeAsync(2_000);
await expect(resultPromise).resolves.toEqual({
ok: false,
providerId: 'opencode',
reason: 'mcp_unavailable',
retryable: true,
diagnostics: [
'Final OpenCode /experimental/tool/ids unavailable - ECONNRESET',
'final transport missing',
],
warnings: [],
});
} finally {
vi.useRealTimers();
}
expect(checkReadiness).toHaveBeenCalledTimes(3);
expect(adapter.getLastOpenCodeTeamLaunchReadiness('/repo')).toBe(finalReadiness);
});
it.each([
{
state: 'not_authenticated' as const,
diagnostics: ['OpenCode provider returned 401 unauthorized'],
},
{
state: 'not_installed' as const,
diagnostics: ['OpenCode runtime binary is not installed'],
},
{
state: 'model_unavailable' as const,
diagnostics: ['Selected model is unavailable'],
},
{
state: 'mcp_unavailable' as const,
diagnostics: ['OpenCode /experimental/tool/ids unavailable - HTTP 403 forbidden'],
},
{
state: 'mcp_unavailable' as const,
diagnostics: ['OpenCode /experimental/tool/ids unavailable - HTTP 404 Not Found'],
},
{
state: 'mcp_unavailable' as const,
diagnostics: [
'OpenCode /experimental/tool/ids unavailable - fetch failed',
'App MCP tool missing: runtime_deliver_message',
],
},
{
state: 'unknown_error' as const,
diagnostics: ['OpenCode bridge contract violation: schema mismatch'],
},
])('does not retry $state readiness failures', async ({ state, diagnostics }) => {
const checkReadiness = vi
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
.mockResolvedValue(readiness({ state, launchAllowed: false, diagnostics }));
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: checkReadiness,
});
await expect(adapter.prepare(launchInput())).resolves.toMatchObject({
ok: false,
reason: state,
});
expect(checkReadiness).toHaveBeenCalledTimes(1);
});
it('launch retries readiness before bridge launch and uses the fresh runtime snapshot', async () => {
const checkReadiness = vi
.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>()
.mockResolvedValueOnce(
readiness({
state: 'mcp_unavailable',
launchAllowed: false,
diagnostics: ['OpenCode /experimental/tool/ids unavailable - Unable to connect'],
})
)
.mockResolvedValueOnce(readiness({ state: 'ready', launchAllowed: true }));
const getLastOpenCodeRuntimeSnapshot = vi.fn(() => runtimeSnapshot('cap-fresh'));
const launchOpenCodeTeam = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
>(() => Promise.resolve(successfulOpenCodeLaunchData()));
const adapter = new OpenCodeTeamRuntimeAdapter({
checkOpenCodeTeamLaunchReadiness: checkReadiness,
getLastOpenCodeRuntimeSnapshot,
launchOpenCodeTeam,
});
vi.useFakeTimers();
try {
const resultPromise = adapter.launch(launchInput());
await Promise.resolve();
await vi.advanceTimersByTimeAsync(750);
await expect(resultPromise).resolves.toMatchObject({
teamLaunchState: 'clean_success',
});
} finally {
vi.useRealTimers();
}
expect(checkReadiness).toHaveBeenCalledTimes(2);
expect(getLastOpenCodeRuntimeSnapshot).toHaveBeenCalledWith('/repo');
expect(launchOpenCodeTeam).toHaveBeenCalledWith(
expect.objectContaining({
expectedCapabilitySnapshotId: 'cap-fresh',
})
);
});
it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => {
const concreteReason =
'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits';

View file

@ -787,6 +787,154 @@ describe('TeamMemberRuntimeAdvisoryService', () => {
expect(advisory).toBeNull();
});
it('suppresses stale OpenCode advisories when task refs can be inferred from the inbox comment', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-18T21:35:00.000Z'));
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 'relay-works-69';
const laneId = 'secondary:opencode:tom';
const taskId = 'a7fd5f34-ff82-4ead-8089-34064454a623';
const laneDir = path.join(
tmpDir,
'teams',
teamName,
'.opencode-runtime',
'lanes',
encodeURIComponent(laneId)
);
await fs.mkdir(laneDir, { recursive: true });
await fs.mkdir(path.join(tmpDir, 'teams', teamName, 'inboxes'), { recursive: true });
await fs.mkdir(path.join(tmpDir, 'tasks', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, '.opencode-runtime', 'lanes.json'),
JSON.stringify({
version: 1,
updatedAt: '2026-05-18T21:27:58.582Z',
lanes: {
[laneId]: { laneId, state: 'active', updatedAt: '2026-05-18T21:27:58.582Z' },
},
}),
'utf8'
);
await fs.writeFile(
path.join(laneDir, 'opencode-prompt-delivery-ledger.json'),
JSON.stringify({
schemaVersion: 1,
updatedAt: '2026-05-18T21:27:58.582Z',
data: [
{
id: 'opencode-prompt:dependency-comment',
teamName,
memberName: 'tom',
laneId,
runId: 'run-1',
runtimeSessionId: 'ses-1',
inboxMessageId: 'dependency-comment-1',
inboxTimestamp: '2026-05-18T21:25:05.428Z',
source: 'watcher',
messageKind: null,
replyRecipient: 'team-lead',
actionMode: null,
taskRefs: [],
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'session_stale',
attempts: 1,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-05-18T21:25:27.592Z',
lastObservedAt: '2026-05-18T21:27:58.582Z',
acceptedAt: '2026-05-18T21:25:27.592Z',
respondedAt: null,
failedAt: '2026-05-18T21:27:58.582Z',
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: 'opencode_session_stale_observe_loop_after_accepted_prompt',
diagnostics: [
'OpenCode API error',
'OpenCode session stayed stale while observing an accepted prompt after 5 attempt(s).',
],
createdAt: '2026-05-18T21:25:05.428Z',
updatedAt: '2026-05-18T21:27:58.582Z',
},
],
}),
'utf8'
);
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'inboxes', 'tom.json'),
JSON.stringify([
{
from: 'team-lead',
to: 'tom',
text: [
'**Comment on task #a7fd5f34** _Calculator styles_',
'',
'> **Dependency resolved** - task #8dc34135 completed.',
'> All blockers for #a7fd5f34 are resolved - this task is ready to start.',
].join('\n'),
timestamp: '2026-05-18T21:25:05.428Z',
read: false,
summary: 'Comment on #a7fd5f34',
messageId: 'dependency-comment-1',
source: 'system_notification',
},
]),
'utf8'
);
await fs.writeFile(
path.join(tmpDir, 'tasks', teamName, `${taskId}.json`),
JSON.stringify({
id: taskId,
displayId: 'a7fd5f34',
subject: 'Calculator styles',
owner: 'tom',
status: 'completed',
updatedAt: '2026-05-18T21:25:21.453Z',
comments: [
{
id: 'result-comment',
author: 'tom',
text: 'Styles completed and verified.',
createdAt: '2026-05-18T21:25:18.441Z',
type: 'regular',
},
],
historyEvents: [
{
id: 'completed-event',
type: 'status_changed',
from: 'in_progress',
to: 'completed',
actor: 'tom',
timestamp: '2026-05-18T21:25:21.453Z',
},
],
}),
'utf8'
);
const service = new TeamMemberRuntimeAdvisoryService({
findMemberLogs: vi.fn(async () => []),
});
const advisory = await service.getMemberAdvisory(teamName, 'tom');
expect(advisory).toBeNull();
});
it('ignores expired retry advisories', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -2274,6 +2274,56 @@ Messages:
expect(rows[0].read).toBe(true);
});
it('uses inferred task refs when relaying legacy OpenCode inbox rows without structured refs', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
const taskRefs = [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }];
hoisted.files.set(
`/mock/teams/${teamName}/config.json`,
JSON.stringify({
name: teamName,
projectPath: '/mock/my-team',
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
],
})
);
seedMemberInbox(teamName, 'jack', [
{
from: 'team-lead',
to: 'jack',
text: '**Comment on task #abcd1234**\n\nPlease continue.',
timestamp: '2026-02-23T17:00:00.000Z',
read: false,
messageId: 'opencode-relay-infer-1',
summary: 'Comment on #abcd1234',
},
]);
const inferSpy = vi
.spyOn(service as any, 'inferOpenCodeInboxMessageTaskRefs')
.mockResolvedValue(taskRefs);
const deliverSpy = vi
.spyOn(service, 'deliverOpenCodeMemberMessage')
.mockResolvedValue({ delivered: true, diagnostics: [] });
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
expect(relay).toMatchObject({ relayed: 1, attempted: 1, delivered: 1, failed: 0 });
expect(inferSpy).toHaveBeenCalledWith(
teamName,
expect.objectContaining({ messageId: 'opencode-relay-infer-1' }),
expect.any(Function)
);
expect(deliverSpy).toHaveBeenCalledWith(
teamName,
expect.objectContaining({
messageId: 'opencode-relay-infer-1',
taskRefs,
})
);
});
it('keeps OpenCode member inbox rows unread while runtime response is pending', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';