agent-ecosystem/src/main/services/team/ChangeExtractorService.ts

1625 lines
56 KiB
TypeScript

import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiagLog';
import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import {
getTaskChangeStateBucket,
isTaskChangeSummaryCacheable,
type TaskChangeStateBucket,
} from '@shared/utils/taskChangeState';
import { createHash } from 'crypto';
import { existsSync } from 'fs';
import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from './opencode/bridge/OpenCodeBridgeCommandContract';
import {
getOpenCodeLaneScopedRuntimeFilePath,
getOpenCodeTeamRuntimeDirectory,
readOpenCodeRuntimeLaneIndex,
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import { TaskChangeComputer } from './TaskChangeComputer';
import { TaskChangeLedgerReader } from './TaskChangeLedgerReader';
import {
buildTaskChangePresenceDescriptor,
computeTaskChangePresenceProjectFingerprint,
normalizeTaskChangePresenceFilePath,
} from './taskChangePresenceUtils';
import { getTaskChangeWorkerClient } from './TaskChangeWorkerClient';
import {
type ResolvedTaskChangeComputeInput,
type TaskChangeEffectiveOptions,
type TaskChangeTaskMeta,
} from './taskChangeWorkerTypes';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamMetaStore } from './TeamMetaStore';
import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository';
import type { OpenCodeLedgerBackfillPort } from './opencode/bridge/OpenCodeReadinessBridge';
import type { OpenCodePromptDeliveryLedgerRecord } from './opencode/delivery/OpenCodePromptDeliveryLedger';
import type { TaskBoundaryParser } from './TaskBoundaryParser';
import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type {
AgentChangeSet,
ChangeStats,
TaskChangeSetV2,
TeamConfig,
TeamTaskChangeSummariesResponse,
TeamTaskChangeSummaryItem,
TeamTaskChangeSummaryRequest,
} from '@shared/types';
const logger = createLogger('Service:ChangeExtractorService');
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
const TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT = 1_000;
const TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT = 200;
const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3;
const TEAM_TASK_CHANGE_SUMMARY_TASK_TIMEOUT_MS = 15_000;
const TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS = 30_000;
/** Кеш-запись: данные + mtime файла + время протухания */
interface CacheEntry {
data: AgentChangeSet;
mtime: number;
expiresAt: number;
}
interface TaskChangeSummaryCacheEntry {
data: TaskChangeSetV2;
expiresAt: number;
}
interface LogFileRef {
filePath: string;
memberName: string;
}
interface OpenCodeBackfillCacheEntry {
backfilledAt: number;
expiresAt: number;
}
interface OpenCodeBackfillAttempt {
attempted: boolean;
backfilled: boolean;
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs);
(timeoutId as ReturnType<typeof setTimeout> & { unref?: () => void }).unref?.();
});
return Promise.race([promise, timeout]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});
}
interface OpenCodeDeliveryContextTempFile {
filePath: string | null;
hash: string | null;
cleanup: () => Promise<void>;
}
interface OpenCodeDeliveryContextPayload {
rawContext: string;
hash: string;
}
export class ChangeExtractorService {
private cache = new Map<string, CacheEntry>();
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
private taskChangeSummaryVersionByTask = new Map<string, number>();
private taskChangeSummaryValidationInFlight = new Set<string>();
private openCodeBackfillInFlight = new Map<string, Promise<OpenCodeBackfillAttempt>>();
private openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
private readonly taskChangeSummaryCacheTtl = 60 * 1000;
private readonly emptyTaskChangeSummaryCacheTtl = 10 * 1000;
private readonly persistedTaskChangeSummaryTtl = 24 * 60 * 60 * 1000;
private readonly openCodeBackfillCacheTtl = 60 * 1000;
private readonly openCodeTeamEligibilityCacheTtl = 30 * 1000;
private readonly maxTaskChangeSummaryCacheEntries = 200;
private readonly isPersistedTaskChangeCacheEnabled =
process.env.CLAUDE_TEAM_ENABLE_PERSISTED_TASK_CHANGE_CACHE !== '0';
private taskChangePresenceRepository: TaskChangePresenceRepository | null = null;
private teamLogSourceTracker: TeamLogSourceTracker | null = null;
private readonly taskChangeComputer: TaskChangeComputer;
private readonly taskChangeLedgerReader = new TaskChangeLedgerReader();
constructor(
private readonly logsFinder: TeamMemberLogsFinder,
boundaryParser: TaskBoundaryParser,
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
private readonly taskChangeSummaryRepository = new JsonTaskChangeSummaryCacheRepository(),
private readonly taskChangeWorkerClient: TaskChangeWorkerClient = getTaskChangeWorkerClient(),
private readonly openCodeLedgerBackfillPort: OpenCodeLedgerBackfillPort | null = null,
private readonly teamMetaStore: TeamMetaStore = new TeamMetaStore()
) {
this.taskChangeComputer = new TaskChangeComputer(logsFinder, boundaryParser);
}
private readConfigForObservation(teamName: string): Promise<TeamConfig | null> {
return typeof this.configReader.getConfigSnapshot === 'function'
? this.configReader.getConfigSnapshot(teamName)
: this.configReader.getConfig(teamName);
}
setTaskChangePresenceServices(
repository: TaskChangePresenceRepository,
tracker: TeamLogSourceTracker
): void {
this.taskChangePresenceRepository = repository;
this.teamLogSourceTracker = tracker;
}
/** Получить все изменения агента */
async getAgentChanges(teamName: string, memberName: string): Promise<AgentChangeSet> {
const cacheKey = `${teamName}:${memberName}`;
const cached = this.cache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const projectPath = await this.resolveProjectPath(teamName);
const { result, latestMtime } = await this.taskChangeComputer.computeAgentChanges(
teamName,
memberName,
projectPath
);
this.cache.set(cacheKey, {
data: result,
mtime: latestMtime,
expiresAt: Date.now() + this.cacheTtl,
});
return result;
}
/** Получить изменения для конкретной задачи (Phase 3: per-task scoping) */
async getTaskChanges(
teamName: string,
taskId: string,
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
stateBucket?: TaskChangeStateBucket;
summaryOnly?: boolean;
forceFresh?: boolean;
}
): Promise<TaskChangeSetV2> {
const initialVersion = this.getTaskChangeSummaryVersion(teamName, taskId);
const includeDetails = options?.summaryOnly !== true;
const taskMeta = await this.readTaskMeta(teamName, taskId);
const effectiveOptions: TaskChangeEffectiveOptions = {
owner: options?.owner ?? taskMeta?.owner,
status: options?.status ?? taskMeta?.status,
intervals: options?.intervals ?? taskMeta?.intervals,
since: options?.since,
};
const projectPath = await this.resolveProjectPath(teamName);
const effectiveStateBucket = taskMeta
? getTaskChangeStateBucket({
status: effectiveOptions.status,
reviewState: taskMeta.reviewState,
historyEvents: taskMeta.historyEvents,
kanbanColumn: taskMeta.kanbanColumn,
})
: (options?.stateBucket ??
getTaskChangeStateBucket({
status: effectiveOptions.status,
}));
const summaryCacheableState = isTaskChangeSummaryCacheable(effectiveStateBucket);
const shouldUseSummaryCache = !includeDetails && summaryCacheableState;
let version = initialVersion;
if (!summaryCacheableState || options?.forceFresh === true) {
await this.invalidateTaskChangeSummaries(teamName, [taskId], {
deletePersisted: true,
});
version = this.getTaskChangeSummaryVersion(teamName, taskId);
}
const resolvedInput: ResolvedTaskChangeComputeInput = {
teamName,
taskId,
taskMeta,
effectiveOptions,
projectPath,
includeDetails,
};
const ledgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (ledgerResult) {
const recoveredLedgerResult = await this.recoverWarningOnlyLedgerResult(
resolvedInput,
ledgerResult
);
const result = recoveredLedgerResult ?? ledgerResult;
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
return result;
}
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
if (openCodeBackfill.backfilled || openCodeBackfill.attempted) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
if (backfilledLedgerResult) {
await this.recordTaskChangePresence(
teamName,
taskId,
taskMeta,
effectiveOptions,
backfilledLedgerResult
);
return backfilledLedgerResult;
}
}
if (!shouldUseSummaryCache) {
const result = await this.computeTaskChangesPreferred(resolvedInput);
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
return result;
}
const cacheKey = this.buildTaskChangeSummaryCacheKey(
teamName,
taskId,
effectiveOptions,
effectiveStateBucket,
version
);
if (options?.forceFresh !== true) {
const cached = this.taskChangeSummaryCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
await this.recordTaskChangePresence(
teamName,
taskId,
taskMeta,
effectiveOptions,
cached.data
);
return cached.data;
}
this.taskChangeSummaryCache.delete(cacheKey);
const inFlight = this.taskChangeSummaryInFlight.get(cacheKey);
if (inFlight) {
return inFlight;
}
const persisted = await this.readPersistedTaskChangeSummary(
teamName,
taskId,
effectiveOptions,
effectiveStateBucket,
taskMeta
);
if (persisted) {
this.setTaskChangeSummaryCache(cacheKey, persisted);
await this.recordTaskChangePresence(
teamName,
taskId,
taskMeta,
effectiveOptions,
persisted
);
return persisted;
}
}
const promise = this.computeTaskChangesPreferred({ ...resolvedInput, includeDetails: false })
.then(async (result) => {
if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) {
return result;
}
this.setTaskChangeSummaryCache(cacheKey, result);
await this.persistTaskChangeSummary(
teamName,
taskId,
effectiveOptions,
effectiveStateBucket,
result,
version
);
await this.recordTaskChangePresence(teamName, taskId, taskMeta, effectiveOptions, result);
return result;
})
.finally(() => {
this.taskChangeSummaryInFlight.delete(cacheKey);
});
this.taskChangeSummaryInFlight.set(cacheKey, promise);
return promise;
}
async getTeamTaskChangeSummaries(
teamName: string,
requests: TeamTaskChangeSummaryRequest[]
): Promise<TeamTaskChangeSummariesResponse> {
const inputRequests = Array.isArray(requests) ? requests : [];
const seenTaskIds = new Set<string>();
const uniqueRequests: TeamTaskChangeSummaryRequest[] = [];
let inspectedRequests = 0;
for (const request of inputRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_INSPECT_LIMIT)) {
inspectedRequests += 1;
if (!request || typeof request !== 'object') {
continue;
}
const taskId = typeof request.taskId === 'string' ? request.taskId.trim() : '';
if (!taskId || seenTaskIds.has(taskId)) {
continue;
}
seenTaskIds.add(taskId);
uniqueRequests.push({ ...request, taskId });
if (uniqueRequests.length > TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT) {
break;
}
}
const cappedRequests = uniqueRequests.slice(0, TEAM_TASK_CHANGE_SUMMARY_BATCH_LIMIT);
const items: TeamTaskChangeSummaryItem[] = cappedRequests.map((request) => ({
taskId: request.taskId.trim(),
changeSet: null,
}));
let cursor = 0;
let timedOut = false;
const batchDeadline = Date.now() + TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS;
const runNext = async (): Promise<void> => {
while (cursor < cappedRequests.length) {
const index = cursor;
cursor += 1;
const request = cappedRequests[index];
const taskId = request.taskId.trim();
const remainingBatchMs = batchDeadline - Date.now();
if (remainingBatchMs <= 0) {
timedOut = true;
continue;
}
try {
const changeSet = await withTimeout(
this.getTaskChanges(teamName, taskId, {
...request.options,
summaryOnly: true,
}),
Math.max(1, Math.min(TEAM_TASK_CHANGE_SUMMARY_TASK_TIMEOUT_MS, remainingBatchMs)),
'Task change summary timed out; refresh later or open the task logs.'
);
items[index] = { taskId, changeSet };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.toLowerCase().includes('timed out')) {
timedOut = true;
}
items[index] = {
taskId,
changeSet: null,
error: message,
};
}
}
};
await Promise.all(
Array.from(
{ length: Math.min(TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY, cappedRequests.length) },
() => runNext()
)
);
const responseItems = timedOut
? items.filter((item) => item.changeSet !== null || Boolean(item.error))
: items;
return {
teamName,
items: responseItems,
computedAt: new Date().toISOString(),
truncated:
timedOut ||
uniqueRequests.length > cappedRequests.length ||
inspectedRequests < inputRequests.length
? true
: undefined,
};
}
async invalidateTaskChangeSummaries(
teamName: string,
taskIds: string[],
options?: { deletePersisted?: boolean }
): Promise<void> {
const uniqueTaskIds = [...new Set(taskIds.filter((taskId) => taskId.length > 0))];
await Promise.all(
uniqueTaskIds.map(async (taskId) => {
this.bumpTaskChangeSummaryVersion(teamName, taskId);
for (const key of [...this.taskChangeSummaryCache.keys()]) {
if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) {
this.taskChangeSummaryCache.delete(key);
}
}
for (const key of [...this.taskChangeSummaryInFlight.keys()]) {
if (this.isTaskChangeSummaryCacheKeyForTask(key, teamName, taskId)) {
this.taskChangeSummaryInFlight.delete(key);
}
}
if (options?.deletePersisted !== false && this.isPersistedTaskChangeCacheEnabled) {
await this.taskChangeSummaryRepository.delete(teamName, taskId);
}
})
);
}
private async computeTaskChangesPreferred(
input: ResolvedTaskChangeComputeInput
): Promise<TaskChangeSetV2> {
if (!this.taskChangeWorkerClient.isAvailable()) {
return this.taskChangeComputer.computeTaskChanges(input);
}
try {
const result = await this.taskChangeWorkerClient.computeTaskChanges(input);
if (this.isValidWorkerTaskChangeResult(result, input)) {
return result;
}
logger.warn(
`Task change worker returned malformed result for ${input.teamName}/${input.taskId}; falling back inline.`
);
} catch (error) {
logger.warn(
`Task change worker failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
);
}
return this.taskChangeComputer.computeTaskChanges(input);
}
private async readLedgerTaskChanges(
input: ResolvedTaskChangeComputeInput
): Promise<TaskChangeSetV2 | null> {
try {
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
return null;
}
const context = await this.logsFinder.getLogSourceWatchContext(input.teamName);
if (!context?.projectDir) {
return null;
}
return await this.taskChangeLedgerReader.readTaskChanges({
teamName: input.teamName,
taskId: input.taskId,
projectDir: context.projectDir,
projectPath: input.projectPath ?? context.projectPath,
includeDetails: input.includeDetails,
});
} catch (error) {
logger.warn(
`Task change ledger read failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
);
return null;
}
}
private async recoverWarningOnlyLedgerResult(
input: ResolvedTaskChangeComputeInput,
ledgerResult: TaskChangeSetV2
): Promise<TaskChangeSetV2 | null> {
if (!this.shouldRecoverWarningOnlyLedgerResult(input, ledgerResult)) {
return null;
}
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(input);
if (openCodeBackfill.backfilled || openCodeBackfill.attempted) {
const backfilledLedgerResult = await this.readLedgerTaskChanges(input);
if (backfilledLedgerResult && backfilledLedgerResult.files.length > 0) {
return this.mergeWarningOnlyLedgerRecovery(ledgerResult, backfilledLedgerResult);
}
}
if (!(await this.shouldUseLegacyWarningOnlyRecovery(input))) {
return null;
}
const fallbackResult = await this.computeTaskChangesPreferred(input);
if (fallbackResult.files.length === 0) {
return null;
}
return this.mergeWarningOnlyLedgerRecovery(ledgerResult, fallbackResult);
}
private shouldRecoverWarningOnlyLedgerResult(
input: ResolvedTaskChangeComputeInput,
ledgerResult: TaskChangeSetV2
): boolean {
return (
ledgerResult.provenance?.sourceKind === 'ledger' &&
ledgerResult.files.length === 0 &&
ledgerResult.warnings.some((warning) => warning.trim().length > 0) &&
this.hasCompletedTaskWorkInterval(input)
);
}
private hasCompletedTaskWorkInterval(input: ResolvedTaskChangeComputeInput): boolean {
const intervals = input.effectiveOptions.intervals ?? input.taskMeta?.intervals ?? [];
return intervals.some(
(interval) =>
typeof interval.startedAt === 'string' &&
interval.startedAt.trim().length > 0 &&
typeof interval.completedAt === 'string' &&
interval.completedAt.trim().length > 0
);
}
private async shouldUseLegacyWarningOnlyRecovery(
input: ResolvedTaskChangeComputeInput
): Promise<boolean> {
const providerId = await this.resolveTaskOwnerProviderId(input);
return providerId === 'codex';
}
private async resolveTaskOwnerProviderId(
input: ResolvedTaskChangeComputeInput
): Promise<string | null> {
const owner = (input.effectiveOptions.owner ?? input.taskMeta?.owner ?? '')
.trim()
.toLowerCase();
if (!owner) {
return null;
}
try {
const config = await this.readConfigForObservation(input.teamName);
const member = (config?.members ?? []).find(
(candidate) => candidate.name.trim().toLowerCase() === owner
);
return member?.providerId ?? null;
} catch {
return null;
}
}
private mergeWarningOnlyLedgerRecovery(
warningOnlyLedger: TaskChangeSetV2,
recovered: TaskChangeSetV2
): TaskChangeSetV2 {
return {
...recovered,
warnings: this.mergeTaskChangeWarnings(warningOnlyLedger.warnings, recovered.warnings),
};
}
private mergeTaskChangeWarnings(...groups: string[][]): string[] {
const warnings = new Set<string>();
for (const group of groups) {
for (const warning of group) {
const trimmed = warning.trim();
if (trimmed) warnings.add(trimmed);
}
}
return [...warnings];
}
private async tryBackfillOpenCodeLedger(
input: ResolvedTaskChangeComputeInput
): Promise<OpenCodeBackfillAttempt> {
if (!this.openCodeLedgerBackfillPort) {
return { attempted: false, backfilled: false };
}
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
return { attempted: false, backfilled: false };
}
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
return { attempted: false, backfilled: false };
}
const context = await this.logsFinder
.getLogSourceWatchContext(input.teamName)
.catch(() => null);
const projectDir = context?.projectDir;
const workspaceRoot = input.projectPath ?? context?.projectPath;
if (
!projectDir ||
!workspaceRoot ||
!path.isAbsolute(projectDir) ||
!path.isAbsolute(workspaceRoot)
) {
return { attempted: false, backfilled: false };
}
const sourceGeneration = this.teamLogSourceTracker
? await this.teamLogSourceTracker
.ensureTracking(input.teamName)
.then((snapshot) => snapshot.logSourceGeneration)
.catch(() => null)
: null;
const deliveryContextRecords = await this.readOpenCodeDeliveryContextRecords(
input.teamName,
input.taskId
);
const deliveryContextPayload = this.buildOpenCodeDeliveryContextPayload(
input.teamName,
input.taskId,
deliveryContextRecords
);
const backfillMemberName = this.resolveOpenCodeBackfillMemberName(
input.effectiveOptions.owner,
deliveryContextRecords
);
const deliveryContextFingerprint = deliveryContextPayload.hash;
const cacheKey = this.buildOpenCodeBackfillCacheKey({
teamName: input.teamName,
taskId: input.taskId,
projectDir,
workspaceRoot,
displayId: input.taskMeta?.displayId,
sourceGeneration,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
});
const now = Date.now();
const cached = this.openCodeBackfillCache.get(cacheKey);
if (cached && cached.expiresAt > now) {
return { attempted: false, backfilled: cached.backfilledAt > 0 };
}
this.openCodeBackfillCache.delete(cacheKey);
if (deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
});
void appendOpenCodeTaskChangeDiag({
event: 'backfill_skipped',
reason: 'no-delivery-context',
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: 0,
deliveryContextFingerprint,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
}).catch(() => undefined);
return { attempted: false, backfilled: false };
}
const existing = this.openCodeBackfillInFlight.get(cacheKey);
if (existing) {
return existing;
}
const promise = this.runOpenCodeBackfill(
input,
projectDir,
workspaceRoot,
cacheKey,
deliveryContextRecords,
deliveryContextPayload,
sourceGeneration,
backfillMemberName
).finally(() => {
this.openCodeBackfillInFlight.delete(cacheKey);
});
this.openCodeBackfillInFlight.set(cacheKey, promise);
return promise;
}
private async runOpenCodeBackfill(
input: ResolvedTaskChangeComputeInput,
projectDir: string,
workspaceRoot: string,
cacheKey: string,
deliveryContextRecords: Awaited<
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
>,
deliveryContextPayload: OpenCodeDeliveryContextPayload,
sourceGeneration: string | null,
backfillMemberName?: string
): Promise<OpenCodeBackfillAttempt> {
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
input.teamName,
input.taskId,
deliveryContextRecords,
deliveryContextPayload
);
try {
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
teamId: input.teamName,
teamName: input.teamName,
taskId: input.taskId,
taskDisplayId: input.taskMeta?.displayId,
projectDir,
workspaceRoot,
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
...(backfillMemberName ? { memberName: backfillMemberName } : {}),
...(deliveryContext.filePath
? {
deliveryContextPath: deliveryContext.filePath,
deliveryContextHash: deliveryContext.hash ?? undefined,
}
: {}),
});
const evidenceContractVersion =
typeof result.opencodeTaskLedgerEvidenceContractVersion === 'number' &&
Number.isInteger(result.opencodeTaskLedgerEvidenceContractVersion)
? result.opencodeTaskLedgerEvidenceContractVersion
: 0;
const hasExpectedEvidenceContract =
evidenceContractVersion >= OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
const diagnostics = hasExpectedEvidenceContract
? (result.diagnostics ?? [])
: [
`OpenCode task ledger evidence contract is unsupported or missing: ${evidenceContractVersion}.`,
...(result.diagnostics ?? []),
];
void appendOpenCodeTaskChangeDiag({
event: 'backfill_result',
reason:
!hasExpectedEvidenceContract && result.importedEvents <= 0
? 'unsupported-evidence-contract'
: this.classifyOpenCodeBackfillResult(result),
teamName: input.teamName,
taskId: input.taskId,
displayId: input.taskMeta?.displayId ?? null,
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
sourceGeneration,
deliveryRecordCount: deliveryContextRecords.length,
deliveryContextFingerprint: deliveryContextPayload.hash,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
result: {
opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion,
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: diagnostics.slice(0, 25),
notices: (result.notices ?? []).slice(0, 25),
}).catch(() => undefined);
const backfilled =
result.importedEvents > 0 ||
(hasExpectedEvidenceContract &&
(result.outcome === 'imported' ||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0)));
if (result.importedEvents > 0) {
await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], {
deletePersisted: true,
});
}
if ((hasExpectedEvidenceContract && backfilled) || deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: backfilled ? Date.now() : 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
});
} else {
this.openCodeBackfillCache.delete(cacheKey);
}
if (diagnostics.length > 0 && result.outcome !== 'no-history') {
logger.debug(
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${diagnostics.join('; ')}`
);
}
return { attempted: true, backfilled };
} catch (error) {
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: backfillMemberName ?? input.effectiveOptions.owner ?? null,
projectDir,
workspaceRoot,
deliveryRecordCount: deliveryContextRecords.length,
deliveryContextFingerprint: deliveryContextPayload.hash,
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
error: error instanceof Error ? error.message : String(error),
}).catch(() => undefined);
if (deliveryContextRecords.length === 0) {
this.openCodeBackfillCache.set(cacheKey, {
backfilledAt: 0,
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
});
} else {
this.openCodeBackfillCache.delete(cacheKey);
}
return { attempted: true, backfilled: false };
} finally {
await deliveryContext.cleanup();
}
}
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();
if (cached && cached.expiresAt > now) {
return cached.value;
}
let value = false;
try {
const [meta, config] = await Promise.all([
this.teamMetaStore.getMeta(teamName).catch(() => null),
this.readConfigForObservation(teamName).catch(() => null),
]);
const hasOpenCodeMember = (config?.members ?? []).some(
(member) => member.providerId === 'opencode'
);
const hasExplicitNonOpenCodeProvider =
(meta?.providerId != null && meta.providerId !== 'opencode') ||
((config?.members?.length ?? 0) > 0 &&
!hasOpenCodeMember &&
(config?.members ?? []).some((member) => typeof member.providerId === 'string'));
value =
meta?.providerId === 'opencode' ||
hasOpenCodeMember ||
(!hasExplicitNonOpenCodeProvider &&
existsSync(getOpenCodeTeamRuntimeDirectory(getTeamsBasePath(), teamName)));
} catch {
value = false;
}
this.openCodeTeamEligibilityCache.set(teamName, {
value,
expiresAt: now + this.openCodeTeamEligibilityCacheTtl,
});
return value;
}
private async createOpenCodeDeliveryContextTempFile(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>,
payload = this.buildOpenCodeDeliveryContextPayload(teamName, taskId, records)
): Promise<OpenCodeDeliveryContextTempFile> {
if (records.length === 0) {
return { filePath: null, hash: null, cleanup: async () => undefined };
}
const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-'));
await chmod(dir, 0o700).catch(() => undefined);
const filePath = path.join(dir, 'delivery-context.json');
await writeFile(filePath, payload.rawContext, { encoding: 'utf8', mode: 0o600 });
return {
filePath,
hash: payload.hash,
cleanup: async () => {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
private buildOpenCodeDeliveryContextPayload(
teamName: string,
taskId: string,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): OpenCodeDeliveryContextPayload {
const rawContext = `${JSON.stringify(
{
schemaVersion: 1,
teamName,
taskId,
records,
},
null,
2
)}\n`;
return {
hash: createHash('sha256').update(rawContext).digest('hex'),
rawContext,
};
}
private async readOpenCodeDeliveryContextRecords(
teamName: string,
taskId: string
): Promise<
{
memberName: string;
laneId?: string;
runtimeSessionId: string | null;
inboxMessageId: string | null;
deliveredUserMessageId: string | null;
observedAssistantMessageId: string | null;
prePromptCursor: string | null;
postPromptCursor: string | null;
taskRefs: { taskId: string; displayId: string; teamName: string }[];
}[]
> {
const teamsBasePath = getTeamsBasePath();
const laneIds = new Set<string>(['primary']);
try {
const index = await readOpenCodeRuntimeLaneIndex(teamsBasePath, teamName);
for (const laneId of Object.keys(index.lanes)) {
if (laneId.trim()) laneIds.add(laneId);
}
} catch {
// Old teams may not have a lane index. The primary fallback covers the initial runtime shape.
}
for (const laneId of await this.readOpenCodeRuntimeLaneIdsFromDisk(teamsBasePath, teamName)) {
laneIds.add(laneId);
}
const records: {
memberName: string;
laneId?: string;
runtimeSessionId: string | null;
inboxMessageId: string | null;
deliveredUserMessageId: string | null;
observedAssistantMessageId: string | null;
prePromptCursor: string | null;
postPromptCursor: string | null;
taskRefs: { taskId: string; displayId: string; teamName: string }[];
}[] = [];
for (const laneId of laneIds) {
const filePath = getOpenCodeLaneScopedRuntimeFilePath({
teamsBasePath,
teamName,
laneId,
fileName: 'opencode-prompt-delivery-ledger.json',
});
const laneRecords = await this.readOpenCodePromptDeliveryLedgerRecords(filePath);
for (const record of laneRecords) {
if (record.teamName !== teamName) continue;
const taskRefs = record.taskRefs.filter((taskRef) => taskRef.teamName === teamName);
if (!taskRefs.some((taskRef) => taskRef.taskId === taskId)) continue;
records.push({
memberName: record.memberName,
laneId: record.laneId || laneId,
runtimeSessionId: record.runtimeSessionId,
inboxMessageId: record.inboxMessageId,
deliveredUserMessageId: record.deliveredUserMessageId,
observedAssistantMessageId: record.observedAssistantMessageId,
prePromptCursor: record.prePromptCursor,
postPromptCursor: record.postPromptCursor,
taskRefs,
});
}
}
return records.slice(-200);
}
private resolveOpenCodeBackfillMemberName(
owner: string | undefined,
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
): string | undefined {
const members = [...new Set(records.map((record) => record.memberName.trim()).filter(Boolean))];
const normalizedOwner = owner?.trim();
if (normalizedOwner && members.includes(normalizedOwner)) {
return normalizedOwner;
}
return members.length === 1 ? members[0] : undefined;
}
private async readOpenCodeRuntimeLaneIdsFromDisk(
teamsBasePath: string,
teamName: string
): Promise<string[]> {
const lanesDir = path.join(getOpenCodeTeamRuntimeDirectory(teamsBasePath, teamName), 'lanes');
const entries = await readdir(lanesDir, { withFileTypes: true }).catch(() => []);
const laneIds: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
let laneId: string;
try {
laneId = decodeURIComponent(entry.name);
} catch {
continue;
}
if (!laneId.trim() || laneId.includes('\0')) continue;
laneIds.push(laneId);
if (laneIds.length >= OPEN_CODE_MAX_DISCOVERED_LANES) break;
}
return laneIds.sort((left, right) => left.localeCompare(right));
}
private async readOpenCodePromptDeliveryLedgerRecords(
filePath: string
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
try {
const raw = await readFile(filePath, 'utf8');
if (Buffer.byteLength(raw, 'utf8') > 1024 * 1024) {
return [];
}
const parsed = JSON.parse(raw) as { data?: unknown };
if (!Array.isArray(parsed.data)) {
return [];
}
return parsed.data.filter(isOpenCodePromptDeliveryLedgerRecord);
} catch {
return [];
}
}
private buildOpenCodeBackfillCacheKey(input: {
teamName: string;
taskId: string;
displayId?: string;
projectDir: string;
workspaceRoot: string;
sourceGeneration?: string | null;
deliveryContextFingerprint: string;
attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE;
evidencePipeline: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE;
}): string {
return JSON.stringify({
teamName: input.teamName,
taskId: input.taskId,
displayId: input.displayId ?? '',
projectDir: normalizeTaskChangePresenceFilePath(input.projectDir),
workspaceRoot: normalizeTaskChangePresenceFilePath(input.workspaceRoot),
sourceGeneration: input.sourceGeneration ?? '',
deliveryContextFingerprint: input.deliveryContextFingerprint,
attributionMode: input.attributionMode,
evidencePipeline: input.evidencePipeline,
});
}
private isValidWorkerTaskChangeResult(
result: TaskChangeSetV2,
input: ResolvedTaskChangeComputeInput
): boolean {
return (
!!result &&
typeof result === 'object' &&
result.teamName === input.teamName &&
result.taskId === input.taskId &&
Array.isArray(result.files)
);
}
/** Получить краткую статистику */
async getChangeStats(teamName: string, memberName: string): Promise<ChangeStats> {
const changes = await this.getAgentChanges(teamName, memberName);
return {
linesAdded: changes.totalLinesAdded,
linesRemoved: changes.totalLinesRemoved,
filesChanged: changes.totalFiles,
};
}
// ---- Private methods ----
/** Read task metadata (owner, status) from the task JSON file */
private async readTaskMeta(teamName: string, taskId: string): Promise<TaskChangeTaskMeta | null> {
try {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const raw = await readFile(taskPath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const intervals = Array.isArray(parsed.workIntervals)
? (parsed.workIntervals as unknown[]).filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
: undefined;
const derivedIntervals = (() => {
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
const rawHistory = parsed.historyEvents;
if (!Array.isArray(rawHistory)) return undefined;
const transitions = rawHistory
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
.filter((h): h is Record<string, unknown> => h !== null)
.filter((h) => h.type === 'status_changed')
.map((h) => ({
to: typeof h.to === 'string' ? h.to : null,
timestamp: typeof h.timestamp === 'string' ? h.timestamp : null,
}))
.filter(
(t): t is { to: string; timestamp: string } => t.to !== null && t.timestamp !== null
)
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
if (transitions.length === 0) return undefined;
const derived: { startedAt: string; completedAt?: string }[] = [];
let currentStart: string | null = null;
for (const t of transitions) {
if (t.to === 'in_progress') {
if (!currentStart) currentStart = t.timestamp;
continue;
}
if (currentStart) {
derived.push({ startedAt: currentStart, completedAt: t.timestamp });
currentStart = null;
}
}
if (currentStart) derived.push({ startedAt: currentStart });
return derived.length > 0 ? derived : undefined;
})();
return {
displayId:
typeof parsed.displayId === 'string' && parsed.displayId.trim().length > 0
? parsed.displayId.trim()
: undefined,
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
status: typeof parsed.status === 'string' ? parsed.status : undefined,
intervals: derivedIntervals,
reviewState:
parsed.reviewState === 'review' ||
parsed.reviewState === 'needsFix' ||
parsed.reviewState === 'approved'
? parsed.reviewState
: 'none',
historyEvents: Array.isArray(parsed.historyEvents) ? parsed.historyEvents : undefined,
kanbanColumn:
parsed.kanbanColumn === 'review' || parsed.kanbanColumn === 'approved'
? parsed.kanbanColumn
: undefined,
};
} catch (error) {
logger.debug(`Failed to read task meta for ${teamName}/${taskId}: ${String(error)}`);
return null;
}
}
/** Получить projectPath из конфига команды */
private async resolveProjectPath(teamName: string): Promise<string | undefined> {
try {
const config = await this.readConfigForObservation(teamName);
return config?.projectPath?.trim() || undefined;
} catch {
return undefined;
}
}
private buildTaskChangeSummaryCacheKey(
teamName: string,
taskId: string,
options: TaskChangeEffectiveOptions,
stateBucket: TaskChangeStateBucket,
version: number
): string {
return `${teamName}:${taskId}:v${version}:${this.buildTaskSignature(options, stateBucket)}`;
}
private normalizeFilePathKey(filePath: string): string {
return normalizeTaskChangePresenceFilePath(filePath);
}
private buildTaskSignature(
options: TaskChangeEffectiveOptions,
stateBucket: TaskChangeStateBucket
): string {
const owner = typeof options.owner === 'string' ? options.owner.trim() : '';
const status = typeof options.status === 'string' ? options.status.trim() : '';
const since = typeof options.since === 'string' ? options.since : '';
const intervals = Array.isArray(options.intervals)
? options.intervals.map((interval) => ({
startedAt: interval.startedAt,
completedAt: interval.completedAt ?? '',
}))
: [];
return JSON.stringify({ owner, status, since, stateBucket, intervals });
}
private setTaskChangeSummaryCache(cacheKey: string, result: TaskChangeSetV2): void {
this.pruneExpiredTaskChangeSummaryCache();
this.taskChangeSummaryCache.set(cacheKey, {
data: result,
expiresAt:
Date.now() +
(result.files.length > 0
? this.taskChangeSummaryCacheTtl
: this.emptyTaskChangeSummaryCacheTtl),
});
while (this.taskChangeSummaryCache.size > this.maxTaskChangeSummaryCacheEntries) {
const oldestKey = this.taskChangeSummaryCache.keys().next().value;
if (!oldestKey) break;
this.taskChangeSummaryCache.delete(oldestKey);
}
}
private pruneExpiredTaskChangeSummaryCache(): void {
const now = Date.now();
for (const [key, entry] of this.taskChangeSummaryCache.entries()) {
if (entry.expiresAt <= now) {
this.taskChangeSummaryCache.delete(key);
}
}
}
private getTaskChangeSummaryVersionKey(teamName: string, taskId: string): string {
return `${teamName}:${taskId}`;
}
private getTaskChangeSummaryVersion(teamName: string, taskId: string): number {
return (
this.taskChangeSummaryVersionByTask.get(
this.getTaskChangeSummaryVersionKey(teamName, taskId)
) ?? 0
);
}
private bumpTaskChangeSummaryVersion(teamName: string, taskId: string): void {
const key = this.getTaskChangeSummaryVersionKey(teamName, taskId);
this.taskChangeSummaryVersionByTask.set(
key,
this.getTaskChangeSummaryVersion(teamName, taskId) + 1
);
}
private isTaskChangeSummaryCacheKeyForTask(
cacheKey: string,
teamName: string,
taskId: string
): boolean {
return cacheKey.startsWith(`${teamName}:${taskId}:`);
}
private async readPersistedTaskChangeSummary(
teamName: string,
taskId: string,
effectiveOptions: TaskChangeEffectiveOptions,
stateBucket: TaskChangeStateBucket,
taskMeta: TaskChangeTaskMeta | null
): Promise<TaskChangeSetV2 | null> {
if (!this.isPersistedTaskChangeCacheEnabled) {
return null;
}
if (!taskMeta || !isTaskChangeSummaryCacheable(stateBucket)) {
await this.taskChangeSummaryRepository.delete(teamName, taskId);
return null;
}
const currentBucket = getTaskChangeStateBucket({
status: taskMeta.status,
reviewState: taskMeta.reviewState,
historyEvents: taskMeta.historyEvents,
kanbanColumn: taskMeta.kanbanColumn,
});
if (!isTaskChangeSummaryCacheable(currentBucket)) {
await this.taskChangeSummaryRepository.delete(teamName, taskId);
return null;
}
const entry = await this.taskChangeSummaryRepository.load(teamName, taskId);
if (!entry) {
return null;
}
const projectFingerprint = await this.computeProjectFingerprint(teamName);
const taskSignature = this.buildTaskSignature(effectiveOptions, currentBucket);
if (
!projectFingerprint ||
entry.taskSignature !== taskSignature ||
entry.projectFingerprint !== projectFingerprint ||
entry.stateBucket !== currentBucket
) {
logger.debug(`Rejecting persisted task-change summary for ${teamName}/${taskId}`);
await this.taskChangeSummaryRepository.delete(teamName, taskId);
return null;
}
this.schedulePersistedTaskChangeSummaryValidation(
teamName,
taskId,
effectiveOptions,
currentBucket,
entry.sourceFingerprint
);
return entry.summary;
}
private schedulePersistedTaskChangeSummaryValidation(
teamName: string,
taskId: string,
effectiveOptions: TaskChangeEffectiveOptions,
expectedBucket: TaskChangeStateBucket,
expectedSourceFingerprint: string
): void {
const validationKey = `${teamName}:${taskId}`;
if (this.taskChangeSummaryValidationInFlight.has(validationKey)) {
return;
}
const version = this.getTaskChangeSummaryVersion(teamName, taskId);
this.taskChangeSummaryValidationInFlight.add(validationKey);
setTimeout(() => {
void this.validatePersistedTaskChangeSummary(
teamName,
taskId,
effectiveOptions,
expectedBucket,
expectedSourceFingerprint,
version
)
.catch((error) => {
logger.debug(
`Background persisted summary validation failed for ${teamName}/${taskId}: ${String(error)}`
);
})
.finally(() => {
this.taskChangeSummaryValidationInFlight.delete(validationKey);
});
}, 0);
}
private async validatePersistedTaskChangeSummary(
teamName: string,
taskId: string,
effectiveOptions: TaskChangeEffectiveOptions,
expectedBucket: TaskChangeStateBucket,
expectedSourceFingerprint: string,
version: number
): Promise<void> {
if (this.getTaskChangeSummaryVersion(teamName, taskId) !== version) {
return;
}
const taskMeta = await this.readTaskMeta(teamName, taskId);
if (!taskMeta) {
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
return;
}
const currentBucket = getTaskChangeStateBucket({
status: taskMeta.status ?? effectiveOptions.status,
reviewState: taskMeta.reviewState,
historyEvents: taskMeta.historyEvents,
kanbanColumn: taskMeta.kanbanColumn,
});
if (!isTaskChangeSummaryCacheable(currentBucket) || currentBucket !== expectedBucket) {
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
return;
}
const logRefs = await this.logsFinder.findLogFileRefsForTask(
teamName,
taskId,
effectiveOptions
);
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
if (!sourceFingerprint || sourceFingerprint !== expectedSourceFingerprint) {
await this.invalidateTaskChangeSummaries(teamName, [taskId], { deletePersisted: true });
}
}
private async persistTaskChangeSummary(
teamName: string,
taskId: string,
effectiveOptions: TaskChangeEffectiveOptions,
stateBucket: TaskChangeStateBucket,
result: TaskChangeSetV2,
generation: number
): Promise<void> {
if (!this.isPersistedTaskChangeCacheEnabled) return;
if (!isTaskChangeSummaryCacheable(stateBucket)) return;
if (result.files.length === 0) return;
if (result.confidence !== 'high' && result.confidence !== 'medium') {
await this.taskChangeSummaryRepository.delete(teamName, taskId);
return;
}
if (this.getTaskChangeSummaryVersion(teamName, taskId) !== generation) {
return;
}
const currentTaskMeta = await this.readTaskMeta(teamName, taskId);
if (!currentTaskMeta) return;
const currentBucket = getTaskChangeStateBucket({
status: currentTaskMeta.status ?? effectiveOptions.status,
reviewState: currentTaskMeta.reviewState,
historyEvents: currentTaskMeta.historyEvents,
kanbanColumn: currentTaskMeta.kanbanColumn,
});
if (!isTaskChangeSummaryCacheable(currentBucket)) {
await this.taskChangeSummaryRepository.delete(teamName, taskId);
return;
}
const logRefs = await this.logsFinder.findLogFileRefsForTask(
teamName,
taskId,
effectiveOptions
);
const sourceFingerprint = await this.computeSourceFingerprint(logRefs);
const projectFingerprint = await this.computeProjectFingerprint(teamName);
if (!sourceFingerprint || !projectFingerprint) {
return;
}
const expiresAt = new Date(Date.now() + this.persistedTaskChangeSummaryTtl).toISOString();
await this.taskChangeSummaryRepository.save(
{
version: 1,
teamName,
taskId,
stateBucket: currentBucket === 'approved' ? 'approved' : 'completed',
taskSignature: this.buildTaskSignature(effectiveOptions, currentBucket),
sourceFingerprint,
projectFingerprint,
writtenAt: new Date().toISOString(),
expiresAt,
extractorConfidence: result.confidence,
summary: result,
debugMeta: {
sourceCount: logRefs.length,
projectPathHash: projectFingerprint,
},
},
{ generation }
);
}
private async computeSourceFingerprint(logRefs: LogFileRef[]): Promise<string | null> {
if (logRefs.length === 0) return null;
const parts: string[] = [];
for (const ref of [...logRefs].sort((a, b) => a.filePath.localeCompare(b.filePath))) {
try {
const stats = await stat(ref.filePath);
parts.push(`${this.normalizeFilePathKey(ref.filePath)}:${stats.size}:${stats.mtimeMs}`);
} catch {
return null;
}
}
return createHash('sha256').update(parts.join('|')).digest('hex');
}
private async computeProjectFingerprint(teamName: string): Promise<string | null> {
const projectPath = await this.resolveProjectPath(teamName);
return computeTaskChangePresenceProjectFingerprint(projectPath);
}
private async recordTaskChangePresence(
teamName: string,
taskId: string,
taskMeta: TaskChangeTaskMeta | null,
effectiveOptions: TaskChangeEffectiveOptions,
result: TaskChangeSetV2
): Promise<void> {
if (!this.taskChangePresenceRepository || !this.teamLogSourceTracker || !taskMeta) {
return;
}
const snapshot = await this.teamLogSourceTracker.ensureTracking(teamName);
if (!snapshot.projectFingerprint || !snapshot.logSourceGeneration) {
return;
}
const reviewability = classifyTaskChangeReviewability(result);
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
if (!resolvedPresence) {
if (reviewability.reviewability === 'diagnostic_only') {
await this.taskChangePresenceRepository.deleteEntry?.(teamName, taskId);
}
return;
}
const descriptor = buildTaskChangePresenceDescriptor({
createdAt: taskMeta.createdAt,
owner: effectiveOptions.owner ?? taskMeta.owner,
status: effectiveOptions.status ?? taskMeta.status,
intervals: effectiveOptions.intervals ?? taskMeta.intervals,
since: effectiveOptions.since,
reviewState: taskMeta.reviewState,
historyEvents: taskMeta.historyEvents,
kanbanColumn: taskMeta.kanbanColumn,
});
const now = new Date().toISOString();
await this.taskChangePresenceRepository.upsertEntry(
teamName,
{
projectFingerprint: snapshot.projectFingerprint,
logSourceGeneration: snapshot.logSourceGeneration,
writtenAt: now,
},
{
taskId,
taskSignature: descriptor.taskSignature,
presence: resolvedPresence,
writtenAt: now,
logSourceGeneration: snapshot.logSourceGeneration,
}
);
}
}
function isOpenCodePromptDeliveryLedgerRecord(
value: unknown
): value is OpenCodePromptDeliveryLedgerRecord {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.teamName === 'string' &&
typeof record.memberName === 'string' &&
typeof record.laneId === 'string' &&
typeof record.inboxMessageId === 'string' &&
Array.isArray(record.taskRefs) &&
record.taskRefs.every(isOpenCodeTaskRefLike) &&
isNullableString(record.runtimeSessionId) &&
isNullableString(record.deliveredUserMessageId) &&
isNullableString(record.observedAssistantMessageId) &&
isNullableString(record.prePromptCursor) &&
isNullableString(record.postPromptCursor)
);
}
function isOpenCodeTaskRefLike(
value: unknown
): value is { taskId: string; displayId: string; teamName: string } {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const ref = value as Record<string, unknown>;
return (
typeof ref.taskId === 'string' &&
ref.taskId.length > 0 &&
typeof ref.displayId === 'string' &&
typeof ref.teamName === 'string' &&
ref.teamName.length > 0
);
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === 'string';
}