diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index c615fa30..3d64e58d 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -1,4 +1,5 @@ import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; +import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiagLog'; import { createLogger } from '@shared/utils/logger'; import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; import { @@ -444,7 +445,8 @@ export class ChangeExtractorService { projectDir, workspaceRoot, cacheKey, - deliveryContextRecords + deliveryContextRecords, + sourceGeneration ).finally(() => { this.openCodeBackfillInFlight.delete(cacheKey); }); @@ -467,7 +469,8 @@ export class ChangeExtractorService { cacheKey: string, deliveryContextRecords: Awaited< ReturnType - > + >, + sourceGeneration: string | null ): Promise { const deliveryContext = await this.createOpenCodeDeliveryContextTempFile( input.teamName, @@ -486,6 +489,31 @@ export class ChangeExtractorService { attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, ...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}), }); + void appendOpenCodeTaskChangeDiag({ + event: 'backfill_result', + reason: this.classifyOpenCodeBackfillResult(result), + teamName: input.teamName, + taskId: input.taskId, + displayId: input.taskMeta?.displayId ?? null, + memberName: input.effectiveOptions.owner ?? null, + projectDir, + workspaceRoot, + sourceGeneration, + deliveryRecordCount: deliveryContextRecords.length, + deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords), + result: { + attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, + outcome: result.outcome, + dryRun: result.dryRun, + scannedSessions: result.scannedSessions, + scannedToolparts: result.scannedToolparts, + candidateEvents: result.candidateEvents, + importedEvents: result.importedEvents, + skippedEvents: result.skippedEvents, + }, + diagnostics: (result.diagnostics ?? []).slice(0, 25), + notices: (result.notices ?? []).slice(0, 25), + }).catch(() => undefined); const backfilled = result.importedEvents > 0 || result.outcome === 'imported' || @@ -516,6 +544,19 @@ export class ChangeExtractorService { logger.warn( `OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` ); + void appendOpenCodeTaskChangeDiag({ + event: 'backfill_exception', + reason: 'exception', + teamName: input.teamName, + taskId: input.taskId, + displayId: input.taskMeta?.displayId ?? null, + memberName: input.effectiveOptions.owner ?? null, + projectDir, + workspaceRoot, + deliveryRecordCount: deliveryContextRecords.length, + deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords), + error: error instanceof Error ? error.message : String(error), + }).catch(() => undefined); if (deliveryContextRecords.length === 0) { this.openCodeBackfillCache.set(cacheKey, { backfilledAt: 0, @@ -530,6 +571,27 @@ export class ChangeExtractorService { } } + private classifyOpenCodeBackfillResult( + result: Awaited> + ): string { + if (result.importedEvents > 0) { + return 'imported'; + } + if (result.candidateEvents > 0 && result.outcome === 'duplicates-only') { + return 'duplicates-only'; + } + if (result.scannedSessions > 0 && result.scannedToolparts > 0 && result.candidateEvents === 0) { + return 'no-write-edit-candidates'; + } + if (result.outcome === 'no-attribution') { + return 'no-attribution'; + } + if (result.outcome === 'no-history') { + return 'no-history'; + } + return result.outcome; + } + private async isOpenCodeTeamCandidate(teamName: string): Promise { const cached = this.openCodeTeamEligibilityCache.get(teamName); const now = Date.now(); diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 1f7750ee..78b49714 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -636,9 +636,24 @@ function buildMemberBootstrapPrompt( function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string { const replyRecipient = input.replyRecipient?.trim() || 'user'; const taskRefs = input.taskRefs?.length ? JSON.stringify(input.taskRefs) : null; + const deliveryContext = + input.messageId && input.taskRefs?.length + ? JSON.stringify({ + schemaVersion: 1, + kind: 'opencode-delivery-context', + teamName: input.teamName, + laneId: input.laneId, + memberName: input.memberName, + inboundMessageId: input.messageId, + taskRefs: input.taskRefs, + }) + : null; return [ '', + deliveryContext + ? `${deliveryContext}` + : null, 'You are running in OpenCode, not Claude Code or Codex native.', 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`, diff --git a/src/main/utils/openCodeTaskChangeDiagLog.ts b/src/main/utils/openCodeTaskChangeDiagLog.ts new file mode 100644 index 00000000..063c90c9 --- /dev/null +++ b/src/main/utils/openCodeTaskChangeDiagLog.ts @@ -0,0 +1,90 @@ +/** + * Dev-only OpenCode task-change diagnostics. + * + * The normal logger is intentionally quiet in production. This file writes a + * bounded NDJSON trace under Electron's logs dir so local dev sessions can + * explain why OpenCode task changes were or were not backfilled. + */ + +import { appendFile, mkdir, stat, truncate } from 'fs/promises'; +import { join } from 'path'; + +const FILE_NAME = 'opencode-task-change-diag.ndjson'; +const MAX_DIAG_FILE_BYTES = 1024 * 1024; + +function getElectronApp(): typeof import('electron').app | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy for tests / non-Electron. + const { app } = require('electron') as typeof import('electron'); + return app ?? null; + } catch { + return null; + } +} + +function isEnabled(): boolean { + if (process.env.CLAUDE_TEAM_OPENCODE_TASK_CHANGE_DIAG === '1') { + return true; + } + if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { + return true; + } + const app = getElectronApp(); + return app !== null && typeof app.isPackaged === 'boolean' && app.isPackaged === false; +} + +function resolveLogsDirectory(): string | null { + const app = getElectronApp(); + if (!app?.getPath) { + return null; + } + try { + return app.getPath('logs'); + } catch { + try { + return join(app.getPath('userData'), 'logs'); + } catch { + return null; + } + } +} + +export async function appendOpenCodeTaskChangeDiag( + entry: Record +): Promise { + if (!isEnabled()) { + return null; + } + const dir = resolveLogsDirectory(); + if (!dir) { + return null; + } + const filePath = join(dir, FILE_NAME); + let line: string; + try { + line = + JSON.stringify({ + t: new Date().toISOString(), + diagFile: filePath, + ...entry, + }) + '\n'; + } catch { + return null; + } + + try { + await mkdir(dir, { recursive: true }); + try { + const st = await stat(filePath); + if (st.size > MAX_DIAG_FILE_BYTES) { + await truncate(filePath, 0); + } + } catch { + // Missing file is fine. + } + await appendFile(filePath, line, 'utf8'); + return filePath; + } catch { + return null; + } +} diff --git a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts index aa0eb900..e6795585 100644 --- a/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts +++ b/test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts @@ -361,6 +361,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => { expect(sentText).toContain('Include source="runtime_delivery"'); expect(sentText).toContain('Include relayOfMessageId="msg-1"'); expect(sentText).toContain('Action mode for this message: delegate.'); + expect(sentText).toContain(''); + expect(sentText).toContain('"kind":"opencode-delivery-context"'); + expect(sentText).toContain('"inboundMessageId":"msg-1"'); expect(sentText).toContain( 'If your reply is about these tasks, include taskRefs exactly: [{"taskId":"task-1","displayId":"abcd1234","teamName":"team-a"}]' );