@davideasden/pi-undo 0.2.16 → 0.2.17

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "type": "module",
6
6
  "keywords": [
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(
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";
@@ -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;
@@ -1010,7 +1010,7 @@ export class SnapshotStore {
1010
1010
  }
1011
1011
  // ignored build/vendor trees 常含数万叶子;复用同一批量 metadata 协议,避免逐路径重复
1012
1012
  // 遍历父目录。Native 与 fallback 都在叶子扫描前后复核共享父目录。
1013
- const nativeEntries = await this.inspectIgnoredMetadataBatches(cwd, candidates, requestDirectory);
1013
+ const nativeEntries = await this.inspectNativeMetadataBatches(cwd, candidates, requestDirectory);
1014
1014
  const kinds = nativeEntries === undefined
1015
1015
  ? await this.collectIgnoredPresentKindsFallback(cwd, candidates)
1016
1016
  : nativeEntries.map((entry) => entry.kind);
@@ -1027,18 +1027,20 @@ export class SnapshotStore {
1027
1027
  return result.sort(comparePaths);
1028
1028
  }
1029
1029
 
1030
- private async inspectIgnoredMetadataBatches(
1030
+ private async inspectNativeMetadataBatches(
1031
1031
  cwd: string,
1032
1032
  paths: readonly string[],
1033
1033
  requestDirectory: string,
1034
1034
  ): Promise<readonly NativeMetadataEntry[] | undefined> {
1035
+ // 空路径不得触发 native inspect;首批 unsupported 才整体回退,中途变化必须 fail closed。
1036
+ if (paths.length === 0) return [];
1035
1037
  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);
1038
+ for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
1039
+ const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
1038
1040
  const inspected = await this.nativeMetadata.inspect(cwd, batch, requestDirectory);
1039
1041
  if (inspected === undefined) {
1040
1042
  if (result.length > 0) {
1041
- throw new SnapshotStoreError("capture_failed", "native ignored metadata 能力在批次间变化");
1043
+ throw new SnapshotStoreError("capture_failed", "native metadata 能力在批次间变化");
1042
1044
  }
1043
1045
  return undefined;
1044
1046
  }
@@ -1052,8 +1054,8 @@ export class SnapshotStore {
1052
1054
  paths: readonly string[],
1053
1055
  ): Promise<readonly NativeMetadataEntry["kind"][]> {
1054
1056
  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);
1057
+ for (let offset = 0; offset < paths.length; offset += METADATA_BATCH_SIZE) {
1058
+ const batch = paths.slice(offset, offset + METADATA_BATCH_SIZE);
1057
1059
  await assertNoSymlinkParents(cwd, batch);
1058
1060
  const kinds = await mapConcurrentOrdered(batch, FILE_SYSTEM_INSPECTION_CONCURRENCY, async (relativePath) => {
1059
1061
  const metadata = await lstat(join(cwd, ...relativePath.split("/"))).catch((error) => {
@@ -1243,12 +1245,12 @@ export class SnapshotStore {
1243
1245
  leaves: readonly VisibleLeaf[],
1244
1246
  requestDirectory?: string,
1245
1247
  ): Promise<void> {
1248
+ if (leaves.length === 0) return;
1249
+ const paths = leaves.map((leaf) => leaf.relativePath);
1246
1250
  if (requestDirectory !== undefined) {
1247
- const inspected = await this.nativeMetadata.inspect(
1248
- cwd,
1249
- leaves.map((leaf) => leaf.relativePath),
1250
- requestDirectory,
1251
- );
1251
+ // 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
1252
+ await assertNoSymlinkParents(cwd, paths);
1253
+ const inspected = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
1252
1254
  if (inspected !== undefined) {
1253
1255
  for (let index = 0; index < leaves.length; index += 1) {
1254
1256
  const leaf = leaves[index]!;
@@ -1257,10 +1259,15 @@ export class SnapshotStore {
1257
1259
  throw new SnapshotStoreError("capture_failed", `捕获期间工作区叶子已变化:${leaf.relativePath}`);
1258
1260
  }
1259
1261
  }
1262
+ await assertNoSymlinkParents(cwd, paths);
1260
1263
  return;
1261
1264
  }
1265
+ await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
1266
+ this.assertVisibleLeafUnchanged(cwd, leaf));
1267
+ await assertNoSymlinkParents(cwd, paths);
1268
+ return;
1262
1269
  }
1263
- await assertNoSymlinkParents(cwd, leaves.map((leaf) => leaf.relativePath));
1270
+ await assertNoSymlinkParents(cwd, paths);
1264
1271
  await mapConcurrentOrdered(leaves, FILE_SYSTEM_INSPECTION_CONCURRENCY, (leaf) =>
1265
1272
  this.assertVisibleLeafUnchanged(cwd, leaf));
1266
1273
  }
@@ -1335,10 +1342,14 @@ export class SnapshotStore {
1335
1342
  exclusions,
1336
1343
  exactExclusions,
1337
1344
  );
1338
- const nativeEntries = await this.nativeMetadata.inspect(cwd, paths, requestDirectory);
1345
+ if (paths.length === 0) return [];
1346
+ // 分批 inspect 只核验当前批次祖先;全部可见路径必须在批次前后各包一层父目录检查。
1347
+ await assertNoSymlinkParents(cwd, paths);
1348
+ const nativeEntries = await this.inspectNativeMetadataBatches(cwd, paths, requestDirectory);
1339
1349
  const metadataEntries = nativeEntries === undefined
1340
1350
  ? await this.collectVisibleLeafMetadataFallback(cwd, paths)
1341
1351
  : nativeEntries.map((entry) => nativeVisibleLeafMetadata(entry));
1352
+ await assertNoSymlinkParents(cwd, paths);
1342
1353
  const leaves: VisibleLeaf[] = [];
1343
1354
  for (let index = 0; index < paths.length; index += 1) {
1344
1355
  const relativePath = paths[index]!;
@@ -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 {