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 { 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<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
>
>,
sourceGeneration: string | null
): Promise<boolean> {
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<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> {
const cached = this.openCodeTeamEligibilityCache.get(teamName);
const now = Date.now();

View file

@ -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 [
'<opencode_app_message_delivery>',
deliveryContext
? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>`
: 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.`,

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 relayOfMessageId="msg-1"');
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(
'If your reply is about these tasks, include taskRefs exactly: [{"taskId":"task-1","displayId":"abcd1234","teamName":"team-a"}]'
);