From 9e1abb03326453a17e1190d87a0a1ae3aebd81ee Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 6 May 2026 20:20:26 +0300 Subject: [PATCH] chore(runtime): pin orchestrator 0.0.20 --- runtime.lock.json | 12 +- .../services/team/TeamProvisioningService.ts | 21 +- .../main/createMemberWorkSyncFeature.test.ts | 591 ++++++++++++++++++ .../team/TeamProvisioningServiceRelay.test.ts | 174 ++++++ 4 files changed, 785 insertions(+), 13 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index 1495a1da..c6b60f81 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.19", - "sourceRef": "v0.0.19", + "version": "0.0.20", + "sourceRef": "v0.0.20", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.19.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.20.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.19.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.20.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.19.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.20.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.19.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.20.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 5c206fd9..26e3311c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -11064,16 +11064,23 @@ export class TeamProvisioningService { return { busy: true, reason: 'opencode_no_active_lane', retryAfterIso }; } - const activeRecord = await this.createOpenCodePromptDeliveryLedger( - input.teamName, - identity.laneId - ) - .getActiveForMember({ + let activeRecord: OpenCodePromptDeliveryLedgerRecord | null; + try { + activeRecord = await this.createOpenCodePromptDeliveryLedger( + input.teamName, + identity.laneId + ).getActiveForMember({ teamName: input.teamName, memberName: identity.canonicalMemberName, laneId: identity.laneId, - }) - .catch(() => null); + }); + } catch { + return { + busy: true, + reason: 'opencode_prompt_ledger_unavailable', + retryAfterIso, + }; + } if (activeRecord) { return { busy: true, diff --git a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts index 5181d4c4..80b3813d 100644 --- a/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts +++ b/test/features/member-work-sync/main/createMemberWorkSyncFeature.test.ts @@ -74,6 +74,111 @@ async function seedShadowReadyMetrics(input: { ); } +async function seedNonBlockingShadowCollectingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + 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 seedBlockingShadowCollectingMetrics(input: { + teamsBasePath: string; + teamName: string; + memberName: string; +}): Promise { + const nowMs = Date.now(); + const firstObservedAt = new Date(nowMs - 1_000).toISOString(); + const secondObservedAt = new Date(nowMs).toISOString(); + 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: 'needs_sync', + agendaFingerprint: 'agenda:v1:seed', + actionableCount: 1, + evaluatedAt: firstObservedAt, + }, + }, + recentEvents: [ + { + id: 'seed-status-0', + teamName: input.teamName, + memberName: input.memberName, + kind: 'status_evaluated', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:seed', + recordedAt: firstObservedAt, + actionableCount: 1, + }, + { + id: 'seed-would-nudge-0', + teamName: input.teamName, + memberName: input.memberName, + kind: 'would_nudge', + state: 'needs_sync', + agendaFingerprint: 'agenda:v1:seed', + recordedAt: secondObservedAt, + actionableCount: 1, + }, + ], + }, + null, + 2 + )}\n`, + 'utf8' + ); +} + async function waitForAssertion(assertion: () => Promise | void): Promise { const deadline = Date.now() + 2_000; let lastError: unknown; @@ -443,6 +548,492 @@ describe('createMemberWorkSyncFeature composition', () => { } }); + it('delivers targeted OpenCode nudges during shadow collection and schedules a delivery wake', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-targeted'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship OpenCode targeted nudge', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledWith({ + teamName, + memberName, + messageId: nudges[0]?.messageId, + providerId: 'opencode', + reason: 'member_work_sync_nudge_inserted', + delayMs: 500, + }); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { state: 'collecting_shadow_data' }, + }); + await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ + state: 'needs_sync', + providerId: 'opencode', + shadow: { wouldNudge: true }, + }); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'delivered', + deliveredMessageId: nudges[0]?.messageId, + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_delivered"'); + expect(journal).not.toContain('"reason":"phase2_not_ready"'); + } finally { + await feature.dispose(); + } + }); + + it('does not apply the OpenCode shadow-collection exception to Codex members', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-codex-shadow-gated'; + const memberName = 'bob'; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'codex' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Keep Codex gated during shadow collection', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + queueQuietWindowMs: 1, + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { state: 'collecting_shadow_data' }, + }); + await expect(feature.getStatus({ teamName, memberName })).resolves.toMatchObject({ + state: 'needs_sync', + providerId: 'codex', + shadow: { wouldNudge: true }, + }); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_skipped"'); + expect(journal).toContain('"reason":"phase2_not_ready"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('blocks targeted OpenCode nudges when phase2 metrics are unsafe', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-blocking-metrics'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Do not nudge when metrics are unsafe', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({ + phase2Readiness: { + reasons: expect.arrayContaining(['would_nudge_rate_high']), + }, + }); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"nudge_skipped"'); + expect(journal).toContain('"reason":"phase2_not_ready"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + + it('recovers targeted OpenCode nudge delivery after unsafe metrics become ready', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-metrics-recovery'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Recover OpenCode nudge after metrics ready', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })).toEqual({}); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + }); + + await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + 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 targeted OpenCode nudges retryable when prompt delivery is busy', async () => { + const claudeRoot = makeTempRoot(); + setClaudeBasePathOverride(claudeRoot); + const teamsBasePath = getTeamsBasePath(); + const teamName = 'team-opencode-busy'; + const memberName = 'alice'; + const nudgeDeliveryWake = { + schedule: vi.fn(async () => undefined), + }; + let promptDeliveryBusy = true; + const promptDeliveryBusySignal = { + isBusy: vi.fn(async () => + promptDeliveryBusy + ? { + busy: true, + reason: 'opencode_prompt_delivery_active', + retryAfterIso: '2026-05-05T12:05:00.000Z', + } + : { busy: false } + ), + }; + const feature = createMemberWorkSyncFeature({ + teamsBasePath, + configReader: { + getConfig: vi.fn(async () => ({ + name: teamName, + members: [{ name: memberName, providerId: 'opencode' }], + })), + } as never, + taskReader: { + getTasks: vi.fn(async () => [ + { + id: 'task-1', + displayId: '11111111', + subject: 'Ship OpenCode busy nudge', + status: 'pending', + owner: memberName, + }, + ]), + } as never, + kanbanManager: { + getState: vi.fn(async () => ({ + teamName, + reviewers: [], + tasks: {}, + })), + } as never, + membersMetaStore: { + getMembers: vi.fn(async () => []), + } as never, + isTeamActive: vi.fn(async () => true), + extraBusySignals: [promptDeliveryBusySignal], + nudgeDeliveryWake, + queueQuietWindowMs: 1, + }); + + try { + await seedNonBlockingShadowCollectingMetrics({ teamsBasePath, teamName, memberName }); + feature.noteTeamChange({ type: 'task', teamName, taskId: 'task-1' } as never); + + await waitForAssertion(async () => { + expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 }); + expect(await readInboxMessages({ teamsBasePath, teamName, memberName })).toEqual([]); + expect(nudgeDeliveryWake.schedule).not.toHaveBeenCalled(); + expect( + Object.values(await readMemberOutboxItems({ teamsBasePath, teamName, memberName })) + ).toEqual([ + expect.objectContaining({ + status: 'failed_retryable', + lastError: 'member_busy:opencode_prompt_delivery_active', + nextAttemptAt: '2026-05-05T12:05:00.000Z', + }), + ]); + }); + + const journal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(journal).toContain('"event":"member_busy"'); + expect(journal).toContain('"reason":"member_busy:opencode_prompt_delivery_active"'); + expect(journal).not.toContain('"event":"nudge_delivered"'); + + promptDeliveryBusy = false; + await forceRetryableOutboxDue({ + teamsBasePath, + teamName, + memberName, + nextAttemptAt: new Date(Date.now() - 1_000).toISOString(), + }); + + await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({ + claimed: 1, + delivered: 1, + superseded: 0, + retryable: 0, + terminal: 0, + }); + await waitForAssertion(async () => { + const nudges = (await readInboxMessages({ teamsBasePath, teamName, memberName })).filter( + (message) => message.messageKind === 'member_work_sync_nudge' + ); + expect(nudges).toHaveLength(1); + expect(nudges[0]?.text).toContain('11111111'); + expect(nudgeDeliveryWake.schedule).toHaveBeenCalledTimes(1); + 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, + }), + ]); + }); + + const recoveredJournal = await fs.promises.readFile( + path.join( + teamsBasePath, + teamName, + 'members', + memberName, + '.member-work-sync', + 'journal.jsonl' + ), + 'utf8' + ); + expect(recoveredJournal).toContain('"event":"nudge_delivered"'); + } finally { + await feature.dispose(); + } + }); + it('keeps nudges gated until shadow readiness is reached, then delivers on the next reconcile', async () => { const claudeRoot = makeTempRoot(); setClaudeBasePathOverride(claudeRoot); diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts index 3919bd64..d79e30c5 100644 --- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts +++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts @@ -156,8 +156,10 @@ vi.mock('agent-teams-controller', () => ({ })); import { buildLegacyInboxMessageId } from '../../../../src/main/services/team/inboxMessageIdentity'; +import * as OpenCodeRuntimeStore from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { TeamConfigReader } from '../../../../src/main/services/team/TeamConfigReader'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; +import { getTeamsBasePath } from '../../../../src/main/utils/pathDecoder'; function seedConfig(teamName: string): void { hoisted.files.set( @@ -2738,4 +2740,176 @@ Messages: const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'); expect(rows[0].read).toBe(false); }); + + it('fails closed when OpenCode prompt ledger cannot be inspected for work-sync busy checks', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + OpenCodeRuntimeStore.getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName), + JSON.stringify({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + primary: { + laneId: 'primary', + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }) + ); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + hoisted.files.set(`${teamsBasePath}/${teamName}/inboxes/jack.json`, JSON.stringify([])); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + getActiveForMember: vi.fn(async () => { + throw new Error('ledger read failed'); + }), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:00.000Z', + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_prompt_ledger_unavailable', + }); + }); + + it('treats unread OpenCode foreground inbox messages as busy for work-sync checks', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'user', + to: 'jack', + text: 'Please check the current issue.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'foreground-message-1', + messageKind: 'direct', + }, + ]) + ); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + }); + + expect(busy).toMatchObject({ + busy: true, + reason: 'opencode_foreground_inbox_unread', + activeMessageId: 'foreground-message-1', + }); + }); + + it('does not treat unread OpenCode work-sync nudges as foreground busy blockers', async () => { + const service = new TeamProvisioningService(); + const teamName = 'my-team'; + const laneId = 'secondary:opencode:jack'; + const teamsBasePath = getTeamsBasePath(); + hoisted.files.set( + `${teamsBasePath}/${teamName}/config.json`, + JSON.stringify({ + name: teamName, + projectPath: '/tmp/my-team', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' }, + ], + }) + ); + hoisted.files.set( + `${teamsBasePath}/${teamName}/inboxes/jack.json`, + JSON.stringify([ + { + from: 'system', + to: 'jack', + text: 'Work sync check.', + timestamp: '2026-02-23T17:31:00.000Z', + read: false, + messageId: 'work-sync-nudge-1', + messageKind: 'member_work_sync_nudge', + }, + ]) + ); + (service as any).resolveOpenCodeMemberDeliveryIdentity = vi.fn(async () => ({ + ok: true, + canonicalMemberName: 'jack', + laneId, + })); + vi.spyOn(OpenCodeRuntimeStore, 'readOpenCodeRuntimeLaneIndex').mockResolvedValue({ + version: 1, + updatedAt: '2026-02-23T17:30:00.000Z', + lanes: { + [laneId]: { + laneId, + state: 'active', + updatedAt: '2026-02-23T17:30:00.000Z', + }, + }, + }); + vi.spyOn(service as any, 'createOpenCodePromptDeliveryLedger').mockReturnValue({ + getActiveForMember: vi.fn(async () => null), + }); + + const busy = await service.getOpenCodeMemberDeliveryBusyStatus({ + teamName, + memberName: 'jack', + nowIso: '2026-02-23T17:31:10.000Z', + }); + + expect(busy).toEqual({ busy: false }); + }); });