fix: harden opencode runtime recovery
This commit is contained in:
parent
9cd5144e1a
commit
85b767e247
10 changed files with 1075 additions and 40 deletions
37
.github/workflows/release.yml
vendored
37
.github/workflows/release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue