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' }}
|
if: ${{ startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Upload stable-named assets for /latest/download links
|
- name: Upload compatibility aliases for older updater clients
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -681,7 +681,7 @@ jobs:
|
||||||
TMP_DIR="$(mktemp -d)"
|
TMP_DIR="$(mktemp -d)"
|
||||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
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-arm64.dmg"]="Agent.Teams.AI-${VERSION}-arm64.dmg"
|
||||||
["Claude-Agent-Teams-UI-x64.dmg"]="Agent.Teams.AI-${VERSION}-x64.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"
|
["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"
|
["Claude-Agent-Teams-UI.pacman"]="agent-teams-ai-${VERSION}.pacman"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Download versioned files and re-upload with stable names
|
for ALIAS_NAME in "${!COMPATIBILITY_ALIASES[@]}"; do
|
||||||
for STABLE_NAME in "${!FILES[@]}"; do
|
VERSIONED_NAME="${COMPATIBILITY_ALIASES[$ALIAS_NAME]}"
|
||||||
VERSIONED_NAME="${FILES[$STABLE_NAME]}"
|
echo "Uploading compatibility alias: ${ALIAS_NAME} -> ${VERSIONED_NAME}"
|
||||||
echo "Downloading ${VERSIONED_NAME} -> ${STABLE_NAME}"
|
|
||||||
gh release download "${TAG}" \
|
gh release download "${TAG}" \
|
||||||
--repo "$REPO" \
|
--repo "$REPO" \
|
||||||
--pattern "${VERSIONED_NAME}" \
|
--pattern "${VERSIONED_NAME}" \
|
||||||
--dir "$TMP_DIR" \
|
--dir "$TMP_DIR" \
|
||||||
--clobber
|
--clobber
|
||||||
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${STABLE_NAME}"
|
cp "${TMP_DIR}/${VERSIONED_NAME}" "${TMP_DIR}/${ALIAS_NAME}"
|
||||||
gh release upload "${TAG}" "${TMP_DIR}/${STABLE_NAME}" --repo "$REPO" --clobber
|
gh release upload "${TAG}" "${TMP_DIR}/${ALIAS_NAME}" --repo "$REPO" --clobber
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Publish canonical updater metadata
|
- name: Publish canonical updater metadata
|
||||||
|
|
@ -734,31 +733,33 @@ jobs:
|
||||||
}
|
}
|
||||||
|
|
||||||
# Canonical Windows feed
|
# Canonical Windows feed
|
||||||
download_asset "Claude-Agent-Teams-UI-Setup.exe"
|
WIN_ASSET="Agent.Teams.AI.Setup.${VERSION}.exe"
|
||||||
WIN_SHA="$(sha512_base64 "Claude-Agent-Teams-UI-Setup.exe")"
|
download_asset "${WIN_ASSET}"
|
||||||
WIN_SIZE="$(file_size "Claude-Agent-Teams-UI-Setup.exe")"
|
WIN_SHA="$(sha512_base64 "${WIN_ASSET}")"
|
||||||
|
WIN_SIZE="$(file_size "${WIN_ASSET}")"
|
||||||
cat > latest.yml <<EOF
|
cat > latest.yml <<EOF
|
||||||
version: ${VERSION}
|
version: ${VERSION}
|
||||||
files:
|
files:
|
||||||
- url: Claude-Agent-Teams-UI-Setup.exe
|
- url: ${WIN_ASSET}
|
||||||
sha512: ${WIN_SHA}
|
sha512: ${WIN_SHA}
|
||||||
size: ${WIN_SIZE}
|
size: ${WIN_SIZE}
|
||||||
path: Claude-Agent-Teams-UI-Setup.exe
|
path: ${WIN_ASSET}
|
||||||
sha512: ${WIN_SHA}
|
sha512: ${WIN_SHA}
|
||||||
releaseDate: '${RELEASE_DATE}'
|
releaseDate: '${RELEASE_DATE}'
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Canonical Linux feed
|
# Canonical Linux feed
|
||||||
download_asset "Claude-Agent-Teams-UI.AppImage"
|
LINUX_ASSET="Agent.Teams.AI-${VERSION}.AppImage"
|
||||||
LINUX_SHA="$(sha512_base64 "Claude-Agent-Teams-UI.AppImage")"
|
download_asset "${LINUX_ASSET}"
|
||||||
LINUX_SIZE="$(file_size "Claude-Agent-Teams-UI.AppImage")"
|
LINUX_SHA="$(sha512_base64 "${LINUX_ASSET}")"
|
||||||
|
LINUX_SIZE="$(file_size "${LINUX_ASSET}")"
|
||||||
cat > latest-linux.yml <<EOF
|
cat > latest-linux.yml <<EOF
|
||||||
version: ${VERSION}
|
version: ${VERSION}
|
||||||
files:
|
files:
|
||||||
- url: Claude-Agent-Teams-UI.AppImage
|
- url: ${LINUX_ASSET}
|
||||||
sha512: ${LINUX_SHA}
|
sha512: ${LINUX_SHA}
|
||||||
size: ${LINUX_SIZE}
|
size: ${LINUX_SIZE}
|
||||||
path: Claude-Agent-Teams-UI.AppImage
|
path: ${LINUX_ASSET}
|
||||||
sha512: ${LINUX_SHA}
|
sha512: ${LINUX_SHA}
|
||||||
releaseDate: '${RELEASE_DATE}'
|
releaseDate: '${RELEASE_DATE}'
|
||||||
EOF
|
EOF
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,6 @@ import {
|
||||||
parseBootstrapRuntimeProofDetail,
|
parseBootstrapRuntimeProofDetail,
|
||||||
validateBootstrapRuntimeProofEnvelope,
|
validateBootstrapRuntimeProofEnvelope,
|
||||||
} from './bootstrap/BootstrapProofValidation';
|
} from './bootstrap/BootstrapProofValidation';
|
||||||
import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer';
|
|
||||||
import {
|
import {
|
||||||
buildNativeAppManagedBootstrapSpecs,
|
buildNativeAppManagedBootstrapSpecs,
|
||||||
buildNativeAppManagedBootstrapSpecsWithDiagnostics,
|
buildNativeAppManagedBootstrapSpecsWithDiagnostics,
|
||||||
|
|
@ -245,6 +244,7 @@ import {
|
||||||
openCodeTaskRefsIncludeAll as openCodeTaskRefsIncludeAllValue,
|
openCodeTaskRefsIncludeAll as openCodeTaskRefsIncludeAllValue,
|
||||||
} from './opencode/delivery/OpenCodeRuntimeDeliveryProofMatching';
|
} from './opencode/delivery/OpenCodeRuntimeDeliveryProofMatching';
|
||||||
import { OpenCodeRuntimeDeliveryProofReader } from './opencode/delivery/OpenCodeRuntimeDeliveryProofReader';
|
import { OpenCodeRuntimeDeliveryProofReader } from './opencode/delivery/OpenCodeRuntimeDeliveryProofReader';
|
||||||
|
import { inferOpenCodeTaskRefsFromInboxMessage } from './opencode/delivery/OpenCodeRuntimeDeliveryTaskRefInference';
|
||||||
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal';
|
||||||
import {
|
import {
|
||||||
type RuntimeDeliveryDestinationPort,
|
type RuntimeDeliveryDestinationPort,
|
||||||
|
|
@ -282,6 +282,7 @@ import {
|
||||||
} from './opencode/store/RuntimeStoreManifest';
|
} from './opencode/store/RuntimeStoreManifest';
|
||||||
import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
||||||
import { buildActionModeProtocol } from './actionModeInstructions';
|
import { buildActionModeProtocol } from './actionModeInstructions';
|
||||||
|
import { getCurrentAgentTeamsMcpHttpTransportEvidence } from './AgentTeamsMcpHttpServer';
|
||||||
import { isAgentTeamsToolUse } from './agentTeamsToolNames';
|
import { isAgentTeamsToolUse } from './agentTeamsToolNames';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { peekAutoResumeService } from './AutoResumeService';
|
import { peekAutoResumeService } from './AutoResumeService';
|
||||||
|
|
@ -23795,6 +23796,27 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: 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(
|
async relayOpenCodeMemberInboxMessages(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
memberName: string,
|
memberName: string,
|
||||||
|
|
@ -23936,6 +23958,12 @@ export class TeamProvisioningService {
|
||||||
})
|
})
|
||||||
.slice(0, 10);
|
.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) {
|
for (const message of unread) {
|
||||||
let existingRecord = await promptLedger
|
let existingRecord = await promptLedger
|
||||||
.getByInboxMessage({
|
.getByInboxMessage({
|
||||||
|
|
@ -24069,8 +24097,23 @@ export class TeamProvisioningService {
|
||||||
options.deliveryMetadata?.actionMode ??
|
options.deliveryMetadata?.actionMode ??
|
||||||
message.actionMode ??
|
message.actionMode ??
|
||||||
null;
|
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 =
|
const effectiveTaskRefs =
|
||||||
existingRecord?.taskRefs ?? options.deliveryMetadata?.taskRefs ?? message.taskRefs ?? [];
|
existingTaskRefs ?? metadataTaskRefs ?? messageTaskRefs ?? inferredTaskRefs;
|
||||||
const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher';
|
const effectiveSource = existingRecord?.source ?? options.source ?? 'watcher';
|
||||||
result.attempted += 1;
|
result.attempted += 1;
|
||||||
const attachmentPayloads = await this.resolveOpenCodeInboxAttachmentPayloads({
|
const attachmentPayloads = await this.resolveOpenCodeInboxAttachmentPayloads({
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { TeamTaskReader } from '../../TeamTaskReader';
|
||||||
import {
|
import {
|
||||||
getOpenCodeRuntimeDeliveryPromptTimeMs,
|
getOpenCodeRuntimeDeliveryPromptTimeMs,
|
||||||
getOpenCodeRuntimeDeliveryRecordTimeMs,
|
getOpenCodeRuntimeDeliveryRecordTimeMs,
|
||||||
|
isPotentialOpenCodeRuntimeDeliveryError,
|
||||||
isTerminalSuccessfulOpenCodeDeliveryRecord,
|
isTerminalSuccessfulOpenCodeDeliveryRecord,
|
||||||
type OpenCodeRuntimeDeliveryProofSnapshot,
|
type OpenCodeRuntimeDeliveryProofSnapshot,
|
||||||
} from './OpenCodeRuntimeDeliveryAdvisoryPolicy';
|
} from './OpenCodeRuntimeDeliveryAdvisoryPolicy';
|
||||||
|
|
@ -17,9 +18,10 @@ import {
|
||||||
normalizeOpenCodeRuntimeDeliveryToken,
|
normalizeOpenCodeRuntimeDeliveryToken,
|
||||||
openCodeTaskRefsIncludeAll,
|
openCodeTaskRefsIncludeAll,
|
||||||
} from './OpenCodeRuntimeDeliveryProofMatching';
|
} from './OpenCodeRuntimeDeliveryProofMatching';
|
||||||
|
import { inferOpenCodeTaskRefsFromInboxMessage } from './OpenCodeRuntimeDeliveryTaskRefInference';
|
||||||
|
|
||||||
import type { OpenCodePromptDeliveryLedgerRecord } from './OpenCodePromptDeliveryLedger';
|
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;
|
const PROOF_READ_CONCURRENCY = 4;
|
||||||
|
|
||||||
|
|
@ -100,7 +102,8 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe
|
||||||
constructor(
|
constructor(
|
||||||
private readonly latestSuccessTimesByMember: ReadonlyMap<string, number>,
|
private readonly latestSuccessTimesByMember: ReadonlyMap<string, number>,
|
||||||
private readonly visibleRepliesByMember: ReadonlyMap<string, readonly IndexedVisibleReply[]>,
|
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(
|
getSnapshot(
|
||||||
|
|
@ -149,13 +152,25 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe
|
||||||
}) ?? null;
|
}) ?? 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 =
|
visibleReply =
|
||||||
visibleReplies
|
visibleReplies
|
||||||
.filter((candidate) =>
|
.filter((candidate) =>
|
||||||
isOpenCodeRecoveredVisibleReplyCandidate({
|
isOpenCodeRecoveredVisibleReplyCandidate({
|
||||||
message: candidate.message,
|
message: candidate.message,
|
||||||
ledgerRecord: record,
|
ledgerRecord: recordWithTaskRefs,
|
||||||
from: memberName,
|
from: memberName,
|
||||||
requireTaskRefs: true,
|
requireTaskRefs: true,
|
||||||
})
|
})
|
||||||
|
|
@ -164,7 +179,7 @@ class MaterializedOpenCodeRuntimeDeliveryProofIndex implements OpenCodeRuntimeDe
|
||||||
}
|
}
|
||||||
|
|
||||||
let taskProgressAt = 0;
|
let taskProgressAt = 0;
|
||||||
for (const taskRef of record.taskRefs) {
|
for (const taskRef of taskRefs) {
|
||||||
const taskId = taskRef.taskId?.trim();
|
const taskId = taskRef.taskId?.trim();
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -197,20 +212,17 @@ export class OpenCodeRuntimeDeliveryProofReader {
|
||||||
async readProofIndex(
|
async readProofIndex(
|
||||||
input: OpenCodeRuntimeDeliveryProofReaderInput
|
input: OpenCodeRuntimeDeliveryProofReaderInput
|
||||||
): Promise<OpenCodeRuntimeDeliveryProofIndex> {
|
): Promise<OpenCodeRuntimeDeliveryProofIndex> {
|
||||||
const [configuredLeadName, visibleRepliesByMember, taskProgressTimes] = await Promise.all([
|
const [configuredLeadName, visibleRepliesByMember, taskProgressProof] = await Promise.all([
|
||||||
this.readConfiguredLeadName(input.teamName),
|
this.readConfiguredLeadName(input.teamName),
|
||||||
this.readVisibleRepliesByMember(input),
|
this.readVisibleRepliesByMember(input),
|
||||||
this.readTaskProgressProofTimes(
|
this.readTaskProgressProof(input.teamName, input.activeMemberKeys, input.recordsByMember),
|
||||||
input.teamName,
|
|
||||||
input.activeMemberKeys,
|
|
||||||
input.recordsByMember
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return new MaterializedOpenCodeRuntimeDeliveryProofIndex(
|
return new MaterializedOpenCodeRuntimeDeliveryProofIndex(
|
||||||
this.readLatestSuccessTimesByMember(input.activeMemberKeys, input.recordsByMember),
|
this.readLatestSuccessTimesByMember(input.activeMemberKeys, input.recordsByMember),
|
||||||
await visibleRepliesByMember(configuredLeadName),
|
await visibleRepliesByMember(configuredLeadName),
|
||||||
taskProgressTimes
|
taskProgressProof.taskProgressTimes,
|
||||||
|
taskProgressProof.inferredTaskRefsByRecordId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,17 +319,24 @@ export class OpenCodeRuntimeDeliveryProofReader {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async readTaskProgressProofTimes(
|
private async readTaskProgressProof(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
activeMemberKeys: ReadonlySet<string>,
|
activeMemberKeys: ReadonlySet<string>,
|
||||||
recordsByMember: ReadonlyMap<string, readonly OpenCodePromptDeliveryLedgerRecord[]>
|
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 taskIdsByMember = new Map<string, Set<string>>();
|
||||||
|
const recordsMissingTaskRefs: OpenCodePromptDeliveryLedgerRecord[] = [];
|
||||||
for (const [memberKey, records] of recordsByMember) {
|
for (const [memberKey, records] of recordsByMember) {
|
||||||
if (!activeMemberKeys.has(memberKey)) {
|
if (!activeMemberKeys.has(memberKey)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
|
if (record.taskRefs.length === 0 && isPotentialOpenCodeRuntimeDeliveryError(record)) {
|
||||||
|
recordsMissingTaskRefs.push(record);
|
||||||
|
}
|
||||||
for (const taskRef of record.taskRefs) {
|
for (const taskRef of record.taskRefs) {
|
||||||
const taskId = taskRef.taskId?.trim();
|
const taskId = taskRef.taskId?.trim();
|
||||||
if (!taskId) {
|
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(() => []);
|
const tasks = await this.taskReader.getTasks(teamName).catch(() => []);
|
||||||
if (tasks.length === 0) {
|
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) {
|
for (const task of tasks) {
|
||||||
const taskId = task.id?.trim();
|
const taskId = task.id?.trim();
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
|
|
@ -353,9 +398,56 @@ export class OpenCodeRuntimeDeliveryProofReader {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const key = getOpenCodeTaskProgressProofKey(memberKey, taskId);
|
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;
|
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',
|
'Bridge server capability snapshot mismatch',
|
||||||
'OpenCode bridge capability snapshot precondition 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(
|
function resolveOpenCodeRuntimeSettlementMode(
|
||||||
input: Pick<OpenCodeTeamRuntimeMessageInput, 'messageKind'>
|
input: Pick<OpenCodeTeamRuntimeMessageInput, 'messageKind'>
|
||||||
|
|
@ -123,7 +183,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
|
|
||||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
||||||
const runtimeOnly = input.runtimeOnly === true;
|
const runtimeOnly = input.runtimeOnly === true;
|
||||||
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
const readiness = await this.checkOpenCodeReadinessWithTransientRetry({
|
||||||
projectPath: input.cwd,
|
projectPath: input.cwd,
|
||||||
selectedModel: input.model ?? null,
|
selectedModel: input.model ?? null,
|
||||||
requireExecutionProbe: !runtimeOnly,
|
requireExecutionProbe: !runtimeOnly,
|
||||||
|
|
@ -154,6 +214,20 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
return this.lastReadinessByProjectPath.get(projectPath) ?? null;
|
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> {
|
async launch(input: TeamRuntimeLaunchInput): Promise<TeamRuntimeLaunchResult> {
|
||||||
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(
|
const memberValidationDiagnostics = validateOpenCodeRuntimeMembers(
|
||||||
input.expectedMembers,
|
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',
|
selectedModel: 'openai/gpt-5.4-mini',
|
||||||
requireExecutionProbe: true,
|
requireExecutionProbe: true,
|
||||||
});
|
});
|
||||||
|
expect(bridge.checkOpenCodeTeamLaunchReadiness).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses runtime-only readiness for model-less preflight checks', async () => {
|
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 () => {
|
it('uses concrete member diagnostics as failed OpenCode hard failure reasons', async () => {
|
||||||
const concreteReason =
|
const concreteReason =
|
||||||
'Latest assistant message msg_123 failed with APIError - Insufficient credits. Add more using https://openrouter.ai/settings/credits';
|
'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();
|
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 () => {
|
it('ignores expired retry advisories', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-advisory-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -2274,6 +2274,56 @@ Messages:
|
||||||
expect(rows[0].read).toBe(true);
|
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 () => {
|
it('keeps OpenCode member inbox rows unread while runtime response is pending', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue