feat: resolve workspace rewind conflicts by path

This commit is contained in:
Brad Groux 2026-07-26 03:37:25 -05:00
parent 292652b0f4
commit 2a1c987cc0
15 changed files with 415 additions and 33 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, 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 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 support digest-bound per-path `accept`, `reject`, and `leave-untouched` decisions, while recoverable storage transactions mutate only selected paths and 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 non-attribution 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

@ -538,7 +538,7 @@ close. Every accepted action has auth-derived attribution and a causal
gate; any recorded-only fallback returns `delivered: false`. Generic process
stdin is not treated as provider delivery.
Workspace checkpoint rewind is deliberately narrower than ordinary conversation fork. `POST /api/agents/:taskId/workspace/checkpoints/rewind` is preview-first, requires exact critical approval, and is available only for an active Codex app-server attempt whose target checkpoint names an earlier exact turn in the same thread. Veritas interrupts the current turn, commits the recoverable workspace transaction, forks the approved provider history, and records the new live cursor plus its checkpoint anchor. Other adapters and ambiguous or item-level cursors fail closed.
Workspace checkpoint rewind is deliberately narrower than ordinary conversation fork. `POST /api/agents/:taskId/workspace/checkpoints/rewind` is preview-first, requires exact critical approval, and is available only for an active Codex app-server attempt whose target checkpoint names an earlier exact turn in the same thread. Operators may resolve an attribution conflict per path with `accept`, `reject`, or `leave-untouched`; the selected paths and canonical decisions are digest-bound through approval, transaction, and recovery. Veritas interrupts the current turn, commits only the approved workspace paths, forks the approved provider history, and records the new live cursor plus its checkpoint anchor. Other adapters, ambiguous or item-level cursors, unresolved non-attribution conflicts, and changed current-state evidence fail closed.
Agents and supervisors can register the same validated manifest with
`POST /api/agents/register` and refresh it through the heartbeat endpoint. Host

View file

@ -2641,11 +2641,18 @@ An `agent:write` principal with local run-control authority can request a previe
"attemptId": "attempt_123",
"targetCheckpointId": "checkpoint_target",
"descendantCheckpointId": "checkpoint_descendant",
"requestId": "operator-generated-idempotency-key"
"requestId": "operator-generated-idempotency-key",
"resolutions": [
{ "path": "src/agent-change.ts", "decision": "accept" },
{ "path": "src/operator-change.ts", "decision": "reject" },
{ "path": "notes/local.md", "decision": "leave-untouched" }
]
}
```
`X-Idempotency-Key` may supply `requestId` and takes precedence over the body. A conflict-free first request returns `202` with a critical, non-mobile approval bound to the checkpoint IDs, stable preview evidence, runtime state, provider evidence revision, and estimated data loss. Repeating the same request after approval revalidates all evidence, quiesces the provider, commits the recoverable storage transaction, and then recovers provider history from the approved checkpoint cursor.
`resolutions` is optional. An unresolved attribution conflict returns `409` with its exact path. Re-submit with one decision per conflicted path: `accept` includes the path in the rewind, while `reject` and `leave-untouched` preserve its descendant content. Unknown paths, duplicate decisions, current-state divergence, Git/index conflicts, exclusions, and incomplete inventories still fail closed. The canonical decisions and selected paths are included in the preview digest, approval action, transaction digest, and recovery record.
`X-Idempotency-Key` may supply `requestId` and takes precedence over the body. A conflict-free or fully resolved first request returns `202` with a critical, non-mobile approval bound to the checkpoint IDs, stable preview evidence, selected paths, resolutions, runtime state, provider evidence revision, and estimated data loss. Repeating the same request after approval revalidates all evidence, quiesces the provider, commits the recoverable storage transaction, and then recovers provider history from the approved checkpoint cursor.
Production runtime recovery currently supports only an active Codex app-server thread with an earlier exact turn cursor in the same thread. Item-level cursors, the currently interrupted turn, other threads, stale runtime evidence, unresolved workspace conflicts, and providers without native fork return `409 Conflict`. A successful response records a new live provider thread plus the checkpoint cursor used as its rewind anchor; it does not pretend the provider reused the old thread ID.

View file

@ -39,17 +39,17 @@ Provider event mappers normalize bounded relative file paths and tool names into
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.
Every preview carries both a full observation digest and a stable evidence digest over ownership, current state, diff, conflicts, and loss estimates. The stable digest excludes only observation timestamps and their derived digests, so an unchanged preview can survive an asynchronous approval round trip while any material evidence change invalidates the approval. A conflict-free preview can drive a private `workspace-checkpoint-rewind-transaction/v1` record. The storage transaction rechecks the descendant immediately before mutation, restores only the approved affected paths through no-follow parent validation and atomic file replacement, verifies the exact target Git and file posture, and commits durable evidence. Any ordinary failure rolls the affected paths back to the descendant checkpoint. An interrupted transaction remains recoverable; recovery accepts only files that still match the recorded target or descendant states and refuses unknown external edits. Attempt-local mutation serialization prevents competing rewind operations inside the owning server process, while the higher service layer must revalidate the authoritative worktree lease before invoking storage.
Every preview carries both a full observation digest and a stable evidence digest over ownership, current state, diff, conflicts, resolutions, selected paths, and loss estimates. The stable digest excludes only observation timestamps and their derived digests, so an unchanged preview can survive an asynchronous approval round trip while any material evidence change invalidates the approval. A conflict-free preview, or one whose attribution conflicts have an explicit per-path `accept`, `reject`, or `leave-untouched` decision, can drive a private `workspace-checkpoint-rewind-transaction/v1` record. `accept` selects the path for rewind; `reject` and `leave-untouched` preserve the descendant path. Other conflict classes remain unresolved and fail closed. The storage transaction rechecks the complete descendant diff immediately before mutation, restores only the selected paths through no-follow parent validation and atomic file replacement, verifies the exact hybrid target/descendant file posture, and commits the canonical decisions as durable evidence. Any ordinary failure rolls the affected paths back to the descendant checkpoint. An interrupted transaction remains recoverable; recovery accepts only files that still match the recorded target or descendant states and refuses unknown external edits. Attempt-local mutation serialization prevents competing rewind operations inside the owning server process, while the higher service layer must revalidate the authoritative worktree lease before invoking storage.
The rewind coordinator requests a critical, non-mobile approval whose exact action binds the stable preview evidence, runtime state, provider evidence revision, checkpoints, and estimated loss. It leaves the provider running while approval is pending. Once approved, the provider runtime port quiesces the exact runtime state, after which the coordinator regenerates the preview and requires the same stable evidence before starting storage mutation. Runtime cursor recovery occurs only after the storage transaction commits. If runtime recovery fails, the coordinator rolls the committed storage transaction back before allowing runtime recovery from the descendant anchor; a failed storage rollback deliberately leaves the provider quiesced for recovery.
The production runtime port currently supports only an active Codex app-server attempt. It interrupts the exact live turn, consumes that terminal notification as quiescence instead of finalizing the attempt, and forks provider history from the approved target turn. The resulting thread receives a new provider ID, so the runtime records both the new live cursor and the checkpoint cursor used as its rewind anchor. A later operator message starts a new native turn on the recovered thread. The adapter refuses item-level cursors, a target in another thread, the currently interrupted turn, providers without native fork, and any runtime whose evidence changed during approval.
Operators call `POST /api/agents/:taskId/workspace/checkpoints/rewind` with the exact active `attemptId`, target and descendant checkpoint IDs, and an idempotent `requestId` (or `X-Idempotency-Key`). The first conflict-free request returns `202` with the critical approval. Repeating the same request after approval revalidates the evidence and returns `200` only after storage and runtime recovery commit. The route requires `agent:write` plus local run-control capability.
Operators call `POST /api/agents/:taskId/workspace/checkpoints/rewind` with the exact active `attemptId`, target and descendant checkpoint IDs, an idempotent `requestId` (or `X-Idempotency-Key`), and optional path resolutions. The first conflict-free or fully resolved request returns `202` with the critical approval. Repeating the same request after approval revalidates the evidence and returns `200` only after storage and runtime recovery commit. The route requires `agent:write` plus local run-control capability.
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 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 partial-hunk resolution inside one file, 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

@ -574,6 +574,10 @@ describe('agent local capability enforcement', () => {
targetCheckpointId: 'checkpoint_target',
descendantCheckpointId: 'checkpoint_descendant',
requestId: 'spoofed-request-id',
resolutions: [
{ path: 'src/agent-change.ts', decision: 'accept' },
{ path: 'src/operator-change.ts', decision: 'leave-untouched' },
],
});
expect(response.status).toBe(202);
@ -582,6 +586,10 @@ describe('agent local capability enforcement', () => {
targetCheckpointId: 'checkpoint_target',
descendantCheckpointId: 'checkpoint_descendant',
requestId: 'rewind-request-123',
resolutions: [
{ path: 'src/agent-change.ts', decision: 'accept' },
{ path: 'src/operator-change.ts', decision: 'leave-untouched' },
],
});
});

View file

@ -107,6 +107,7 @@ async function rewindPreview(
estimatedDiscardedBytes:
current.files.find((candidate) => candidate.path === file.path)?.size ?? 0,
attribution,
selectedForRewind: true,
conflicts: [],
}));
const payload = {
@ -165,6 +166,8 @@ async function rewindPreview(
targetCursorAvailable: Boolean(target.conversationCursor),
},
files: previewFiles,
resolutions: [],
selectedPaths: previewFiles.map((file) => file.path),
exclusions: {
targetCount: target.excludedCount,
descendantCount: descendant.excludedCount,
@ -172,11 +175,13 @@ async function rewindPreview(
inventoryIncomplete: false,
},
conflicts: [],
unresolvedConflicts: [],
estimatedDataLossBytes: previewFiles.reduce(
(total, file) => total + file.estimatedDiscardedBytes,
0
),
safeForAutomaticRewind: true,
safeForApprovedRewind: true,
};
const preview = {
...payload,
@ -602,6 +607,84 @@ describe('FileWorkspaceCheckpointRepository', () => {
);
});
it('commits only explicitly accepted paths and preserves rejected descendant files', async () => {
const { worktreePath, storePath } = await fixture();
await fs.rm(path.join(worktreePath, 'binary.bin'));
await fs.rm(path.join(worktreePath, 'linked.txt'));
const repository = new FileWorkspaceCheckpointRepository({ baseDir: storePath });
const scope = {
workspaceId: 'workspace-selective',
taskId: 'task-selective',
attemptId: 'attempt-selective',
boundary: 'manual' as const,
worktreePath,
worktreeManifestId: 'manifest-selective',
};
const target = await repository.capture({
...scope,
operationId: 'selective-target',
conversationCursor: 'cursor-target',
});
await fs.writeFile(path.join(worktreePath, 'tracked.txt'), 'descendant content\n');
await fs.writeFile(path.join(worktreePath, 'added.txt'), 'preserve this descendant file\n');
const descendant = await repository.capture({
...scope,
operationId: 'selective-descendant',
parentCheckpointId: target.id,
conversationCursor: 'cursor-descendant',
});
const original = await rewindPreview(repository, worktreePath, target, descendant, [
'added.txt',
'tracked.txt',
]);
const { digest: _digest, evidenceDigest: _evidenceDigest, ...originalPayload } = original;
const resolvedPayload = {
...originalPayload,
files: original.files.map((file) =>
file.path === 'added.txt'
? {
...file,
resolution: 'reject' as const,
selectedForRewind: false,
}
: file
),
resolutions: [{ path: 'added.txt', decision: 'reject' as const }],
selectedPaths: ['tracked.txt'],
estimatedDataLossBytes:
original.files.find((file) => file.path === 'tracked.txt')?.estimatedDiscardedBytes ?? 0,
safeForAutomaticRewind: false,
safeForApprovedRewind: true,
};
const resolvedWithEvidence = {
...resolvedPayload,
evidenceDigest: digestWorkspaceCheckpointRewindEvidence(resolvedPayload),
};
const preview = {
...resolvedWithEvidence,
digest: digestRunLaunchValue(resolvedWithEvidence),
};
const transaction = await repository.rewind({
...scope,
operationId: 'selective-operation',
preview,
});
expect(transaction).toMatchObject({
state: 'committed',
affectedPaths: ['tracked.txt'],
resolutions: [{ path: 'added.txt', decision: 'reject' }],
restoredPathCount: 1,
});
await expect(fs.readFile(path.join(worktreePath, 'tracked.txt'), 'utf8')).resolves.toBe(
'tracked worktree\n'
);
await expect(fs.readFile(path.join(worktreePath, 'added.txt'), 'utf8')).resolves.toBe(
'preserve this descendant file\n'
);
});
it('rolls a partially applied rewind back to its durable descendant checkpoint', async () => {
const { worktreePath, storePath } = await fixture();
await fs.rm(path.join(worktreePath, 'binary.bin'));

View file

@ -311,6 +311,74 @@ describe('WorkspaceCheckpointRewindPreviewService', () => {
expect(fixture.ownership.getManifest).toHaveBeenCalledTimes(2);
});
it.each([
['accept', true],
['reject', false],
['leave-untouched', false],
] as const)(
'binds an explicit %s decision to an attribution conflict',
async (decision, selectedForRewind) => {
const target = checkpoint(targetId, hash('d'));
const descendant = checkpoint(descendantId, hash('e'), {
parentCheckpointId: target.id,
});
const fixture = service(target, descendant, checkpointDiff('operator'), current(descendant));
const result = await fixture.preview.preview({
taskEnvelope: envelope(),
taskId: 'task-872',
attemptId: 'attempt-872',
targetCheckpointId: target.id,
descendantCheckpointId: descendant.id,
resolutions: [{ path: 'file.ts', decision }],
});
expect(result).toMatchObject({
safeForAutomaticRewind: false,
safeForApprovedRewind: true,
resolutions: [{ path: 'file.ts', decision }],
selectedPaths: selectedForRewind ? ['file.ts'] : [],
unresolvedConflicts: [],
estimatedDataLossBytes: selectedForRewind ? 12 : 0,
files: [{ path: 'file.ts', resolution: decision, selectedForRewind }],
});
expect(result.conflicts).toContainEqual(
expect.objectContaining({ kind: 'attribution-ambiguous', path: 'file.ts' })
);
}
);
it('rejects duplicate or unknown path decisions before approval', async () => {
const target = checkpoint(targetId, hash('d'));
const descendant = checkpoint(descendantId, hash('e'), {
parentCheckpointId: target.id,
});
const fixture = service(target, descendant, checkpointDiff('operator'), current(descendant));
const input = {
taskEnvelope: envelope(),
taskId: 'task-872',
attemptId: 'attempt-872',
targetCheckpointId: target.id,
descendantCheckpointId: descendant.id,
};
await expect(
fixture.preview.preview({
...input,
resolutions: [
{ path: 'file.ts', decision: 'accept' },
{ path: 'file.ts', decision: 'reject' },
],
})
).rejects.toThrow('duplicate path resolutions');
await expect(
fixture.preview.preview({
...input,
resolutions: [{ path: 'other.ts', decision: 'leave-untouched' }],
})
).rejects.toThrow('unknown or unsafe path');
});
it('blocks divergent, excluded, incomplete, or non-agent changes with explicit conflicts', async () => {
const target = checkpoint(targetId, hash('d'), {
files: [],

View file

@ -122,6 +122,8 @@ function preview(evidenceDigest = hash('3'), digest = hash('4')): WorkspaceCheck
git: { headWillChange: false, branchWillChange: false, indexWillChange: false },
conversation: { cursorWillChange: true, targetCursorAvailable: true },
files: [],
resolutions: [],
selectedPaths: [],
exclusions: {
targetCount: 0,
descendantCount: 0,
@ -129,8 +131,10 @@ function preview(evidenceDigest = hash('3'), digest = hash('4')): WorkspaceCheck
inventoryIncomplete: false,
},
conflicts: [],
unresolvedConflicts: [],
estimatedDataLossBytes: 12,
safeForAutomaticRewind: true,
safeForApprovedRewind: true,
evidenceDigest,
digest,
};
@ -259,6 +263,27 @@ function fixture(options: {
}
describe('WorkspaceCheckpointRewindService', () => {
it('rejects unresolved conflicts before inspecting or quiescing the runtime', async () => {
const conflict = {
kind: 'file-diverged' as const,
path: 'file.ts',
message: 'Current file no longer matches the descendant checkpoint.',
};
const unsafe = {
...preview(),
conflicts: [conflict],
unresolvedConflicts: [conflict],
safeForAutomaticRewind: false,
safeForApprovedRewind: false,
};
const test = fixture({ previews: [unsafe] });
await expect(test.service.execute(test.request)).rejects.toThrow('unresolved conflicts');
expect(test.runtime.inspect).not.toHaveBeenCalled();
expect(test.runtime.quiesce).not.toHaveBeenCalled();
expect(test.approvalBroker.request).not.toHaveBeenCalled();
});
it('returns the exact approval request without quiescing a live runtime', async () => {
const test = fixture({ approvalStatus: 'pending' });

View file

@ -225,6 +225,29 @@ const workspaceCheckpointRewindSchema = z
targetCheckpointId: z.string().trim().min(1).max(240),
descendantCheckpointId: z.string().trim().min(1).max(240),
requestId: z.string().trim().min(8).max(240),
resolutions: z
.array(
z
.object({
path: z
.string()
.min(1)
.max(4_096)
.refine(
(value) =>
!value.includes('\0') &&
!value.includes('\\') &&
!value.startsWith('/') &&
!/^[A-Za-z]:/.test(value) &&
!value.split('/').includes('..'),
{ message: 'Resolution paths must be safe relative paths.' }
),
decision: z.enum(['accept', 'reject', 'leave-untouched']),
})
.strict()
)
.max(10_000)
.optional(),
})
.strict();

View file

@ -211,6 +211,17 @@ export const WorkspaceCheckpointRewindTransactionSchema = z
worktreeRootDigest: digestSchema,
state: z.enum(['prepared', 'applying', 'committed', 'rolling-back', 'rolled-back']),
affectedPaths: z.array(relativePathSchema).max(100_000),
resolutions: z
.array(
z
.object({
path: relativePathSchema,
decision: z.enum(['accept', 'reject', 'leave-untouched']),
})
.strict()
)
.max(100_000)
.optional(),
restoredPathCount: z.number().int().nonnegative(),
recoveryCheckpointId: identifierSchema,
startedAt: z.iso.datetime(),

View file

@ -4849,6 +4849,7 @@ export class ClawdbotAgentService {
targetCheckpointId: string;
descendantCheckpointId: string;
requestId: string;
resolutions?: WorkspaceCheckpointRewindRequest['resolutions'];
}
): Promise<WorkspaceCheckpointRewindResult> {
await this.assertActiveRunControl(taskId, 'interrupt', input.attemptId);

View file

@ -7,6 +7,7 @@ import type {
WorkspaceCheckpointRewindConflict,
WorkspaceCheckpointRewindFilePreview,
WorkspaceCheckpointRewindPreview,
WorkspaceCheckpointRewindResolution,
} from '@veritas-kanban/shared';
import { ConflictError } from '../middleware/error-handler.js';
import {
@ -20,6 +21,7 @@ import {
} from './workspace-checkpoint-ownership-service.js';
import { digestRunLaunchValue } from '../utils/run-launch-manifest-digest.js';
import { digestWorkspaceCheckpointRewindEvidence } from '../utils/workspace-checkpoint-rewind-digest.js';
import { normalizeWorkspaceEvidencePath } from './provider-event-evidence.js';
export interface WorkspaceCheckpointRewindPreviewInput {
taskEnvelope: TaskEnvelope;
@ -27,6 +29,7 @@ export interface WorkspaceCheckpointRewindPreviewInput {
attemptId: string;
targetCheckpointId: string;
descendantCheckpointId: string;
resolutions?: WorkspaceCheckpointRewindResolution[];
}
export interface WorkspaceCheckpointRewindPreviewServiceOptions {
@ -190,6 +193,13 @@ export class WorkspaceCheckpointRewindPreviewService {
const descendantFiles = new Map(descendant.files.map((file) => [file.path, file]));
const currentFiles = new Map(current.files.map((file) => [file.path, file]));
const resolutions = normalizeResolutions(
input.resolutions,
new Set(checkpointDiff.files.map((file) => file.path))
);
const resolutionsByPath = new Map(
resolutions.map((resolution) => [resolution.path, resolution])
);
const files = checkpointDiff.files.map((file) => {
const fileConflicts = inspectFileConflicts(
file.path,
@ -208,6 +218,10 @@ export class WorkspaceCheckpointRewindPreviewService {
});
}
conflicts.push(...fileConflicts);
const resolution = resolutionsByPath.get(file.path);
const selectedForRewind = resolution
? resolution.decision === 'accept'
: fileConflicts.length === 0;
const action = rewindAction(file.kind);
const estimatedDiscardedBytes =
action === 'restore-mode' ? 0 : (currentFiles.get(file.path)?.size ?? 0);
@ -216,9 +230,18 @@ export class WorkspaceCheckpointRewindPreviewService {
action,
estimatedDiscardedBytes,
...(file.attribution ? { attribution: file.attribution } : {}),
...(resolution ? { resolution: resolution.decision } : {}),
selectedForRewind,
conflicts: fileConflicts,
} satisfies WorkspaceCheckpointRewindFilePreview;
});
const unresolvedConflicts = conflicts.filter(
(conflict) => !resolutionSettlesConflict(conflict, resolutionsByPath)
);
const selectedPaths = files
.filter((file) => file.selectedForRewind)
.map((file) => file.path)
.sort((left, right) => left.localeCompare(right));
const preview = {
schemaVersion: 'workspace-checkpoint-rewind-preview/v1' as const,
@ -243,6 +266,8 @@ export class WorkspaceCheckpointRewindPreviewService {
targetCursorAvailable: Boolean(target.conversationCursor),
},
files,
resolutions,
selectedPaths,
exclusions: {
targetCount: target.excludedCount,
descendantCount: descendant.excludedCount,
@ -250,11 +275,13 @@ export class WorkspaceCheckpointRewindPreviewService {
inventoryIncomplete,
},
conflicts,
unresolvedConflicts,
estimatedDataLossBytes: files.reduce(
(total, file) => total + file.estimatedDiscardedBytes,
(total, file) => total + (file.selectedForRewind ? file.estimatedDiscardedBytes : 0),
0
),
safeForAutomaticRewind: conflicts.length === 0,
safeForAutomaticRewind: conflicts.length === 0 && resolutions.length === 0,
safeForApprovedRewind: unresolvedConflicts.length === 0,
};
const payload = {
...preview,
@ -264,6 +291,44 @@ export class WorkspaceCheckpointRewindPreviewService {
}
}
function normalizeResolutions(
resolutions: WorkspaceCheckpointRewindResolution[] | undefined,
availablePaths: Set<string>
): WorkspaceCheckpointRewindResolution[] {
if ((resolutions?.length ?? 0) > 10_000) {
throw new ConflictError('Workspace rewind exceeds the path-resolution limit.');
}
const normalized: WorkspaceCheckpointRewindResolution[] = [];
const seen = new Set<string>();
for (const resolution of resolutions ?? []) {
if (!['accept', 'reject', 'leave-untouched'].includes(resolution.decision)) {
throw new ConflictError('Workspace rewind resolution decision is unsupported.', {
path: resolution.path,
});
}
const path = normalizeWorkspaceEvidencePath(resolution.path);
if (!path || path !== resolution.path || !availablePaths.has(path)) {
throw new ConflictError('Workspace rewind resolution references an unknown or unsafe path.', {
path: resolution.path,
});
}
if (seen.has(path)) {
throw new ConflictError('Workspace rewind contains duplicate path resolutions.', { path });
}
seen.add(path);
normalized.push({ path, decision: resolution.decision });
}
return normalized.sort((left, right) => left.path.localeCompare(right.path));
}
function resolutionSettlesConflict(
conflict: WorkspaceCheckpointRewindConflict,
resolutions: Map<string, WorkspaceCheckpointRewindResolution>
): boolean {
if (conflict.kind !== 'attribution-ambiguous' || !conflict.path) return false;
return resolutions.has(conflict.path);
}
function inspectFileConflicts(
path: string,
expected: WorkspaceCheckpointFile | undefined,

View file

@ -3,6 +3,7 @@ import type {
RunApprovalRequest,
TaskEnvelope,
WorkspaceCheckpointRewindPreview,
WorkspaceCheckpointRewindResolution,
WorkspaceCheckpointRewindTransaction,
} from '@veritas-kanban/shared';
import { ConflictError, ForbiddenError } from '../middleware/error-handler.js';
@ -23,6 +24,7 @@ export interface WorkspaceCheckpointRewindRequest {
targetCheckpointId: string;
descendantCheckpointId: string;
requestId: string;
resolutions?: WorkspaceCheckpointRewindResolution[];
}
export interface WorkspaceCheckpointRewindRuntimeSnapshot {
@ -99,9 +101,9 @@ export class WorkspaceCheckpointRewindService {
async execute(input: WorkspaceCheckpointRewindRequest): Promise<WorkspaceCheckpointRewindResult> {
const preview = await this.previews.preview(input);
if (!preview.safeForAutomaticRewind) {
if (!preview.safeForApprovedRewind) {
throw new ConflictError('Workspace rewind preview contains unresolved conflicts.', {
conflicts: preview.conflicts,
conflicts: preview.unresolvedConflicts,
});
}
const runtimeBeforeApproval = await this.runtime.inspect(input);
@ -129,7 +131,7 @@ export class WorkspaceCheckpointRewindService {
requestKind: 'approval',
actionClass: 'filesystem',
action: `Rewind workspace to ${input.targetCheckpointId}`,
details: `${preview.files.length} affected paths; estimated ${preview.estimatedDataLossBytes} bytes discarded`,
details: `${preview.selectedPaths.length} of ${preview.files.length} paths selected; estimated ${preview.estimatedDataLossBytes} bytes discarded`,
resourceScope: [
`worktree:${preview.ownership.manifestId}`,
`checkpoint:${input.targetCheckpointId}`,
@ -152,6 +154,8 @@ export class WorkspaceCheckpointRewindService {
previewEvidenceDigest: preview.evidenceDigest,
runtimeStateDigest: runtimeBeforeApproval.stateDigest,
runtimeEvidenceRevision: runtimeBeforeApproval.evidenceRevision,
resolutions: preview.resolutions,
selectedPaths: preview.selectedPaths,
estimatedDataLossBytes: preview.estimatedDataLossBytes,
},
});

View file

@ -709,33 +709,41 @@ export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRep
preview.workspaceId !== input.workspaceId ||
preview.taskId !== input.taskId ||
preview.attemptId !== input.attemptId ||
!preview.safeForAutomaticRewind ||
preview.conflicts.length > 0 ||
preview.files.some((file) => file.conflicts.length > 0) ||
!preview.safeForApprovedRewind ||
preview.unresolvedConflicts.length > 0 ||
!validResolvedPreview(preview) ||
!preview.checkpointDiff.directParent ||
(preview.checkpointDiff.files.length > 0 &&
!preview.checkpointDiff.attribution?.evidenceComplete) ||
preview.checkpointDiff.files.some(
(file) =>
file.attribution?.source !== 'agent-tool' || file.attribution.confidence !== 'high'
) ||
preview.git.headWillChange ||
preview.git.branchWillChange ||
preview.git.indexWillChange
) {
throw new ConflictError(
'Workspace rewind requires an intact conflict-free automatic preview.'
'Workspace rewind requires an intact conflict-free or explicitly resolved preview.'
);
}
const affectedPaths = [...new Set(preview.files.map((file) => file.path))].sort((left, right) =>
left.localeCompare(right)
);
const affectedPaths = [...preview.selectedPaths];
const diffPaths = [...new Set(preview.checkpointDiff.files.map((file) => file.path))].sort(
(left, right) => left.localeCompare(right)
);
if (JSON.stringify(affectedPaths) !== JSON.stringify(diffPaths)) {
const previewPaths = [...new Set(preview.files.map((file) => file.path))].sort((left, right) =>
left.localeCompare(right)
);
const previewSelectedPaths = preview.files
.filter((file) => file.selectedForRewind)
.map((file) => file.path)
.sort((left, right) => left.localeCompare(right));
const canonicalAffectedPaths = [...new Set(affectedPaths)].sort((left, right) =>
left.localeCompare(right)
);
if (
previewPaths.length !== preview.files.length ||
JSON.stringify(previewPaths) !== JSON.stringify(diffPaths) ||
JSON.stringify(affectedPaths) !== JSON.stringify(canonicalAffectedPaths) ||
JSON.stringify(affectedPaths) !== JSON.stringify(previewSelectedPaths) ||
affectedPaths.some((candidate) => !diffPaths.includes(candidate))
) {
throw new ConflictError(
'Workspace rewind preview paths do not match its attributed checkpoint diff.'
'Workspace rewind selected paths do not match its resolved checkpoint preview.'
);
}
for (const candidate of affectedPaths) validateCurrentInspectionPath(candidate);
@ -795,6 +803,7 @@ export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRep
descendantCheckpointDigest: descendant.digest,
worktreeRootDigest: target.worktreeRootDigest,
affectedPaths,
resolutions: preview.resolutions,
});
const existing = await this.getRewind({ ...scope, transactionId });
if (existing) {
@ -819,13 +828,13 @@ export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRep
);
const current = await this.inspectCurrent({
worktreePath: canonicalRoot,
paths: affectedPaths,
paths: diffPaths,
maxFileBytes: Math.max(target.policy.maxFileBytes, descendant.policy.maxFileBytes),
maxBytes: Math.max(target.policy.maxBytes, descendant.policy.maxBytes),
});
if (
!sameCurrentState(current, preview.current) ||
!checkpointMatchesCurrent(descendant, current, affectedPaths)
!checkpointMatchesCurrent(descendant, current, diffPaths)
) {
throw new ConflictError(
'Workspace rewind current state no longer matches its approved descendant.'
@ -850,6 +859,7 @@ export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRep
worktreeRootDigest: target.worktreeRootDigest,
state: 'prepared',
affectedPaths,
...(preview.resolutions.length > 0 ? { resolutions: preview.resolutions } : {}),
restoredPathCount: 0,
recoveryCheckpointId: descendant.id,
startedAt,
@ -868,11 +878,11 @@ export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRep
try {
const immediatelyBefore = await this.inspectCurrent({
worktreePath: canonicalRoot,
paths: affectedPaths,
paths: diffPaths,
maxFileBytes: Math.max(target.policy.maxFileBytes, descendant.policy.maxFileBytes),
maxBytes: Math.max(target.policy.maxBytes, descendant.policy.maxBytes),
});
if (!checkpointMatchesCurrent(descendant, immediatelyBefore, affectedPaths)) {
if (!checkpointMatchesCurrent(descendant, immediatelyBefore, diffPaths)) {
throw new ConflictError(
'Workspace rewind descendant changed after the transaction was prepared.'
);
@ -884,11 +894,13 @@ export class FileWorkspaceCheckpointRepository implements WorkspaceCheckpointRep
await this.applyCheckpointFiles(canonicalRoot, target, affectedPaths, 'apply');
const restored = await this.inspectCurrent({
worktreePath: canonicalRoot,
paths: affectedPaths,
paths: diffPaths,
maxFileBytes: target.policy.maxFileBytes,
maxBytes: target.policy.maxBytes,
});
if (!checkpointMatchesCurrent(target, restored, affectedPaths)) {
if (
!checkpointSelectionMatchesCurrent(target, descendant, restored, affectedPaths, diffPaths)
) {
throw new ConflictError('Workspace rewind did not produce the exact target state.');
}
const completedAt = this.now().toISOString();
@ -1806,6 +1818,35 @@ function checkpointFilesMatchCurrent(
);
}
function checkpointSelectionMatchesCurrent(
target: WorkspaceCheckpoint,
descendant: WorkspaceCheckpoint,
current: WorkspaceCheckpointCurrentState,
selectedPaths: string[],
allPaths: string[]
): boolean {
if (
target.worktreeRootDigest !== current.worktreeRootDigest ||
target.git.head !== current.git.head ||
target.git.branch !== current.git.branch ||
target.git.indexDigest !== current.git.indexDigest
) {
return false;
}
if (selectedPaths.length === allPaths.length) {
return checkpointMatchesCurrent(target, current, allPaths);
}
const selected = new Set(selectedPaths);
return (
checkpointFilesMatchCurrent(target, current, selectedPaths) &&
checkpointFilesMatchCurrent(
descendant,
current,
allPaths.filter((candidate) => !selected.has(candidate))
)
);
}
function rewindRecoveryMatchesKnownStates(
target: WorkspaceCheckpoint,
descendant: WorkspaceCheckpoint,
@ -1832,6 +1873,38 @@ function rewindRecoveryMatchesKnownStates(
});
}
function validResolvedPreview(preview: WorkspaceCheckpointRewindPreview): boolean {
const resolutions = new Map<string, (typeof preview.resolutions)[number]>();
for (const resolution of preview.resolutions) {
if (resolutions.has(resolution.path)) return false;
resolutions.set(resolution.path, resolution);
}
if (
[...resolutions.keys()].some(
(candidate) => !preview.files.some((file) => file.path === candidate)
)
) {
return false;
}
for (const file of preview.files) {
const resolution = resolutions.get(file.path);
if (file.resolution !== resolution?.decision) return false;
if (file.selectedForRewind !== (resolution ? resolution.decision === 'accept' : true)) {
return false;
}
for (const conflict of file.conflicts) {
if (conflict.kind !== 'attribution-ambiguous' || conflict.path !== file.path || !resolution) {
return false;
}
}
}
return preview.conflicts.every(
(conflict) =>
conflict.kind === 'attribution-ambiguous' &&
Boolean(conflict.path && resolutions.has(conflict.path))
);
}
function checkpointFileMatchesCurrent(
expected: WorkspaceCheckpointFile | undefined,
actual: WorkspaceCheckpointCurrentFile | undefined

View file

@ -214,6 +214,13 @@ export interface WorkspaceCheckpointCurrentState {
}
export type WorkspaceCheckpointRewindAction = 'delete' | 'restore' | 'restore-mode';
export type WorkspaceCheckpointRewindResolutionDecision = 'accept' | 'reject' | 'leave-untouched';
export interface WorkspaceCheckpointRewindResolution {
path: string;
decision: WorkspaceCheckpointRewindResolutionDecision;
}
export type WorkspaceCheckpointRewindConflictKind =
| 'worktree-root-changed'
| 'head-diverged'
@ -242,6 +249,8 @@ export interface WorkspaceCheckpointRewindFilePreview {
action: WorkspaceCheckpointRewindAction;
estimatedDiscardedBytes: number;
attribution?: WorkspaceCheckpointHunkAttribution;
resolution?: WorkspaceCheckpointRewindResolutionDecision;
selectedForRewind: boolean;
conflicts: WorkspaceCheckpointRewindConflict[];
}
@ -270,6 +279,8 @@ export interface WorkspaceCheckpointRewindPreview {
targetCursorAvailable: boolean;
};
files: WorkspaceCheckpointRewindFilePreview[];
resolutions: WorkspaceCheckpointRewindResolution[];
selectedPaths: string[];
exclusions: {
targetCount: number;
descendantCount: number;
@ -277,8 +288,10 @@ export interface WorkspaceCheckpointRewindPreview {
inventoryIncomplete: boolean;
};
conflicts: WorkspaceCheckpointRewindConflict[];
unresolvedConflicts: WorkspaceCheckpointRewindConflict[];
estimatedDataLossBytes: number;
safeForAutomaticRewind: boolean;
safeForApprovedRewind: boolean;
evidenceDigest: string;
digest: string;
}
@ -304,6 +317,7 @@ export interface WorkspaceCheckpointRewindTransaction {
worktreeRootDigest: string;
state: WorkspaceCheckpointRewindTransactionState;
affectedPaths: string[];
resolutions?: WorkspaceCheckpointRewindResolution[];
restoredPathCount: number;
recoveryCheckpointId: string;
startedAt: string;