feat: add opencode task change diagnostics
This commit is contained in:
parent
fe3a1a3cb3
commit
9a3e17ce70
4 changed files with 172 additions and 2 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
|
|
|
|||
90
src/main/utils/openCodeTaskChangeDiagLog.ts
Normal file
90
src/main/utils/openCodeTaskChangeDiagLog.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}]'
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue