feat: add opencode task change diagnostics

This commit is contained in:
777genius 2026-04-27 11:23:35 +03:00
parent fe3a1a3cb3
commit 9a3e17ce70
4 changed files with 172 additions and 2 deletions

View file

@ -1,4 +1,5 @@
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiagLog';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
import { import {
@ -444,7 +445,8 @@ export class ChangeExtractorService {
projectDir, projectDir,
workspaceRoot, workspaceRoot,
cacheKey, cacheKey,
deliveryContextRecords deliveryContextRecords,
sourceGeneration
).finally(() => { ).finally(() => {
this.openCodeBackfillInFlight.delete(cacheKey); this.openCodeBackfillInFlight.delete(cacheKey);
}); });
@ -467,7 +469,8 @@ export class ChangeExtractorService {
cacheKey: string, cacheKey: string,
deliveryContextRecords: Awaited< deliveryContextRecords: Awaited<
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']> ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
> >,
sourceGeneration: string | null
): Promise<boolean> { ): Promise<boolean> {
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile( const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
input.teamName, input.teamName,
@ -486,6 +489,31 @@ export class ChangeExtractorService {
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}), ...(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 = const backfilled =
result.importedEvents > 0 || result.importedEvents > 0 ||
result.outcome === 'imported' || result.outcome === 'imported' ||
@ -516,6 +544,19 @@ export class ChangeExtractorService {
logger.warn( logger.warn(
`OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}` `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) { if (deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, { this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: 0, backfilledAt: 0,
@ -530,6 +571,27 @@ export class ChangeExtractorService {
} }
} }
private classifyOpenCodeBackfillResult(
result: Awaited<ReturnType<OpenCodeLedgerBackfillPort['backfillOpenCodeTaskLedger']>>
): 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<boolean> { private async isOpenCodeTeamCandidate(teamName: string): Promise<boolean> {
const cached = this.openCodeTeamEligibilityCache.get(teamName); const cached = this.openCodeTeamEligibilityCache.get(teamName);
const now = Date.now(); const now = Date.now();

View file

@ -636,9 +636,24 @@ function buildMemberBootstrapPrompt(
function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string { function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput): string {
const replyRecipient = input.replyRecipient?.trim() || 'user'; const replyRecipient = input.replyRecipient?.trim() || 'user';
const taskRefs = input.taskRefs?.length ? JSON.stringify(input.taskRefs) : null; 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 [ return [
'<opencode_app_message_delivery>', '<opencode_app_message_delivery>',
deliveryContext
? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>`
: null,
'You are running in OpenCode, not Claude Code or Codex native.', '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).', '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.`, `Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,

View file

@ -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<string, unknown>
): Promise<string | null> {
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;
}
}

View file

@ -361,6 +361,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).toContain('Include source="runtime_delivery"'); expect(sentText).toContain('Include source="runtime_delivery"');
expect(sentText).toContain('Include relayOfMessageId="msg-1"'); expect(sentText).toContain('Include relayOfMessageId="msg-1"');
expect(sentText).toContain('Action mode for this message: delegate.'); expect(sentText).toContain('Action mode for this message: delegate.');
expect(sentText).toContain('<opencode_delivery_context>');
expect(sentText).toContain('"kind":"opencode-delivery-context"');
expect(sentText).toContain('"inboundMessageId":"msg-1"');
expect(sentText).toContain( expect(sentText).toContain(
'If your reply is about these tasks, include taskRefs exactly: [{"taskId":"task-1","displayId":"abcd1234","teamName":"team-a"}]' 'If your reply is about these tasks, include taskRefs exactly: [{"taskId":"task-1","displayId":"abcd1234","teamName":"team-a"}]'
); );