@davideasden/pi-undo 0.2.20 → 0.2.22
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 +38 -2
- package/src/pi-runtime.ts +30 -2
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) {
|
|
@@ -450,7 +452,9 @@ export class UndoControllerImpl implements UndoController {
|
|
|
450
452
|
}
|
|
451
453
|
|
|
452
454
|
async beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined> {
|
|
453
|
-
|
|
455
|
+
// Pi 0.86+ 在树导航期间也会让 isIdle() 暂时返回 false;只有 pi-undo
|
|
456
|
+
// 已经记录了尚未 settle 的 run 时,才需要中止并取消导航。
|
|
457
|
+
if (this.staged !== undefined) {
|
|
454
458
|
await this.dependencies.abortAgent();
|
|
455
459
|
return { cancel: true };
|
|
456
460
|
}
|
|
@@ -830,6 +834,21 @@ export class UndoControllerImpl implements UndoController {
|
|
|
830
834
|
}
|
|
831
835
|
}
|
|
832
836
|
|
|
837
|
+
/**
|
|
838
|
+
* settled capture 对暂态失败重试一次:async subagent 等并发写入者可能撞上撕裂断言
|
|
839
|
+
* (捕获期间叶子变化)或短暂持有 workspace lock。settled 失败会清空整个 undo 历史,
|
|
840
|
+
* 不能因一次暂态冲突就放弃;持续失败仍由调用方走原有 historyPaused 路径。
|
|
841
|
+
*/
|
|
842
|
+
private async captureSettledBaselineWithRetry(baseline: SnapshotManifest): Promise<SnapshotManifest> {
|
|
843
|
+
try {
|
|
844
|
+
return await this.captureBaselineWithWorkspaceLock(baseline);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
if (!isTransientCaptureFailure(error)) throw error;
|
|
847
|
+
await sleep(250);
|
|
848
|
+
return await this.captureBaselineWithWorkspaceLock(baseline);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
833
852
|
private async compensate(
|
|
834
853
|
descriptor: OperationDescriptor,
|
|
835
854
|
rollback: SnapshotManifest,
|
|
@@ -1012,3 +1031,20 @@ function noop(): OperationResult {
|
|
|
1012
1031
|
function truncateReason(reason: string): string {
|
|
1013
1032
|
return reason.replace(/[\u0000-\u001F\u007F]+/g, " ").trim().slice(0, 120);
|
|
1014
1033
|
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* 判断 capture 失败是否为并发写入者导致的暂态失败:撕裂断言
|
|
1037
|
+
* (SnapshotStoreError capture_failed,如“捕获期间工作区叶子已变化”)与
|
|
1038
|
+
* workspace lock 超时(WorkspaceLockError lock_timeout)。用 name/code 鸭子类型
|
|
1039
|
+
* 判断以保持 controller 对存储实现的解耦。
|
|
1040
|
+
*/
|
|
1041
|
+
function isTransientCaptureFailure(error: unknown): boolean {
|
|
1042
|
+
if (typeof error !== "object" || error === null) return false;
|
|
1043
|
+
const { name, code } = error as { name?: unknown; code?: unknown };
|
|
1044
|
+
return (name === "SnapshotStoreError" && code === "capture_failed") ||
|
|
1045
|
+
(name === "WorkspaceLockError" && code === "lock_timeout");
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function sleep(ms: number): Promise<void> {
|
|
1049
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1050
|
+
}
|
package/src/pi-runtime.ts
CHANGED
|
@@ -285,7 +285,13 @@ function rebuildControllerState(
|
|
|
285
285
|
identity: SessionFileIdentity,
|
|
286
286
|
): ControllerInitialState {
|
|
287
287
|
const state = sessionStateFor(manager);
|
|
288
|
-
const
|
|
288
|
+
const branch = physicalBranch(manager, manager.getLeafId());
|
|
289
|
+
const checkpoints = checkpointFrontierAfterDetachedRun(
|
|
290
|
+
manager,
|
|
291
|
+
identity,
|
|
292
|
+
branch,
|
|
293
|
+
state.getCheckpoints(identity),
|
|
294
|
+
);
|
|
289
295
|
const cursor = state.getCursor(identity);
|
|
290
296
|
let undoStack = [...checkpoints];
|
|
291
297
|
if (cursor !== null) {
|
|
@@ -305,12 +311,34 @@ function rebuildControllerState(
|
|
|
305
311
|
if (sourceCursor === undefined) throw new Error("cursor redo safety manifest 缺失");
|
|
306
312
|
return { checkpoint, targetManifestId: sourceCursor.rollbackManifestId };
|
|
307
313
|
});
|
|
308
|
-
const branch = physicalBranch(manager, manager.getLeafId());
|
|
309
314
|
const lastBarrier = findLastIndex(branch, (entry) => entry.type === "custom" && entry.customType === "pi-undo:barrier");
|
|
310
315
|
const lastCheckpoint = findLastIndex(branch, (entry) => entry.type === "custom" && entry.customType === "pi-undo:checkpoint");
|
|
311
316
|
return { undoStack, redoStack, historyPaused: lastBarrier > lastCheckpoint };
|
|
312
317
|
}
|
|
313
318
|
|
|
319
|
+
/**
|
|
320
|
+
* redo 或树导航后的新 run 会挂在 cursor 后面,使前一个 checkpoint 脱离当前物理 branch。
|
|
321
|
+
* start entry 保存了 run 前的逻辑叶,利用它恢复可信的 checkpoint frontier,再接上当前 branch。
|
|
322
|
+
*/
|
|
323
|
+
function checkpointFrontierAfterDetachedRun(
|
|
324
|
+
manager: ReadonlySessionManager,
|
|
325
|
+
identity: SessionFileIdentity,
|
|
326
|
+
branch: readonly Record<string, unknown>[],
|
|
327
|
+
current: readonly CheckpointRecord[],
|
|
328
|
+
): CheckpointRecord[] {
|
|
329
|
+
const start = [...branch].reverse().find((entry) => entry.type === "custom" && entry.customType === "pi-undo:start");
|
|
330
|
+
if (start === undefined || !isRecord(start.data)) return [...current];
|
|
331
|
+
const sourceLogicalLeaf = start.data.sourceLogicalLeaf;
|
|
332
|
+
if (typeof sourceLogicalLeaf !== "string") return [...current];
|
|
333
|
+
const sourceCheckpoint = findCheckpointByEndLeaf(manager, identity, sourceLogicalLeaf);
|
|
334
|
+
if (sourceCheckpoint === undefined) return [...current];
|
|
335
|
+
const inherited = checkpointFrontierById(manager, identity, sourceCheckpoint.checkpointId);
|
|
336
|
+
return [
|
|
337
|
+
...inherited,
|
|
338
|
+
...current.filter((checkpoint) => !inherited.some((candidate) => candidate.checkpointId === checkpoint.checkpointId)),
|
|
339
|
+
].filter((checkpoint, index, all) => all.findIndex((candidate) => candidate.checkpointId === checkpoint.checkpointId) === index);
|
|
340
|
+
}
|
|
341
|
+
|
|
314
342
|
function checkpointFrontierById(
|
|
315
343
|
manager: ReadonlySessionManager,
|
|
316
344
|
identity: SessionFileIdentity,
|