@davideasden/pi-undo 0.2.20 → 0.2.21

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/README.md CHANGED
@@ -68,6 +68,16 @@ pi -e /absolute/path/to/pi-undo/extensions/pi-undo.ts
68
68
 
69
69
  Work with Pi normally. `pi-undo` automatically records a boundary after each completed agent run.
70
70
 
71
+ ### Recovery
72
+
73
+ If an operation fails mid-flight (for example, the process is killed while files are being restored), pi-undo locks undo history and reports `recovery_required`. Run:
74
+
75
+ ```text
76
+ /undo-recover
77
+ ```
78
+
79
+ to re-run recovery in place: it re-verifies pending journals, settles provably-safe transactions, rebuilds the undo/redo stacks, and refreshes the status line — the in-process equivalent of restarting the session window. If the workspace is still locked afterwards, the blocking transaction belongs to another session in the same workspace; open (or restart) that session and run `/undo-recover` there.
80
+
71
81
  ### Diff
72
82
 
73
83
  ```text
@@ -38,6 +38,13 @@ interface DeferredPrompt {
38
38
 
39
39
  export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi: ExtensionAPI) => void {
40
40
  return (pi) => {
41
+ if (process.env.PI_SUBAGENT_CHILD === "1") {
42
+ // pi-subagents 的 runner 子进程会加载 ambient extensions,并用该环境变量
43
+ // 标记自身(其父扩展据此保持惰性)。pi-undo 只在主会话生效:避免子进程
44
+ // 重复 capture 争用 workspace lock。子代理的工作区变更由父会话的 run 级
45
+ // before/after 快照覆盖,撤回语义不受影响。
46
+ return;
47
+ }
41
48
  let runtime: PiUndoRuntime | undefined;
42
49
  let runtimeContext: ExtensionContext | undefined;
43
50
  let generation = 0;
@@ -47,6 +54,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
47
54
  let activeCommands = new Set<symbol>();
48
55
  let activeAction: "undo" | "redo" | undefined;
49
56
  let captureFailureNotified = false;
57
+ let recoveryHintNotified = false;
50
58
 
51
59
  const initialize = async (context: ExtensionContext): Promise<void> => {
52
60
  const currentGeneration = ++generation;
@@ -57,6 +65,7 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
57
65
  activeCommands = new Set<symbol>();
58
66
  activeAction = undefined;
59
67
  captureFailureNotified = false;
68
+ recoveryHintNotified = false;
60
69
  try {
61
70
  const next = await runtimeFactory(context, pi);
62
71
  if (currentGeneration !== generation) return;
@@ -168,6 +177,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
168
177
  };
169
178
  }
170
179
  active.reporter.result(result, performance.now() - commandStarted);
180
+ if (result.code === "recovery_required" && !recoveryHintNotified) {
181
+ recoveryHintNotified = true;
182
+ context.ui.notify("pi-undo: run /undo-recover to retry recovery", "info");
183
+ }
171
184
  const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
172
185
  if (
173
186
  action === "undo" && result.code === "ok" && result.refillPrompt !== undefined &&
@@ -227,6 +240,28 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
227
240
  description: "Review files changed by an Agent run (latest, or /diff N)",
228
241
  handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
229
242
  });
243
+ pi.registerCommand("undo-recover", {
244
+ description: "Re-run pi-undo recovery and refresh undo history",
245
+ handler: async (_args: string, context: ExtensionCommandContext) => {
246
+ if (activeCommands.size > 0) {
247
+ context.ui.notify("pi-undo: wait for the current operation to finish before recovering", "warning");
248
+ return;
249
+ }
250
+ // 原地重建 runtime:等价于重启窗口,复用启动 recovery 语义。
251
+ await initialize(context);
252
+ const active = runtime;
253
+ if (active === undefined) return;
254
+ const history = active.controller.history();
255
+ if (history.locked) {
256
+ context.ui.notify(
257
+ `pi-undo: still locked (${active.controller.recoveryReason?.() ?? "pending journal"}); resolve the blocking session, then run /undo-recover again`,
258
+ "warning",
259
+ );
260
+ return;
261
+ }
262
+ context.ui.notify(`pi-undo: recovery complete (undo:${history.undoCount} redo:${history.redoCount})`, "info");
263
+ },
264
+ });
230
265
 
231
266
  pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
232
267
  pi.on("input", async (event: InputEvent, context: ExtensionContext) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davideasden/pi-undo",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "Persistent workspace undo and redo for Pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/controller.ts CHANGED
@@ -401,7 +401,9 @@ export class UndoControllerImpl implements UndoController {
401
401
  profiler === undefined ? operation() : profiler.measure(phase, operation);
402
402
  try {
403
403
  // settled 复用 run 开始时的 before;helper 在缺少 captureBaseline 时回退完整 capture。
404
- const after = await measure("settled.capture", () => this.captureBaselineWithWorkspaceLock(staged.before));
404
+ // 撕裂捕获(并发写撞上断言窗口)是暂态的:短暂退避后重试一次,成功则不清空历史。
405
+ const after = await measure("settled.capture", () =>
406
+ this.captureSettledBaselineWithRetry(staged.before));
405
407
  const changedPaths = await measure("settled.changedPaths", () =>
406
408
  this.dependencies.changedPaths(staged.before, after));
407
409
  if (changedPaths.length > 0 && this.dependencies.prepareDurableRestore !== undefined) {
@@ -830,6 +832,21 @@ export class UndoControllerImpl implements UndoController {
830
832
  }
831
833
  }
832
834
 
835
+ /**
836
+ * settled capture 对暂态失败重试一次:async subagent 等并发写入者可能撞上撕裂断言
837
+ * (捕获期间叶子变化)或短暂持有 workspace lock。settled 失败会清空整个 undo 历史,
838
+ * 不能因一次暂态冲突就放弃;持续失败仍由调用方走原有 historyPaused 路径。
839
+ */
840
+ private async captureSettledBaselineWithRetry(baseline: SnapshotManifest): Promise<SnapshotManifest> {
841
+ try {
842
+ return await this.captureBaselineWithWorkspaceLock(baseline);
843
+ } catch (error) {
844
+ if (!isTransientCaptureFailure(error)) throw error;
845
+ await sleep(250);
846
+ return await this.captureBaselineWithWorkspaceLock(baseline);
847
+ }
848
+ }
849
+
833
850
  private async compensate(
834
851
  descriptor: OperationDescriptor,
835
852
  rollback: SnapshotManifest,
@@ -1012,3 +1029,20 @@ function noop(): OperationResult {
1012
1029
  function truncateReason(reason: string): string {
1013
1030
  return reason.replace(/[\u0000-\u001F\u007F]+/g, " ").trim().slice(0, 120);
1014
1031
  }
1032
+
1033
+ /**
1034
+ * 判断 capture 失败是否为并发写入者导致的暂态失败:撕裂断言
1035
+ * (SnapshotStoreError capture_failed,如“捕获期间工作区叶子已变化”)与
1036
+ * workspace lock 超时(WorkspaceLockError lock_timeout)。用 name/code 鸭子类型
1037
+ * 判断以保持 controller 对存储实现的解耦。
1038
+ */
1039
+ function isTransientCaptureFailure(error: unknown): boolean {
1040
+ if (typeof error !== "object" || error === null) return false;
1041
+ const { name, code } = error as { name?: unknown; code?: unknown };
1042
+ return (name === "SnapshotStoreError" && code === "capture_failed") ||
1043
+ (name === "WorkspaceLockError" && code === "lock_timeout");
1044
+ }
1045
+
1046
+ function sleep(ms: number): Promise<void> {
1047
+ return new Promise((resolve) => setTimeout(resolve, ms));
1048
+ }