@davideasden/pi-undo 0.2.16 → 0.2.18

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.
@@ -9,7 +9,7 @@ import type {
9
9
  SessionTreeEvent as PiSessionTreeEvent,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
 
12
- import type { UndoController } from "../src/controller.ts";
12
+ import type { OperationResult, UndoController } from "../src/controller.ts";
13
13
  import { browseDiff } from "../src/diff-ui.ts";
14
14
  import { computeCheckpointDiff, type DiffSource, formatDiffSummary, sanitizeDisplayText } from "../src/diff-view.ts";
15
15
  import { createPiUndoRuntime } from "../src/pi-runtime.ts";
@@ -19,7 +19,7 @@ export interface PiUndoRuntime {
19
19
  readonly controller: UndoController;
20
20
  readonly reporter: StatusReporter;
21
21
  readonly diffSource?: DiffSource;
22
- readonly recovery?: { readonly files?: number; readonly opId?: string };
22
+ readonly recovery?: { readonly reason?: string; readonly files?: number; readonly opId?: string };
23
23
  setCommandContext?(context: ExtensionCommandContext | undefined): void;
24
24
  isInternalNavigation?(): boolean;
25
25
  }
@@ -63,8 +63,12 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
63
63
  runtime = next;
64
64
  await next.controller.recover();
65
65
  const history = next.controller.history();
66
- if (history.locked) next.reporter.setRecoveryRequired("pending journal", next.recovery);
67
- else next.reporter.setReady(history.undoCount, history.redoCount);
66
+ if (history.locked) {
67
+ next.reporter.setRecoveryRequired(
68
+ next.controller.recoveryReason?.() ?? next.recovery?.reason ?? "pending journal",
69
+ next.recovery,
70
+ );
71
+ } else next.reporter.setReady(history.undoCount, history.redoCount);
68
72
  // 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
69
73
  next.controller.warmUp();
70
74
  } catch (error) {
@@ -117,7 +121,9 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
117
121
  if (expectedGeneration !== generation || runtime !== active) return;
118
122
  const history = active.controller.history();
119
123
  if (history.locked) {
120
- active.reporter.setRecoveryRequired(lockedReason);
124
+ active.reporter.setRecoveryRequired(
125
+ active.controller.recoveryReason?.() ?? lockedReason,
126
+ );
121
127
  restoreDeferredPrompts(runtimeContext);
122
128
  } else {
123
129
  active.reporter.setReady(history.undoCount, history.redoCount);
@@ -137,22 +143,32 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
137
143
  }
138
144
  const commandToken = Symbol(action);
139
145
  const commandSet = activeCommands;
146
+ // 只有当前执行命令能安装/清理导航上下文;busy 的第二个命令不得覆盖或清空它。
147
+ const ownsCommandContext = commandSet.size === 0;
140
148
  commandSet.add(commandToken);
141
149
  activeAction = action;
142
150
  active.reporter.setPhase(action === "undo" ? "undoing" : "redoing");
143
- active.setCommandContext?.(context);
151
+ if (ownsCommandContext) active.setCommandContext?.(context);
144
152
  const commandStarted = performance.now();
145
- let result;
153
+ let result: OperationResult;
146
154
  try {
147
155
  result = action === "undo"
148
156
  ? await active.controller.undo()
149
157
  : await active.controller.redo();
150
158
  } finally {
151
- active.setCommandContext?.(undefined);
159
+ if (ownsCommandContext) active.setCommandContext?.(undefined);
152
160
  commandSet.delete(commandToken);
153
161
  if (commandSet === activeCommands && commandSet.size === 0) activeAction = undefined;
154
162
  }
155
163
  if (commandGeneration !== generation || runtime !== active) return;
164
+ const history = active.controller.history();
165
+ if (history.locked && result.code === "busy") {
166
+ result = {
167
+ ...result,
168
+ code: "recovery_required",
169
+ message: active.controller.recoveryReason?.() ?? "pending journal",
170
+ };
171
+ }
156
172
  active.reporter.result(result, performance.now() - commandStarted);
157
173
  const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
158
174
  if (
@@ -305,7 +321,11 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
305
321
  if (active === undefined) return;
306
322
  await active.controller.agentSettled();
307
323
  if (runtime !== active || generation !== settledGeneration) return;
308
- resumeDeferredPrompts(active, settledGeneration, "session state ambiguous");
324
+ resumeDeferredPrompts(
325
+ active,
326
+ settledGeneration,
327
+ active.controller.recoveryReason?.() ?? "session state ambiguous",
328
+ );
309
329
  });
310
330
  pi.on("session_before_tree", async (event: PiSessionBeforeTreeEvent) => {
311
331
  if (runtime === undefined) return { cancel: true };
@@ -326,7 +346,11 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
326
346
  navigationTargetLeafId: event.summaryEntry?.parentId ?? event.newLeafId,
327
347
  });
328
348
  if (active !== undefined && runtime === active && generation === treeGeneration) {
329
- resumeDeferredPrompts(active, treeGeneration, "session state ambiguous");
349
+ resumeDeferredPrompts(
350
+ active,
351
+ treeGeneration,
352
+ active.controller.recoveryReason?.() ?? "session state ambiguous",
353
+ );
330
354
  }
331
355
  });
332
356
  pi.on("session_shutdown", async () => {
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
+ "license": "MIT",
5
6
  "type": "module",
6
7
  "keywords": [
7
8
  "pi-package",
package/src/controller.ts CHANGED
@@ -150,6 +150,8 @@ export interface UndoController {
150
150
  cancelTree?(): Promise<void>;
151
151
  recover(): Promise<void>;
152
152
  history(): HistoryState;
153
+ /** 当前 recovery lock 的原因;未锁定时返回 undefined。 */
154
+ recoveryReason?(): string | undefined;
153
155
  /** 后台预热快照缓存:立即返回,失败静默;后续 capture 会先等预热完成。 */
154
156
  warmUp(): void;
155
157
  /** 最近一次输入前快照是否失败(此时本次 run 不可 undo,但输入不受影响)。 */
@@ -175,6 +177,7 @@ export interface ControllerInitialState {
175
177
  readonly redoStack?: readonly ControllerRedoEntry[];
176
178
  readonly historyPaused?: boolean;
177
179
  readonly locked?: boolean;
180
+ readonly recoveryReason?: string;
178
181
  readonly recoveryCompleted?: boolean;
179
182
  }
180
183
 
@@ -198,6 +201,7 @@ export class UndoControllerImpl implements UndoController {
198
201
  private staged: StagedRun | undefined;
199
202
  private pendingTree: PendingTree | undefined;
200
203
  private locked = false;
204
+ private lockedReason: string | undefined;
201
205
  private historyPaused = false;
202
206
  private operationInFlight = false;
203
207
  private operationAction: "undo" | "redo" | undefined;
@@ -219,6 +223,7 @@ export class UndoControllerImpl implements UndoController {
219
223
  this.redoStack.push(...(initialState.redoStack ?? []));
220
224
  this.historyPaused = initialState.historyPaused ?? false;
221
225
  this.locked = initialState.locked ?? false;
226
+ this.lockedReason = initialState.recoveryReason;
222
227
  this.recoveryCompleted = initialState.recoveryCompleted ?? false;
223
228
  }
224
229
 
@@ -226,6 +231,26 @@ export class UndoControllerImpl implements UndoController {
226
231
  return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
227
232
  }
228
233
 
234
+ recoveryReason(): string | undefined {
235
+ return this.locked ? this.lockedReason ?? "pending journal" : undefined;
236
+ }
237
+
238
+ private lock(reason: string): void {
239
+ this.locked = true;
240
+ if (this.lockedReason === undefined) {
241
+ const normalized = truncateReason(reason);
242
+ this.lockedReason = normalized.length === 0 ? "recovery_required" : normalized;
243
+ }
244
+ }
245
+
246
+ private recoveryResult(changedFiles = 0): OperationResult {
247
+ return {
248
+ code: "recovery_required",
249
+ changedFiles,
250
+ message: this.recoveryReason(),
251
+ };
252
+ }
253
+
229
254
  captureFailed(): boolean {
230
255
  return this.lastCaptureFailed;
231
256
  }
@@ -306,13 +331,13 @@ export class UndoControllerImpl implements UndoController {
306
331
  sourceLogicalLeaf: this.staged.sourceLogicalLeaf,
307
332
  });
308
333
  if (this.staged.startEntryId === null) {
309
- this.locked = true;
334
+ this.lock("start_entry_missing");
310
335
  this.staged = undefined;
311
336
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
312
337
  return;
313
338
  }
314
339
  } catch {
315
- this.locked = true;
340
+ this.lock("start_entry_append_failed");
316
341
  this.staged = undefined;
317
342
  return;
318
343
  }
@@ -361,13 +386,13 @@ export class UndoControllerImpl implements UndoController {
361
386
  if (this.locked || staged === undefined) return;
362
387
  if (staged.startEntryId === undefined || staged.startEntryId === null) {
363
388
  // Pi 没有提供已落盘的 start entry ID,不能把后续 assistant 输出归属到该 checkpoint。
364
- this.locked = true;
389
+ this.lock("start_entry_missing");
365
390
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
366
391
  return;
367
392
  }
368
393
  const userEntryId = this.dependencies.findUserEntryAfter(staged.startEntryId);
369
394
  if (userEntryId === null) {
370
- this.locked = true;
395
+ this.lock("user_entry_missing");
371
396
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "user_entry_missing" }).catch(() => {});
372
397
  return;
373
398
  }
@@ -375,7 +400,8 @@ export class UndoControllerImpl implements UndoController {
375
400
  const measure = <T>(phase: string, operation: () => Promise<T>): Promise<T> =>
376
401
  profiler === undefined ? operation() : profiler.measure(phase, operation);
377
402
  try {
378
- const after = await measure("settled.capture", () => this.captureWithWorkspaceLock());
403
+ // settled 复用 run 开始时的 before;helper 在缺少 captureBaseline 时回退完整 capture。
404
+ const after = await measure("settled.capture", () => this.captureBaselineWithWorkspaceLock(staged.before));
379
405
  const changedPaths = await measure("settled.changedPaths", () =>
380
406
  this.dependencies.changedPaths(staged.before, after));
381
407
  if (changedPaths.length > 0 && this.dependencies.prepareDurableRestore !== undefined) {
@@ -396,7 +422,7 @@ export class UndoControllerImpl implements UndoController {
396
422
  const checkpointEntryId = await measure("settled.checkpoint", () =>
397
423
  this.dependencies.appendControl("pi-undo:checkpoint", checkpoint));
398
424
  if (checkpointEntryId === null) {
399
- this.locked = true;
425
+ this.lock("checkpoint_entry_missing");
400
426
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "checkpoint_entry_missing" }).catch(() => {});
401
427
  return;
402
428
  }
@@ -410,11 +436,13 @@ export class UndoControllerImpl implements UndoController {
410
436
  }
411
437
 
412
438
  async undo(): Promise<OperationResult> {
439
+ if (this.locked) return this.recoveryResult();
413
440
  if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
414
441
  return this.runOperation("undo");
415
442
  }
416
443
 
417
444
  async redo(): Promise<OperationResult> {
445
+ if (this.locked) return this.recoveryResult();
418
446
  if (this.historyPaused) return { code: "history_paused", changedFiles: 0 };
419
447
  // 新 run 开始时 redo frontier 已失效;空栈命令不得为了确认 noop 而中断正在运行的 Agent。
420
448
  if (this.redoStack.length === 0) return noop();
@@ -441,7 +469,7 @@ export class UndoControllerImpl implements UndoController {
441
469
  return undefined;
442
470
  } catch {
443
471
  if (lease !== undefined) {
444
- await lease.release().catch(() => { this.locked = true; });
472
+ await lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
445
473
  }
446
474
  this.operationInFlight = false;
447
475
  return { cancel: true };
@@ -454,7 +482,7 @@ export class UndoControllerImpl implements UndoController {
454
482
  this.pendingTree = undefined;
455
483
  try {
456
484
  if ((event.navigationTargetLeafId ?? event.newLeafId) !== pending.descriptor.toLogicalLeaf) {
457
- this.locked = true;
485
+ this.lock("session_navigation_diverged");
458
486
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
459
487
  return;
460
488
  }
@@ -468,7 +496,7 @@ export class UndoControllerImpl implements UndoController {
468
496
  { opId: pending.descriptor.opId },
469
497
  );
470
498
  if (applied.code !== "ok") {
471
- this.locked = true;
499
+ this.lock("restore_failed");
472
500
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
473
501
  return;
474
502
  }
@@ -477,7 +505,7 @@ export class UndoControllerImpl implements UndoController {
477
505
  this.createTreeCursor(pending.descriptor, event.newLeafId, pending.undoStack),
478
506
  );
479
507
  if (cursorResult.kind !== "durable") {
480
- this.locked = true;
508
+ this.lock(cursorResult.kind === "recovery_required" ? cursorResult.reason : "cursor_recovery_required");
481
509
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
482
510
  return;
483
511
  }
@@ -486,9 +514,9 @@ export class UndoControllerImpl implements UndoController {
486
514
  this.undoStack.splice(0, this.undoStack.length, ...pending.undoStack);
487
515
  this.redoStack.length = 0;
488
516
  } catch {
489
- this.locked = true;
517
+ this.lock("tree_recovery_failed");
490
518
  } finally {
491
- await pending.lease.release().catch(() => { this.locked = true; });
519
+ await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
492
520
  this.operationInFlight = false;
493
521
  }
494
522
  }
@@ -501,9 +529,9 @@ export class UndoControllerImpl implements UndoController {
501
529
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTING");
502
530
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTED");
503
531
  } catch {
504
- this.locked = true;
532
+ this.lock("tree_cancel_failed");
505
533
  } finally {
506
- await pending.lease.release().catch(() => { this.locked = true; });
534
+ await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
507
535
  this.operationInFlight = false;
508
536
  }
509
537
  }
@@ -518,9 +546,9 @@ export class UndoControllerImpl implements UndoController {
518
546
  const recovery = (async (): Promise<void> => {
519
547
  try {
520
548
  const result = await this.dependencies.recoverPending();
521
- if (result.kind === "locked") this.locked = true;
549
+ if (result.kind === "locked") this.lock(result.reason ?? "recovery_failed");
522
550
  } catch {
523
- this.locked = true;
551
+ this.lock("recovery_failed");
524
552
  } finally {
525
553
  this.recoveryCompleted = true;
526
554
  }
@@ -530,7 +558,8 @@ export class UndoControllerImpl implements UndoController {
530
558
  }
531
559
 
532
560
  private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
533
- if (this.locked || this.operationInFlight) return { code: "busy", changedFiles: 0 };
561
+ if (this.locked) return this.recoveryResult();
562
+ if (this.operationInFlight) return { code: "busy", changedFiles: 0 };
534
563
  const profile = new OperationProfiler();
535
564
  const done = (result: OperationResult): OperationResult => profile.attach(result);
536
565
  this.operationInFlight = true;
@@ -600,7 +629,7 @@ export class UndoControllerImpl implements UndoController {
600
629
  return done({ code: "restore_failed_safe", changedFiles: 0 });
601
630
  }
602
631
  if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
603
- this.locked = true;
632
+ this.lock("session_navigation_diverged");
604
633
  await profile.measure("journal", () =>
605
634
  this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
606
635
  return done({ code: "recovery_required", changedFiles: 0 });
@@ -620,7 +649,7 @@ export class UndoControllerImpl implements UndoController {
620
649
  const cursor = this.createCursor(descriptor, action, checkpoint);
621
650
  const cursorResult = await profile.measure("cursor", () => this.dependencies.appendCursor(cursor));
622
651
  if (cursorResult.kind === "recovery_required") {
623
- this.locked = true;
652
+ this.lock(cursorResult.reason);
624
653
  await profile.measure("journal", () =>
625
654
  this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
626
655
  return done({ code: "recovery_required", changedFiles: applied.verifiedPaths });
@@ -639,13 +668,13 @@ export class UndoControllerImpl implements UndoController {
639
668
  this.lastSafetyManifestId = rollback.manifestId;
640
669
  return done(this.advanceHistory(action, checkpoint, { code: "ok", changedFiles: applied.verifiedPaths }));
641
670
  } catch {
642
- this.locked = true;
643
- return done({ code: "recovery_required", changedFiles: 0 });
671
+ this.lock("operation_failed");
672
+ return done(this.recoveryResult());
644
673
  } finally {
645
674
  const activeLease = lease;
646
675
  if (activeLease !== undefined) {
647
676
  await profile.measure("unlock", () =>
648
- activeLease.release().catch(() => { this.locked = true; }));
677
+ activeLease.release().catch(() => { this.lock("workspace_lock_release_failed"); }));
649
678
  }
650
679
  if (this.operationProfiler === profile) this.operationProfiler = undefined;
651
680
  this.operationAction = undefined;
@@ -703,7 +732,7 @@ export class UndoControllerImpl implements UndoController {
703
732
  return { code: "restore_failed_safe", changedFiles: 0 };
704
733
  }
705
734
  if (navigation.logicalLeafId !== descriptor.toLogicalLeaf) {
706
- this.locked = true;
735
+ this.lock("session_navigation_diverged");
707
736
  await profile.measure("journal", () =>
708
737
  this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED"));
709
738
  return { code: "recovery_required", changedFiles: 0 };
@@ -716,7 +745,7 @@ export class UndoControllerImpl implements UndoController {
716
745
  const cursorResult = await profile.measure("cursor", () =>
717
746
  this.dependencies.appendCursor(this.createCursor(descriptor, action, checkpoint)));
718
747
  if (cursorResult.kind === "recovery_required") {
719
- this.locked = true;
748
+ this.lock(cursorResult.reason);
720
749
  await profile.measure("journal", () =>
721
750
  this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {}));
722
751
  return { code: "recovery_required", changedFiles: 0 };
@@ -741,9 +770,9 @@ export class UndoControllerImpl implements UndoController {
741
770
  await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
742
771
  return { code: "restore_failed_safe", changedFiles: 0 };
743
772
  } catch {
744
- this.locked = true;
773
+ this.lock("session_only_recovery_failed");
745
774
  await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
746
- return { code: "recovery_required", changedFiles: 0 };
775
+ return this.recoveryResult();
747
776
  }
748
777
  }
749
778
 
@@ -825,9 +854,9 @@ export class UndoControllerImpl implements UndoController {
825
854
  } catch {
826
855
  // 下面统一进入 recovery lock。
827
856
  }
828
- this.locked = true;
857
+ this.lock("compensation_failed");
829
858
  await this.dependencies.journal.setPhase(descriptor.opId, "RECOVERY_REQUIRED").catch(() => {});
830
- return { code: "recovery_required", changedFiles: failure.verifiedPaths };
859
+ return this.recoveryResult(failure.verifiedPaths);
831
860
  }
832
861
 
833
862
  private createCheckpoint(
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
  import { copyFile, link, lstat, open, readFile, rename, rm, type FileHandle } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
 
@@ -194,7 +194,12 @@ export async function createDurablePack(
194
194
  await rm(temporary, { force: true }).catch(() => {});
195
195
  throw error;
196
196
  }
197
- const packChecksum = checksum(Buffer.concat([MAGIC, lengthBytes, headerBytes, ...payloads]));
197
+ const digest = createHash("sha256");
198
+ digest.update(MAGIC);
199
+ digest.update(lengthBytes);
200
+ digest.update(headerBytes);
201
+ for (const payload of payloads) digest.update(payload);
202
+ const packChecksum = digest.digest("hex");
198
203
  return durablePackFromInput(input.opId, input.planDigest, packPath, packChecksum, entries);
199
204
  }
200
205
 
package/src/journal.ts CHANGED
@@ -133,6 +133,27 @@ export class JournalStore {
133
133
  return result;
134
134
  }
135
135
 
136
+ /**
137
+ * 判断 foreign PREPARED transaction 是否是严格意义上的 session-only 空操作。
138
+ * 这是纯只读检查;任何目录、控制文件或 plan 证据不确定时都返回 false。
139
+ */
140
+ async isInertForeignPrepared(pending: PendingJournal): Promise<boolean> {
141
+ if (
142
+ pending.state.phase !== "PREPARED" ||
143
+ pending.descriptor.scopePaths.length !== 0 ||
144
+ !isInertRestorePlan(pending.plan, pending.descriptor)
145
+ ) return false;
146
+ try {
147
+ const current = await this.load(pending.descriptor.opId);
148
+ if (!samePendingJournal(current, pending) || !isInertRestorePlan(current.plan, current.descriptor)) return false;
149
+ if (!await hasOnlyControlFiles(this.operationDirectory(pending.descriptor.opId))) return false;
150
+ const verified = await this.load(pending.descriptor.opId);
151
+ return samePendingJournal(verified, current) && await hasOnlyControlFiles(this.operationDirectory(pending.descriptor.opId));
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+
136
157
  async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
137
158
  if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
138
159
  const pending = await this.load(opId);
@@ -319,6 +340,84 @@ function isRecord(value: unknown): value is Record<string, unknown> {
319
340
  return typeof value === "object" && value !== null && !Array.isArray(value);
320
341
  }
321
342
 
343
+ function isInertRestorePlan(value: unknown, descriptor: OperationDescriptor): boolean {
344
+ if (!isRecord(value)) return false;
345
+ const expectedKeys = [
346
+ "boundaryRoots",
347
+ "currentManifestId",
348
+ "deletePaths",
349
+ "planDigest",
350
+ "scopePaths",
351
+ "targetManifestId",
352
+ "writePaths",
353
+ ].sort();
354
+ if (canonicalJson(Object.keys(value).sort()) !== canonicalJson(expectedKeys)) return false;
355
+ if (
356
+ value.currentManifestId !== descriptor.rollbackManifestId ||
357
+ value.targetManifestId !== descriptor.targetManifestId ||
358
+ !isEmptyArray(value.boundaryRoots) ||
359
+ !isEmptyArray(value.deletePaths) ||
360
+ !isEmptyArray(value.writePaths) ||
361
+ !isEmptyArray(value.scopePaths) ||
362
+ typeof value.planDigest !== "string" ||
363
+ !/^[0-9a-f]{64}$/.test(value.planDigest)
364
+ ) return false;
365
+ try {
366
+ return checksum(canonicalJson({
367
+ currentManifestId: value.currentManifestId,
368
+ targetManifestId: value.targetManifestId,
369
+ boundaryRoots: value.boundaryRoots,
370
+ deletePaths: value.deletePaths,
371
+ writePaths: value.writePaths,
372
+ scopePaths: value.scopePaths,
373
+ })) === value.planDigest;
374
+ } catch {
375
+ return false;
376
+ }
377
+ }
378
+
379
+ async function hasOnlyControlFiles(directory: string): Promise<boolean> {
380
+ const expected = ["descriptor.json", "restore-plan.json", "state.json"];
381
+ try {
382
+ const metadata = await lstat(directory);
383
+ if (!metadata.isDirectory() || metadata.isSymbolicLink()) return false;
384
+ } catch {
385
+ return false;
386
+ }
387
+ let entries;
388
+ try {
389
+ entries = await readdir(directory, { withFileTypes: true });
390
+ } catch {
391
+ return false;
392
+ }
393
+ if (entries.length !== expected.length || !expected.every((name) => entries.some((entry) => entry.name === name))) {
394
+ return false;
395
+ }
396
+ try {
397
+ for (const name of expected) {
398
+ const entry = entries.find((candidate) => candidate.name === name);
399
+ if (entry === undefined || !entry.isFile() || entry.isSymbolicLink()) return false;
400
+ const stats = await lstat(join(directory, name));
401
+ if (!stats.isFile() || stats.isSymbolicLink()) return false;
402
+ }
403
+ return true;
404
+ } catch {
405
+ return false;
406
+ }
407
+ }
408
+
409
+ function samePendingJournal(left: PendingJournal, right: PendingJournal): boolean {
410
+ try {
411
+ return canonicalJson(left) === canonicalJson(right);
412
+ } catch {
413
+ return false;
414
+ }
415
+ }
416
+
417
+ function isEmptyArray(value: unknown): value is readonly unknown[] {
418
+ return Array.isArray(value) && value.length === 0;
419
+ }
420
+
322
421
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
323
422
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
324
423
  }
package/src/pi-runtime.ts CHANGED
@@ -64,6 +64,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
64
64
  workspaceIdentity: initialTopology.workspaceIdentity,
65
65
  getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
66
66
  loadPending: () => journal.loadPending(),
67
+ assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
67
68
  inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
68
69
  finalizeCursor: (pending, inspection) => finalizeCursorMarker(
69
70
  pending.descriptor.sessionIdentity.path,
@@ -259,6 +260,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
259
260
  const controller = new UndoControllerImpl(dependencies, {
260
261
  ...rebuildControllerState(manager, sessionIdentity),
261
262
  locked: startupRecovery.kind === "locked",
263
+ recoveryReason: startupRecovery.kind === "locked" ? startupRecovery.reason : undefined,
262
264
  recoveryCompleted: true,
263
265
  });
264
266
  return {
@@ -266,7 +268,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
266
268
  reporter: new StatusReporter(context),
267
269
  diffSource: store,
268
270
  recovery: startupRecovery.kind === "locked"
269
- ? { files: startupRecovery.files, opId: startupRecovery.opId }
271
+ ? { reason: startupRecovery.reason, files: startupRecovery.files, opId: startupRecovery.opId }
270
272
  : undefined,
271
273
  setCommandContext(next: ExtensionCommandContext | undefined): void {
272
274
  commandContext = next;
package/src/recovery.ts CHANGED
@@ -9,6 +9,8 @@ export interface JournalRecoveryDependencies {
9
9
  readonly workspaceIdentity: string;
10
10
  readonly getLogicalLeafId: () => string | null;
11
11
  readonly loadPending: () => Promise<readonly PendingJournal[]>;
12
+ /** 仅允许严格证明无工作区 mutation 的 foreign PREPARED 空事务被忽略。 */
13
+ readonly assessForeignTransaction?: (journal: PendingJournal) => Promise<boolean>;
12
14
  readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
13
15
  readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
14
16
  readonly recoverMutations: (
@@ -65,6 +67,7 @@ export class JournalRecovery {
65
67
  for (const journal of pending) {
66
68
  const identityError = this.identityError(journal);
67
69
  if (identityError !== null) {
70
+ if (identityError === "session_identity_mismatch" && await this.canIgnoreForeignTransaction(journal)) continue;
68
71
  return { kind: "locked", reason: identityError, operations: recovered };
69
72
  }
70
73
  let inspection: CursorMarkerInspection;
@@ -131,6 +134,17 @@ export class JournalRecovery {
131
134
  return { kind: "recovered", operations: recovered };
132
135
  }
133
136
 
137
+ private async canIgnoreForeignTransaction(journal: PendingJournal): Promise<boolean> {
138
+ const assess = this.dependencies.assessForeignTransaction;
139
+ if (assess === undefined) return false;
140
+ try {
141
+ return await assess(journal);
142
+ } catch {
143
+ // 评估失败等同于证据不足,保留 foreign identity lock。
144
+ return false;
145
+ }
146
+ }
147
+
134
148
  private identityError(journal: PendingJournal): string | null {
135
149
  if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
136
150
  return "workspace_identity_mismatch";
@@ -38,6 +38,7 @@ const PREPARED_PLAN_CACHE_LIMIT = 16;
38
38
  const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
39
39
  const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
40
40
  const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
41
+ const RESTORE_FILE_VERIFY_CONCURRENCY = 32;
41
42
 
42
43
  export interface RestorePlan {
43
44
  currentManifestId: ManifestId;
@@ -519,7 +520,13 @@ export class RestoreEngine {
519
520
  return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
520
521
  }
521
522
  try {
522
- await this.assertCompleteVisibleSubset(topologyBefore, [current, target], options.mutationJournal);
523
+ await this.assertCompleteVisibleSubset(
524
+ topologyBefore,
525
+ [current, target],
526
+ options.mutationJournal,
527
+ [],
528
+ this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
529
+ );
523
530
  } catch {
524
531
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
525
532
  }
@@ -588,6 +595,11 @@ export class RestoreEngine {
588
595
  );
589
596
  return result;
590
597
  }
598
+ try {
599
+ await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
600
+ } catch {
601
+ // 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
602
+ }
591
603
  const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
592
604
  if (!preflight.ok) {
593
605
  return {
@@ -625,7 +637,13 @@ export class RestoreEngine {
625
637
 
626
638
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
627
639
  assertUnchangedTopology(topologyBefore, topologyAfter);
628
- await this.assertCompleteVisibleSubset(topologyAfter, [target], options.mutationJournal);
640
+ await this.assertCompleteVisibleSubset(
641
+ topologyAfter,
642
+ [target],
643
+ options.mutationJournal,
644
+ [],
645
+ this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
646
+ );
629
647
  const verification = await this.verifyTarget(
630
648
  target,
631
649
  currentPaths,
@@ -702,6 +720,7 @@ export class RestoreEngine {
702
720
  ? []
703
721
  : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
704
722
  }),
723
+ this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
705
724
  );
706
725
  const totalPaths = plan.deletePaths.length + plan.writePaths.length;
707
726
  if ((await options.mutationJournal.load()).length !== 0) {
@@ -887,6 +906,41 @@ export class RestoreEngine {
887
906
  return result;
888
907
  }
889
908
 
909
+ private completeCoverageOwnedPaths(
910
+ scopePaths: readonly string[] | undefined,
911
+ ownedPaths: readonly ReadonlyMap<string, OwnedPath>[],
912
+ ): readonly ReadonlyMap<string, OwnedPath>[] | undefined {
913
+ return scopePaths === undefined ? ownedPaths : undefined;
914
+ }
915
+
916
+ private async prefetchCompleteRestoreBlobs(
917
+ plan: RestorePlan,
918
+ current: SnapshotManifest,
919
+ target: SnapshotManifest,
920
+ currentPaths: ReadonlyMap<string, OwnedPath>,
921
+ targetPaths: ReadonlyMap<string, OwnedPath>,
922
+ ): Promise<void> {
923
+ if (plan.scopePaths !== undefined || !SnapshotStore.supportsValidatedBlobBatch(this.store)) return;
924
+ const requestsFor = (paths: ReadonlyMap<string, OwnedPath>, extraPaths: readonly string[] = []) => {
925
+ const requests = [];
926
+ const requested = new Set([...plan.writePaths, ...extraPaths]);
927
+ for (const path of requested) {
928
+ const owned = paths.get(path);
929
+ if (owned === undefined || owned.entry.kind !== "file" || owned.entry.blobId === null) continue;
930
+ requests.push({
931
+ rootPath: owned.root.relativeRoot,
932
+ blobId: owned.entry.blobId,
933
+ relativePath: owned.entry.relativePath,
934
+ });
935
+ }
936
+ return requests;
937
+ };
938
+ await Promise.all([
939
+ this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
940
+ this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
941
+ ]);
942
+ }
943
+
890
944
  private assertCurrentTopology(
891
945
  current: SnapshotManifest,
892
946
  target: SnapshotManifest,
@@ -926,12 +980,13 @@ export class RestoreEngine {
926
980
  const paths = [...new Set([...currentPaths.keys(), ...targetPaths.keys()])]
927
981
  .filter((path) => scope === undefined || scope.has(path))
928
982
  .sort(comparePaths);
929
- let verifiedPaths = 0;
930
- for (const path of paths) {
931
- if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) {
932
- verifiedPaths += 1;
933
- continue;
934
- }
983
+ const results: Array<boolean | undefined> = new Array(paths.length);
984
+ let nextIndex = 0;
985
+ let stop = false;
986
+ let failure: unknown;
987
+ let failureIndex: number | undefined;
988
+ const verifyPath = async (path: string): Promise<boolean> => {
989
+ if (await this.pathIsShadowedByTarget(target.manifestId, path, targetPaths)) return true;
935
990
  const currentPath = currentPaths.get(path);
936
991
  const targetPath = targetPaths.get(path);
937
992
  const matchesCurrent = currentPath !== undefined &&
@@ -941,7 +996,37 @@ export class RestoreEngine {
941
996
  const matchesAbsentSide = !matchesCurrent && !matchesTarget &&
942
997
  (currentPath === undefined || targetPath === undefined) &&
943
998
  await this.pathIsAbsent(path);
944
- if (!matchesCurrent && !matchesTarget && !matchesAbsentSide) {
999
+ return matchesCurrent || matchesTarget || matchesAbsentSide;
1000
+ };
1001
+ const worker = async (): Promise<void> => {
1002
+ while (!stop && nextIndex < paths.length) {
1003
+ const index = nextIndex;
1004
+ nextIndex += 1;
1005
+ try {
1006
+ const ok = await verifyPath(paths[index]!);
1007
+ results[index] = ok;
1008
+ if (!ok) stop = true;
1009
+ } catch (error) {
1010
+ if (failureIndex === undefined || index < failureIndex) {
1011
+ failure = error;
1012
+ failureIndex = index;
1013
+ }
1014
+ stop = true;
1015
+ }
1016
+ }
1017
+ };
1018
+ if (paths.length > 0) {
1019
+ await Promise.all(Array.from(
1020
+ { length: Math.min(RESTORE_FILE_VERIFY_CONCURRENCY, paths.length) },
1021
+ () => worker(),
1022
+ ));
1023
+ }
1024
+ let verifiedPaths = 0;
1025
+ for (let index = 0; index < results.length; index += 1) {
1026
+ const ok = results[index];
1027
+ if (ok === false) return { ok: false, verifiedPaths, totalPaths: paths.length };
1028
+ if (ok === undefined) {
1029
+ if (failureIndex === index && failure !== undefined) throw failure;
945
1030
  return { ok: false, verifiedPaths, totalPaths: paths.length };
946
1031
  }
947
1032
  verifiedPaths += 1;
@@ -1168,7 +1253,13 @@ export class RestoreEngine {
1168
1253
  await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
1169
1254
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1170
1255
  assertUnchangedTopology(topologyBefore, topologyAfter);
1171
- await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
1256
+ await this.assertCompleteVisibleSubset(
1257
+ topologyAfter,
1258
+ [current],
1259
+ options.mutationJournal,
1260
+ [],
1261
+ this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1262
+ );
1172
1263
  const verification = await this.verifyTarget(
1173
1264
  current,
1174
1265
  targetPaths,
@@ -1205,7 +1296,13 @@ export class RestoreEngine {
1205
1296
  try {
1206
1297
  const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1207
1298
  assertUnchangedTopology(topologyBefore, topologyAfter);
1208
- await this.assertCompleteVisibleSubset(topologyAfter, [current], options.mutationJournal);
1299
+ await this.assertCompleteVisibleSubset(
1300
+ topologyAfter,
1301
+ [current],
1302
+ options.mutationJournal,
1303
+ [],
1304
+ this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1305
+ );
1209
1306
  const verification = await this.verifyTarget(
1210
1307
  current,
1211
1308
  targetPaths,
@@ -1397,16 +1494,17 @@ export class RestoreEngine {
1397
1494
  allowedManifests: readonly SnapshotManifest[],
1398
1495
  mutationJournal?: MutationJournal,
1399
1496
  extraExclusions: readonly string[] = [],
1497
+ ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
1400
1498
  ): Promise<void> {
1401
1499
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
1402
1500
  return;
1403
1501
  }
1404
1502
  const allowedPaths = new Set<string>();
1405
- for (const manifest of allowedManifests) {
1503
+ for (const [index, manifest] of allowedManifests.entries()) {
1406
1504
  for (const path of ignoredWorkspacePaths(manifest)) {
1407
1505
  allowedPaths.add(path);
1408
1506
  }
1409
- const paths = await this.readOwnedPaths(manifest);
1507
+ const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1410
1508
  for (const [path, owned] of paths) {
1411
1509
  if (owned.entry.kind !== "directory") {
1412
1510
  allowedPaths.add(path);
@@ -1435,23 +1533,18 @@ export class RestoreEngine {
1435
1533
  deletePaths: readonly string[],
1436
1534
  scopePaths?: readonly string[],
1437
1535
  ): Promise<{ verifiedPaths: number; totalPaths: number; pathFingerprints: string[] }> {
1438
- const pathFingerprints: string[] = [];
1439
1536
  const scope = scopePaths === undefined ? undefined : new Set(scopePaths);
1440
- for (const [path, owned] of targetPaths) {
1441
- if (scope !== undefined && !scope.has(path)) continue;
1442
- pathFingerprints.push(await this.verifyEntry(target.manifestId, owned));
1443
- }
1444
- let verifiedPaths = pathFingerprints.length;
1445
- let totalPaths = pathFingerprints.length;
1446
- for (const path of deletePaths) {
1447
- if (
1448
- targetPaths.has(path) ||
1449
- currentPaths.get(path)?.entry.kind === "directory" ||
1450
- hasNonDirectoryAncestor(path, targetPaths)
1451
- ) {
1452
- continue;
1453
- }
1454
- totalPaths += 1;
1537
+ const scopedTargets = [...targetPaths].filter(([path]) => scope === undefined || scope.has(path));
1538
+ const pathFingerprints = await mapConcurrentOrdered(
1539
+ scopedTargets,
1540
+ RESTORE_FILE_VERIFY_CONCURRENCY,
1541
+ ([, owned]) => this.verifyEntry(target.manifestId, owned),
1542
+ );
1543
+ const remainingDeletes = deletePaths.filter((path) =>
1544
+ !targetPaths.has(path) &&
1545
+ currentPaths.get(path)?.entry.kind !== "directory" &&
1546
+ !hasNonDirectoryAncestor(path, targetPaths));
1547
+ await mapConcurrentOrdered(remainingDeletes, RESTORE_FILE_VERIFY_CONCURRENCY, async (path) => {
1455
1548
  try {
1456
1549
  await lstat(this.absolutePath(path));
1457
1550
  throw new Error(`目标应删除的路径仍然存在:${path}`);
@@ -1460,11 +1553,10 @@ export class RestoreEngine {
1460
1553
  throw error;
1461
1554
  }
1462
1555
  }
1463
- verifiedPaths += 1;
1464
- }
1556
+ });
1465
1557
  return {
1466
- verifiedPaths,
1467
- totalPaths,
1558
+ verifiedPaths: pathFingerprints.length + remainingDeletes.length,
1559
+ totalPaths: pathFingerprints.length + remainingDeletes.length,
1468
1560
  pathFingerprints,
1469
1561
  };
1470
1562
  }
@@ -40,7 +40,7 @@ const HASH_BATCH_MAX_ARGUMENT_BYTES = process.platform === "win32" ? 24 * 1024 :
40
40
  const HASH_BATCH_CONCURRENCY = 4;
41
41
  const ROOT_CAPTURE_CONCURRENCY = 4;
42
42
  const FILE_SYSTEM_INSPECTION_CONCURRENCY = 32;
43
- const IGNORED_METADATA_BATCH_SIZE = 1_024;
43
+ const METADATA_BATCH_SIZE = 1_024;
44
44
  const INDEX_BATCH_MAX_ENTRIES = 4_096;
45
45
  const INDEX_BATCH_MAX_BYTES = 8 * 1024 * 1024;
46
46
  const BLOB_CACHE_MAX_BYTES = 128 * 1024 * 1024;
@@ -210,6 +210,8 @@ export class SnapshotStore {
210
210
  private readonly blobCache = new Map<string, CachedBlob>();
211
211
  private readonly visibleLeafCache = new Map<string, Map<string, CachedVisibleLeaf>>();
212
212
  private readonly leafCacheDirectoriesLoaded = new Set<string>();
213
+ private readonly configuredPrivateRepositories = new Set<string>();
214
+ private readonly leafCacheDirtyDirectories = new Set<string>();
213
215
  private blobCacheBytes = 0;
214
216
 
215
217
  constructor(options: SnapshotStoreOptions = {}) {
@@ -741,6 +743,61 @@ export class SnapshotStore {
741
743
  return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
742
744
  }
743
745
 
746
+ /** 按 root 批量预取普通文件 blob;membership 与 manifest 校验仍走只读路径。 */
747
+ async prefetchBlobs(id: ManifestId, requests: readonly SnapshotBlobRequest[]): Promise<void> {
748
+ if (requests.length === 0) return;
749
+ for (const request of requests) {
750
+ relativeSafePath("/", request.rootPath);
751
+ if (!isObjectId(request.blobId)) {
752
+ throw new SnapshotStoreError("object_missing", "blob ID 无效");
753
+ }
754
+ if (request.relativePath === undefined) {
755
+ throw new SnapshotStoreError("object_missing", "blob 预取必须提供 root-relative path");
756
+ }
757
+ }
758
+ const manifestPath = await this.findManifestPath(id);
759
+ const manifest = await this.loadManifest(id);
760
+ const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
761
+ const storeDirectory = dirname(dirname(manifestPath));
762
+ const byRoot = new Map<string, SnapshotBlobRequest[]>();
763
+ for (const request of requests) {
764
+ const grouped = byRoot.get(request.rootPath) ?? [];
765
+ grouped.push(request);
766
+ byRoot.set(request.rootPath, grouped);
767
+ }
768
+ try {
769
+ const grouped = new Map<string, Map<string, CapturedTreeEntry>>();
770
+ for (const [rootPath, rootRequests] of byRoot) {
771
+ const root = roots.get(rootPath);
772
+ if (root === undefined) {
773
+ throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
774
+ }
775
+ if (root.state !== "active" || root.treeId === null) {
776
+ throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
777
+ }
778
+ const gitDirectory = this.rootGitDirectory(storeDirectory, root);
779
+ const entries = await this.readTreeEntries(gitDirectory, root.treeId);
780
+ const byPath = new Map(entries.map((entry) => [entry.relativePath, entry]));
781
+ const unique = grouped.get(gitDirectory) ?? new Map<string, CapturedTreeEntry>();
782
+ for (const request of rootRequests) {
783
+ const safeRelativePath = relativeSafePath("/", request.relativePath!);
784
+ const entry = byPath.get(safeRelativePath);
785
+ if (entry === undefined || entry.objectId !== request.blobId) {
786
+ throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
787
+ }
788
+ unique.set(entry.objectId, entry);
789
+ }
790
+ grouped.set(gitDirectory, unique);
791
+ }
792
+ for (const [gitDirectory, unique] of grouped) {
793
+ await this.preloadBlobBytes(gitDirectory, [...unique.values()]);
794
+ }
795
+ } catch (error) {
796
+ if (error instanceof SnapshotStoreError) throw error;
797
+ throw new SnapshotStoreError("object_missing", "blob 无法预取", { cause: error });
798
+ }
799
+ }
800
+
744
801
  private readBlobsValidated(
745
802
  id: ManifestId,
746
803
  requests: readonly SnapshotBlobRequest[],
@@ -1010,7 +1067,7 @@ export class SnapshotStore {
1010
1067
  }
1011
1068
  // ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
1012
1069
  // 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
1013
- const nativeEntries = await this.inspectIgnoredMetadataBatches(cwd, candidates, requestDirectory);
1070
+ const nativeEntries = await this.inspectNativeMetadataBatches(cwd, candidates, requestDirectory);
1014
1071
  const kinds = nativeEntries === undefined
1015
1072
  ? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
1016
1073
  : nativeEntries.map((entry) => entry.kind);
@@ -1027,18 +1084,20 @@ export class SnapshotStore {
1027
1084
  return result.sort(comparePaths);
1028
1085
  }
1029
1086
 
1030
- private async inspectIgnoredMetadataBatches(
1087
+ private async inspectNativeMetadataBatches(
1031
1088
  cwd: string,
1032
1089
  paths: readonly string[],
1033
1090
  requestDirectory: string,
1034
1091
  ): Promise<readonly NativeMetadataEntry[] | undefined> {
1092
+ // 空路径不得触发 native inspect;首批 unsupported 才整体回退,中途变化必须 fail closed。
1093
+ if (paths.length === 0) return [];
1035
1094
  const result: NativeMetadataEntry[] = [];
1036
- for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
1037
- const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
1095
+ for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
1096
+ const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
1038
1097
  const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
1039
1098
  if (inspected === undefined) {
1040
1099
  if (result.length > 0) {
1041
- throw new SnapshotStoreError("capture_failed", "native ignored metadata 能力在批次间变化");
1100
+ throw new SnapshotStoreError("capture_failed", "native metadata 能力在批次间变化");
1042
1101
  }
1043
1102
  return undefined;
1044
1103
  }
@@ -1052,8 +1111,8 @@ export class SnapshotStore {
1052
1111
  paths: readonly string[],
1053
1112
  ): Promise<readonly NativeMetadataEntry["kind"][]> {
1054
1113
  const result: NativeMetadataEntry["kind"][] = [];
1055
- for (let offset = 0; offset < paths.length; offset += IGNORED_METADATA_BATCH_SIZE) {
1056
- const batch = paths.slice(offset, offset + IGNORED_METADATA_BATCH_SIZE);
1114
+ for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
1115
+ const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
1057
1116
  await assertNoSymlinkParents(cwd, batch);
1058
1117
  const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
1059
1118
  const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
@@ -1153,9 +1212,10 @@ export class SnapshotStore {
1153
1212
 
1154
1213
  private rememberVisibleLeaves(update: VisibleLeafCacheUpdate): void {
1155
1214
  const { gitDirectory, staged, inclusions } = update;
1215
+ const previous = this.visibleLeafCache.get(gitDirectory);
1156
1216
  const cache = inclusions !== null && inclusions.length === 0
1157
1217
  ? new Map<string, CachedVisibleLeaf>()
1158
- : new Map(this.visibleLeafCache.get(gitDirectory));
1218
+ : new Map(previous);
1159
1219
  if (inclusions !== null && inclusions.length > 0) {
1160
1220
  for (const relativePath of cache.keys()) {
1161
1221
  if (inclusions.some((inclusion) => isPathAtOrBelow(inclusion, relativePath))) {
@@ -1172,6 +1232,9 @@ export class SnapshotStore {
1172
1232
  }
1173
1233
  cache.set(leaf.relativePath, { ...leaf, objectId, verifiedAtNs: staged.verifiedAtNs });
1174
1234
  }
1235
+ if (!samePersistedLeafCache(previous, cache)) {
1236
+ this.leafCacheDirtyDirectories.add(storeDirectoryForGitDirectory(gitDirectory));
1237
+ }
1175
1238
  this.visibleLeafCache.set(gitDirectory, cache);
1176
1239
  }
1177
1240
 
@@ -1208,6 +1271,7 @@ export class SnapshotStore {
1208
1271
 
1209
1272
  /** 把当前 storeDirectory 范围内的叶子缓存原子写入磁盘(best-effort)。 */
1210
1273
  private async persistLeafCache(storeDirectory: string): Promise<void> {
1274
+ if (!this.leafCacheDirtyDirectories.has(storeDirectory)) return;
1211
1275
  const prefix = `${storeDirectory}${sep}`;
1212
1276
  const entries: Record<string, Record<string, PersistedLeafCacheEntry>> = {};
1213
1277
  for (const [gitDirectory, cache] of this.visibleLeafCache) {
@@ -1233,6 +1297,7 @@ export class SnapshotStore {
1233
1297
  Buffer.from(JSON.stringify({ schemaVersion: 1, entries }), "utf8"),
1234
1298
  0o600,
1235
1299
  );
1300
+ this.leafCacheDirtyDirectories.delete(storeDirectory);
1236
1301
  } catch {
1237
1302
  // 缓存写入是 best-effort:失败只影响下次性能,不影响正确性。
1238
1303
  }
@@ -1243,12 +1308,12 @@ export class SnapshotStore {
1243
1308
  leaves: readonly VisibleLeaf[],
1244
1309
  requestDirectory?: string,
1245
1310
  ): Promise<void> {
1311
+ if (leaves.length === 0) return;
1312
+ const paths = leaves.map((leaf) => leaf.relativePath);
1246
1313
  if (requestDirectory !== undefined) {
1247
- const inspected = await this.nativeMetadata.inspect(
1248
- cwd,
1249
- leaves.map((leaf) => leaf.relativePath),
1250
- requestDirectory,
1251
- );
1314
+ // 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
1315
+ await assertNoSymlinkParents(cwd, paths);
1316
+ const inspected = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
1252
1317
  if (inspected !== undefined) {
1253
1318
  for (let index = 0; index < leaves.length; index += 1) {
1254
1319
  const leaf = leaves[index]!;
@@ -1257,10 +1322,15 @@ export class SnapshotStore {
1257
1322
  throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
1258
1323
  }
1259
1324
  }
1325
+ await assertNoSymlinkParents(cwd, paths);
1260
1326
  return;
1261
1327
  }
1328
+ await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
1329
+ this.assertVisibleLeafUnchanged(cwd, leaf));
1330
+ await assertNoSymlinkParents(cwd, paths);
1331
+ return;
1262
1332
  }
1263
- await assertNoSymlinkParents(cwd, leaves.map((leaf) => leaf.relativePath));
1333
+ await assertNoSymlinkParents(cwd, paths);
1264
1334
  await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
1265
1335
  this.assertVisibleLeafUnchanged(cwd, leaf));
1266
1336
  }
@@ -1335,10 +1405,14 @@ export class SnapshotStore {
1335
1405
  exclusions,
1336
1406
  exactExclusions,
1337
1407
  );
1338
- const nativeEntries = await this.nativeMetadata.inspect(cwd, paths, requestDirectory);
1408
+ if (paths.length === 0) return [];
1409
+ // 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
1410
+ await assertNoSymlinkParents(cwd, paths);
1411
+ const nativeEntries = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
1339
1412
  const metadataEntries = nativeEntries === undefined
1340
1413
  ? await this.collectVisibleLeafMetadataFallback(cwd, paths)
1341
1414
  : nativeEntries.map((entry) => nativeVisibleLeafMetadata(entry));
1415
+ await assertNoSymlinkParents(cwd, paths);
1342
1416
  const leaves: VisibleLeaf[] = [];
1343
1417
  for (let index = 0; index < paths.length; index += 1) {
1344
1418
  const relativePath = paths[index]!;
@@ -1417,9 +1491,11 @@ export class SnapshotStore {
1417
1491
  }
1418
1492
 
1419
1493
  private async configurePrivateRepository(gitDirectory: string): Promise<void> {
1494
+ if (this.configuredPrivateRepositories.has(gitDirectory)) return;
1420
1495
  const environment = cleanGitEnvironment();
1421
1496
  await this.runGit(["--git-dir", gitDirectory, "config", "gc.auto", "0"], { env: environment });
1422
1497
  await this.runGit(["--git-dir", gitDirectory, "config", "maintenance.auto", "false"], { env: environment });
1498
+ this.configuredPrivateRepositories.add(gitDirectory);
1423
1499
  }
1424
1500
 
1425
1501
  private async assertNoAlternates(gitDirectory: string): Promise<void> {
@@ -1971,6 +2047,35 @@ function blobCacheKey(gitDirectory: string, objectId: string): string {
1971
2047
  return `${gitDirectory}\0${objectId}`;
1972
2048
  }
1973
2049
 
2050
+ function storeDirectoryForGitDirectory(gitDirectory: string): string {
2051
+ return dirname(dirname(dirname(gitDirectory)));
2052
+ }
2053
+
2054
+ function samePersistedLeafCache(
2055
+ left: ReadonlyMap<string, CachedVisibleLeaf> | undefined,
2056
+ right: ReadonlyMap<string, CachedVisibleLeaf>,
2057
+ ): boolean {
2058
+ if (left === undefined) return right.size === 0;
2059
+ if (left.size !== right.size) return false;
2060
+ for (const [path, entry] of right) {
2061
+ const existing = left.get(path);
2062
+ const existingTrusted = existing !== undefined &&
2063
+ existing.verifiedAtNs > existing.changedAtNs + RACY_CLEAN_WINDOW_NS;
2064
+ const entryTrusted = entry.verifiedAtNs > entry.changedAtNs + RACY_CLEAN_WINDOW_NS;
2065
+ if (
2066
+ existing === undefined ||
2067
+ existing.kind !== entry.kind ||
2068
+ existing.mode !== entry.mode ||
2069
+ existing.fingerprint !== entry.fingerprint ||
2070
+ existing.cacheable !== entry.cacheable ||
2071
+ existing.objectId !== entry.objectId ||
2072
+ existing.changedAtNs !== entry.changedAtNs ||
2073
+ existingTrusted !== entryTrusted
2074
+ ) return false;
2075
+ }
2076
+ return true;
2077
+ }
2078
+
1974
2079
  function blobReadBatches(entries: readonly CapturedTreeEntry[]): CapturedTreeEntry[][] {
1975
2080
  const result: CapturedTreeEntry[][] = [];
1976
2081
  let batch: CapturedTreeEntry[] = [];
@@ -32,11 +32,12 @@ export class StatusReporter {
32
32
  reason: string,
33
33
  details?: { readonly files?: number; readonly opId?: string },
34
34
  ): void {
35
+ const safeReason = sanitize(reason) || "recovery_required";
35
36
  if (details?.files !== undefined && details.opId !== undefined) {
36
- this.setStatus(`recovery_required files:${details.files} op:${sanitize(details.opId)}`);
37
+ this.setStatus(`recovery_required reason:${safeReason} files:${details.files} op:${sanitize(details.opId)}`);
37
38
  return;
38
39
  }
39
- this.setStatus(`recovery required: ${sanitize(reason)}`);
40
+ this.setStatus(`recovery required: ${safeReason}`);
40
41
  }
41
42
 
42
43
  clear(): void {