@anionex/dsh-turn-rewind 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/git.js CHANGED
@@ -1,30 +1,38 @@
1
- import { execFile } from 'node:child_process';
2
- import { lstat, realpath } from 'node:fs/promises';
3
- import { dirname, isAbsolute, resolve } from 'node:path';
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { link, lstat, mkdir, open, readFile, realpath, unlink } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
5
  import { ChangeLedgerError, errorMessage } from './errors.js';
5
- import { canonicalDirectory, isNodeError, validateRelativePath } from './path-utils.js';
6
+ import { canonicalDirectory, isNodeError, syncDirectory, validateRelativePath } from './path-utils.js';
6
7
  const GIT_MAX_BUFFER = 32 * 1024 * 1024;
7
8
  /** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
8
9
  export async function discoverRepository(cwd, signal) {
9
10
  const root = await discoverRepositoryRoot(cwd, signal);
10
- const sparse = (await gitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true';
11
+ const sparse = (await runGitOptional(root, ['config', '--bool', '--get', 'core.sparseCheckout'], signal))?.trim() === 'true';
11
12
  if (sparse) {
12
13
  throw new ChangeLedgerError('SPARSE_CHECKOUT_UNSUPPORTED', 'sparse-checkout worktrees are not supported because absent paths are ambiguous');
13
14
  }
14
- const gitDirRaw = stripLineEnding(await git(root, ['rev-parse', '--git-dir'], signal));
15
+ const gitDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal));
15
16
  const gitDir = await realpath(resolve(root, gitDirRaw));
16
- const commonDirRaw = stripLineEnding(await git(root, ['rev-parse', '--git-common-dir'], signal));
17
+ const commonDirRaw = stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal));
17
18
  const commonDir = await realpath(resolve(root, commonDirRaw));
18
- const stageOutput = await git(root, ['ls-files', '--stage', '-z'], signal);
19
+ const stageOutput = await runGit(root, ['ls-files', '--stage', '-z'], signal);
19
20
  const submodules = parseSubmodules(stageOutput);
20
21
  if (submodules.length > 0) {
21
22
  throw new ChangeLedgerError('SUBMODULE_UNSUPPORTED', `submodules require independent restore points; unsupported gitlinks: ${submodules.slice(0, 10).join(', ')}`);
22
23
  }
23
- const pathOutput = await git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal);
24
- const paths = [...new Set(splitNul(pathOutput).map(validateRelativePath))].sort(comparePaths);
25
- const head = trimOptional(await gitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal));
26
- const branch = trimOptional(await gitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal));
27
- const stagedOutput = await git(root, ['diff', '--cached', '--name-only', '-z'], signal);
24
+ const pathOutput = await runGit(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], signal);
25
+ // `ls-files --others` refuses to descend into embedded repositories, such as a
26
+ // linked worktree nested inside this checkout; it reports the directory itself
27
+ // with a trailing slash (for example `nested/`). Those directories are separate
28
+ // checkouts, not files of this worktree, so exclude them from the snapshot
29
+ // inventory instead of rejecting the entry as a non-canonical path.
30
+ const paths = [...new Set(splitNul(pathOutput)
31
+ .filter((path) => !path.endsWith('/'))
32
+ .map(validateRelativePath))].sort(comparePaths);
33
+ const head = trimOptional(await runGitOptional(root, ['rev-parse', '--verify', '--quiet', 'HEAD'], signal));
34
+ const branch = trimOptional(await runGitOptional(root, ['symbolic-ref', '--quiet', '--short', 'HEAD'], signal));
35
+ const stagedOutput = await runGit(root, ['diff', '--cached', '--name-only', '-z'], signal);
28
36
  const stagedPaths = splitNul(stagedOutput).map(validateRelativePath).sort(comparePaths);
29
37
  const operation = await gitOperation(gitDir);
30
38
  return {
@@ -42,13 +50,55 @@ export async function discoverRepository(cwd, signal) {
42
50
  /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
43
51
  export async function discoverRepositoryRoot(cwd, signal) {
44
52
  const canonicalCwd = await canonicalDirectory(cwd);
45
- const rootRaw = await git(canonicalCwd, ['rev-parse', '--show-toplevel'], signal);
53
+ const rootRaw = await runGit(canonicalCwd, ['rev-parse', '--show-toplevel'], signal);
46
54
  const root = await realpath(stripLineEnding(rootRaw));
47
55
  if (!isAbsolute(root)) {
48
56
  throw new ChangeLedgerError('GIT_ROOT_INVALID', `git returned a non-absolute worktree root: ${JSON.stringify(root)}`);
49
57
  }
50
58
  return root;
51
59
  }
60
+ /** Create or read the per-worktree identity used by refs and cross-store locking. */
61
+ export async function ensureGitWorktreeIdentity(cwd, signal) {
62
+ const root = await discoverRepositoryRoot(cwd, signal);
63
+ const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))));
64
+ const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))));
65
+ const identityDir = join(gitDir, 'dsh-turn-rewind');
66
+ await mkdir(identityDir, { recursive: true, mode: 0o700 });
67
+ const worktreeId = await ensureWorktreeId(identityDir, commonDir, gitDir);
68
+ return {
69
+ root,
70
+ gitDir,
71
+ commonDir,
72
+ worktreeId,
73
+ lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
74
+ };
75
+ }
76
+ /** Resolve a moved linked worktree from its common directory and durable identity. */
77
+ export async function resolveGitWorktreeRoot(commonDir, worktreeId, fallback, signal) {
78
+ const candidates = [fallback];
79
+ try {
80
+ const output = await runGit(commonDir, [`--git-dir=${commonDir}`, 'worktree', 'list', '--porcelain', '-z'], signal);
81
+ for (const record of output.split('\0\0')) {
82
+ const first = record.split('\0', 1)[0];
83
+ if (first?.startsWith('worktree '))
84
+ candidates.push(first.slice('worktree '.length));
85
+ }
86
+ }
87
+ catch {
88
+ // The fallback below still gives deterministic failure details if the common directory is gone.
89
+ }
90
+ for (const candidate of [...new Set(candidates)]) {
91
+ try {
92
+ const identity = await readGitWorktreeIdentity(candidate, signal);
93
+ if (identity.worktreeId === worktreeId && identity.commonDir === commonDir)
94
+ return identity.root;
95
+ }
96
+ catch {
97
+ // Missing/pruned/unrelated worktrees are not candidates for this durable identity.
98
+ }
99
+ }
100
+ throw new ChangeLedgerError('GIT_WORKTREE_MISSING', `cannot locate Git worktree ${worktreeId} under ${JSON.stringify(commonDir)}`);
101
+ }
52
102
  /** Return true when two repository fences refer to the same checkout state. */
53
103
  export function sameRepositoryFence(left, right) {
54
104
  return left.root === right.root
@@ -57,20 +107,21 @@ export function sameRepositoryFence(left, right) {
57
107
  && left.branch === right.branch
58
108
  && left.operation === right.operation;
59
109
  }
60
- async function git(cwd, args, signal) {
110
+ /** Run Git with stable non-interactive defaults and return UTF-8 output. */
111
+ export async function runGit(cwd, args, signal, options = {}) {
61
112
  return new Promise((resolvePromise, reject) => {
62
113
  execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
63
114
  encoding: 'utf8',
64
- maxBuffer: GIT_MAX_BUFFER,
115
+ maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
65
116
  windowsHide: true,
66
117
  signal,
67
- env: {
68
- ...process.env,
69
- GIT_OPTIONAL_LOCKS: '0',
70
- GIT_TERMINAL_PROMPT: '0',
71
- },
118
+ env: gitEnvironment(options.env),
72
119
  }, (error, stdout, stderr) => {
73
120
  if (error !== null) {
121
+ if (signal?.aborted === true) {
122
+ reject(signal.reason ?? error);
123
+ return;
124
+ }
74
125
  reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.trim() || errorMessage(error)}`, { cause: error }));
75
126
  return;
76
127
  }
@@ -78,9 +129,10 @@ async function git(cwd, args, signal) {
78
129
  });
79
130
  });
80
131
  }
81
- async function gitOptional(cwd, args, signal) {
132
+ /** Run Git and return undefined for its ordinary "not found" exit status. */
133
+ export async function runGitOptional(cwd, args, signal, options = {}) {
82
134
  try {
83
- return await git(cwd, args, signal);
135
+ return await runGit(cwd, args, signal, options);
84
136
  }
85
137
  catch (error) {
86
138
  if (error instanceof ChangeLedgerError && error.code === 'GIT_COMMAND_FAILED' && gitExitCode(error.cause) === 1)
@@ -88,6 +140,105 @@ async function gitOptional(cwd, args, signal) {
88
140
  throw error;
89
141
  }
90
142
  }
143
+ /** Run Git with binary stdin and return UTF-8 stdout. */
144
+ export async function runGitInput(cwd, args, input, signal, options = {}) {
145
+ return new Promise((resolvePromise, reject) => {
146
+ const child = spawn('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
147
+ windowsHide: true,
148
+ signal,
149
+ env: gitEnvironment(options.env),
150
+ stdio: ['pipe', 'pipe', 'pipe'],
151
+ });
152
+ const stdout = [];
153
+ const stderr = [];
154
+ let stdoutBytes = 0;
155
+ child.stdout.on('data', (chunk) => {
156
+ stdoutBytes += chunk.length;
157
+ if (stdoutBytes <= GIT_MAX_BUFFER)
158
+ stdout.push(chunk);
159
+ });
160
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
161
+ let settled = false;
162
+ const fail = (error) => {
163
+ if (settled)
164
+ return;
165
+ settled = true;
166
+ if (signal?.aborted === true) {
167
+ reject(signal.reason ?? error);
168
+ return;
169
+ }
170
+ reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${errorMessage(error)}`, { cause: error }));
171
+ };
172
+ child.on('error', fail);
173
+ child.stdin.on('error', fail);
174
+ child.on('close', (code) => {
175
+ if (settled)
176
+ return;
177
+ if (code !== 0) {
178
+ settled = true;
179
+ const message = Buffer.concat(stderr).toString('utf8').trim();
180
+ reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${message || `exit ${String(code)}`}`));
181
+ return;
182
+ }
183
+ if (stdoutBytes > GIT_MAX_BUFFER) {
184
+ settled = true;
185
+ reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} output exceeded ${GIT_MAX_BUFFER} bytes`));
186
+ return;
187
+ }
188
+ settled = true;
189
+ resolvePromise(Buffer.concat(stdout).toString('utf8'));
190
+ });
191
+ child.stdin.end(input);
192
+ });
193
+ }
194
+ /** Run Git and return raw stdout bytes. */
195
+ export async function runGitBuffer(cwd, args, signal, options = {}) {
196
+ return new Promise((resolvePromise, reject) => {
197
+ execFile('git', ['-c', 'core.quotepath=false', '-C', cwd, ...args], {
198
+ encoding: 'buffer',
199
+ maxBuffer: options.maxBuffer ?? GIT_MAX_BUFFER,
200
+ windowsHide: true,
201
+ signal,
202
+ env: gitEnvironment(options.env),
203
+ }, (error, stdout, stderr) => {
204
+ if (error !== null) {
205
+ if (signal?.aborted === true) {
206
+ reject(signal.reason ?? error);
207
+ return;
208
+ }
209
+ reject(new ChangeLedgerError('GIT_COMMAND_FAILED', `git ${args.join(' ')} failed in ${JSON.stringify(cwd)}: ${stderr.toString('utf8').trim() || errorMessage(error)}`, { cause: error }));
210
+ return;
211
+ }
212
+ resolvePromise(stdout);
213
+ });
214
+ });
215
+ }
216
+ function gitEnvironment(extra) {
217
+ const env = { ...process.env };
218
+ for (const key of [
219
+ 'GIT_DIR',
220
+ 'GIT_COMMON_DIR',
221
+ 'GIT_WORK_TREE',
222
+ 'GIT_INDEX_FILE',
223
+ 'GIT_OBJECT_DIRECTORY',
224
+ 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
225
+ 'GIT_QUARANTINE_PATH',
226
+ 'GIT_REPLACE_REF_BASE',
227
+ 'GIT_CONFIG_PARAMETERS',
228
+ 'GIT_CONFIG_COUNT',
229
+ ])
230
+ delete env[key];
231
+ for (const key of Object.keys(env)) {
232
+ if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(key))
233
+ delete env[key];
234
+ }
235
+ return {
236
+ ...env,
237
+ ...extra,
238
+ GIT_OPTIONAL_LOCKS: '0',
239
+ GIT_TERMINAL_PROMPT: '0',
240
+ };
241
+ }
91
242
  function gitExitCode(error) {
92
243
  if (error === null || typeof error !== 'object' || !('code' in error))
93
244
  return undefined;
@@ -149,6 +300,135 @@ function stripLineEnding(value) {
149
300
  function comparePaths(left, right) {
150
301
  return Buffer.from(left).compare(Buffer.from(right));
151
302
  }
303
+ function parseWorktreeId(value) {
304
+ const worktreeId = value.trim();
305
+ if (!/^[0-9a-f]{32}$/.test(worktreeId)) {
306
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed');
307
+ }
308
+ return worktreeId;
309
+ }
310
+ async function readGitWorktreeIdentity(cwd, signal) {
311
+ const root = await discoverRepositoryRoot(cwd, signal);
312
+ const gitDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-dir'], signal))));
313
+ const commonDir = await realpath(resolve(root, stripLineEnding(await runGit(root, ['rev-parse', '--git-common-dir'], signal))));
314
+ const worktreeId = await ensureWorktreeId(join(gitDir, 'dsh-turn-rewind'), commonDir, gitDir);
315
+ return {
316
+ root,
317
+ gitDir,
318
+ commonDir,
319
+ worktreeId,
320
+ lockPath: join(commonDir, 'dsh-turn-rewind', 'locks', `${worktreeId}.json`),
321
+ };
322
+ }
323
+ async function ensureWorktreeId(identityDir, commonDir, gitDir) {
324
+ const identityPath = join(identityDir, 'worktree-id');
325
+ const backupPath = join(identityDir, 'worktree-id.backup');
326
+ const initializedPath = join(identityDir, 'worktree-id.initialized');
327
+ const localPaths = [identityPath, backupPath, initializedPath];
328
+ const centralDir = join(commonDir, 'dsh-turn-rewind-worktree-identities');
329
+ const centralPath = join(centralDir, `${createHash('sha256').update(gitDir).digest('hex')}.id`);
330
+ await mkdir(identityDir, { recursive: true, mode: 0o700 });
331
+ await mkdir(centralDir, { recursive: true, mode: 0o700 });
332
+ const localCopies = await Promise.all(localPaths.map(readIdentityCopy));
333
+ const centralCopy = await readIdentityCopy(centralPath);
334
+ const valid = [...localCopies, centralCopy].flatMap(copy => copy.value === undefined ? [] : [copy.value]);
335
+ if (new Set(valid).size > 1) {
336
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity copies disagree');
337
+ }
338
+ const localValid = localCopies.flatMap(copy => copy.value === undefined ? [] : [copy.value]);
339
+ const localDurable = localValid[0];
340
+ const durable = localDurable ?? (gitDir === commonDir ? centralCopy.value : undefined);
341
+ if (durable !== undefined) {
342
+ for (const path of [...localPaths, centralPath]) {
343
+ if (await publishIdentityFile(path, durable, true) !== durable) {
344
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`);
345
+ }
346
+ }
347
+ await syncDirectory(identityDir);
348
+ await syncDirectory(centralDir);
349
+ return durable;
350
+ }
351
+ if (localCopies.some(copy => copy.malformed) || centralCopy.malformed) {
352
+ throw new ChangeLedgerError('STATE_CORRUPT', 'Git worktree identity is malformed and no valid durable copy remains');
353
+ }
354
+ if (gitDir !== commonDir && centralCopy.value !== undefined) {
355
+ throw new ChangeLedgerError('STATE_CORRUPT', 'linked Git worktree identity is ambiguous because only a detached central copy remains');
356
+ }
357
+ const candidate = randomBytes(16).toString('hex');
358
+ const chosen = await publishIdentityFile(identityPath, candidate, false);
359
+ for (const path of [backupPath, initializedPath, centralPath]) {
360
+ if (await publishIdentityFile(path, chosen, true) !== chosen) {
361
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy disagrees at ${JSON.stringify(path)}`);
362
+ }
363
+ }
364
+ await syncDirectory(identityDir);
365
+ await syncDirectory(centralDir);
366
+ return chosen;
367
+ }
368
+ async function readIdentityCopy(path) {
369
+ try {
370
+ return { value: parseWorktreeId(await readFile(path, 'utf8')), malformed: false };
371
+ }
372
+ catch (error) {
373
+ if (isNodeError(error, 'ENOENT'))
374
+ return { malformed: false };
375
+ if (error instanceof ChangeLedgerError && error.code === 'STATE_CORRUPT')
376
+ return { malformed: true };
377
+ throw error;
378
+ }
379
+ }
380
+ async function publishIdentityFile(path, value, repairMalformed) {
381
+ const directory = dirname(path);
382
+ await mkdir(directory, { recursive: true, mode: 0o700 });
383
+ const temporary = join(directory, `.${randomBytes(16).toString('hex')}.tmp`);
384
+ try {
385
+ const handle = await open(temporary, 'wx', 0o600);
386
+ try {
387
+ await handle.writeFile(`${value}\n`);
388
+ await handle.sync();
389
+ }
390
+ finally {
391
+ await handle.close();
392
+ }
393
+ for (let attempt = 0; attempt < 8; attempt += 1) {
394
+ try {
395
+ await link(temporary, path);
396
+ await syncDirectory(directory);
397
+ return value;
398
+ }
399
+ catch (error) {
400
+ if (!isNodeError(error, 'EEXIST'))
401
+ throw error;
402
+ }
403
+ const existing = await readIdentityCopy(path);
404
+ if (existing.value !== undefined)
405
+ return existing.value;
406
+ if (!existing.malformed)
407
+ continue;
408
+ if (!repairMalformed) {
409
+ throw new ChangeLedgerError('STATE_CORRUPT', `Git worktree identity copy is malformed at ${JSON.stringify(path)}`);
410
+ }
411
+ try {
412
+ await unlink(path);
413
+ await syncDirectory(directory);
414
+ }
415
+ catch (error) {
416
+ if (!isNodeError(error, 'ENOENT'))
417
+ throw error;
418
+ }
419
+ }
420
+ throw new ChangeLedgerError('STATE_CORRUPT', `could not repair Git worktree identity copy at ${JSON.stringify(path)}`);
421
+ }
422
+ finally {
423
+ try {
424
+ await unlink(temporary);
425
+ }
426
+ catch (error) {
427
+ if (!isNodeError(error, 'ENOENT'))
428
+ throw error;
429
+ }
430
+ }
431
+ }
152
432
  /** Return the Git metadata directory for diagnostics. */
153
433
  export function gitMetadataParent(state) {
154
434
  return dirname(state.commonDir);
package/lib/index.js CHANGED
@@ -18,7 +18,7 @@ export class ChangeLedgerService {
18
18
  });
19
19
  void this.engine.initialize().then((reconciled) => {
20
20
  if (reconciled > 0) {
21
- ctx.logger.warn(`[change-ledger] marked ${reconciled} interrupted restore operation(s) for manual recovery`);
21
+ ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`);
22
22
  }
23
23
  else {
24
24
  ctx.logger.info(`[change-ledger] ready; state=${this.engine.config.storageDir}`);
package/lib/path-utils.js CHANGED
@@ -107,6 +107,13 @@ export async function pathExists(path) {
107
107
  }
108
108
  /** Ensure every existing parent below `root` is a real directory, never a symlink. */
109
109
  export async function ensureSafeParents(root, target) {
110
+ await walkSafeParents(root, target, true);
111
+ }
112
+ /** Validate existing parents without creating missing directories. */
113
+ export async function assertSafeParents(root, target) {
114
+ await walkSafeParents(root, target, false);
115
+ }
116
+ async function walkSafeParents(root, target, createMissing) {
110
117
  if (!isWithin(root, target) || target === root) {
111
118
  throw new ChangeLedgerError('UNSAFE_TARGET', `restore target is outside the workspace: ${JSON.stringify(target)}`);
112
119
  }
@@ -128,6 +135,8 @@ export async function ensureSafeParents(root, target) {
128
135
  catch (error) {
129
136
  if (!isNodeError(error, 'ENOENT'))
130
137
  throw error;
138
+ if (!createMissing)
139
+ return;
131
140
  await mkdir(current, { mode: 0o755 });
132
141
  }
133
142
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { createDeadline } from './deadline.js';
2
3
  import { ChangeLedgerError, errorMessage } from './errors.js';
3
4
  import { discoverRepositoryRoot } from './git.js';
4
5
  export const REWIND_HTTP_PATH = '/turn-rewind';
@@ -11,6 +12,7 @@ export class TurnCheckpointCoordinator {
11
12
  captures = new Map();
12
13
  pending = new Set();
13
14
  failures = new Map();
15
+ skips = new Map();
14
16
  workspaceTails = new Map();
15
17
  constructor(engine) {
16
18
  this.engine = engine;
@@ -28,55 +30,90 @@ export class TurnCheckpointCoordinator {
28
30
  const key = checkpointKey(sessionId, turn);
29
31
  if (this.pending.has(key))
30
32
  return { status: 'pending' };
33
+ const reason = this.skips.get(key);
34
+ if (reason !== undefined)
35
+ return { status: 'skipped', reason };
31
36
  const error = this.failures.get(key);
32
37
  return error === undefined ? { status: 'missing' } : { status: 'failed', error };
33
38
  }
34
39
  async capture(ctx, agent, turn, signal) {
35
40
  const key = checkpointKey(agent.id, turn);
36
- const existing = this.captures.get(key);
37
- if (existing !== undefined) {
38
- await existing;
39
- return;
40
- }
41
41
  const cwd = agent.session.header.cwd;
42
42
  if (cwd === undefined)
43
43
  return;
44
44
  const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
45
45
  if (start === undefined) {
46
- this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
46
+ await this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
47
47
  return;
48
48
  }
49
- this.pending.add(key);
50
- this.failures.delete(key);
51
- const capture = (async () => {
52
- try {
53
- const workspace = await discoverRepositoryRoot(cwd, signal);
54
- await this.serializeWorkspace(workspace, signal, async () => {
55
- await this.engine.createTurnCheckpoint({
56
- cwd: workspace,
57
- sessionId: agent.id,
58
- turn,
59
- turnStartSeq: start.seq,
60
- signal,
49
+ const timeoutMs = this.engine.config.turnCheckpointTimeoutMs;
50
+ const outcomeDeadline = createDeadline(timeoutMs);
51
+ const outcomeSignal = AbortSignal.any([signal, outcomeDeadline.signal]);
52
+ const reserveMs = Math.min(250, Math.max(10, Math.ceil(timeoutMs / 5)));
53
+ const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs));
54
+ const captureSignal = AbortSignal.any([signal, captureDeadline.signal]);
55
+ try {
56
+ const existing = this.captures.get(key);
57
+ if (existing !== undefined) {
58
+ await waitWithSignal(existing, captureSignal).catch(() => undefined);
59
+ return;
60
+ }
61
+ this.pending.add(key);
62
+ this.failures.delete(key);
63
+ this.skips.delete(key);
64
+ const capture = (async () => {
65
+ try {
66
+ const workspace = await discoverRepositoryRoot(cwd, captureSignal);
67
+ await this.serializeWorkspace(workspace, captureSignal, async () => {
68
+ try {
69
+ await this.engine.createTurnCheckpoint({
70
+ cwd: workspace,
71
+ sessionId: agent.id,
72
+ turn,
73
+ turnStartSeq: start.seq,
74
+ signal: captureSignal,
75
+ });
76
+ }
77
+ catch (error) {
78
+ await this.recordFailure(ctx, agent.id, turn, checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal), { cwd: workspace, turnStartSeq: start.seq });
79
+ }
61
80
  });
62
- });
81
+ }
82
+ catch (error) {
83
+ await this.recordFailure(ctx, agent.id, turn, checkpointTimeoutError(error, timeoutMs, captureDeadline.signal, signal), { cwd, turnStartSeq: start.seq });
84
+ }
85
+ finally {
86
+ this.pending.delete(key);
87
+ }
88
+ })();
89
+ this.captures.set(key, capture);
90
+ try {
91
+ await waitWithSignal(capture, outcomeSignal);
63
92
  }
64
93
  catch (error) {
65
- this.recordFailure(ctx, agent.id, turn, error);
66
- }
67
- finally {
94
+ const bounded = checkpointTimeoutError(error, timeoutMs, outcomeDeadline.signal, signal);
95
+ const message = errorMessage(bounded);
68
96
  this.pending.delete(key);
97
+ if (bounded instanceof ChangeLedgerError && isCheckpointSkip(bounded.code)) {
98
+ this.skips.set(key, message);
99
+ }
100
+ else {
101
+ this.failures.set(key, message);
102
+ }
69
103
  }
70
- })();
71
- this.captures.set(key, capture);
72
- await capture;
104
+ }
105
+ finally {
106
+ captureDeadline.cancel();
107
+ outcomeDeadline.cancel();
108
+ }
73
109
  }
74
110
  async serializeWorkspace(workspace, signal, task) {
75
111
  const previous = this.workspaceTails.get(workspace) ?? Promise.resolve();
76
- const current = previous.catch(() => undefined).then(async () => {
112
+ const current = (async () => {
113
+ await waitWithSignal(previous.catch(() => undefined), signal);
77
114
  signal.throwIfAborted();
78
115
  await task();
79
- });
116
+ })();
80
117
  this.workspaceTails.set(workspace, current);
81
118
  try {
82
119
  await current;
@@ -86,12 +123,57 @@ export class TurnCheckpointCoordinator {
86
123
  this.workspaceTails.delete(workspace);
87
124
  }
88
125
  }
89
- recordFailure(ctx, sessionId, turn, error) {
126
+ async recordFailure(ctx, sessionId, turn, error, skipContext) {
90
127
  const message = errorMessage(error);
128
+ if (error instanceof ChangeLedgerError && isCheckpointSkip(error.code)) {
129
+ this.skips.set(checkpointKey(sessionId, turn), message);
130
+ if (skipContext !== undefined) {
131
+ try {
132
+ await this.engine.recordTurnCheckpointSkip({
133
+ cwd: skipContext.cwd,
134
+ sessionId,
135
+ turn,
136
+ turnStartSeq: skipContext.turnStartSeq,
137
+ reason: message,
138
+ });
139
+ }
140
+ catch (persistError) {
141
+ ctx.logger.warn(`[turn-rewind] could not persist checkpoint skip for ${sessionId} turn ${String(turn)}: ${errorMessage(persistError)}`);
142
+ }
143
+ }
144
+ ctx.logger.warn(`[turn-rewind] checkpoint skipped for ${sessionId} turn ${String(turn)}: ${message}`);
145
+ return;
146
+ }
91
147
  this.failures.set(checkpointKey(sessionId, turn), message);
92
148
  ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`);
93
149
  }
94
150
  }
151
+ function isCheckpointSkip(code) {
152
+ return code === 'TURN_CHECKPOINT_DISABLED'
153
+ || code === 'TURN_CHECKPOINT_TIMEOUT'
154
+ || code === 'TURN_CHECKPOINT_NEW_CONTENT_LIMIT'
155
+ || code === 'GIT_OBJECT_STORE_UNSUPPORTED'
156
+ || code === 'GIT_INDEX_UNSUPPORTED';
157
+ }
158
+ async function waitWithSignal(promise, signal) {
159
+ signal.throwIfAborted();
160
+ let rejectAbort;
161
+ const aborted = new Promise((_resolve, reject) => { rejectAbort = reject; });
162
+ const onAbort = () => rejectAbort?.(signal.reason);
163
+ signal.addEventListener('abort', onAbort, { once: true });
164
+ try {
165
+ return await Promise.race([promise, aborted]);
166
+ }
167
+ finally {
168
+ signal.removeEventListener('abort', onAbort);
169
+ }
170
+ }
171
+ function checkpointTimeoutError(error, timeoutMs, deadline, callerSignal) {
172
+ if (!callerSignal.aborted && deadline.aborted) {
173
+ return new ChangeLedgerError('TURN_CHECKPOINT_TIMEOUT', `automatic checkpoint exceeded ${timeoutMs} ms`, { cause: error });
174
+ }
175
+ return error;
176
+ }
95
177
  /** Register the same-origin preview/apply endpoint consumed by the browser half. */
96
178
  export function installRewindHttp(ctx, engine, coordinator) {
97
179
  ctx.effect(() => ctx.webServer.register({
@@ -113,7 +195,15 @@ export function createRewindHttpHandler(ctx, engine, coordinator) {
113
195
  const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
114
196
  const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
115
197
  if (checkpoint === undefined) {
116
- json(response, 200, coordinator.state(sessionId, target.turn));
198
+ const durableSkip = await engine.findTurnCheckpointSkip({
199
+ cwd: target.cwd,
200
+ sessionId,
201
+ turn: target.turn,
202
+ turnStartSeq: target.turnStartSeq,
203
+ });
204
+ json(response, 200, durableSkip === undefined
205
+ ? coordinator.state(sessionId, target.turn)
206
+ : { status: 'skipped', reason: durableSkip.reason });
117
207
  return;
118
208
  }
119
209
  const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id });