feat: attribute checkpoint changes by exact hunks

This commit is contained in:
Brad Groux 2026-07-26 03:23:21 -05:00
parent ab635669a4
commit b0fbecfe7d
8 changed files with 328 additions and 28 deletions

View file

@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added preview-first, attributable workspace checkpoint rewind for run-owned worktrees. Immutable content-addressed checkpoints capture Git, index, files, exclusions, ownership, conversation cursors, causal hunk evidence, bounded retention, and direct comparisons; conflict-aware previews bind stable evidence to exact critical approval; recoverable storage transactions preserve descendant state on failure. The production control route now quiesces an exact active Codex app-server turn and forks an earlier approved turn into a new live provider thread, while ambiguous cursors, external edits, unsupported providers, stale runtime evidence, and unresolved conflicts fail closed (#872).
- Added preview-first, attributable workspace checkpoint rewind for run-owned worktrees. Immutable content-addressed checkpoints capture Git, index, files, exclusions, ownership, conversation cursors, exact provider hunk ranges where explicit unified diffs exist, bounded retention, and direct comparisons; conflict-aware previews bind stable evidence to exact critical approval; recoverable storage transactions preserve descendant state on failure. The production control route now quiesces an exact active Codex app-server turn and forks an earlier approved turn into a new live provider thread, while ambiguous cursors, external edits, unsupported providers, stale runtime evidence, and unresolved conflicts fail closed (#872).
- Added durable execution-tree cancellation and a provider-neutral fan-out
circuit breaker. Operators can cancel one queued launch or an entire root
objective through REST, `vk admission`, and Operations; root cancellation is

View file

@ -35,7 +35,7 @@ Captures are serialized per attempt and chained through `parentCheckpointId`. Ev
Direct parent-to-child checkpoints can be compared without touching the worktree. The bounded comparison reports affected captured files, line-numbered unified hunks, content digests, mode changes, and whether HEAD, branch, index, or Git status changed. Comparisons fail closed if either checkpoint is missing, the checkpoints are not directly chained, or their worktree ownership evidence differs.
Provider event mappers normalize bounded relative file paths and tool names into the causal journal. The attribution service considers only evidence between the two checkpoint-created events. Explicit provider file events and known path-bearing write tools are agent evidence; operator file events are operator evidence; system file events are external evidence. Every changed hunk inherits the conservative file-window attribution. Missing or mixed evidence is `unknown`, and missing checkpoint event boundaries mark the evidence window incomplete.
Provider event mappers normalize bounded relative file paths and tool names into the causal journal. The attribution service considers only evidence between the two checkpoint-created events. Explicit provider file events and known path-bearing write tools are agent evidence; operator file events are operator evidence; system file events are external evidence. When every write event for a file carries bounded unified-diff hunk ranges, each checkpoint hunk is attributed only from overlapping old and new line ranges. This can distinguish agent and operator changes in different hunks of the same file. Any unscoped write evidence falls back to conservative file-window attribution; missing, non-overlapping, or mixed exact evidence remains `unknown`. Missing checkpoint event boundaries mark the complete evidence window unavailable.
Rewind preview revalidates the durable worktree lease before and after a no-follow current-state inspection. It compares the current worktree root, HEAD, branch, index, status, affected file hashes, modes, exclusions, and attribution against the expected descendant checkpoint. The result lists reverse file actions, Git and conversation-cursor changes, estimated discarded bytes, and explicit blockers. Automatic rewind is safe only when every current-state and ownership check matches and every changed file is exclusively supported by high-confidence agent evidence.
@ -49,7 +49,7 @@ Operators call `POST /api/agents/:taskId/workspace/checkpoints/rewind` with the
Retention pruning accepts explicit checkpoint-count, logical-byte, age, and protected-checkpoint limits. An active run always preserves every discovered complete chain tip even when configured limits are zero, including conservative preservation of concurrent branches. Cleanup reports the exact metadata bytes removed and logical content bytes dereferenced. Content-addressed blob garbage collection is deliberately deferred until it can coordinate safely with concurrent captures, so retention never claims those shared blob bytes as reclaimed.
This foundation does not yet claim exact overlapping-hunk attribution, selective conflict resolution, provider-runtime rewind outside the exact Codex app-server turn-fork case, or shared blob garbage collection. Those layers must consume the immutable repository and remain preview-first.
This foundation does not yet claim selective conflict resolution, provider-runtime rewind outside the exact Codex app-server turn-fork case, or shared blob garbage collection. Those layers must consume the immutable repository and remain preview-first.
## Code

View file

@ -495,6 +495,31 @@ describe('provider run event mappers', () => {
kind: 'tool.started',
payload: { tool: 'Write', paths: ['docs/guide.md'] },
});
expect(
getProviderRunEventMapper('codex-app-server').mapEvent('item/fileChange/patchUpdated', {
changes: [
{
path: 'server/src/index.ts',
kind: 'update',
diff: '@@ -10,2 +10,3 @@\n-old\n+new\n+extra',
},
],
})
).toMatchObject({
kind: 'file.changed',
payload: {
paths: ['server/src/index.ts'],
hunkRanges: [
{
path: 'server/src/index.ts',
oldStart: 10,
oldLines: 2,
newStart: 10,
newLines: 3,
},
],
},
});
});
it('deduplicates retries without collapsing distinct phases that share one item ID', () => {

View file

@ -231,4 +231,117 @@ describe('WorkspaceCheckpointAttributionService', () => {
});
expect(result.files[0].attribution?.source).toBe('unknown');
});
it('attributes separate hunks when every write event carries exact unified-diff ranges', async () => {
const path = 'mixed-ranges.ts';
const file = changedFile(path);
file.hunks = [
{
header: '@@ -10 +10 @@',
oldStart: 10,
oldLines: 1,
newStart: 10,
newLines: 1,
lines: [],
},
{
header: '@@ -30,2 +30,2 @@',
oldStart: 30,
oldLines: 2,
newStart: 30,
newLines: 2,
lines: [],
},
{
header: '@@ -50 +50 @@',
oldStart: 50,
oldLines: 1,
newStart: 50,
newLines: 1,
lines: [],
},
{
header: '@@ -70 +70 @@',
oldStart: 70,
oldLines: 1,
newStart: 70,
newLines: 1,
lines: [],
},
];
const checkpointDiff = diff([path]);
checkpointDiff.files = [file];
const events = [
event(1, 'workspace.checkpoint.created', systemSource, {
checkpointId: fromCheckpointId,
}),
event(2, 'file.changed', codexSource, {
paths: [path],
hunkRanges: [
{ path, oldStart: 10, oldLines: 1, newStart: 10, newLines: 1 },
{ path, oldStart: 70, oldLines: 1, newStart: 70, newLines: 1 },
],
}),
event(3, 'file.changed', operatorSource, {
paths: [path],
hunkRanges: [
{ path, oldStart: 30, oldLines: 2, newStart: 30, newLines: 2 },
{ path, oldStart: 70, oldLines: 1, newStart: 70, newLines: 1 },
],
}),
event(4, 'workspace.checkpoint.created', systemSource, {
checkpointId: toCheckpointId,
}),
];
const service = new WorkspaceCheckpointAttributionService({
diffs: { compare: vi.fn(async () => checkpointDiff) },
events: {
list: vi.fn(async () => ({
schemaVersion: 'run-event/v1',
taskId: input.taskId,
attemptId: input.attemptId,
events,
nextCursor: 4,
hasMore: false,
})),
},
});
const result = await service.compare(input);
expect(result.files[0].attribution).toMatchObject({
source: 'unknown',
confidence: 'ambiguous',
basis: 'mixed-file-evidence',
scope: 'checkpoint-file-window',
});
expect(result.files[0].hunks[0].attribution).toMatchObject({
source: 'agent-tool',
confidence: 'high',
basis: 'hunk-range-event',
scope: 'checkpoint-hunk-window',
evidenceEventIds: ['event-2'],
});
expect(result.files[0].hunks[1].attribution).toMatchObject({
source: 'operator',
confidence: 'high',
basis: 'hunk-range-event',
scope: 'checkpoint-hunk-window',
evidenceEventIds: ['event-3'],
});
expect(result.files[0].hunks[2].attribution).toEqual({
source: 'unknown',
confidence: 'none',
basis: 'no-hunk-evidence',
scope: 'checkpoint-hunk-window',
evidenceEventIds: [],
});
expect(result.files[0].hunks[3].attribution).toEqual({
source: 'unknown',
confidence: 'ambiguous',
basis: 'mixed-hunk-evidence',
scope: 'checkpoint-hunk-window',
evidenceEventIds: ['event-2', 'event-3'],
});
});
});

View file

@ -10,6 +10,14 @@ const FILE_PATH_KEYS = new Set([
'relativePath',
]);
export interface ProviderEventHunkRange {
path: string;
oldStart: number;
oldLines: number;
newStart: number;
newLines: number;
}
export function extractProviderEventPaths(value: unknown): string[] {
const paths = new Set<string>();
const seen = new Set<object>();
@ -39,6 +47,53 @@ export function extractProviderEventPaths(value: unknown): string[] {
return [...paths].sort((left, right) => left.localeCompare(right));
}
export function extractProviderEventHunkRanges(value: unknown): ProviderEventHunkRange[] {
const ranges: ProviderEventHunkRange[] = [];
const seen = new Set<object>();
const visit = (candidate: unknown, depth: number): void => {
if (depth > 8 || ranges.length >= 100 || !candidate || typeof candidate !== 'object') return;
if (seen.has(candidate)) return;
seen.add(candidate);
if (Array.isArray(candidate)) {
for (const entry of candidate.slice(0, 100)) visit(entry, depth + 1);
return;
}
const record = candidate as Record<string, unknown>;
const rawPath =
typeof record.path === 'string'
? record.path
: typeof record.file_path === 'string'
? record.file_path
: typeof record.filePath === 'string'
? record.filePath
: undefined;
const path = rawPath ? normalizeWorkspaceEvidencePath(rawPath) : undefined;
if (path && typeof record.diff === 'string' && record.diff.length <= 1_048_576) {
for (const range of parseUnifiedDiffHunkRanges(record.diff)) {
ranges.push({ path, ...range });
if (ranges.length >= 100) break;
}
}
for (const child of Object.values(record).slice(0, 256)) {
visit(child, depth + 1);
if (ranges.length >= 100) break;
}
};
visit(value, 0);
const unique = new Map(
ranges.map((range) => [
`${range.path}:${range.oldStart}:${range.oldLines}:${range.newStart}:${range.newLines}`,
range,
])
);
return [...unique.values()].sort(
(left, right) =>
left.path.localeCompare(right.path) ||
left.oldStart - right.oldStart ||
left.newStart - right.newStart
);
}
export function extractProviderEventToolName(value: unknown): string | undefined {
const seen = new Set<object>();
const visit = (candidate: unknown, depth: number): string | undefined => {
@ -108,6 +163,28 @@ export function normalizeWorkspaceEvidencePath(value: string): string | undefine
return segments.join('/');
}
function parseUnifiedDiffHunkRanges(diff: string): Array<Omit<ProviderEventHunkRange, 'path'>> {
const ranges: Array<Omit<ProviderEventHunkRange, 'path'>> = [];
const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm;
for (const match of diff.matchAll(header)) {
const oldStart = Number(match[1]);
const oldLines = match[2] === undefined ? 1 : Number(match[2]);
const newStart = Number(match[3]);
const newLines = match[4] === undefined ? 1 : Number(match[4]);
if (
![oldStart, oldLines, newStart, newLines].every(Number.isSafeInteger) ||
oldStart > 10_000_000 ||
newStart > 10_000_000 ||
oldLines > 1_000_000 ||
newLines > 1_000_000
) {
continue;
}
ranges.push({ oldStart, oldLines, newStart, newLines });
}
return ranges;
}
function hasControlCharacters(value: string): boolean {
for (const character of value) {
const code = character.codePointAt(0) ?? 0;

View file

@ -1,6 +1,7 @@
import { createHash } from 'node:crypto';
import type { ExecutableAgentProvider, RunEventKind } from '@veritas-kanban/shared';
import {
extractProviderEventHunkRanges,
extractProviderEventPaths,
extractProviderEventToolName,
} from './provider-event-evidence.js';
@ -64,11 +65,13 @@ function providerPayload(
const carriesFileEvidence =
kind === 'file.changed' || kind === 'tool.started' || kind === 'tool.completed';
const paths = carriesFileEvidence ? extractProviderEventPaths(event) : [];
const hunkRanges = carriesFileEvidence ? extractProviderEventHunkRanges(event) : [];
const tool = carriesFileEvidence ? extractProviderEventToolName(event) : undefined;
return {
providerType,
summary,
...(paths.length > 0 ? { paths } : {}),
...(hunkRanges.length > 0 ? { hunkRanges } : {}),
...(tool ? { tool } : {}),
raw: event,
};
@ -150,7 +153,9 @@ function itemKind(type: string, event: Record<string, unknown>): RunEventKind {
if (
itemType.includes('file_change') ||
itemType.includes('filechange') ||
normalized.includes('file.change')
normalized.includes('file.change') ||
normalized.includes('filechange') ||
normalized.includes('file/change')
) {
return 'file.changed';
}

View file

@ -3,15 +3,18 @@ import type {
RunEventPage,
RunEventQuery,
WorkspaceCheckpointDiff,
WorkspaceCheckpointDiffHunk,
WorkspaceCheckpointFileDiff,
WorkspaceCheckpointHunkAttribution,
} from '@veritas-kanban/shared';
import { ConflictError } from '../middleware/error-handler.js';
import {
extractProviderEventHunkRanges,
extractProviderEventPaths,
extractProviderEventToolName,
isWriteCapableProviderTool,
normalizeWorkspaceEvidencePath,
type ProviderEventHunkRange,
} from './provider-event-evidence.js';
import { RunEventJournalService } from './run-event-journal-service.js';
import {
@ -39,12 +42,11 @@ interface CheckpointEventWindow {
interface AttributionEvidence {
source: Exclude<WorkspaceCheckpointHunkAttribution['source'], 'unknown'>;
basis: Exclude<
WorkspaceCheckpointHunkAttribution['basis'],
'mixed-file-evidence' | 'no-file-evidence'
>;
basis:
'provider-file-event' | 'write-tool-event' | 'operator-file-event' | 'filesystem-file-event';
event: RunEventEnvelope;
tool?: string;
hunkRanges: ProviderEventHunkRange[];
}
export class WorkspaceCheckpointAttributionService {
@ -145,10 +147,25 @@ export class WorkspaceCheckpointAttributionService {
evidence: AttributionEvidence[]
): WorkspaceCheckpointFileDiff {
const attribution = summarizeAttribution(evidence);
const hasExactHunkEvidence =
evidence.length > 0 &&
evidence.every((entry) => entry.hunkRanges.some((range) => range.path === file.path));
return {
...file,
attribution,
hunks: file.hunks.map((hunk) => ({ ...hunk, attribution })),
hunks: file.hunks.map((hunk) => ({
...hunk,
attribution: hasExactHunkEvidence
? summarizeAttribution(
evidence.filter((entry) =>
entry.hunkRanges.some(
(range) => range.path === file.path && overlapsHunk(range, hunk)
)
),
'checkpoint-hunk-window'
)
: attribution,
})),
};
}
@ -167,26 +184,27 @@ export class WorkspaceCheckpointAttributionService {
}
private evidenceForEvent(event: RunEventEnvelope): AttributionEvidence | undefined {
const hunkRanges = normalizedEventHunkRanges(event);
if (event.kind === 'file.changed') {
if (event.source.provider === 'operator') {
return { source: 'operator', basis: 'operator-file-event', event };
return { source: 'operator', basis: 'operator-file-event', event, hunkRanges };
}
if (event.source.provider === 'system') {
return { source: 'external', basis: 'filesystem-file-event', event };
return { source: 'external', basis: 'filesystem-file-event', event, hunkRanges };
}
return { source: 'agent-tool', basis: 'provider-file-event', event };
return { source: 'agent-tool', basis: 'provider-file-event', event, hunkRanges };
}
if (event.kind === 'tool.started' || event.kind === 'tool.completed') {
const tool = normalizedEventTool(event);
if (!isWriteCapableProviderTool(tool)) return undefined;
if (event.source.provider === 'operator') {
return { source: 'operator', basis: 'operator-file-event', event, tool };
return { source: 'operator', basis: 'operator-file-event', event, tool, hunkRanges };
}
if (event.source.provider === 'system') {
return { source: 'external', basis: 'filesystem-file-event', event, tool };
return { source: 'external', basis: 'filesystem-file-event', event, tool, hunkRanges };
}
return { source: 'agent-tool', basis: 'write-tool-event', event, tool };
return { source: 'agent-tool', basis: 'write-tool-event', event, tool, hunkRanges };
}
return undefined;
}
@ -210,13 +228,51 @@ function normalizedEventTool(event: RunEventEnvelope): string | undefined {
: extractProviderEventToolName(event.payload);
}
function summarizeAttribution(evidence: AttributionEvidence[]): WorkspaceCheckpointHunkAttribution {
function normalizedEventHunkRanges(event: RunEventEnvelope): ProviderEventHunkRange[] {
const normalized = event.payload.hunkRanges;
if (Array.isArray(normalized)) {
return normalized.flatMap((entry) => {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [];
const record = entry as Record<string, unknown>;
const path =
typeof record.path === 'string' ? normalizeWorkspaceEvidencePath(record.path) : undefined;
if (
!path ||
!isBoundedRangeValue(record.oldStart, 10_000_000) ||
!isBoundedRangeValue(record.oldLines, 1_000_000) ||
!isBoundedRangeValue(record.newStart, 10_000_000) ||
!isBoundedRangeValue(record.newLines, 1_000_000)
) {
return [];
}
return [
{
path,
oldStart: record.oldStart as number,
oldLines: record.oldLines as number,
newStart: record.newStart as number,
newLines: record.newLines as number,
},
];
});
}
return extractProviderEventHunkRanges(event.payload.raw ?? event.payload);
}
function isBoundedRangeValue(value: unknown, maximum: number): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= maximum;
}
function summarizeAttribution(
evidence: AttributionEvidence[],
scope: WorkspaceCheckpointHunkAttribution['scope'] = 'checkpoint-file-window'
): WorkspaceCheckpointHunkAttribution {
if (evidence.length === 0) {
return {
source: 'unknown',
confidence: 'none',
basis: 'no-file-evidence',
scope: 'checkpoint-file-window',
basis: scope === 'checkpoint-hunk-window' ? 'no-hunk-evidence' : 'no-file-evidence',
scope,
evidenceEventIds: [],
};
}
@ -226,8 +282,8 @@ function summarizeAttribution(evidence: AttributionEvidence[]): WorkspaceCheckpo
return {
source: 'unknown',
confidence: 'ambiguous',
basis: 'mixed-file-evidence',
scope: 'checkpoint-file-window',
basis: scope === 'checkpoint-hunk-window' ? 'mixed-hunk-evidence' : 'mixed-file-evidence',
scope,
evidenceEventIds: eventIds,
};
}
@ -248,8 +304,13 @@ function summarizeAttribution(evidence: AttributionEvidence[]): WorkspaceCheckpo
return {
source,
confidence: 'high',
basis: bases.size === 1 ? evidence[0].basis : sourceBasis(source),
scope: 'checkpoint-file-window',
basis:
scope === 'checkpoint-hunk-window'
? 'hunk-range-event'
: bases.size === 1
? evidence[0].basis
: sourceBasis(source),
scope,
evidenceEventIds: eventIds,
...(providers.size === 1 && provider ? { provider } : {}),
...(agents.size === 1 && agent ? { agent } : {}),
@ -257,6 +318,25 @@ function summarizeAttribution(evidence: AttributionEvidence[]): WorkspaceCheckpo
};
}
function overlapsHunk(range: ProviderEventHunkRange, hunk: WorkspaceCheckpointDiffHunk): boolean {
return (
lineRangesOverlap(range.oldStart, range.oldLines, hunk.oldStart, hunk.oldLines) &&
lineRangesOverlap(range.newStart, range.newLines, hunk.newStart, hunk.newLines)
);
}
function lineRangesOverlap(
leftStart: number,
leftLines: number,
rightStart: number,
rightLines: number
): boolean {
if (leftLines === 0 && rightLines === 0) return leftStart === rightStart;
if (leftLines === 0) return leftStart >= rightStart && leftStart <= rightStart + rightLines;
if (rightLines === 0) return rightStart >= leftStart && rightStart <= leftStart + leftLines;
return leftStart < rightStart + rightLines && rightStart < leftStart + leftLines;
}
function trustedCheckpointEventId(event: RunEventEnvelope): string | undefined {
return event.kind === 'workspace.checkpoint.created' &&
event.source.provider === 'system' &&
@ -268,10 +348,7 @@ function trustedCheckpointEventId(event: RunEventEnvelope): string | undefined {
function sourceBasis(
source: Exclude<WorkspaceCheckpointHunkAttribution['source'], 'unknown'>
): Exclude<
WorkspaceCheckpointHunkAttribution['basis'],
'mixed-file-evidence' | 'no-file-evidence'
> {
): 'provider-file-event' | 'write-tool-event' | 'operator-file-event' | 'filesystem-file-event' {
if (source === 'operator') return 'operator-file-event';
if (source === 'external') return 'filesystem-file-event';
return 'provider-file-event';

View file

@ -109,14 +109,17 @@ export type WorkspaceCheckpointAttributionBasis =
| 'write-tool-event'
| 'operator-file-event'
| 'filesystem-file-event'
| 'hunk-range-event'
| 'mixed-file-evidence'
| 'no-file-evidence';
| 'mixed-hunk-evidence'
| 'no-file-evidence'
| 'no-hunk-evidence';
export interface WorkspaceCheckpointHunkAttribution {
source: WorkspaceCheckpointAttributionSource;
confidence: WorkspaceCheckpointAttributionConfidence;
basis: WorkspaceCheckpointAttributionBasis;
scope: 'checkpoint-file-window';
scope: 'checkpoint-file-window' | 'checkpoint-hunk-window';
evidenceEventIds: string[];
provider?: string;
agent?: string;