@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 +10 -0
- package/extensions/pi-undo.ts +35 -0
- package/package.json +1 -1
- package/src/controller.ts +35 -1
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
|
package/extensions/pi-undo.ts
CHANGED
|
@@ -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
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
|
-
|
|
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
|
+
}
|