agent-ecosystem/test/main/services/team/OpenCodeAgendaSyncRecovery.safe-e2e.test.ts
infiniti c57c513cf1
fix(opencode): recover agenda sync after missing proof
Recover OpenCode agenda sync after protocol-proof-missing delivery failures and harden Anthropic provider readiness handling.
2026-05-14 11:12:37 +03:00

486 lines
14 KiB
TypeScript

import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMemberWorkSyncFeature } from '@features/member-work-sync/main';
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
import {
OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
type OpenCodePromptDeliveryLedgerRecord,
} from '@main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger';
import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeRuntimeLaneIndexPath,
} from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import type { InboxMessage, TaskRef } from '@shared/types/team';
const tempRoots: string[] = [];
function makeTempRoot(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'opencode-agenda-sync-e2e-'));
tempRoots.push(root);
return root;
}
afterEach(() => {
setClaudeBasePathOverride(null);
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
const deadline = Date.now() + 5_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
await assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
if (lastError) {
throw lastError;
}
await assertion();
}
async function seedNonBlockingShadowCollectingMetrics(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
}): Promise<void> {
const metricsPath = path.join(
input.teamsBasePath,
input.teamName,
'.member-work-sync',
'indexes',
'metrics.json'
);
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
await fs.promises.writeFile(
metricsPath,
`${JSON.stringify(
{
schemaVersion: 2,
members: {
[input.memberName]: {
memberName: input.memberName,
state: 'caught_up',
agendaFingerprint: 'agenda:v1:seed',
actionableCount: 0,
evaluatedAt: '2026-01-01T00:00:00.000Z',
},
},
recentEvents: Array.from({ length: 18 }, (_, index) => ({
id: `seed-status-${index}`,
teamName: input.teamName,
memberName: input.memberName,
kind: 'status_evaluated',
state: 'caught_up',
agendaFingerprint: `agenda:v1:seed-${index}`,
recordedAt: new Date(Date.UTC(2026, 0, 1, index * 6)).toISOString(),
actionableCount: 0,
})),
},
null,
2
)}\n`,
'utf8'
);
}
async function seedTeamConfig(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
}): Promise<void> {
const configPath = path.join(input.teamsBasePath, input.teamName, 'config.json');
await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
await fs.promises.writeFile(
configPath,
`${JSON.stringify(
{
name: input.teamName,
projectPath: path.join(input.teamsBasePath, input.teamName, 'project'),
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{
name: input.memberName,
role: 'developer',
providerId: 'opencode',
model: 'openrouter/test',
},
],
},
null,
2
)}\n`,
'utf8'
);
}
async function seedInbox(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
messages: InboxMessage[];
}): Promise<void> {
const inboxPath = path.join(
input.teamsBasePath,
input.teamName,
'inboxes',
`${input.memberName}.json`
);
await fs.promises.mkdir(path.dirname(inboxPath), { recursive: true });
await fs.promises.writeFile(inboxPath, `${JSON.stringify(input.messages, null, 2)}\n`, 'utf8');
}
async function readInboxMessages(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
}): Promise<InboxMessage[]> {
const inboxPath = path.join(
input.teamsBasePath,
input.teamName,
'inboxes',
`${input.memberName}.json`
);
const raw = await fs.promises.readFile(inboxPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? (parsed as InboxMessage[]) : [];
}
async function readMemberOutboxItems(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
}): Promise<
Record<
string,
{ status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string }
>
> {
const outboxPath = path.join(
input.teamsBasePath,
input.teamName,
'members',
input.memberName,
'.member-work-sync',
'outbox.json'
);
const raw = await fs.promises.readFile(outboxPath, 'utf8');
const parsed = JSON.parse(raw) as {
items?: Record<
string,
{ status?: string; lastError?: string; nextAttemptAt?: string; deliveredMessageId?: string }
>;
};
return parsed.items ?? {};
}
async function seedOpenCodeRuntimeLane(input: {
teamsBasePath: string;
teamName: string;
laneId: string;
records: OpenCodePromptDeliveryLedgerRecord[];
}): Promise<void> {
const now = '2026-02-23T17:30:00.000Z';
const laneIndexPath = getOpenCodeRuntimeLaneIndexPath(input.teamsBasePath, input.teamName);
await fs.promises.mkdir(path.dirname(laneIndexPath), { recursive: true });
await fs.promises.writeFile(
laneIndexPath,
`${JSON.stringify(
{
version: 1,
updatedAt: now,
lanes: {
[input.laneId]: {
laneId: input.laneId,
state: 'active',
updatedAt: now,
},
},
},
null,
2
)}\n`,
'utf8'
);
const ledgerPath = getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath: input.teamsBasePath,
teamName: input.teamName,
laneId: input.laneId,
fileName: 'opencode-prompt-delivery-ledger.json',
});
await fs.promises.mkdir(path.dirname(ledgerPath), { recursive: true });
await fs.promises.writeFile(
ledgerPath,
`${JSON.stringify(
{
schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION,
updatedAt: now,
data: input.records,
},
null,
2
)}\n`,
'utf8'
);
}
function buildProofMissingRecord(input: {
teamName: string;
memberName: string;
laneId: string;
inboxMessageId: string;
taskRefs: TaskRef[];
}): OpenCodePromptDeliveryLedgerRecord {
return {
id: `opencode-prompt:${input.inboxMessageId}`,
teamName: input.teamName,
memberName: input.memberName,
laneId: input.laneId,
runId: 'run-1',
runtimeSessionId: 'session-1',
inboxMessageId: input.inboxMessageId,
inboxTimestamp: '2026-02-23T17:31:00.000Z',
source: 'watcher',
messageKind: 'default',
replyRecipient: 'team-lead',
actionMode: 'do',
taskRefs: input.taskRefs,
payloadHash: 'sha256:test',
status: 'failed_terminal',
responseState: 'responded_non_visible_tool',
attempts: 3,
maxAttempts: 3,
acceptanceUnknown: false,
nextAttemptAt: null,
lastAttemptAt: '2026-02-23T17:31:10.000Z',
lastObservedAt: '2026-02-23T17:31:15.000Z',
acceptedAt: '2026-02-23T17:31:05.000Z',
respondedAt: '2026-02-23T17:31:15.000Z',
failedAt: '2026-02-23T17:31:20.000Z',
inboxReadCommittedAt: null,
inboxReadCommitError: null,
prePromptCursor: null,
postPromptCursor: null,
deliveredUserMessageId: 'msg-user',
observedAssistantMessageId: 'msg-assistant',
observedAssistantPreview: null,
observedToolCallNames: ['task_get', 'glob'],
observedVisibleMessageId: null,
visibleReplyMessageId: null,
visibleReplyInbox: null,
visibleReplyCorrelation: null,
lastReason: 'non_visible_tool_without_task_progress',
diagnostics: ['non_visible_tool_without_task_progress'],
createdAt: '2026-02-23T17:31:00.000Z',
updatedAt: '2026-02-23T17:31:20.000Z',
};
}
function createFeature(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
service: TeamProvisioningService;
nudgeDeliveryWake: { schedule: ReturnType<typeof vi.fn> };
}) {
return createMemberWorkSyncFeature({
teamsBasePath: input.teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: input.teamName,
members: [{ name: input.memberName, providerId: 'opencode' }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Recover OpenCode agenda sync',
status: 'pending',
owner: input.memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName: input.teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
isTeamActive: vi.fn(async () => true),
extraBusySignals: [
{
isBusy: (busyInput) => input.service.getOpenCodeMemberDeliveryBusyStatus(busyInput),
},
],
nudgeDeliveryWake: input.nudgeDeliveryWake,
queueQuietWindowMs: 1,
});
}
describe('OpenCode agenda-sync proof-missing recovery safe e2e', () => {
it('delivers a work-sync nudge without marking the proof-missing foreground message read', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-opencode-agenda-sync-recovery';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
const taskRef: TaskRef = { teamName, taskId: 'task-1', displayId: '11111111' };
const foregroundMessageId = 'proof-missing-message-1';
const service = new TeamProvisioningService();
const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined) };
const feature = createFeature({
teamsBasePath,
teamName,
memberName,
service,
nudgeDeliveryWake,
});
try {
await seedTeamConfig({ teamsBasePath, teamName, memberName });
await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
await seedInbox({
teamsBasePath,
teamName,
memberName,
messages: [
{
from: 'team-lead',
to: memberName,
text: 'Please continue task #11111111.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: foregroundMessageId,
messageKind: 'default',
taskRefs: [taskRef],
},
],
});
await seedOpenCodeRuntimeLane({
teamsBasePath,
teamName,
laneId,
records: [
buildProofMissingRecord({
teamName,
memberName,
laneId,
inboxMessageId: foregroundMessageId,
taskRefs: [taskRef],
}),
],
});
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
await waitForAssertion(async () => {
const inbox = await readInboxMessages({ teamsBasePath, teamName, memberName });
const foreground = inbox.find((message) => message.messageId === foregroundMessageId);
const nudges = inbox.filter((message) => message.messageKind === 'member_work_sync_nudge');
expect(foreground).toMatchObject({ read: false });
expect(nudges).toHaveLength(1);
expect(nudges[0]?.text).toContain('11111111');
expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({
teamName,
memberName,
messageId: nudges[0]?.messageId,
providerId: 'opencode',
reason: 'member_work_sync_nudge_inserted',
delayMs: 500,
});
expect(
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
).toEqual([
expect.objectContaining({
status: 'delivered',
deliveredMessageId: nudges[0]?.messageId,
}),
]);
});
} finally {
await feature.dispose();
}
});
it('keeps the nudge retryable when unread foreground lacks proof-missing ledger evidence', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-opencode-agenda-sync-no-proof';
const memberName = 'jack';
const laneId = 'secondary:opencode:jack';
const taskRef: TaskRef = { teamName, taskId: 'task-1', displayId: '11111111' };
const service = new TeamProvisioningService();
const nudgeDeliveryWake = { schedule: vi.fn(async () => undefined) };
const feature = createFeature({
teamsBasePath,
teamName,
memberName,
service,
nudgeDeliveryWake,
});
try {
await seedTeamConfig({ teamsBasePath, teamName, memberName });
await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName });
await seedInbox({
teamsBasePath,
teamName,
memberName,
messages: [
{
from: 'team-lead',
to: memberName,
text: 'Please continue task #11111111.',
timestamp: '2026-02-23T17:31:00.000Z',
read: false,
messageId: 'foreground-message-1',
messageKind: 'default',
taskRefs: [taskRef],
},
],
});
await seedOpenCodeRuntimeLane({ teamsBasePath, teamName, laneId, records: [] });
feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never);
await waitForAssertion(async () => {
const inbox = await readInboxMessages({ teamsBasePath, teamName, memberName });
expect(inbox.filter((message) => message.messageKind === 'member_work_sync_nudge')).toEqual(
[]
);
expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled();
expect(
Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName }))
).toEqual([
expect.objectContaining({
status: 'failed_retryable',
lastError: 'member_busy:opencode_foreground_inbox_unread',
}),
]);
});
} finally {
await feature.dispose();
}
});
});