diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index ff724899..5c16676e 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -188,6 +188,63 @@ function appendRow(filePath, row) { return row; } +const RUNTIME_DELIVERY_DUPLICATE_NOTICE = + 'Duplicate runtime_delivery ignored. The visible reply is already recorded for this relayOfMessageId; do not call agent-teams_message_send again with the same text unless you have new information.'; + +function normalizeComparableText(value) { + return String(value || '') + .trim() + .replace(/\r\n/g, '\n') + .replace(/[ \t]+/g, ' '); +} + +function normalizeComparableParticipant(value) { + return String(value || '').trim().toLowerCase(); +} + +function getRuntimeDeliveryDuplicate(list, row) { + if ( + row.source !== 'runtime_delivery' || + typeof row.relayOfMessageId !== 'string' || + row.relayOfMessageId.trim().length === 0 + ) { + return null; + } + + const relayOfMessageId = row.relayOfMessageId.trim(); + const from = normalizeComparableParticipant(row.from); + const to = normalizeComparableParticipant(row.to); + const text = normalizeComparableText(row.text); + if (!from || !to || !text) { + return null; + } + + return ( + list.find( + (candidate) => + candidate && + candidate.source === 'runtime_delivery' && + String(candidate.relayOfMessageId || '').trim() === relayOfMessageId && + normalizeComparableParticipant(candidate.from) === from && + normalizeComparableParticipant(candidate.to) === to && + normalizeComparableText(candidate.text) === text + ) || null + ); +} + +function appendInboxRow(filePath, row) { + const current = readJson(filePath, []); + const list = Array.isArray(current) ? current : []; + const duplicate = getRuntimeDeliveryDuplicate(list, row); + if (duplicate) { + return { row: duplicate, deduplicated: true }; + } + + list.push(row); + writeJson(filePath, list); + return { row, deduplicated: false }; +} + function sendInboxMessage(paths, flags) { const memberName = typeof flags.member === 'string' && flags.member.trim() @@ -204,11 +261,18 @@ function sendInboxMessage(paths, flags) { to: memberName, read: false, }); - appendRow(getInboxPath(paths, memberName), payload); + const appended = appendInboxRow(getInboxPath(paths, memberName), payload); return { deliveredToInbox: true, - messageId: payload.messageId, - message: payload, + messageId: appended.row.messageId, + message: appended.row, + ...(appended.deduplicated + ? { + deduplicated: true, + duplicateOfMessageId: appended.row.messageId, + deduplicationNotice: RUNTIME_DELIVERY_DUPLICATE_NOTICE, + } + : {}), }; } diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index 1cfb6144..6ac878a4 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -235,6 +235,44 @@ describe('agent-teams-controller API', () => { expect(delivered.deliveredToInbox).toBe(true); }); + it('deduplicates repeated runtime_delivery replies to the same inbound message', () => { + const claudeDir = makeClaudeDir(); + const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + config.members = [ + { name: 'alice', role: 'team-lead' }, + { name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' }, + ]; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const first = controller.messages.sendMessage({ + to: 'user', + from: 'bob', + text: 'Да, я здесь!', + source: 'runtime_delivery', + relayOfMessageId: 'msg-inbound-1', + }); + const second = controller.messages.sendMessage({ + to: 'user', + from: 'bob', + text: ' Да, я здесь! ', + source: 'runtime_delivery', + relayOfMessageId: 'msg-inbound-1', + }); + + const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json'); + const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8')); + expect(rows).toHaveLength(1); + expect(second).toMatchObject({ + deliveredToInbox: true, + deduplicated: true, + messageId: first.messageId, + duplicateOfMessageId: first.messageId, + deduplicationNotice: expect.stringContaining('do not call agent-teams_message_send again'), + }); + }); + it('strips hallucinated zero task placeholder prefixes from visible messages', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/resources/pricing.json b/resources/pricing.json index c7ad688d..b8e4c05a 100644 --- a/resources/pricing.json +++ b/resources/pricing.json @@ -18,6 +18,7 @@ }, "anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "bedrock_converse", @@ -41,6 +42,7 @@ }, "anthropic.claude-haiku-4-5@20251001": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "bedrock_converse", @@ -261,6 +263,7 @@ }, "anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -288,6 +291,7 @@ }, "anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -317,6 +321,7 @@ }, "global.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -346,6 +351,7 @@ }, "us.anthropic.claude-opus-4-6-v1": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -433,6 +439,7 @@ }, "anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -477,6 +484,7 @@ }, "global.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", @@ -507,6 +515,7 @@ }, "us.anthropic.claude-opus-4-7": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -597,6 +606,7 @@ }, "anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "bedrock_converse", @@ -625,6 +635,7 @@ }, "global.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "litellm_provider": "bedrock_converse", @@ -653,6 +664,7 @@ }, "us.anthropic.claude-sonnet-4-6": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "litellm_provider": "bedrock_converse", @@ -767,11 +779,13 @@ }, "anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, "output_cost_per_token_above_200k_tokens": 0.0000225, "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost_above_200k_tokens": 6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -2678,11 +2692,13 @@ }, "global.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.00000375, + "cache_creation_input_token_cost_above_1hr": 0.000006, "cache_read_input_token_cost": 3e-7, "input_cost_per_token": 0.000003, "input_cost_per_token_above_200k_tokens": 0.000006, "output_cost_per_token_above_200k_tokens": 0.0000225, "cache_creation_input_token_cost_above_200k_tokens": 0.0000075, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012, "cache_read_input_token_cost_above_200k_tokens": 6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -2739,6 +2755,7 @@ }, "global.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.00000125, + "cache_creation_input_token_cost_above_1hr": 0.000002, "cache_read_input_token_cost": 1e-7, "input_cost_per_token": 0.000001, "litellm_provider": "bedrock_converse", @@ -3493,6 +3510,7 @@ }, "us.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 0.000001375, + "cache_creation_input_token_cost_above_1hr": 0.0000022, "cache_read_input_token_cost": 1.1e-7, "input_cost_per_token": 0.0000011, "litellm_provider": "bedrock_converse", @@ -3644,11 +3662,13 @@ }, "us.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 0.000004125, + "cache_creation_input_token_cost_above_1hr": 0.0000066, "cache_read_input_token_cost": 3.3e-7, "input_cost_per_token": 0.0000033, "input_cost_per_token_above_200k_tokens": 0.0000066, "output_cost_per_token_above_200k_tokens": 0.00002475, "cache_creation_input_token_cost_above_200k_tokens": 0.00000825, + "cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132, "cache_read_input_token_cost_above_200k_tokens": 6.6e-7, "litellm_provider": "bedrock_converse", "max_input_tokens": 200000, @@ -3749,6 +3769,7 @@ }, "us.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.000006875, + "cache_creation_input_token_cost_above_1hr": 0.000011, "cache_read_input_token_cost": 5.5e-7, "input_cost_per_token": 0.0000055, "litellm_provider": "bedrock_converse", @@ -3776,6 +3797,7 @@ }, "global.anthropic.claude-opus-4-5-20251101-v1:0": { "cache_creation_input_token_cost": 0.00000625, + "cache_creation_input_token_cost_above_1hr": 0.00001, "cache_read_input_token_cost": 5e-7, "input_cost_per_token": 0.000005, "litellm_provider": "bedrock_converse", diff --git a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts index 81902547..eb98d87f 100644 --- a/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts +++ b/src/features/member-work-sync/core/application/MemberWorkSyncNudgeDispatcher.ts @@ -1,5 +1,6 @@ -import type { MemberWorkSyncOutboxItem } from '../../contracts'; import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit'; + +import type { MemberWorkSyncOutboxItem } from '../../contracts'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; diff --git a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts index 39f66af3..a3cc0e99 100644 --- a/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts +++ b/src/features/member-work-sync/core/application/RuntimeTurnSettledIngestor.ts @@ -1,10 +1,11 @@ import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit'; + +import type { RuntimeTurnSettledEvent } from '../domain'; import type { MemberWorkSyncAuditJournalPort, MemberWorkSyncClockPort, MemberWorkSyncLoggerPort, } from './ports'; -import type { RuntimeTurnSettledEvent } from '../domain'; import type { RuntimeTurnSettledEventStorePort, RuntimeTurnSettledPayloadNormalizerPort, diff --git a/src/features/member-work-sync/core/application/index.ts b/src/features/member-work-sync/core/application/index.ts index 704debbc..94bd511d 100644 --- a/src/features/member-work-sync/core/application/index.ts +++ b/src/features/member-work-sync/core/application/index.ts @@ -1,6 +1,6 @@ +export * from './MemberWorkSyncAudit'; export * from './MemberWorkSyncDiagnosticsReader'; export * from './MemberWorkSyncMetricsReader'; -export * from './MemberWorkSyncAudit'; export * from './MemberWorkSyncNudgeDispatcher'; export * from './MemberWorkSyncNudgeOutboxPlanner'; export * from './MemberWorkSyncPendingReportIntentReplayer'; diff --git a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts index edb54e8a..110e8c88 100644 --- a/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts +++ b/src/features/member-work-sync/main/adapters/input/MemberWorkSyncTeamChangeRouter.ts @@ -142,11 +142,10 @@ export class MemberWorkSyncTeamChangeRouter { runAfterMs?: number ): Promise { const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName); - if (this.materializer) { + const materializer = this.materializer; + if (materializer) { await Promise.allSettled( - activeMembers.map((memberName) => - this.materializer?.materializeMember(teamName, memberName) - ) + activeMembers.map((memberName) => materializer.materializeMember(teamName, memberName)) ); } for (const memberName of activeMembers) { diff --git a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts index 11c4fef4..abf29074 100644 --- a/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts +++ b/src/features/member-work-sync/main/composition/createMemberWorkSyncFeature.ts @@ -20,8 +20,8 @@ import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTas import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer'; import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer'; import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer'; -import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal'; +import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore'; import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter'; import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore'; import { diff --git a/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts b/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts index 9b47e604..6dda5604 100644 --- a/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts +++ b/src/features/member-work-sync/main/infrastructure/FileMemberWorkSyncAuditJournal.ts @@ -1,8 +1,7 @@ +import { withFileLock } from '@main/services/team/fileLock'; import { appendFile, mkdir, rename, rm, stat } from 'fs/promises'; import { dirname } from 'path'; -import { withFileLock } from '@main/services/team/fileLock'; - import type { MemberWorkSyncAuditEvent, MemberWorkSyncAuditJournalPort, diff --git a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts index fdc57e9e..b445645b 100644 --- a/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts +++ b/src/features/member-work-sync/main/infrastructure/FileRuntimeTurnSettledEventStore.ts @@ -2,13 +2,14 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis import path from 'path'; import { isRuntimeTurnSettledProvider } from '../../core/domain'; -import type { RuntimeTurnSettledProvider } from '../../core/domain'; + import type { RuntimeTurnSettledClaimedPayload, RuntimeTurnSettledEventStorePort, RuntimeTurnSettledInvalidResult, RuntimeTurnSettledProcessedResult, } from '../../core/application'; +import type { RuntimeTurnSettledProvider } from '../../core/domain'; import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024; diff --git a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts index 27635ace..224a0cde 100644 --- a/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts +++ b/src/features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths.ts @@ -1,6 +1,5 @@ -import { join } from 'path'; - import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths'; +import { join } from 'path'; export class MemberWorkSyncStorePaths { private readonly memberStorage: TeamMemberStoragePaths; diff --git a/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts b/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts index eecd8375..38934909 100644 --- a/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts +++ b/src/features/member-work-sync/main/infrastructure/OpenCodeTurnSettledPayloadNormalizer.ts @@ -2,6 +2,7 @@ import { buildRuntimeTurnSettledSourceId, type RuntimeTurnSettledProvider, } from '../../core/domain'; + import type { MemberWorkSyncHashPort, RuntimeTurnSettledPayloadNormalization, diff --git a/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts index 13c5c4e2..7a4ff376 100644 --- a/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts +++ b/src/features/member-work-sync/main/infrastructure/RuntimeTurnSettledSpoolInitializer.ts @@ -1,7 +1,7 @@ -import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller'; -import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment'; import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings'; +import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths'; +import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller'; import type { RuntimeTurnSettledProvider } from '../../core/domain'; diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 41ee27ad..366ef653 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -47,11 +47,19 @@ export class TeamInboxWriter { ...(request.slashCommand && { slashCommand: request.slashCommand }), ...(request.commandOutput && { commandOutput: request.commandOutput }), }; + let resultMessageId = messageId; + let resultDeduplicated = false; await withFileLock(inboxPath, async () => { await withInboxLock(inboxPath, async () => { for (let attempt = 0; attempt < 3; attempt++) { const list = await this.readInbox(inboxPath); + const duplicate = this.findRuntimeDeliveryDuplicate(list, payload); + if (duplicate) { + resultMessageId = duplicate.messageId ?? messageId; + resultDeduplicated = true; + return; + } list.push(payload); await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2)); const written = await this.readInbox(inboxPath); @@ -66,10 +74,56 @@ export class TeamInboxWriter { return { deliveredToInbox: true, - messageId, + messageId: resultMessageId, + ...(resultDeduplicated ? { deduplicated: true } : {}), }; } + private findRuntimeDeliveryDuplicate( + messages: readonly InboxMessage[], + payload: InboxMessage + ): InboxMessage | null { + if ( + payload.source !== 'runtime_delivery' || + typeof payload.relayOfMessageId !== 'string' || + payload.relayOfMessageId.trim().length === 0 + ) { + return null; + } + + const relayOfMessageId = payload.relayOfMessageId.trim(); + const from = this.normalizeComparableParticipant(payload.from); + const to = this.normalizeComparableParticipant(payload.to); + const text = this.normalizeComparableText(payload.text); + if (!from || !to || !text) { + return null; + } + + return ( + messages.find( + (candidate) => + candidate.source === 'runtime_delivery' && + (candidate.relayOfMessageId ?? '').trim() === relayOfMessageId && + this.normalizeComparableParticipant(candidate.from) === from && + this.normalizeComparableParticipant(candidate.to) === to && + this.normalizeComparableText(candidate.text) === text + ) ?? null + ); + } + + private normalizeComparableParticipant(value: unknown): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; + } + + private normalizeComparableText(value: unknown): string { + return typeof value === 'string' + ? value + .trim() + .replace(/\r\n/g, '\n') + .replace(/[ \t]+/g, ' ') + : ''; + } + private async readInbox(inboxPath: string): Promise { let raw: string; try { diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index 3c18ea0f..1dfc33ed 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -122,6 +122,67 @@ function normalizeOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } +function decodeJsonStringLiteral(value: string): string { + try { + return JSON.parse(`"${value}"`) as string; + } catch { + return value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\'); + } +} + +function extractLooseJsonStringField(text: string, fieldName: string): string | undefined { + const strictMatch = new RegExp(`"${fieldName}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(text); + if (strictMatch?.[1]) { + return decodeJsonStringLiteral(strictMatch[1]).trim() || undefined; + } + + const looseMatch = new RegExp(`"${fieldName}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)$`).exec(text); + return looseMatch?.[1] ? decodeJsonStringLiteral(looseMatch[1]).trim() || undefined : undefined; +} + +function joinUniqueReasonParts(parts: readonly (string | undefined)[]): string | undefined { + const uniqueParts = Array.from( + new Set(parts.map((part) => part?.trim()).filter((part): part is string => !!part)) + ); + return uniqueParts.length > 0 ? uniqueParts.join(': ') : undefined; +} + +function extractMessageSendRoutingReason(text: string): string | undefined { + const trimmed = text.trim(); + if (!trimmed.includes('Message sent to') && !trimmed.includes('"routing"')) { + return undefined; + } + + try { + const parsed = JSON.parse(trimmed) as { + success?: unknown; + message?: unknown; + routing?: { summary?: unknown; content?: unknown }; + }; + if (parsed.success === true && parsed.routing && typeof parsed.routing === 'object') { + return joinUniqueReasonParts([ + typeof parsed.routing.summary === 'string' ? parsed.routing.summary : undefined, + typeof parsed.routing.content === 'string' ? parsed.routing.content : undefined, + ]); + } + } catch { + // Fall through to loose extraction for persisted reasons truncated by older builds. + } + + return joinUniqueReasonParts([ + extractLooseJsonStringField(trimmed, 'summary'), + extractLooseJsonStringField(trimmed, 'content'), + ]); +} + +export function normalizeLaunchFailureReasonText(value: unknown): string | undefined { + const text = normalizeOptionalString(value); + if (!text) { + return undefined; + } + return extractMessageSendRoutingReason(text) ?? text; +} + function normalizeMemberName(name: string): string { return name.trim(); } @@ -148,8 +209,8 @@ function buildDiagnostics( } else if (member.runtimeAlive && !member.bootstrapConfirmed) { diagnostics.push('waiting for teammate check-in'); } - if (member.hardFailureReason) - diagnostics.push(`hard failure reason: ${member.hardFailureReason}`); + const hardFailureReason = normalizeLaunchFailureReasonText(member.hardFailureReason); + if (hardFailureReason) diagnostics.push(`hard failure reason: ${hardFailureReason}`); if (member.skippedForLaunch) { diagnostics.push( member.skipReason @@ -473,9 +534,7 @@ function normalizePersistedMemberState( hardFailure, hardFailureReason: !hardFailure ? undefined - : typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 - ? parsed.hardFailureReason.trim() - : undefined, + : normalizeLaunchFailureReasonText(parsed.hardFailureReason), pendingPermissionRequestIds: normalizePendingPermissionRequestIds( parsed.pendingPermissionRequestIds ), diff --git a/src/main/services/team/TeamMemberStoragePaths.ts b/src/main/services/team/TeamMemberStoragePaths.ts index 92fd8cef..5fb4e2fc 100644 --- a/src/main/services/team/TeamMemberStoragePaths.ts +++ b/src/main/services/team/TeamMemberStoragePaths.ts @@ -1,8 +1,7 @@ +import { atomicWriteAsync } from '@main/utils/atomicWrite'; import { mkdir, readFile } from 'fs/promises'; import { join } from 'path'; -import { atomicWriteAsync } from '@main/utils/atomicWrite'; - export interface TeamMemberStorageMetaFile { schemaVersion: 1; memberName: string; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 51141d2a..d3eac7b0 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -168,8 +168,8 @@ import { } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { createRuntimeRunTombstoneStore, - RuntimeStaleEvidenceError, type RuntimeEvidenceKind, + RuntimeStaleEvidenceError, } from './opencode/store/RuntimeRunTombstoneStore'; import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore'; import { buildActionModeProtocol } from './actionModeInstructions'; @@ -210,6 +210,7 @@ import { createPersistedLaunchSnapshot, deriveTeamLaunchAggregateState, hasMixedPersistedLaunchMetadata, + normalizeLaunchFailureReasonText, snapshotFromRuntimeMemberStatuses, snapshotToMemberSpawnStatuses, } from './TeamLaunchStateEvaluator'; @@ -1626,7 +1627,7 @@ interface PromptSizeSummary { lines: number; } -const MEMBER_LAUNCH_GRACE_MS = 150_000; +const MEMBER_LAUNCH_GRACE_MS = 120_000; const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000; export function shouldWarnOnUnreadableMemberAuditConfig(params: { @@ -1745,6 +1746,67 @@ function isDefinitiveOpenCodePreLaunchFailure( ); } +const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC = + 'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'; + +function buildOpenCodeUncommittedBootstrapDiagnostic(storage: { + manifestEntryCount: number | null; + manifestUpdatedAt: string | null; + fileNames: string[]; +}): string[] { + return [ + OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC, + `OpenCode lane manifest entries: ${storage.manifestEntryCount ?? 0}`, + ...(storage.manifestUpdatedAt + ? [`OpenCode lane manifest updated at: ${storage.manifestUpdatedAt}`] + : []), + storage.fileNames.length > 0 + ? `OpenCode lane files: ${storage.fileNames.slice(0, 8).join(', ')}` + : 'OpenCode lane files: none', + ]; +} + +function downgradeUncommittedOpenCodeBootstrapEvidence( + evidence: TeamRuntimeMemberLaunchEvidence, + diagnostics: readonly string[] +): TeamRuntimeMemberLaunchEvidence { + const hasRuntimeHandle = hasOpenCodeRuntimeHandle(evidence); + return { + ...evidence, + launchState: hasRuntimeHandle ? 'runtime_pending_bootstrap' : 'starting', + agentToolAccepted: hasRuntimeHandle, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + hardFailureReason: undefined, + livenessKind: hasRuntimeHandle + ? evidence.livenessKind === 'confirmed_bootstrap' + ? 'runtime_process_candidate' + : (evidence.livenessKind ?? 'runtime_process_candidate') + : 'registered_only', + runtimeDiagnostic: hasRuntimeHandle + ? 'OpenCode runtime handle is present, but bootstrap evidence was not committed.' + : 'OpenCode bootstrap confirmation was not committed to lane runtime evidence.', + runtimeDiagnosticSeverity: 'warning', + diagnostics: Array.from(new Set([...evidence.diagnostics, ...diagnostics])), + }; +} + +function summarizeRuntimeLaunchResultMembers( + members: Record +): TeamLaunchAggregateState { + const values = Object.values(members); + if ( + values.some((member) => member.launchState === 'failed_to_start' || member.hardFailure === true) + ) { + return 'partial_failure'; + } + if (values.length > 0 && values.every((member) => member.launchState === 'confirmed_alive')) { + return 'clean_success'; + } + return 'partial_pending'; +} + function hasOpenCodeRuntimeHandle( value: | Pick @@ -2518,7 +2580,7 @@ function extractHeartbeatTimestamp(text: string, fallback?: string): string | un } function extractBootstrapFailureReason(text: string): string | null { - const trimmed = text.trim(); + const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim(); if (!trimmed) return null; if (isBootstrapInstructionPrompt(trimmed)) return null; const lower = trimmed.toLowerCase(); @@ -6054,9 +6116,14 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_runtime_not_active' }; } } + const inMemorySecondaryLaneRunId = + laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' + ? this.getCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId) + : null; let runtimeRunId = laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' ? (liveSecondaryLaneRunId ?? + inMemorySecondaryLaneRunId ?? (await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId))) : (trackedRunId ?? (await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId))); @@ -6102,8 +6169,11 @@ export class TeamProvisioningService { } if ( runtimeActive && + runtimeRunId && laneIdentity.laneKind === 'secondary' && - laneIdentity.laneOwnerProviderId === 'opencode' + laneIdentity.laneOwnerProviderId === 'opencode' && + !liveSecondaryLaneRunId && + !inMemorySecondaryLaneRunId ) { const laneStorage = await inspectOpenCodeRuntimeLaneStorage({ teamsBasePath: getTeamsBasePath(), @@ -17556,6 +17626,64 @@ export class TeamProvisioningService { this.emitMemberSpawnChange(run, lane.member.name); } + private async guardCommittedOpenCodeSecondaryLaneEvidence(params: { + teamName: string; + laneId: string; + result: TeamRuntimeLaunchResult; + memberName: string; + }): Promise { + const memberEvidence = params.result.members[params.memberName]; + if (!memberEvidence) { + return params.result; + } + + const claimsBootstrapConfirmed = + memberEvidence.launchState === 'confirmed_alive' || + memberEvidence.bootstrapConfirmed === true || + memberEvidence.livenessKind === 'confirmed_bootstrap'; + if (!claimsBootstrapConfirmed) { + return params.result; + } + + const storage = await inspectOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName: params.teamName, + laneId: params.laneId, + }); + if (storage.hasRuntimeEvidenceOnDisk) { + return params.result; + } + + const diagnostics = buildOpenCodeUncommittedBootstrapDiagnostic(storage); + const members = { + ...params.result.members, + [params.memberName]: downgradeUncommittedOpenCodeBootstrapEvidence( + memberEvidence, + diagnostics + ), + }; + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: params.teamName, + laneId: params.laneId, + state: 'active', + diagnostics, + }).catch((error: unknown) => { + logger.warn( + `[${params.teamName}] Failed to annotate OpenCode lane ${params.laneId} after uncommitted bootstrap evidence: ${getErrorMessage(error)}` + ); + }); + + const teamLaunchState = summarizeRuntimeLaunchResultMembers(members); + return { + ...params.result, + launchPhase: teamLaunchState === 'clean_success' ? params.result.launchPhase : 'active', + teamLaunchState, + members, + diagnostics: Array.from(new Set([...params.result.diagnostics, ...diagnostics])), + }; + } + private buildMixedPersistedLaunchSnapshotForRun( run: ProvisioningRun, launchPhase: PersistedTeamLaunchPhase @@ -17899,7 +18027,7 @@ export class TeamProvisioningService { laneId: lane.laneId, runId: lane.runId, }); - const result = await adapter.launch({ + const rawResult = await adapter.launch({ runId: lane.runId, laneId: lane.laneId, teamName: run.teamName, @@ -17923,6 +18051,12 @@ export class TeamProvisioningService { ], previousLaunchState, }); + const result = await this.guardCommittedOpenCodeSecondaryLaneEvidence({ + teamName: run.teamName, + laneId: lane.laneId, + memberName: lane.member.name, + result: rawResult, + }); if (run.cancelRequested || run.processKilled) { this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId); return; diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index b2cc28a3..4d26595b 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -13,6 +13,7 @@ import { buildMemberAvatarMap, buildMemberLaunchPresentation, displayMemberName, + isOpenCodeRelaunchActionable, } from '@renderer/utils/memberHelpers'; import { buildMemberLaunchDiagnosticsPayload, @@ -241,11 +242,20 @@ export const MemberCard = ({ const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch; const hasLiveLaunchControls = isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true; - const canRetryLaunch = - (showFailedLaunchBadge || showSkippedLaunchBadge) && + const hasRestartMemberControl = + !isRemoved && !isLeadMember(member) && Boolean(onRestartMember) && - hasLiveLaunchControls; + hasLiveLaunchControls && + runtimeEntry?.restartable !== false; + const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({ + member, + spawnEntry, + runtimeEntry, + }); + const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable; + const canRetryLaunch = + (showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl; const canSkipFailedLaunch = showFailedLaunchBadge && !isLeadMember(member) && @@ -258,9 +268,14 @@ export const MemberCard = ({ !isFailedLaunch && !isSkippedLaunch && (Boolean(activityTask) || !isAwaitingReply); - const handleRetryFailedLaunch = async ( - event: React.MouseEvent - ): Promise => { + const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate'; + const restartActionBusyLabel = canRelaunchOpenCode + ? 'Relaunching OpenCode teammate' + : 'Retrying teammate'; + const restartActionErrorFallback = canRelaunchOpenCode + ? 'Failed to relaunch OpenCode teammate' + : 'Failed to retry teammate'; + const handleRestartMember = async (event: React.MouseEvent): Promise => { event.preventDefault(); event.stopPropagation(); if (!onRestartMember || retryingLaunch) { @@ -271,7 +286,7 @@ export const MemberCard = ({ try { await onRestartMember(member.name); } catch (error) { - setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate'); + setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback); } finally { setRetryingLaunch(false); } @@ -448,6 +463,29 @@ export const MemberCard = ({ > {launchBadgeLabel} + {canRelaunchOpenCode ? ( + + + + + + {retryLaunchError ?? + (retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)} + + + ) : null} ) : showFailedLaunchBadge ? ( @@ -499,10 +537,10 @@ export const MemberCard = ({