@davideasden/pi-undo 0.2.18 → 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;
@@ -69,8 +78,6 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
69
78
  next.recovery,
70
79
  );
71
80
  } else next.reporter.setReady(history.undoCount, history.redoCount);
72
- // 后台预热快照缓存,把新会话首次冷 capture 移出第一条 prompt 的关键路径。
73
- next.controller.warmUp();
74
81
  } catch (error) {
75
82
  if (currentGeneration !== generation) return;
76
83
  runtime = undefined;
@@ -170,6 +177,10 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
170
177
  };
171
178
  }
172
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
+ }
173
184
  const hasDeferredPrompt = deferredPrompts.length > 0 || replaying !== undefined;
174
185
  if (
175
186
  action === "undo" && result.code === "ok" && result.refillPrompt !== undefined &&
@@ -229,6 +240,28 @@ export function createPiUndoExtension(runtimeFactory: PiUndoRuntimeFactory): (pi
229
240
  description: "Review files changed by an Agent run (latest, or /diff N)",
230
241
  handler: async (args: string, context: ExtensionCommandContext) => runDiff(args, context),
231
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
+ });
232
265
 
233
266
  pi.on("session_start", async (_event: unknown, context: ExtensionContext) => initialize(context));
234
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.18",
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
+ }
package/src/journal.ts CHANGED
@@ -2,6 +2,7 @@ import { appendFile, lstat, readFile, readdir, rm } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
4
  import { fsyncDirectory, fsyncFile, writeContentAddressed, writeJsonAtomic } from "./atomic-fs.ts";
5
+ import { hasDurablePack } from "./durable-pack.ts";
5
6
  import {
6
7
  assertCursor,
7
8
  assertJournalState,
@@ -13,6 +14,7 @@ import type {
13
14
  CursorState,
14
15
  JournalPhase,
15
16
  JournalState,
17
+ MutationRecord,
16
18
  OperationDescriptor,
17
19
  } from "./model.ts";
18
20
  import { MutationJournal } from "./mutation-journal.ts";
@@ -154,6 +156,31 @@ export class JournalStore {
154
156
  }
155
157
  }
156
158
 
159
+ /**
160
+ * 判断 transaction 的 mutation 义务是否已全部履行且净效果回到操作前状态:
161
+ * 所有记录都达到 CLEANED,且按路径折叠的 fingerprint 链首尾相接(前向变更
162
+ * 被补偿变更精确抵消);没有任何 mutation 记录且无 durable pack 时视为从未发生。
163
+ * 这是纯只读检查;评估期间 journal 发生变化或任何证据不确定时都返回 false。
164
+ */
165
+ async isFullyCompensated(pending: PendingJournal): Promise<boolean> {
166
+ try {
167
+ const current = await this.load(pending.descriptor.opId);
168
+ if (!samePendingJournal(current, pending)) return false;
169
+ const mutationJournal = this.mutationJournal(pending.descriptor.opId);
170
+ const records = await mutationJournal.load();
171
+ const compensated = records.length === 0
172
+ ? !await hasDurablePack(mutationJournal)
173
+ : fingerprintChainsFoldToIdentity(records);
174
+ if (!compensated) return false;
175
+ const verified = await this.load(pending.descriptor.opId);
176
+ if (!samePendingJournal(verified, current)) return false;
177
+ const reloaded = await mutationJournal.load();
178
+ return reloaded.length === records.length && fingerprintChainsFoldToIdentity(reloaded);
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
157
184
  async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
158
185
  if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
159
186
  const pending = await this.load(opId);
@@ -414,6 +441,24 @@ function samePendingJournal(left: PendingJournal, right: PendingJournal): boolea
414
441
  }
415
442
  }
416
443
 
444
+ function fingerprintChainsFoldToIdentity(records: readonly MutationRecord[]): boolean {
445
+ const chains = new Map<string, { source: string; target: string }>();
446
+ for (const record of records) {
447
+ if (record.state !== "CLEANED") return false;
448
+ const chain = chains.get(record.path);
449
+ if (chain === undefined) {
450
+ chains.set(record.path, { source: record.sourceFingerprint, target: record.targetFingerprint });
451
+ continue;
452
+ }
453
+ if (record.sourceFingerprint !== chain.target) return false;
454
+ chain.target = record.targetFingerprint;
455
+ }
456
+ for (const chain of chains.values()) {
457
+ if (chain.target !== chain.source) return false;
458
+ }
459
+ return true;
460
+ }
461
+
417
462
  function isEmptyArray(value: unknown): value is readonly unknown[] {
418
463
  return Array.isArray(value) && value.length === 0;
419
464
  }
package/src/pi-runtime.ts CHANGED
@@ -65,6 +65,7 @@ export async function createPiUndoRuntime(context: ExtensionContext, pi: Extensi
65
65
  getLogicalLeafId: () => sessionStateFor(manager).getLogicalLeafId(),
66
66
  loadPending: () => journal.loadPending(),
67
67
  assessForeignTransaction: (pending) => journal.isInertForeignPrepared(pending),
68
+ assessCompensatedTransaction: (pending) => journal.isFullyCompensated(pending),
68
69
  inspectCursor: (pending) => inspectCursorMarkers(pending.descriptor.sessionIdentity.path, pending.descriptor),
69
70
  finalizeCursor: (pending, inspection) => finalizeCursorMarker(
70
71
  pending.descriptor.sessionIdentity.path,
package/src/recovery.ts CHANGED
@@ -11,6 +11,8 @@ export interface JournalRecoveryDependencies {
11
11
  readonly loadPending: () => Promise<readonly PendingJournal[]>;
12
12
  /** 仅允许严格证明无工作区 mutation 的 foreign PREPARED 空事务被忽略。 */
13
13
  readonly assessForeignTransaction?: (journal: PendingJournal) => Promise<boolean>;
14
+ /** 判断 transaction 是否已被完全补偿(mutation 全部 CLEANED 且净效果回到操作前状态),可直接 settle。 */
15
+ readonly assessCompensatedTransaction?: (journal: PendingJournal) => Promise<boolean>;
14
16
  readonly inspectCursor: (journal: PendingJournal) => Promise<CursorMarkerInspection>;
15
17
  readonly finalizeCursor: (journal: PendingJournal, inspection: Extract<CursorMarkerInspection, { kind: "match" }>) => Promise<void>;
16
18
  readonly recoverMutations: (
@@ -67,9 +69,22 @@ export class JournalRecovery {
67
69
  for (const journal of pending) {
68
70
  const identityError = this.identityError(journal);
69
71
  if (identityError !== null) {
70
- if (identityError === "session_identity_mismatch" && await this.canIgnoreForeignTransaction(journal)) continue;
72
+ if (identityError === "session_identity_mismatch") {
73
+ if (await this.settleCompensated(journal)) {
74
+ recovered += 1;
75
+ continue;
76
+ }
77
+ if (await this.canIgnoreForeignTransaction(journal)) continue;
78
+ }
71
79
  return { kind: "locked", reason: identityError, operations: recovered };
72
80
  }
81
+ // 同一会话(重启或第二窗口)内,完全补偿的事务同样可直接 settle:
82
+ // 工作区义务已全部履行且无 cursor 提交证据,session leaf 位置对
83
+ // ABORTED 终结不构成安全输入,无需通过 leaf 校验。
84
+ if (await this.settleCompensated(journal)) {
85
+ recovered += 1;
86
+ continue;
87
+ }
73
88
  let inspection: CursorMarkerInspection;
74
89
  try {
75
90
  inspection = await this.dependencies.inspectCursor(journal);
@@ -145,6 +160,38 @@ export class JournalRecovery {
145
160
  }
146
161
  }
147
162
 
163
+ /**
164
+ * 终结完全补偿的 pending transaction(own session 重启/第二窗口与 foreign 会话均适用)。
165
+ * 调用方持有 workspace lock,pending 非终态事务的 owner 操作已确定性结束;
166
+ * mutation 义务全部履行且净效果回到操作前状态时,无需触碰工作区即可 settle。
167
+ * cursor marker 必须缺失——marker 存在意味着操作已提交,与补偿证据矛盾,fail closed。
168
+ */
169
+ private async settleCompensated(journal: PendingJournal): Promise<boolean> {
170
+ const assess = this.dependencies.assessCompensatedTransaction;
171
+ if (assess === undefined) return false;
172
+ let compensated: boolean;
173
+ try {
174
+ compensated = await assess(journal);
175
+ } catch {
176
+ // 评估失败等同于证据不足,退回原有恢复语义。
177
+ return false;
178
+ }
179
+ if (!compensated) return false;
180
+ let inspection: CursorMarkerInspection;
181
+ try {
182
+ inspection = await this.dependencies.inspectCursor(journal);
183
+ } catch {
184
+ return false;
185
+ }
186
+ if (inspection.kind !== "absent") return false;
187
+ try {
188
+ await this.dependencies.settle(journal.descriptor.opId, "ABORTED");
189
+ } catch {
190
+ return false;
191
+ }
192
+ return true;
193
+ }
194
+
148
195
  private identityError(journal: PendingJournal): string | null {
149
196
  if (journal.descriptor.workspaceIdentity !== this.dependencies.workspaceIdentity) {
150
197
  return "workspace_identity_mismatch";
@@ -4,8 +4,11 @@ import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
 
7
+ import { writeJsonAtomic } from "./atomic-fs.ts";
7
8
  import {
8
9
  createDurablePack,
10
+ hasDurablePack,
11
+ loadDurablePack,
9
12
  publishCachedDurablePack,
10
13
  removeDurablePack,
11
14
  type DurableLeafInput,
@@ -33,8 +36,11 @@ import {
33
36
  type ReplaceFileRequest,
34
37
  } from "./quarantine.ts";
35
38
  import { SnapshotStore, SnapshotStoreError } from "./snapshot-store.ts";
39
+ import { WorkspaceLock } from "./workspace-lock.ts";
36
40
 
37
41
  const PREPARED_PLAN_CACHE_LIMIT = 16;
42
+ const DURABLE_CACHE_INDEX_FILE = "index.json";
43
+ const DURABLE_PACK_MEMORY_MAX_BYTES = 64 * 1024 * 1024;
38
44
  const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
39
45
  const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
40
46
  const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
@@ -74,6 +80,8 @@ export interface RestoreEngineOptions {
74
80
  readonly store: SnapshotStore;
75
81
  readonly discovery?: RootDiscovery;
76
82
  readonly beforeMutation?: (mutation: RestoreMutation) => void | Promise<void>;
83
+ /** 仅用于测试:覆盖进程内已加载 durable pack 的内存预算。 */
84
+ readonly durablePackCacheMaxBytes?: number;
77
85
  }
78
86
 
79
87
  export interface RestoreMutation {
@@ -95,6 +103,40 @@ interface PreparedRestorePlan {
95
103
  readonly targetPaths: ReadonlyMap<string, OwnedPath>;
96
104
  }
97
105
 
106
+ interface DurableCacheIndexEntry {
107
+ readonly currentManifestId: ManifestId;
108
+ readonly targetManifestId: ManifestId;
109
+ readonly scopePaths: readonly string[];
110
+ readonly planDigest: string;
111
+ readonly packChecksum: string;
112
+ }
113
+
114
+ interface DurableCacheIndex {
115
+ readonly schemaVersion: 1;
116
+ entries: DurableCacheIndexEntry[];
117
+ }
118
+
119
+ interface CachedDurablePack {
120
+ readonly currentManifestId: ManifestId;
121
+ readonly targetManifestId: ManifestId;
122
+ readonly path: string;
123
+ readonly packChecksum: string;
124
+ readonly pinReason: string;
125
+ readonly pinsDeferred: boolean;
126
+ }
127
+
128
+ interface CachedDurablePair {
129
+ readonly planDigest: string;
130
+ readonly pack: DurablePack;
131
+ readonly bytes: number;
132
+ }
133
+
134
+ type DurableIndexDisk =
135
+ | { readonly kind: "ok"; readonly index: DurableCacheIndex }
136
+ | { readonly kind: "missing" }
137
+ | { readonly kind: "corrupt" }
138
+ | { readonly kind: "unavailable" };
139
+
98
140
  interface MutationContext {
99
141
  readonly phase: RestoreMutation["phase"];
100
142
  readonly sourceManifestId: ManifestId;
@@ -115,17 +157,17 @@ export class RestoreEngine {
115
157
  private readonly discovery: RootDiscovery;
116
158
  private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
117
159
  private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
118
- private readonly durablePackCache = new Map<string, {
119
- readonly currentManifestId: ManifestId;
120
- readonly targetManifestId: ManifestId;
121
- readonly path: string;
122
- readonly packChecksum: string;
123
- readonly pinReason: string;
124
- }>();
125
- private readonly durablePackByPair = new Map<string, {
126
- readonly planDigest: string;
127
- readonly pack: DurablePack;
128
- }>();
160
+ private readonly durablePackCache = new Map<string, CachedDurablePack>();
161
+ private readonly durablePackByPair = new Map<string, CachedDurablePair>();
162
+ private readonly durableIndexLock = new WorkspaceLock({
163
+ leaseMs: 10_000,
164
+ retryMs: 25,
165
+ acquireTimeoutMs: 1_000,
166
+ });
167
+ private readonly durablePackCacheMaxBytes: number;
168
+ private durableIndex: DurableCacheIndex | undefined;
169
+ private durableIndexQueue: Promise<void> = Promise.resolve();
170
+ private durablePackMemoryBytes = 0;
129
171
 
130
172
  constructor(options: RestoreEngineOptions) {
131
173
  this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
@@ -133,6 +175,10 @@ export class RestoreEngine {
133
175
  this.store = options.store;
134
176
  this.discovery = options.discovery ?? new RootDiscovery();
135
177
  this.beforeMutation = options.beforeMutation;
178
+ this.durablePackCacheMaxBytes = options.durablePackCacheMaxBytes ?? DURABLE_PACK_MEMORY_MAX_BYTES;
179
+ if (!Number.isSafeInteger(this.durablePackCacheMaxBytes) || this.durablePackCacheMaxBytes <= 0) {
180
+ throw new Error("durable pack 内存预算必须是正整数");
181
+ }
136
182
  }
137
183
 
138
184
  private async compatibleApplyOptions(): Promise<{
@@ -226,14 +272,27 @@ export class RestoreEngine {
226
272
  target: SnapshotManifest,
227
273
  scopePaths: readonly string[],
228
274
  ): Promise<boolean> {
229
- const cached = this.durablePackByPair.get(durablePairKey(current.manifestId, target.manifestId, scopePaths));
230
- if (cached === undefined) return false;
275
+ const pairKey = durablePairKey(current.manifestId, target.manifestId, scopePaths);
276
+ let cached = this.durablePackByPair.get(pairKey);
277
+ if (cached !== undefined && !this.durablePackCache.has(cached.planDigest)) {
278
+ this.dropResidentDurablePack(pairKey);
279
+ cached = undefined;
280
+ }
281
+ const fromPersistentIndex = cached === undefined;
282
+ if (cached === undefined) {
283
+ cached = await this.readIndexedDurablePack(current.manifestId, target.manifestId, scopePaths);
284
+ }
285
+ if (cached === undefined || cached.pack.opId !== `cache-${cached.planDigest}`) return false;
231
286
  const cacheJournal = new MutationJournal(
232
287
  join(dirname(cached.pack.storagePath), "mutations.jsonl"),
233
288
  `cache-${cached.planDigest}`,
234
289
  );
235
290
  try {
236
291
  if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) return false;
292
+ if (fromPersistentIndex) {
293
+ const expected = await this.plan(current, target, scopePaths);
294
+ if (expected.planDigest !== cached.planDigest) return false;
295
+ }
237
296
  const topology = await this.discovery.discover(this.workspaceRoot);
238
297
  this.assertCurrentTopology(current, target, topology);
239
298
  const native = await createNativeFileBatch({
@@ -241,7 +300,19 @@ export class RestoreEngine {
241
300
  planDigest: cached.planDigest,
242
301
  journal: cacheJournal,
243
302
  });
244
- return native !== undefined && await native.verifySource(cached.pack);
303
+ if (native === undefined || !await native.verifySource(cached.pack)) return false;
304
+ if (fromPersistentIndex) {
305
+ await this.rememberDurablePackInMemory(
306
+ cached.planDigest,
307
+ current,
308
+ target,
309
+ scopePaths,
310
+ cached.pack,
311
+ `durable-cache:${cached.planDigest}`,
312
+ false,
313
+ );
314
+ }
315
+ return true;
245
316
  } catch {
246
317
  return false;
247
318
  }
@@ -287,7 +358,7 @@ export class RestoreEngine {
287
358
  throw error;
288
359
  }
289
360
  await this.rememberDurablePack(
290
- plan,
361
+ plan.planDigest,
291
362
  current,
292
363
  target,
293
364
  scopePaths,
@@ -298,41 +369,245 @@ export class RestoreEngine {
298
369
  }
299
370
 
300
371
  private async rememberDurablePack(
301
- plan: RestorePlan,
372
+ planDigest: string,
302
373
  current: SnapshotManifest,
303
374
  target: SnapshotManifest,
304
375
  scopePaths: readonly string[],
305
376
  pack: DurablePack,
306
377
  pinReason: string,
307
378
  ): Promise<void> {
308
- this.durablePackCache.set(plan.planDigest, {
379
+ await this.rememberDurablePackInMemory(planDigest, current, target, scopePaths, pack, pinReason, true);
380
+ await this.enqueueDurableIndex(async () => {
381
+ const lease = await this.acquireDurableIndexLease();
382
+ if (lease === undefined) return;
383
+ try {
384
+ const disk = await this.readDurableIndexFromDisk();
385
+ if (disk.kind === "unavailable") return;
386
+ this.durableIndex = disk.kind === "ok"
387
+ ? disk.index
388
+ : { schemaVersion: 1, entries: [] };
389
+ this.upsertDurableIndexEntry({
390
+ currentManifestId: current.manifestId,
391
+ targetManifestId: target.manifestId,
392
+ scopePaths: [...scopePaths],
393
+ planDigest,
394
+ packChecksum: pack.packChecksum,
395
+ });
396
+ await this.evictPersistedDurablePacks(planDigest);
397
+ await this.persistDurableIndex();
398
+ } finally {
399
+ await lease.release().catch(() => {});
400
+ }
401
+ });
402
+ }
403
+
404
+ private async rememberDurablePackInMemory(
405
+ planDigest: string,
406
+ current: SnapshotManifest,
407
+ target: SnapshotManifest,
408
+ scopePaths: readonly string[],
409
+ pack: DurablePack,
410
+ pinReason: string,
411
+ pinsDeferred: boolean,
412
+ ): Promise<void> {
413
+ this.durablePackCache.delete(planDigest);
414
+ this.durablePackCache.set(planDigest, {
309
415
  currentManifestId: current.manifestId,
310
416
  targetManifestId: target.manifestId,
311
417
  path: pack.storagePath,
312
418
  packChecksum: pack.packChecksum,
313
419
  pinReason,
420
+ pinsDeferred,
314
421
  });
315
- this.durablePackByPair.set(durablePairKey(current.manifestId, target.manifestId, scopePaths), {
316
- planDigest: plan.planDigest,
422
+ this.trimDurablePackMetadata();
423
+ await this.rememberResidentDurablePack(
424
+ durablePairKey(current.manifestId, target.manifestId, scopePaths),
425
+ planDigest,
317
426
  pack,
318
- });
427
+ );
428
+ }
429
+
430
+ private trimDurablePackMetadata(): void {
319
431
  while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
320
- const oldest = this.durablePackCache.entries().next().value as
321
- | readonly [string, { readonly currentManifestId: ManifestId; readonly targetManifestId: ManifestId; readonly path: string; readonly packChecksum: string; readonly pinReason: string }]
322
- | undefined;
432
+ const oldest = this.durablePackCache.keys().next().value as string | undefined;
433
+ if (oldest === undefined) break;
434
+ this.durablePackCache.delete(oldest);
435
+ this.dropResidentDurablePacks(oldest);
436
+ }
437
+ }
438
+
439
+ private async rememberResidentDurablePack(
440
+ pairKey: string,
441
+ planDigest: string,
442
+ pack: DurablePack,
443
+ ): Promise<void> {
444
+ const bytes = await this.durablePackFileBytes(pack.storagePath);
445
+ const previous = this.durablePackByPair.get(pairKey);
446
+ if (previous !== undefined) {
447
+ this.durablePackMemoryBytes -= previous.bytes;
448
+ this.durablePackByPair.delete(pairKey);
449
+ }
450
+ this.durablePackByPair.set(pairKey, { planDigest, pack, bytes });
451
+ this.durablePackMemoryBytes += bytes;
452
+ while (this.durablePackMemoryBytes > this.durablePackCacheMaxBytes) {
453
+ const oldest = this.durablePackByPair.keys().next().value as string | undefined;
323
454
  if (oldest === undefined) break;
324
- const [digest, entry] = oldest;
325
- this.durablePackCache.delete(digest);
326
- for (const [key, pair] of this.durablePackByPair) {
327
- if (pair.planDigest === digest) this.durablePackByPair.delete(key);
455
+ this.dropResidentDurablePack(oldest);
456
+ }
457
+ }
458
+
459
+ private dropResidentDurablePack(pairKey: string): void {
460
+ const cached = this.durablePackByPair.get(pairKey);
461
+ if (cached === undefined) return;
462
+ this.durablePackByPair.delete(pairKey);
463
+ this.durablePackMemoryBytes = Math.max(0, this.durablePackMemoryBytes - cached.bytes);
464
+ }
465
+
466
+ private dropResidentDurablePacks(planDigest: string): void {
467
+ for (const [pairKey, cached] of this.durablePackByPair) {
468
+ if (cached.planDigest === planDigest) this.dropResidentDurablePack(pairKey);
469
+ }
470
+ }
471
+
472
+ private async readIndexedDurablePack(
473
+ currentManifestId: ManifestId,
474
+ targetManifestId: ManifestId,
475
+ scopePaths: readonly string[],
476
+ ): Promise<CachedDurablePair | undefined> {
477
+ const pairKey = durablePairKey(currentManifestId, targetManifestId, scopePaths);
478
+ const existing = this.durablePackByPair.get(pairKey);
479
+ if (existing !== undefined && this.durablePackCache.has(existing.planDigest)) return existing;
480
+ if (existing !== undefined) this.dropResidentDurablePack(pairKey);
481
+ const disk = await this.enqueueDurableIndex(async () => {
482
+ const result = await this.readDurableIndexFromDisk();
483
+ if (result.kind === "ok") this.durableIndex = result.index;
484
+ return result;
485
+ });
486
+ const entries = disk.kind === "ok"
487
+ ? disk.index.entries
488
+ : disk.kind === "unavailable" ? this.durableIndex?.entries : undefined;
489
+ const entry = entries?.find((candidate) =>
490
+ durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) === pairKey);
491
+ if (entry === undefined || !isDigest(entry.planDigest) || !isDigest(entry.packChecksum)) return undefined;
492
+ try {
493
+ const cacheRoot = await this.store.durableCacheDirectory();
494
+ const journal = new MutationJournal(
495
+ join(cacheRoot, entry.planDigest, "mutations.jsonl"),
496
+ `cache-${entry.planDigest}`,
497
+ );
498
+ if (!await hasDurablePack(journal)) return undefined;
499
+ const pack = await loadDurablePack(journal, entry.planDigest, true);
500
+ if (pack.packChecksum !== entry.packChecksum || pack.planDigest !== entry.planDigest) return undefined;
501
+ return {
502
+ planDigest: entry.planDigest,
503
+ pack,
504
+ bytes: await this.durablePackFileBytes(pack.storagePath),
505
+ };
506
+ } catch {
507
+ return undefined;
508
+ }
509
+ }
510
+
511
+ private enqueueDurableIndex<T>(operation: () => Promise<T>): Promise<T> {
512
+ const run = this.durableIndexQueue.then(operation, operation);
513
+ this.durableIndexQueue = run.then(() => undefined, () => undefined);
514
+ return run;
515
+ }
516
+
517
+ private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
518
+ try {
519
+ return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
520
+ } catch {
521
+ return undefined;
522
+ }
523
+ }
524
+
525
+ private async durableIndexLockIdentity(): Promise<string> {
526
+ const cacheRoot = await this.store.durableCacheDirectory();
527
+ try {
528
+ return `durable-cache:${await realpath(cacheRoot)}`;
529
+ } catch {
530
+ return `durable-cache:${resolve(cacheRoot)}`;
531
+ }
532
+ }
533
+
534
+ private async readDurableIndexFromDisk(): Promise<DurableIndexDisk> {
535
+ try {
536
+ const cacheRoot = await this.store.durableCacheDirectory();
537
+ const raw = await readFile(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), "utf8");
538
+ try {
539
+ return { kind: "ok", index: parseDurableCacheIndex(JSON.parse(raw)) };
540
+ } catch {
541
+ return { kind: "corrupt" };
328
542
  }
329
- await Promise.all([...new Set([entry.currentManifestId, entry.targetManifestId])].map(
330
- (manifestId) => this.store.unpin(manifestId, entry.pinReason).catch(() => {}),
331
- ));
332
- await rm(dirname(entry.path), { recursive: true, force: true }).catch(() => {});
543
+ } catch (error) {
544
+ if (hasErrorCode(error, "ENOENT")) return { kind: "missing" };
545
+ return { kind: "unavailable" };
546
+ }
547
+ }
548
+
549
+ private upsertDurableIndexEntry(entry: DurableCacheIndexEntry): void {
550
+ const index = this.durableIndex ?? { schemaVersion: 1, entries: [] };
551
+ const pairKey = durablePairKey(entry.currentManifestId, entry.targetManifestId, entry.scopePaths);
552
+ index.entries = index.entries.filter((candidate) =>
553
+ candidate.planDigest !== entry.planDigest &&
554
+ durablePairKey(candidate.currentManifestId, candidate.targetManifestId, candidate.scopePaths) !== pairKey);
555
+ index.entries.push(entry);
556
+ this.durableIndex = index;
557
+ }
558
+
559
+ private async persistDurableIndex(): Promise<void> {
560
+ if (this.durableIndex === undefined) return;
561
+ try {
562
+ const cacheRoot = await this.store.durableCacheDirectory();
563
+ await writeJsonAtomic(join(cacheRoot, DURABLE_CACHE_INDEX_FILE), {
564
+ schemaVersion: 1,
565
+ entries: this.durableIndex.entries,
566
+ });
567
+ } catch {
568
+ // 索引只是跨会话候选提示;写入失败不得让已经 pin 成功的 pack 准备失败。
569
+ }
570
+ }
571
+
572
+ private async evictPersistedDurablePacks(keepPlanDigest: string): Promise<void> {
573
+ while (this.durableIndex !== undefined && this.durableIndex.entries.length > PREPARED_PLAN_CACHE_LIMIT) {
574
+ const oldest = this.durableIndex.entries.find((entry) => entry.planDigest !== keepPlanDigest);
575
+ if (oldest === undefined) break;
576
+ await this.dropPersistedDurablePack(oldest.planDigest);
333
577
  }
334
578
  }
335
579
 
580
+ private async dropPersistedDurablePack(planDigest: string): Promise<void> {
581
+ const cached = this.durablePackCache.get(planDigest);
582
+ const indexed = this.durableIndex?.entries.find((entry) => entry.planDigest === planDigest);
583
+ this.durablePackCache.delete(planDigest);
584
+ this.dropResidentDurablePacks(planDigest);
585
+ if (this.durableIndex !== undefined) {
586
+ this.durableIndex.entries = this.durableIndex.entries.filter((entry) => entry.planDigest !== planDigest);
587
+ }
588
+ const pinReason = cached?.pinReason ?? `durable-cache:${planDigest}`;
589
+ const manifests = [...new Set([
590
+ ...(cached === undefined ? [] : [cached.currentManifestId, cached.targetManifestId]),
591
+ ...(indexed === undefined ? [] : [indexed.currentManifestId, indexed.targetManifestId]),
592
+ ])];
593
+ await Promise.all(manifests.map((manifestId) => this.store.unpin(manifestId, pinReason).catch(() => {})));
594
+ try {
595
+ await rm(join(await this.store.durableCacheDirectory(), planDigest), { recursive: true, force: true });
596
+ } catch {
597
+ // 淘汰失败只影响缓存占用,不影响当前 restore 正确性。
598
+ }
599
+ }
600
+
601
+ private async durablePackFileBytes(path: string): Promise<number> {
602
+ try {
603
+ const stats = await lstat(path);
604
+ if (stats.isFile() && !stats.isSymbolicLink() && stats.size > 0) return Number(stats.size);
605
+ } catch {
606
+ // 文件可能在加载后被其他进程淘汰;不能把已加载的 pack 当作零字节常驻。
607
+ }
608
+ return this.durablePackCacheMaxBytes + 1;
609
+ }
610
+
336
611
  private rememberPreparedPlan(
337
612
  plan: RestorePlan,
338
613
  currentPaths: ReadonlyMap<string, OwnedPath>,
@@ -391,8 +666,9 @@ export class RestoreEngine {
391
666
  if (!hasValidPlanDigest(plan)) {
392
667
  throw new Error("restore plan digest 与语义字段不匹配");
393
668
  }
669
+ const cachedDurablePack = this.durablePackCache.get(plan.planDigest);
394
670
  const pinsDeferred = effectiveOptions.deferDurability === true &&
395
- this.durablePackCache.has(plan.planDigest);
671
+ cachedDurablePack?.pinsDeferred === true;
396
672
  const recoveryReason = `restore:${plan.planDigest}`;
397
673
  const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
398
674
  const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
@@ -946,10 +1222,7 @@ export class RestoreEngine {
946
1222
  target: SnapshotManifest,
947
1223
  actual: RootTopology,
948
1224
  ): void {
949
- if (
950
- actual.workspaceIdentity !== current.workspaceIdentity ||
951
- actual.fingerprint !== current.topologyFingerprint
952
- ) {
1225
+ if (!sameTopologyModuloIdentity(actual, current)) {
953
1226
  throw new Error("apply 前 workspace topology 与 current manifest 不一致");
954
1227
  }
955
1228
  const currentRoots = new Map(current.roots.map((root) => [root.relativeRoot, root]));
@@ -958,14 +1231,6 @@ export class RestoreEngine {
958
1231
  if (currentRoot !== undefined && targetRoot.state === "active" && currentRoot.state !== "active") {
959
1232
  throw new Error(`restore 不能把 inactive root 物化为 active:${targetRoot.relativeRoot}`);
960
1233
  }
961
- if (
962
- currentRoot !== undefined &&
963
- targetRoot.state === "active" &&
964
- (currentRoot.sourceIdentity !== targetRoot.sourceIdentity ||
965
- currentRoot.privateRepositoryId !== targetRoot.privateRepositoryId)
966
- ) {
967
- throw new Error(`restore boundary root identity 冲突:${targetRoot.relativeRoot}`);
968
- }
969
1234
  }
970
1235
  }
971
1236
 
@@ -1744,6 +2009,28 @@ function sameEntry(left: RestorePath, right: RestorePath): boolean {
1744
2009
  left.linkText === right.linkText;
1745
2010
  }
1746
2011
 
2012
+ // sourceIdentity/privateRepositoryId 会随 git remote 配置漂移(例如后来补充 remote origin),
2013
+ // 它们是仓库元数据而非工作区内容;restore 的内容安全由逐文件校验
2014
+ // (verifyKnownState、assertCompleteVisibleSubset、verifyTarget)保证。
2015
+ // 因此这里只比较结构性拓扑字段:root 集合、parentRoot、state 与 gitlinkOid。
2016
+ function sameTopologyModuloIdentity(actual: RootTopology, expected: SnapshotManifest): boolean {
2017
+ if (actual.workspaceIdentity !== expected.workspaceIdentity) return false;
2018
+ const expectedRoots = new Map(expected.roots.map((root) => [root.relativeRoot, root]));
2019
+ if (actual.roots.length !== expectedRoots.size) return false;
2020
+ for (const root of actual.roots) {
2021
+ const expectedRoot = expectedRoots.get(root.relativeRoot);
2022
+ if (
2023
+ expectedRoot === undefined ||
2024
+ expectedRoot.parentRoot !== root.parentRoot ||
2025
+ expectedRoot.state !== root.state ||
2026
+ (expectedRoot.gitlinkOid ?? null) !== (root.gitlinkOid ?? null)
2027
+ ) {
2028
+ return false;
2029
+ }
2030
+ }
2031
+ return true;
2032
+ }
2033
+
1747
2034
  function assertCompatibleManifests(
1748
2035
  current: SnapshotManifest,
1749
2036
  target: SnapshotManifest,
@@ -1995,6 +2282,40 @@ function durablePairKey(
1995
2282
  return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
1996
2283
  }
1997
2284
 
2285
+ function isDigest(value: unknown): value is string {
2286
+ return typeof value === "string" && /^[0-9a-f]{64}$/.test(value);
2287
+ }
2288
+
2289
+ function parseDurableCacheIndex(value: unknown): DurableCacheIndex {
2290
+ if (!isRecord(value) || value.schemaVersion !== 1 || !Array.isArray(value.entries)) {
2291
+ throw new Error("durable cache index 无效");
2292
+ }
2293
+ const entries: DurableCacheIndexEntry[] = [];
2294
+ for (const entry of value.entries) {
2295
+ if (
2296
+ !isRecord(entry) ||
2297
+ !isDigest(entry.currentManifestId) ||
2298
+ !isDigest(entry.targetManifestId) ||
2299
+ !isDigest(entry.planDigest) ||
2300
+ !isDigest(entry.packChecksum) ||
2301
+ !Array.isArray(entry.scopePaths) ||
2302
+ entry.scopePaths.some((path) => typeof path !== "string")
2303
+ ) continue;
2304
+ entries.push({
2305
+ currentManifestId: entry.currentManifestId as ManifestId,
2306
+ targetManifestId: entry.targetManifestId as ManifestId,
2307
+ scopePaths: entry.scopePaths.filter((path): path is string => typeof path === "string"),
2308
+ planDigest: entry.planDigest,
2309
+ packChecksum: entry.packChecksum,
2310
+ });
2311
+ }
2312
+ return { schemaVersion: 1, entries };
2313
+ }
2314
+
2315
+ function isRecord(value: unknown): value is Record<string, unknown> {
2316
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2317
+ }
2318
+
1998
2319
  function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
1999
2320
  return typeof error === "object" && error !== null && "code" in error && error.code === code;
2000
2321
  }
@@ -1,4 +1,4 @@
1
- import { lstat, readdir, realpath } from "node:fs/promises";
1
+ import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
3
 
4
4
  import { checksum, topologyFingerprint } from "./encoding.ts";
@@ -6,6 +6,7 @@ import { GitRunner } from "./git-runner.ts";
6
6
  import type { DiscoveryRoot } from "./model.ts";
7
7
 
8
8
  const DIRECTORY_SCAN_CONCURRENCY = 16;
9
+ const GIT_POINTER_MAX_BYTES = 4096;
9
10
 
10
11
  interface RepositoryInfo {
11
12
  readonly absoluteRoot: string;
@@ -17,6 +18,7 @@ interface RepositoryInfo {
17
18
  type RepositoryInspection =
18
19
  | { readonly kind: "active"; readonly repository: RepositoryInfo }
19
20
  | { readonly kind: "broken"; readonly absoluteRoot: string }
21
+ | { readonly kind: "stale"; readonly absoluteRoot: string }
20
22
  | { readonly kind: "absent" };
21
23
 
22
24
  interface DiscoveredRoot {
@@ -68,7 +70,7 @@ export class RootDiscovery {
68
70
  outerRepository.repository.absoluteRoot,
69
71
  this.activeRoot(workspaceIdentity, outerRepository.repository),
70
72
  );
71
- } else if (outerRepository.kind === "broken") {
73
+ } else if (outerRepository.kind === "broken" || outerRepository.kind === "stale") {
72
74
  activeRoots.set(outerRepository.absoluteRoot, brokenRoot(workspaceIdentity, outerRepository.absoluteRoot));
73
75
  } else {
74
76
  activeRoots.set(workspaceIdentity, syntheticRoot(workspaceIdentity));
@@ -117,6 +119,8 @@ export class RootDiscovery {
117
119
  );
118
120
  } else if (inspection.kind === "broken") {
119
121
  activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot));
122
+ } else if (inspection.kind === "stale") {
123
+ activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot));
120
124
  }
121
125
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
122
126
  }
@@ -145,6 +149,9 @@ export class RootDiscovery {
145
149
  if (marker === "invalid") {
146
150
  return { kind: "broken", absoluteRoot };
147
151
  }
152
+ if (await deadWorktreePointer(absoluteRoot)) {
153
+ return { kind: "stale", absoluteRoot };
154
+ }
148
155
 
149
156
  const details = await this.gitOutput([
150
157
  "-C",
@@ -274,6 +281,46 @@ function brokenRoot(workspaceIdentity: string, absoluteRoot: string): Discovered
274
281
  };
275
282
  }
276
283
 
284
+ // git worktree 的 .git 文件是 "gitdir: <path>" 指针;项目从其他机器/位置搬移后,
285
+ // 指针常指向本机不存在的 gitdir(例如虚拟机共享目录的绝对路径)。这种可证明失效的
286
+ // 指针不再代表可用仓库,按未初始化根处理:内容不进入快照,也不会被 restore 触碰。
287
+ async function deadWorktreePointer(absoluteRoot: string): Promise<boolean> {
288
+ const pointerPath = join(absoluteRoot, ".git");
289
+ let content: string;
290
+ try {
291
+ const marker = await lstat(pointerPath);
292
+ if (!marker.isFile()) return false;
293
+ content = await readFile(pointerPath, "utf8");
294
+ } catch {
295
+ return false;
296
+ }
297
+ if (content.length > GIT_POINTER_MAX_BYTES) return false;
298
+ const firstLine = content.split("\n", 1)[0] ?? "";
299
+ const match = /^gitdir: (.+)$/.exec(firstLine.trimEnd());
300
+ if (match === null) return false;
301
+ const gitDirectory = isAbsolute(match[1]) ? resolve(match[1]) : resolve(absoluteRoot, match[1]);
302
+ try {
303
+ await stat(gitDirectory);
304
+ return false;
305
+ } catch (error) {
306
+ return hasErrorCode(error, "ENOENT");
307
+ }
308
+ }
309
+
310
+ function staleWorktreeRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot {
311
+ const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot);
312
+ const sourceIdentity = `stale-worktree:${absoluteRoot}`;
313
+ return {
314
+ absoluteRoot,
315
+ relativeRoot,
316
+ gitBacked: true,
317
+ state: "uninitialized",
318
+ sourceIdentity,
319
+ privateRepositoryId: checksum(sourceIdentity),
320
+ treeId: null,
321
+ };
322
+ }
323
+
277
324
  function buildRoots(discovered: readonly DiscoveredRoot[]): DiscoveryRoot[] {
278
325
  const unique = new Map<string, DiscoveredRoot>();
279
326
  for (const root of discovered) {