@davideasden/pi-undo 0.2.22 → 0.2.25

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/src/controller.ts CHANGED
@@ -2,6 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { performance } from "node:perf_hooks";
3
3
 
4
4
  import { canonicalJson, checksum } from "./encoding.ts";
5
+ import {
6
+ checkOperation, createOperationScope, currentOperationContext, runWithOperationContext, operationFailure, isUnconfirmedExit,
7
+ configuredTimeout, operationHasUnconfirmedExit, rethrowOperationFailure, withRecoveryBudget,
8
+ type OperationScope, type ProcessDiagnostic,
9
+ } from "./operation-context.ts";
5
10
  import type {
6
11
  CheckpointRecord,
7
12
  CursorState,
@@ -67,6 +72,11 @@ export interface ControllerDependencies {
67
72
  }>;
68
73
  readonly journal: JournalPort;
69
74
  readonly clock: () => number;
75
+ readonly operationTimeoutMs?: number;
76
+ readonly onProgress?: (phase: string) => void;
77
+ readonly onOperationStart?: (opId: string) => void;
78
+ readonly onProcess?: (diagnostic: ProcessDiagnostic) => void;
79
+ readonly onOperationEnd?: (opId: string, result: OperationResult) => Promise<void>;
70
80
  }
71
81
 
72
82
  export interface JournalPort {
@@ -116,6 +126,7 @@ export interface InputContext {
116
126
 
117
127
  export interface SessionBeforeTreeEvent {
118
128
  readonly targetLeafId: string | null;
129
+ readonly signal?: AbortSignal;
119
130
  }
120
131
 
121
132
  export interface SessionBeforeTreeResult {
@@ -148,6 +159,8 @@ export interface UndoController {
148
159
  beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined>;
149
160
  afterTree(event: SessionTreeEvent): Promise<void>;
150
161
  cancelTree?(): Promise<void>;
162
+ cancelOperation?(): boolean;
163
+ dispose?(): Promise<void>;
151
164
  recover(): Promise<void>;
152
165
  history(): HistoryState;
153
166
  /** 当前 recovery lock 的原因;未锁定时返回 undefined。 */
@@ -160,6 +173,12 @@ export interface UndoController {
160
173
  captureFailureReason(): string | undefined;
161
174
  }
162
175
 
176
+ interface RunCompletion {
177
+ started: boolean;
178
+ readonly promise: Promise<void>;
179
+ readonly resolve: () => void;
180
+ }
181
+
163
182
  interface StagedRun {
164
183
  readonly rawPrompt: string;
165
184
  readonly before: SnapshotManifest;
@@ -214,6 +233,26 @@ export class UndoControllerImpl implements UndoController {
214
233
  private warmUpManifest: SnapshotManifest | undefined;
215
234
  private pendingInputCapture: { readonly token: symbol; readonly promise: Promise<void> } | undefined;
216
235
  private deferAgentStartUntilMessageEnd = false;
236
+ private runCompletion: RunCompletion | undefined;
237
+ private settling: Promise<void> | undefined;
238
+ private inputCommit: Promise<void> | undefined;
239
+ private treePreparation: Promise<SessionBeforeTreeResult | undefined> | undefined;
240
+ private treeApplication: Promise<void> | undefined;
241
+ private treeScope: OperationScope | undefined;
242
+ private treeCancellationRequested = false;
243
+ private disposed = false;
244
+ private activeScope: OperationScope | undefined;
245
+ private activeOperation: Promise<OperationResult> | undefined;
246
+ private readonly backgroundTasks = new Set<Promise<unknown>>();
247
+ private readonly backgroundScopes = new Set<OperationScope>();
248
+ private unsafeExit = false;
249
+ private operationId: string | undefined;
250
+ private transaction: {
251
+ descriptor: OperationDescriptor;
252
+ rollback?: SnapshotManifest;
253
+ target?: SnapshotManifest;
254
+ cursorDurable: boolean;
255
+ } | undefined;
217
256
  private recoveryInFlight: Promise<void> | undefined;
218
257
  private recoveryCompleted = false;
219
258
 
@@ -227,6 +266,64 @@ export class UndoControllerImpl implements UndoController {
227
266
  this.recoveryCompleted = initialState.recoveryCompleted ?? false;
228
267
  }
229
268
 
269
+ cancelOperation(): boolean {
270
+ if (this.activeScope === undefined || this.transaction?.cursorDurable) return false;
271
+ this.activeScope.cancel();
272
+ return true;
273
+ }
274
+
275
+ async dispose(): Promise<void> {
276
+ this.disposed = true;
277
+ this.cancelOperation();
278
+ for (const scope of this.backgroundScopes) scope.cancel();
279
+ await this.activeOperation;
280
+ await this.cancelTree();
281
+ await Promise.allSettled([...this.backgroundTasks, this.inputCommit, this.settling]);
282
+ this.staged = undefined;
283
+ this.finishRun(this.runCompletion);
284
+ if (this.unsafeExit) throw new Error("process_exit_unconfirmed:旧任务未确认停止,不能重建 runtime");
285
+ }
286
+
287
+ private operationTimeout(): number {
288
+ return this.dependencies.operationTimeoutMs ?? configuredTimeout("PI_UNDO_OPERATION_TIMEOUT_MS", 300_000);
289
+ }
290
+
291
+ private recordUnsafeExit(error: unknown): boolean {
292
+ if (!isUnconfirmedExit(error) && !operationHasUnconfirmedExit()) return false;
293
+ this.unsafeExit = true;
294
+ this.lock("process_exit_unconfirmed");
295
+ return true;
296
+ }
297
+
298
+ private runBackground<T>(body: () => Promise<T>): Promise<T> {
299
+ const scope = createOperationScope({ timeoutMs: this.operationTimeout() });
300
+ this.backgroundScopes.add(scope);
301
+ const task = runWithOperationContext(scope.context, async () => {
302
+ try {
303
+ return await body();
304
+ } finally {
305
+ if (operationHasUnconfirmedExit()) this.recordUnsafeExit({ code: "process_exit_unconfirmed" });
306
+ }
307
+ }).finally(() => {
308
+ scope.dispose();
309
+ this.backgroundScopes.delete(scope);
310
+ this.backgroundTasks.delete(task);
311
+ });
312
+ this.backgroundTasks.add(task);
313
+ return task;
314
+ }
315
+
316
+ private async withRecoveryBudget<T>(body: () => Promise<T>): Promise<T> {
317
+ try {
318
+ return await withRecoveryBudget(body, {
319
+ timeoutMs: this.operationTimeout(), onProgress: this.dependencies.onProgress,
320
+ });
321
+ } catch (error) {
322
+ this.recordUnsafeExit(error);
323
+ throw error;
324
+ }
325
+ }
326
+
230
327
  history(): HistoryState {
231
328
  return { undoCount: this.undoStack.length, redoCount: this.redoStack.length, locked: this.locked };
232
329
  }
@@ -256,14 +353,15 @@ export class UndoControllerImpl implements UndoController {
256
353
  }
257
354
 
258
355
  warmUp(): void {
259
- if (this.locked || this.warmUpInFlight !== undefined) return;
260
- this.warmUpInFlight = (async () => {
356
+ if (this.disposed || this.locked || this.warmUpInFlight !== undefined) return;
357
+ this.warmUpInFlight = this.runBackground(async () => {
261
358
  try {
262
359
  this.warmUpManifest = await this.captureWithWorkspaceLock();
263
- } catch {
360
+ } catch (error) {
361
+ this.recordUnsafeExit(error);
264
362
  // 预热是 best-effort:失败静默,正式 capture 会再次尝试并上报。
265
363
  }
266
- })();
364
+ });
267
365
  }
268
366
 
269
367
  captureFailureReason(): string | undefined {
@@ -275,13 +373,15 @@ export class UndoControllerImpl implements UndoController {
275
373
  }
276
374
 
277
375
  async prepareInput(text: string, context: InputContext): Promise<InputEventResult> {
278
- if (this.promptDeferralInFlight) return { action: "defer" };
376
+ if (this.disposed || this.unsafeExit || this.promptDeferralInFlight) return { action: "defer" };
279
377
  if (this.locked) return { action: "continue" };
280
378
  if (this.operationInFlight) return { action: "defer" };
281
379
  if (context.streaming || text.length === 0) return { action: "continue" };
380
+ if (this.disposed || this.runCompletion?.started || this.settling !== undefined) return { action: "defer" };
381
+ const run = this.beginRun();
282
382
  try {
283
- const before = await this.captureInputBaseline();
284
- this.stageInput(text, before);
383
+ const before = await this.runBackground(() => this.captureInputBaseline());
384
+ if (this.runCompletion === run && !this.disposed) this.stageInput(text, before);
285
385
  return { action: "continue" };
286
386
  } catch (error) {
287
387
  // 无法证明输入前状态:放弃记录本次历史,但绝不吞掉用户输入。
@@ -291,16 +391,18 @@ export class UndoControllerImpl implements UndoController {
291
391
  }
292
392
 
293
393
  beginInput(text: string, context: InputContext): InputEventResult {
294
- if (this.promptDeferralInFlight) return { action: "defer" };
394
+ if (this.disposed || this.unsafeExit || this.promptDeferralInFlight) return { action: "defer" };
295
395
  if (this.locked) return { action: "continue" };
296
396
  if (this.operationInFlight) return { action: "defer" };
297
397
  if (context.streaming || text.length === 0) return { action: "continue" };
398
+ if (this.disposed || this.runCompletion?.started || this.settling !== undefined) return { action: "defer" };
399
+ this.beginRun();
298
400
  this.staged = undefined;
299
401
  this.lastCaptureFailed = false;
300
402
  this.lastCaptureFailureMessage = undefined;
301
403
  this.deferAgentStartUntilMessageEnd = true;
302
404
  const token = Symbol("input-capture");
303
- const promise = this.captureInputForToken(text, token);
405
+ const promise = this.runBackground(() => this.captureInputForToken(text, token));
304
406
  this.pendingInputCapture = { token, promise };
305
407
  void promise.then(() => {
306
408
  if (this.pendingInputCapture?.token === token) this.pendingInputCapture = undefined;
@@ -308,8 +410,16 @@ export class UndoControllerImpl implements UndoController {
308
410
  return { action: "continue" };
309
411
  }
310
412
 
311
- async commitInput(): Promise<void> {
312
- if (!this.deferAgentStartUntilMessageEnd) return;
413
+ commitInput(): Promise<void> {
414
+ if (this.inputCommit !== undefined) return this.inputCommit;
415
+ const commit = this.commitCapturedInput();
416
+ this.inputCommit = commit.finally(() => { this.inputCommit = undefined; });
417
+ return this.inputCommit;
418
+ }
419
+
420
+ private async commitCapturedInput(): Promise<void> {
421
+ if (!this.deferAgentStartUntilMessageEnd || this.disposed) return;
422
+ if (this.runCompletion !== undefined) this.runCompletion.started = true;
313
423
  const pending = this.pendingInputCapture;
314
424
  if (pending !== undefined) await pending.promise;
315
425
  this.pendingInputCapture = undefined;
@@ -318,26 +428,48 @@ export class UndoControllerImpl implements UndoController {
318
428
  }
319
429
 
320
430
  async beforeAgentStart(): Promise<void> {
431
+ if (this.runCompletion !== undefined) this.runCompletion.started = true;
321
432
  if (this.deferAgentStartUntilMessageEnd) return;
322
433
  await this.startAgentRun();
323
434
  }
324
435
 
436
+ private beginRun(): RunCompletion {
437
+ this.finishRun(this.runCompletion);
438
+ let resolve!: () => void;
439
+ const promise = new Promise<void>((done) => { resolve = done; });
440
+ const run = { started: false, promise, resolve };
441
+ this.runCompletion = run;
442
+ return run;
443
+ }
444
+
445
+ private finishRun(run: RunCompletion | undefined): void {
446
+ if (this.runCompletion === run) this.runCompletion = undefined;
447
+ run?.resolve();
448
+ }
449
+
325
450
  private async startAgentRun(): Promise<void> {
326
- if (this.locked || this.staged === undefined) return;
451
+ if (this.locked || this.disposed || this.staged === undefined) {
452
+ this.finishRun(this.runCompletion);
453
+ return;
454
+ }
455
+ const staged = this.staged;
456
+ if (staged.startEntryId !== undefined) return;
327
457
  try {
328
- this.staged.startEntryId = await this.dependencies.appendControl("pi-undo:start", {
458
+ staged.startEntryId = await this.dependencies.appendControl("pi-undo:start", {
329
459
  schemaVersion: 1,
330
- beforeManifestId: this.staged.before.manifestId,
331
- sourceLogicalLeaf: this.staged.sourceLogicalLeaf,
460
+ beforeManifestId: staged.before.manifestId,
461
+ sourceLogicalLeaf: staged.sourceLogicalLeaf,
332
462
  });
333
463
  if (this.staged.startEntryId === null) {
334
464
  this.lock("start_entry_missing");
465
+ this.finishRun(this.runCompletion);
335
466
  this.staged = undefined;
336
467
  await this.dependencies.appendControl("pi-undo:barrier", { reason: "start_entry_missing" }).catch(() => {});
337
468
  return;
338
469
  }
339
470
  } catch {
340
471
  this.lock("start_entry_append_failed");
472
+ this.finishRun(this.runCompletion);
341
473
  this.staged = undefined;
342
474
  return;
343
475
  }
@@ -377,13 +509,26 @@ export class UndoControllerImpl implements UndoController {
377
509
 
378
510
  private recordCaptureFailure(error: unknown): void {
379
511
  this.lastCaptureFailed = true;
512
+ this.recordUnsafeExit(error);
380
513
  this.lastCaptureFailureMessage = truncateReason(error instanceof Error ? error.message : String(error));
381
514
  }
382
515
 
383
- async agentSettled(): Promise<void> {
516
+ agentSettled(): Promise<void> {
517
+ if (this.disposed) return Promise.resolve();
518
+ if (this.settling !== undefined) return this.settling;
519
+ const run = this.runCompletion;
520
+ const settled = this.runBackground(() => this.settleRun());
521
+ this.settling = settled.finally(() => {
522
+ this.settling = undefined;
523
+ this.finishRun(run);
524
+ });
525
+ return this.settling;
526
+ }
527
+
528
+ private async settleRun(): Promise<void> {
384
529
  const staged = this.staged;
385
530
  this.staged = undefined;
386
- if (this.locked || staged === undefined) return;
531
+ if (this.disposed || this.locked || staged === undefined) return;
387
532
  if (staged.startEntryId === undefined || staged.startEntryId === null) {
388
533
  // Pi 没有提供已落盘的 start entry ID,不能把后续 assistant 输出归属到该 checkpoint。
389
534
  this.lock("start_entry_missing");
@@ -417,7 +562,11 @@ export class UndoControllerImpl implements UndoController {
417
562
  measure("settled.prepareUndo", () =>
418
563
  this.dependencies.prepareDurableRestore!(after, staged.before, changedPaths)),
419
564
  ];
420
- await Promise.allSettled(preparations);
565
+ const outcomes = await Promise.allSettled(preparations);
566
+ for (const outcome of outcomes) {
567
+ if (outcome.status === "rejected" && this.recordUnsafeExit(outcome.reason)) throw outcome.reason;
568
+ }
569
+ checkOperation();
421
570
  }
422
571
  const endLeafId = this.dependencies.getLogicalLeafId() ?? staged.startEntryId;
423
572
  const checkpoint = this.createCheckpoint(staged, after, changedPaths, userEntryId, endLeafId);
@@ -429,7 +578,8 @@ export class UndoControllerImpl implements UndoController {
429
578
  return;
430
579
  }
431
580
  this.undoStack.push(checkpoint);
432
- } catch {
581
+ } catch (error) {
582
+ this.recordUnsafeExit(error);
433
583
  this.historyPaused = true;
434
584
  this.undoStack.length = 0;
435
585
  this.redoStack.length = 0;
@@ -451,28 +601,54 @@ export class UndoControllerImpl implements UndoController {
451
601
  return this.runOperation("redo");
452
602
  }
453
603
 
454
- async beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined> {
604
+ beforeTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined> {
605
+ if (this.treeScope !== undefined) return Promise.resolve({ cancel: true });
606
+ this.treeCancellationRequested = event.signal?.aborted ?? false;
607
+ const scope = createOperationScope({ timeoutMs: this.operationTimeout(), signal: event.signal });
608
+ this.treeScope = scope;
609
+ const preparation = runWithOperationContext(scope.context, () => this.prepareTree(event));
610
+ this.treePreparation = preparation.finally(() => {
611
+ this.treePreparation = undefined;
612
+ if (this.pendingTree === undefined) this.finishTreeScope();
613
+ });
614
+ return this.treePreparation;
615
+ }
616
+
617
+ private finishTreeScope(): void {
618
+ this.treeScope?.dispose();
619
+ this.treeScope = undefined;
620
+ }
621
+
622
+ private async prepareTree(event: SessionBeforeTreeEvent): Promise<SessionBeforeTreeResult | undefined> {
455
623
  // Pi 0.86+ 在树导航期间也会让 isIdle() 暂时返回 false;只有 pi-undo
456
624
  // 已经记录了尚未 settle 的 run 时,才需要中止并取消导航。
457
- if (this.staged !== undefined) {
625
+ if (this.staged !== undefined || this.pendingInputCapture !== undefined ||
626
+ this.runCompletion !== undefined || this.settling !== undefined || this.disposed) {
458
627
  await this.dependencies.abortAgent();
459
628
  return { cancel: true };
460
629
  }
461
- if (this.locked || this.historyPaused || this.operationInFlight) return { cancel: true };
630
+ if (this.locked || this.historyPaused || this.operationInFlight || this.treeCancellationRequested) return { cancel: true };
462
631
  this.operationInFlight = true;
463
632
  let lease: { release(): Promise<void> } | undefined;
464
633
  try {
634
+ checkOperation();
465
635
  lease = await this.dependencies.acquireWorkspaceLock();
466
636
  const rollback = await this.dependencies.capture();
637
+ checkOperation();
467
638
  const targetState = await this.dependencies.resolveTreeTarget(event.targetLeafId);
468
639
  const target = await this.dependencies.loadManifest(targetState.targetManifestId);
469
640
  const plan = await this.dependencies.planRestore(rollback, target);
470
641
  const descriptor = this.createDescriptor("tree", rollback, target, plan, targetState.logicalLeafId);
471
642
  await this.dependencies.journal.prepare(descriptor, plan);
472
643
  this.pendingTree = { descriptor, rollback, target, plan, undoStack: targetState.undoStack, lease };
644
+ if (this.treeCancellationRequested || this.treeScope?.context.signal.aborted) {
645
+ await this.withRecoveryBudget(() => this.cancelPreparedTree());
646
+ return { cancel: true };
647
+ }
473
648
  return undefined;
474
- } catch {
475
- if (lease !== undefined) {
649
+ } catch (error) {
650
+ this.recordUnsafeExit(error);
651
+ if (lease !== undefined && !this.unsafeExit) {
476
652
  await lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
477
653
  }
478
654
  this.operationInFlight = false;
@@ -480,12 +656,23 @@ export class UndoControllerImpl implements UndoController {
480
656
  }
481
657
  }
482
658
 
483
- async afterTree(event: SessionTreeEvent): Promise<void> {
659
+ afterTree(event: SessionTreeEvent): Promise<void> {
660
+ if (this.treeApplication !== undefined) return this.treeApplication;
661
+ const application = runWithOperationContext(this.treeScope?.context, () => this.applyPreparedTree(event));
662
+ this.treeApplication = application.finally(() => {
663
+ this.treeApplication = undefined;
664
+ this.finishTreeScope();
665
+ });
666
+ return this.treeApplication;
667
+ }
668
+
669
+ private async applyPreparedTree(event: SessionTreeEvent): Promise<void> {
484
670
  const pending = this.pendingTree;
485
671
  if (pending === undefined) return;
486
672
  this.pendingTree = undefined;
487
673
  try {
488
- if ((event.navigationTargetLeafId ?? event.newLeafId) !== pending.descriptor.toLogicalLeaf) {
674
+ const navigationTarget = event.navigationTargetLeafId === undefined ? event.newLeafId : event.navigationTargetLeafId;
675
+ if (navigationTarget !== pending.descriptor.toLogicalLeaf) {
489
676
  this.lock("session_navigation_diverged");
490
677
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
491
678
  return;
@@ -494,6 +681,7 @@ export class UndoControllerImpl implements UndoController {
494
681
  { phase: "SESSION_MOVED", observedLogicalLeaf: event.newLeafId },
495
682
  { phase: "APPLYING" },
496
683
  ]);
684
+ checkOperation();
497
685
  const applied = await this.dependencies.applyRestore(
498
686
  pending.plan,
499
687
  pending.target,
@@ -504,6 +692,7 @@ export class UndoControllerImpl implements UndoController {
504
692
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
505
693
  return;
506
694
  }
695
+ checkOperation();
507
696
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "FILES_VERIFIED");
508
697
  const cursorResult = await this.dependencies.appendCursor(
509
698
  this.createTreeCursor(pending.descriptor, event.newLeafId, pending.undoStack),
@@ -517,25 +706,45 @@ export class UndoControllerImpl implements UndoController {
517
706
  await this.dependencies.journal.markCommitted(pending.descriptor.opId);
518
707
  this.undoStack.splice(0, this.undoStack.length, ...pending.undoStack);
519
708
  this.redoStack.length = 0;
520
- } catch {
709
+ } catch (error) {
710
+ this.recordUnsafeExit(error);
521
711
  this.lock("tree_recovery_failed");
522
712
  } finally {
523
- await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
713
+ if (!this.unsafeExit && !operationHasUnconfirmedExit()) {
714
+ await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
715
+ }
524
716
  this.operationInFlight = false;
525
717
  }
526
718
  }
527
719
 
528
720
  async cancelTree(): Promise<void> {
721
+ this.treeCancellationRequested = true;
722
+ this.treeScope?.cancel();
723
+ await this.treePreparation;
724
+ await this.treeApplication;
725
+ if (this.pendingTree !== undefined) await this.withRecoveryBudget(() => this.cancelPreparedTree());
726
+ this.finishTreeScope();
727
+ }
728
+
729
+ private async cancelPreparedTree(): Promise<void> {
529
730
  const pending = this.pendingTree;
530
731
  if (pending === undefined) return;
531
732
  this.pendingTree = undefined;
532
733
  try {
734
+ if (this.dependencies.getLogicalLeafId() !== pending.descriptor.fromLogicalLeaf) {
735
+ this.lock("tree_cancel_session_moved");
736
+ await this.dependencies.journal.setPhase(pending.descriptor.opId, "RECOVERY_REQUIRED");
737
+ return;
738
+ }
533
739
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTING");
534
740
  await this.dependencies.journal.setPhase(pending.descriptor.opId, "ABORTED");
535
- } catch {
741
+ } catch (error) {
742
+ this.recordUnsafeExit(error);
536
743
  this.lock("tree_cancel_failed");
537
744
  } finally {
538
- await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
745
+ if (!this.unsafeExit && !operationHasUnconfirmedExit()) {
746
+ await pending.lease.release().catch(() => { this.lock("workspace_lock_release_failed"); });
747
+ }
539
748
  this.operationInFlight = false;
540
749
  }
541
750
  }
@@ -561,10 +770,33 @@ export class UndoControllerImpl implements UndoController {
561
770
  await recovery;
562
771
  }
563
772
 
564
- private async runOperation(action: "undo" | "redo"): Promise<OperationResult> {
773
+ private runOperation(action: "undo" | "redo"): Promise<OperationResult> {
774
+ if (this.disposed || this.operationInFlight || this.activeOperation !== undefined) return Promise.resolve({ code: "busy", changedFiles: 0 });
775
+ this.operationId = `op-${randomUUID()}`;
776
+ this.dependencies.onOperationStart?.(this.operationId);
777
+ const scope = createOperationScope({
778
+ timeoutMs: this.operationTimeout(), onProgress: this.dependencies.onProgress, onProcess: this.dependencies.onProcess,
779
+ });
780
+ this.activeScope = scope;
781
+ const opId = this.operationId;
782
+ const task = runWithOperationContext(scope.context, () => this.performOperation(action)).then(async (result) => {
783
+ await this.dependencies.onOperationEnd?.(opId, result).catch(() => {});
784
+ return result;
785
+ });
786
+ this.activeOperation = task.finally(() => {
787
+ scope.dispose();
788
+ this.activeScope = undefined;
789
+ this.activeOperation = undefined;
790
+ this.transaction = undefined;
791
+ this.operationId = undefined;
792
+ });
793
+ return this.activeOperation;
794
+ }
795
+
796
+ private async performOperation(action: "undo" | "redo"): Promise<OperationResult> {
565
797
  if (this.locked) return this.recoveryResult();
566
798
  if (this.operationInFlight) return { code: "busy", changedFiles: 0 };
567
- const profile = new OperationProfiler();
799
+ const profile = new OperationProfiler(this.dependencies.onProgress);
568
800
  const done = (result: OperationResult): OperationResult => profile.attach(result);
569
801
  this.operationInFlight = true;
570
802
  this.operationAction = action;
@@ -574,16 +806,25 @@ export class UndoControllerImpl implements UndoController {
574
806
  let lease: { release(): Promise<void> } | undefined;
575
807
  try {
576
808
  if (!await profile.measure("idle", () => this.ensureIdle())) {
809
+ checkOperation();
577
810
  return done({ code: "idle_timeout", changedFiles: 0 });
578
811
  }
579
- // 中断中的 run 会在 waitForIdle() 内由 agentSettled() 推入栈,必须在此之后选择目标。
812
+ if (!await profile.measure("checkpoint", () => this.waitForCheckpoint())) {
813
+ checkOperation();
814
+ return done({ code: "idle_timeout", changedFiles: 0 });
815
+ }
816
+ if (this.locked) return done(this.recoveryResult());
817
+ if (this.historyPaused) return done({ code: "history_paused", changedFiles: 0 });
818
+ if (this.disposed) return done({ code: "busy", changedFiles: 0 });
819
+ // Pi 空闲早于扩展 settled 完成;只有本轮完成凭据已终结才能选择目标。
580
820
  const redo = action === "redo" ? this.redoStack.at(-1) : undefined;
581
821
  const checkpoint = action === "undo" ? this.undoStack.at(-1) : redo?.checkpoint;
582
822
  if (checkpoint === undefined) return done(noop());
583
823
  const targetManifestId = redo?.targetManifestId;
584
824
  try {
585
825
  lease = await profile.measure("lock", () => this.dependencies.acquireWorkspaceLock());
586
- } catch {
826
+ } catch (error) {
827
+ if (operationFailure(error) !== undefined || isUnconfirmedExit(error)) throw error;
587
828
  return done({ code: "busy", changedFiles: 0 });
588
829
  }
589
830
  if (checkpoint.changedPaths.length === 0) {
@@ -606,7 +847,8 @@ export class UndoControllerImpl implements UndoController {
606
847
  restoreTargetManifestId,
607
848
  checkpoint.changedPaths,
608
849
  ));
609
- } catch {
850
+ } catch (error) {
851
+ if (operationFailure(error) !== undefined || isUnconfirmedExit(error)) throw error;
610
852
  return done({ code: "capture_failed", changedFiles: 0 });
611
853
  }
612
854
  let target: SnapshotManifest;
@@ -618,10 +860,12 @@ export class UndoControllerImpl implements UndoController {
618
860
  plan = await profile.measure("plan", () =>
619
861
  this.dependencies.planRestore(rollback, target, checkpoint.changedPaths));
620
862
  targetLogicalLeaf = this.dependencies.resolveSessionTarget(action, checkpoint);
621
- } catch {
863
+ } catch (error) {
864
+ if (operationFailure(error) !== undefined || isUnconfirmedExit(error)) throw error;
622
865
  return done({ code: "restore_failed_safe", changedFiles: 0 });
623
866
  }
624
867
  const descriptor = this.createDescriptor(action, rollback, target, plan, targetLogicalLeaf);
868
+ this.transaction = { descriptor, rollback, target, cursorDurable: false };
625
869
  await profile.measure("journal", () => this.dependencies.journal.prepare(descriptor, plan));
626
870
  const navigation = await profile.measure("navigate", () =>
627
871
  this.dependencies.navigateSession(action, checkpoint));
@@ -645,8 +889,9 @@ export class UndoControllerImpl implements UndoController {
645
889
  const applied = await profile.measure("apply", () =>
646
890
  this.dependencies.applyRestore(plan, target, { opId: descriptor.opId }));
647
891
  if (applied.code !== "ok") {
648
- return done(await profile.measure("compensate", () =>
649
- this.compensate(descriptor, rollback, target, applied)));
892
+ const result = await this.compensate(descriptor, rollback, target, applied);
893
+ return done(result.code === "restore_failed_safe" && applied.failureCode !== undefined
894
+ ? { ...result, code: applied.failureCode } : result);
650
895
  }
651
896
  await profile.measure("journal", () =>
652
897
  this.dependencies.journal.setPhase(descriptor.opId, "FILES_VERIFIED"));
@@ -665,18 +910,32 @@ export class UndoControllerImpl implements UndoController {
665
910
  totalPaths: applied.totalPaths,
666
911
  })));
667
912
  }
913
+ this.transaction.cursorDurable = true;
668
914
  await profile.measure("commit", async () => {
669
915
  await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
670
916
  await this.dependencies.journal.markCommitted(descriptor.opId);
671
917
  });
672
918
  this.lastSafetyManifestId = rollback.manifestId;
673
919
  return done(this.advanceHistory(action, checkpoint, { code: "ok", changedFiles: applied.verifiedPaths }));
674
- } catch {
920
+ } catch (error) {
921
+ if (this.recordUnsafeExit(error)) return done(this.recoveryResult());
922
+ const reason = operationFailure(error);
923
+ const tx = this.transaction;
924
+ if (reason !== undefined && !tx?.cursorDurable) {
925
+ if (tx !== undefined) {
926
+ const recovered = tx.rollback !== undefined && tx.target !== undefined
927
+ ? await this.compensate(tx.descriptor, tx.rollback, tx.target, { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 })
928
+ : await this.compensateSessionOnly(tx.descriptor);
929
+ if (recovered.code === "recovery_required") return done(recovered);
930
+ }
931
+ return done({ code: reason, changedFiles: 0 });
932
+ }
675
933
  this.lock("operation_failed");
676
934
  return done(this.recoveryResult());
677
935
  } finally {
936
+ if (operationHasUnconfirmedExit()) this.recordUnsafeExit({ code: "process_exit_unconfirmed" });
678
937
  const activeLease = lease;
679
- if (activeLease !== undefined) {
938
+ if (activeLease !== undefined && !this.unsafeExit) {
680
939
  await profile.measure("unlock", () =>
681
940
  activeLease.release().catch(() => { this.lock("workspace_lock_release_failed"); }));
682
941
  }
@@ -725,6 +984,7 @@ export class UndoControllerImpl implements UndoController {
725
984
  plan,
726
985
  targetLogicalLeaf,
727
986
  );
987
+ this.transaction = { descriptor, cursorDurable: false };
728
988
  await profile.measure("journal", () => this.dependencies.journal.prepare(descriptor, plan));
729
989
  const navigation = await profile.measure("navigate", () =>
730
990
  this.dependencies.navigateSession(action, checkpoint));
@@ -757,6 +1017,7 @@ export class UndoControllerImpl implements UndoController {
757
1017
  if (cursorResult.kind === "volatile") {
758
1018
  return profile.measure("compensate", () => this.compensateSessionOnly(descriptor));
759
1019
  }
1020
+ this.transaction.cursorDurable = true;
760
1021
  await profile.measure("commit", async () => {
761
1022
  await this.dependencies.journal.setPhase(descriptor.opId, "CURSOR_COMMITTED");
762
1023
  await this.dependencies.journal.markCommitted(descriptor.opId);
@@ -766,6 +1027,15 @@ export class UndoControllerImpl implements UndoController {
766
1027
  }
767
1028
 
768
1029
  private async compensateSessionOnly(descriptor: OperationDescriptor): Promise<OperationResult> {
1030
+ try {
1031
+ return await this.withRecoveryBudget(() => this.compensateSessionOnlyWithinBudget(descriptor));
1032
+ } catch {
1033
+ this.lock("session_only_recovery_failed");
1034
+ return this.recoveryResult();
1035
+ }
1036
+ }
1037
+
1038
+ private async compensateSessionOnlyWithinBudget(descriptor: OperationDescriptor): Promise<OperationResult> {
769
1039
  try {
770
1040
  await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
771
1041
  if (!await this.dependencies.restoreSessionLeaf(descriptor.fromLogicalLeaf)) {
@@ -800,12 +1070,27 @@ export class UndoControllerImpl implements UndoController {
800
1070
  }
801
1071
  }
802
1072
 
1073
+ private async waitForCheckpoint(): Promise<boolean> {
1074
+ const deadline = currentOperationContext()?.deadline ?? Date.now() + this.operationTimeout();
1075
+ for (const task of [this.pendingInputCapture?.promise, this.inputCommit]) {
1076
+ if (task !== undefined && !await waitForCompletion(task, deadline)) return false;
1077
+ }
1078
+ const run = this.runCompletion;
1079
+ if (run?.started) return waitForCompletion(run.promise, deadline);
1080
+ // 输入预检失败时没有 message_end/settled;候选快照不属于已启动的 run。
1081
+ this.staged = undefined;
1082
+ this.deferAgentStartUntilMessageEnd = false;
1083
+ this.finishRun(run);
1084
+ return true;
1085
+ }
1086
+
803
1087
  private async ensureIdle(): Promise<boolean> {
804
1088
  if (this.dependencies.isAgentIdle()) return true;
805
1089
  try {
806
1090
  await this.dependencies.abortAgent();
807
1091
  return await this.dependencies.waitForIdle(this.dependencies.clock() + 30_000);
808
- } catch {
1092
+ } catch (error) {
1093
+ rethrowOperationFailure(error);
809
1094
  return false;
810
1095
  }
811
1096
  }
@@ -818,8 +1103,11 @@ export class UndoControllerImpl implements UndoController {
818
1103
  const lease = await this.dependencies.acquireWorkspaceLock();
819
1104
  try {
820
1105
  return await this.dependencies.capture();
1106
+ } catch (error) {
1107
+ this.recordUnsafeExit(error);
1108
+ throw error;
821
1109
  } finally {
822
- await lease.release();
1110
+ if (!this.unsafeExit) await lease.release();
823
1111
  }
824
1112
  }
825
1113
 
@@ -829,8 +1117,11 @@ export class UndoControllerImpl implements UndoController {
829
1117
  const lease = await this.dependencies.acquireWorkspaceLock();
830
1118
  try {
831
1119
  return await captureBaseline(baseline);
1120
+ } catch (error) {
1121
+ this.recordUnsafeExit(error);
1122
+ throw error;
832
1123
  } finally {
833
- await lease.release();
1124
+ if (!this.unsafeExit) await lease.release();
834
1125
  }
835
1126
  }
836
1127
 
@@ -843,6 +1134,7 @@ export class UndoControllerImpl implements UndoController {
843
1134
  try {
844
1135
  return await this.captureBaselineWithWorkspaceLock(baseline);
845
1136
  } catch (error) {
1137
+ rethrowOperationFailure(error);
846
1138
  if (!isTransientCaptureFailure(error)) throw error;
847
1139
  await sleep(250);
848
1140
  return await this.captureBaselineWithWorkspaceLock(baseline);
@@ -854,6 +1146,21 @@ export class UndoControllerImpl implements UndoController {
854
1146
  rollback: SnapshotManifest,
855
1147
  target: SnapshotManifest,
856
1148
  failure: RestoreResult,
1149
+ ): Promise<OperationResult> {
1150
+ try {
1151
+ this.dependencies.onProgress?.("compensate");
1152
+ return await this.withRecoveryBudget(() => this.compensateWithinBudget(descriptor, rollback, target, failure));
1153
+ } catch {
1154
+ this.lock("compensation_failed");
1155
+ return this.recoveryResult();
1156
+ }
1157
+ }
1158
+
1159
+ private async compensateWithinBudget(
1160
+ descriptor: OperationDescriptor,
1161
+ rollback: SnapshotManifest,
1162
+ target: SnapshotManifest,
1163
+ failure: RestoreResult,
857
1164
  ): Promise<OperationResult> {
858
1165
  try {
859
1166
  await this.dependencies.journal.setPhase(descriptor.opId, "ABORTING");
@@ -870,7 +1177,8 @@ export class UndoControllerImpl implements UndoController {
870
1177
  await this.dependencies.journal.setPhase(descriptor.opId, "ABORTED");
871
1178
  return { code: "restore_failed_safe", changedFiles: failure.verifiedPaths };
872
1179
  }
873
- } catch {
1180
+ } catch (error) {
1181
+ this.recordUnsafeExit(error);
874
1182
  // 下面统一进入 recovery lock。
875
1183
  }
876
1184
  this.lock("compensation_failed");
@@ -927,7 +1235,7 @@ export class UndoControllerImpl implements UndoController {
927
1235
  const scopePaths = [...(plan.scopePaths ?? [...plan.deletePaths, ...plan.writePaths])].sort();
928
1236
  const payload = {
929
1237
  schemaVersion: 1 as const,
930
- opId: `op-${randomUUID()}`,
1238
+ opId: this.operationId ?? `op-${randomUUID()}`,
931
1239
  sessionIdentity: this.dependencies.sessionIdentity,
932
1240
  workspaceIdentity: this.dependencies.workspaceIdentity,
933
1241
  action,
@@ -1002,7 +1310,11 @@ function emptyRestorePlan(currentManifestId: ManifestId, targetManifestId: Manif
1002
1310
  class OperationProfiler {
1003
1311
  private readonly durations = new Map<string, number>();
1004
1312
 
1313
+ constructor(private readonly onProgress?: (phase: string) => void) {}
1314
+
1005
1315
  async measure<T>(phase: string, operation: () => Promise<T>): Promise<T> {
1316
+ if (phase !== "unlock" && phase !== "commit" && !phase.startsWith("settled.")) checkOperation();
1317
+ this.onProgress?.(phase);
1006
1318
  const started = performance.now();
1007
1319
  try {
1008
1320
  return await operation();
@@ -1024,6 +1336,26 @@ class OperationProfiler {
1024
1336
  }
1025
1337
  }
1026
1338
 
1339
+ async function waitForCompletion(task: Promise<void>, deadline: number): Promise<boolean> {
1340
+ let timer: ReturnType<typeof setTimeout> | undefined;
1341
+ const signal = currentOperationContext()?.signal;
1342
+ let onAbort: (() => void) | undefined;
1343
+ try {
1344
+ checkOperation();
1345
+ return await Promise.race([
1346
+ task.then(() => true, () => false),
1347
+ new Promise<boolean>((resolve) => {
1348
+ timer = setTimeout(() => resolve(false), Math.max(0, deadline - Date.now()));
1349
+ onAbort = () => resolve(false);
1350
+ signal?.addEventListener("abort", onAbort, { once: true });
1351
+ }),
1352
+ ]);
1353
+ } finally {
1354
+ if (timer !== undefined) clearTimeout(timer);
1355
+ if (onAbort !== undefined) signal?.removeEventListener("abort", onAbort);
1356
+ }
1357
+ }
1358
+
1027
1359
  function noop(): OperationResult {
1028
1360
  return { code: "noop", changedFiles: 0 };
1029
1361
  }