@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.
@@ -17,6 +17,7 @@ import {
17
17
  } from "./durable-pack.ts";
18
18
  import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
19
19
  import { MutationJournal } from "./mutation-journal.ts";
20
+ import { allCompleted, checkOperation, rethrowOperationFailure, isUnconfirmedExit, operationFailure, withRecoveryBudget } from "./operation-context.ts";
20
21
  import { createNativeFileBatch } from "./native-restore.ts";
21
22
  import { recoverPackedMutations } from "./packed-recovery.ts";
22
23
  import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
@@ -61,6 +62,7 @@ export interface RestoreResult {
61
62
  verifiedPaths: number;
62
63
  totalPaths: number;
63
64
  postFingerprint?: string;
65
+ failureCode?: "operation_cancelled" | "operation_timeout";
64
66
  }
65
67
 
66
68
  export interface RestoreEngine {
@@ -97,6 +99,16 @@ interface OwnedPath {
97
99
  readonly root: SnapshotRoot;
98
100
  }
99
101
 
102
+ interface VisibleSubsetCheck {
103
+ /**
104
+ * 调用方刚刚完成 topology discovery 且随后没有文件 mutation 时为 true,可跳过枚举入口的重复校验。
105
+ * 枚举结束后的校验仍然执行,所以该窗口内的漂移依然被拒绝。
106
+ */
107
+ readonly topologyValidated: boolean;
108
+ readonly extraExclusions?: readonly string[];
109
+ readonly ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[];
110
+ }
111
+
100
112
  interface PreparedRestorePlan {
101
113
  readonly plan: RestorePlan;
102
114
  readonly currentPaths: ReadonlyMap<string, OwnedPath>;
@@ -207,12 +219,12 @@ export class RestoreEngine {
207
219
  const canonicalScopePaths = scope === undefined ? undefined : [...scope];
208
220
  assertCompatibleManifests(current, target, scope);
209
221
  const isScopedPath = (path: string): boolean => scope === undefined || scope.has(path);
210
- await Promise.all([
222
+ await allCompleted([
211
223
  this.store.assertComplete(current.manifestId, canonicalScopePaths),
212
224
  this.store.assertComplete(target.manifestId, canonicalScopePaths),
213
225
  ]);
214
226
 
215
- const [currentPaths, targetPaths] = await Promise.all([
227
+ const [currentPaths, targetPaths] = await allCompleted([
216
228
  this.readOwnedPaths(current, canonicalScopePaths),
217
229
  this.readOwnedPaths(target, canonicalScopePaths),
218
230
  ]);
@@ -293,7 +305,7 @@ export class RestoreEngine {
293
305
  const expected = await this.plan(current, target, scopePaths);
294
306
  if (expected.planDigest !== cached.planDigest) return false;
295
307
  }
296
- const topology = await this.discovery.discover(this.workspaceRoot);
308
+ const topology = await this.discovery.discover(this.workspaceRoot, "safety-snapshot");
297
309
  this.assertCurrentTopology(current, target, topology);
298
310
  const native = await createNativeFileBatch({
299
311
  workspaceRoot: this.workspaceRoot,
@@ -313,7 +325,8 @@ export class RestoreEngine {
313
325
  );
314
326
  }
315
327
  return true;
316
- } catch {
328
+ } catch (error) {
329
+ rethrowOperationFailure(error);
317
330
  return false;
318
331
  }
319
332
  }
@@ -517,7 +530,8 @@ export class RestoreEngine {
517
530
  private async acquireDurableIndexLease(): Promise<{ release(): Promise<void> } | undefined> {
518
531
  try {
519
532
  return await this.durableIndexLock.acquire(await this.durableIndexLockIdentity());
520
- } catch {
533
+ } catch (error) {
534
+ rethrowOperationFailure(error);
521
535
  return undefined;
522
536
  }
523
537
  }
@@ -737,7 +751,7 @@ export class RestoreEngine {
737
751
  compatibilityMode: boolean,
738
752
  pinsDeferred: boolean,
739
753
  ): Promise<RestoreResult> {
740
- const [current, storedTarget] = await Promise.all([
754
+ const [current, storedTarget] = await allCompleted([
741
755
  this.store.loadManifest(plan.currentManifestId),
742
756
  this.store.loadManifest(target.manifestId),
743
757
  ]);
@@ -773,13 +787,14 @@ export class RestoreEngine {
773
787
  if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) {
774
788
  throw new Error("restore workspace root 必须使用 canonical identity");
775
789
  }
776
- topologyBefore = await this.discovery.discover(this.workspaceRoot);
790
+ topologyBefore = await this.discovery.discover(this.workspaceRoot, "restore-pre");
777
791
  this.assertCurrentTopology(current, target, topologyBefore);
778
- } catch {
792
+ } catch (error) {
793
+ rethrowOperationFailure(error);
779
794
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
780
795
  }
781
796
  const [currentPaths, targetPaths] = prepared === undefined
782
- ? await Promise.all([
797
+ ? await allCompleted([
783
798
  this.readOwnedPaths(current, plan.scopePaths),
784
799
  this.readOwnedPaths(target, plan.scopePaths),
785
800
  ])
@@ -796,14 +811,18 @@ export class RestoreEngine {
796
811
  return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
797
812
  }
798
813
  try {
814
+ // 兼容恢复路径会先在此窗口内补偿 pending mutations,因此只有非补偿路径可以跳过枚举入口的重复发现。
799
815
  await this.assertCompleteVisibleSubset(
800
816
  topologyBefore,
801
817
  [current, target],
802
818
  options.mutationJournal,
803
- [],
804
- this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
819
+ {
820
+ topologyValidated: !compatibilityMode,
821
+ ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [currentPaths, targetPaths]),
822
+ },
805
823
  );
806
- } catch {
824
+ } catch (error) {
825
+ rethrowOperationFailure(error);
807
826
  return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
808
827
  }
809
828
  let durablePack: DurablePack | undefined;
@@ -833,7 +852,8 @@ export class RestoreEngine {
833
852
  entries: await this.durablePackEntries(current, target, currentPaths, targetPaths, plan, options.opId),
834
853
  });
835
854
  }
836
- } catch {
855
+ } catch (error) {
856
+ rethrowOperationFailure(error);
837
857
  await removeDurablePack(options.mutationJournal).catch(() => {});
838
858
  }
839
859
  }
@@ -873,7 +893,8 @@ export class RestoreEngine {
873
893
  }
874
894
  try {
875
895
  await this.prefetchCompleteRestoreBlobs(plan, current, target, currentPaths, targetPaths);
876
- } catch {
896
+ } catch (error) {
897
+ rethrowOperationFailure(error);
877
898
  // 预取是性能优化;失败时继续走原有逐文件校验和可恢复 mutation 路径。
878
899
  }
879
900
  const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
@@ -911,14 +932,16 @@ export class RestoreEngine {
911
932
  );
912
933
  await this.writePlannedPaths(target.manifestId, targetPaths, plan.writePaths, mutationContext);
913
934
 
914
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
935
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
915
936
  assertUnchangedTopology(topologyBefore, topologyAfter);
916
937
  await this.assertCompleteVisibleSubset(
917
938
  topologyAfter,
918
939
  [target],
919
940
  options.mutationJournal,
920
- [],
921
- this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
941
+ {
942
+ topologyValidated: true,
943
+ ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
944
+ },
922
945
  );
923
946
  const verification = await this.verifyTarget(
924
947
  target,
@@ -936,20 +959,17 @@ export class RestoreEngine {
936
959
  return await this.mutationsAreClean(options.mutationJournal)
937
960
  ? result
938
961
  : { code: "recovery_required", verifiedPaths: 0, totalPaths: verification.totalPaths };
939
- } catch {
940
- if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
941
- return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
942
- }
943
- return this.rollback(
944
- current,
945
- target,
946
- topologyBefore,
947
- currentPaths,
948
- targetPaths,
949
- options,
950
- plan.scopePaths,
951
- !durablePackEnabled,
952
- );
962
+ } catch (error) {
963
+ if (isUnconfirmedExit(error)) throw error;
964
+ return withRecoveryBudget(async () => {
965
+ if (!await this.restorePendingMutations(mutationContext.quarantine, options.mutationJournal)) {
966
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
967
+ }
968
+ const result = await this.rollback(
969
+ current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, !durablePackEnabled,
970
+ );
971
+ return { ...result, failureCode: operationFailure(error) };
972
+ });
953
973
  }
954
974
  }
955
975
 
@@ -984,49 +1004,45 @@ export class RestoreEngine {
984
1004
  ): Promise<RestoreResult> {
985
1005
  try {
986
1006
  await nativeRun(pack);
987
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1007
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
988
1008
  assertUnchangedTopology(topologyBefore, topologyAfter);
989
1009
  await this.assertCompleteVisibleSubset(
990
1010
  topologyAfter,
991
1011
  [target],
992
1012
  options.mutationJournal,
993
- pack.paths().flatMap((path) => {
994
- const artifacts = pack.artifacts(path);
995
- return artifacts === undefined
996
- ? []
997
- : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
998
- }),
999
- this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
1013
+ {
1014
+ topologyValidated: true,
1015
+ extraExclusions: pack.paths().flatMap((path) => {
1016
+ const artifacts = pack.artifacts(path);
1017
+ return artifacts === undefined
1018
+ ? []
1019
+ : [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
1020
+ }),
1021
+ ownedPaths: this.completeCoverageOwnedPaths(plan.scopePaths, [targetPaths]),
1022
+ },
1000
1023
  );
1001
1024
  const totalPaths = plan.deletePaths.length + plan.writePaths.length;
1002
1025
  if ((await options.mutationJournal.load()).length !== 0) {
1003
1026
  return { code: "recovery_required", verifiedPaths: 0, totalPaths };
1004
1027
  }
1005
1028
  return { code: "ok", verifiedPaths: totalPaths, totalPaths };
1006
- } catch {
1007
- const packedRecovery = await recoverPackedMutations({
1008
- workspaceRoot: this.workspaceRoot,
1009
- journal: options.mutationJournal,
1010
- planDigest: plan.planDigest,
1011
- decision: "rollback",
1029
+ } catch (error) {
1030
+ if (isUnconfirmedExit(error)) throw error;
1031
+ return withRecoveryBudget(async () => {
1032
+ const packedRecovery = await recoverPackedMutations({
1033
+ workspaceRoot: this.workspaceRoot,
1034
+ journal: options.mutationJournal,
1035
+ planDigest: plan.planDigest,
1036
+ decision: "rollback",
1037
+ });
1038
+ if (packedRecovery.kind !== "clean") {
1039
+ return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.deletePaths.length + plan.writePaths.length };
1040
+ }
1041
+ const result = await this.rollback(
1042
+ current, target, topologyBefore, currentPaths, targetPaths, options, plan.scopePaths, false,
1043
+ );
1044
+ return { ...result, failureCode: operationFailure(error) };
1012
1045
  });
1013
- if (packedRecovery.kind !== "clean") {
1014
- return {
1015
- code: "recovery_required",
1016
- verifiedPaths: 0,
1017
- totalPaths: plan.deletePaths.length + plan.writePaths.length,
1018
- };
1019
- }
1020
- return this.rollback(
1021
- current,
1022
- target,
1023
- topologyBefore,
1024
- currentPaths,
1025
- targetPaths,
1026
- options,
1027
- plan.scopePaths,
1028
- false,
1029
- );
1030
1046
  }
1031
1047
  }
1032
1048
 
@@ -1041,7 +1057,7 @@ export class RestoreEngine {
1041
1057
  const paths = [...new Set([...plan.deletePaths, ...plan.writePaths])].sort(comparePaths);
1042
1058
  const useValidatedBatch = SnapshotStore.supportsValidatedBlobBatch(this.store);
1043
1059
  const [currentBlobBytes, targetBlobBytes] = useValidatedBatch
1044
- ? await Promise.all([
1060
+ ? await allCompleted([
1045
1061
  this.readDurableBlobBytes(current.manifestId, paths, currentPaths),
1046
1062
  this.readDurableBlobBytes(target.manifestId, paths, targetPaths),
1047
1063
  ])
@@ -1211,7 +1227,7 @@ export class RestoreEngine {
1211
1227
  }
1212
1228
  return requests;
1213
1229
  };
1214
- await Promise.all([
1230
+ await allCompleted([
1215
1231
  this.store.prefetchBlobs(current.manifestId, requestsFor(currentPaths, plan.deletePaths)),
1216
1232
  this.store.prefetchBlobs(target.manifestId, requestsFor(targetPaths)),
1217
1233
  ]);
@@ -1332,6 +1348,7 @@ export class RestoreEngine {
1332
1348
  fileBatchRoot = undefined;
1333
1349
  };
1334
1350
  for (const path of deletePaths) {
1351
+ checkOperation();
1335
1352
  if (await this.pathIsShadowedByTarget(targetManifestId, path, targetPaths)) continue;
1336
1353
  const source = context.sourcePaths.get(path);
1337
1354
  const live = await lstat(this.absolutePath(path)).catch((error) => {
@@ -1516,14 +1533,16 @@ export class RestoreEngine {
1516
1533
  context,
1517
1534
  );
1518
1535
  await this.writePlannedPaths(current.manifestId, currentPaths, rollbackPlan.writePaths, context);
1519
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1536
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "restore-post");
1520
1537
  assertUnchangedTopology(topologyBefore, topologyAfter);
1521
1538
  await this.assertCompleteVisibleSubset(
1522
1539
  topologyAfter,
1523
1540
  [current],
1524
1541
  options.mutationJournal,
1525
- [],
1526
- this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1542
+ {
1543
+ topologyValidated: true,
1544
+ ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1545
+ },
1527
1546
  );
1528
1547
  const verification = await this.verifyTarget(
1529
1548
  current,
@@ -1559,14 +1578,16 @@ export class RestoreEngine {
1559
1578
  }
1560
1579
  if (rollbackPlan !== undefined) {
1561
1580
  try {
1562
- const topologyAfter = await this.discovery.discover(this.workspaceRoot);
1581
+ const topologyAfter = await this.discovery.discover(this.workspaceRoot, "compensation");
1563
1582
  assertUnchangedTopology(topologyBefore, topologyAfter);
1564
1583
  await this.assertCompleteVisibleSubset(
1565
1584
  topologyAfter,
1566
1585
  [current],
1567
1586
  options.mutationJournal,
1568
- [],
1569
- this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1587
+ {
1588
+ topologyValidated: true,
1589
+ ownedPaths: this.completeCoverageOwnedPaths(scopePaths, [currentPaths]),
1590
+ },
1570
1591
  );
1571
1592
  const verification = await this.verifyTarget(
1572
1593
  current,
@@ -1621,7 +1642,9 @@ export class RestoreEngine {
1621
1642
  context.ordinal += 1;
1622
1643
  const ordinal = context.ordinal;
1623
1644
  const beforeInstall = async (): Promise<void> => {
1645
+ checkOperation();
1624
1646
  await this.beforeMutation?.({ phase: context.phase, ordinal, kind, path });
1647
+ checkOperation();
1625
1648
  };
1626
1649
  if (!deferHook) await beforeInstall();
1627
1650
  await this.assertMutationPath(path);
@@ -1656,6 +1679,7 @@ export class RestoreEngine {
1656
1679
  };
1657
1680
  for (const kind of ["directory", "leaf"] as const) {
1658
1681
  for (const path of writePaths) {
1682
+ checkOperation();
1659
1683
  const target = targetPaths.get(path);
1660
1684
  if (target === undefined) {
1661
1685
  throw new Error(`${context.phase} plan 引用了 manifest 外路径:${path}`);
@@ -1715,6 +1739,7 @@ export class RestoreEngine {
1715
1739
  targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
1716
1740
  ...(this.beforeMutation === undefined ? {} : {
1717
1741
  beforeInstall: async () => {
1742
+ checkOperation();
1718
1743
  await this.beforeMutation?.({
1719
1744
  phase: context.phase,
1720
1745
  ordinal,
@@ -1757,9 +1782,8 @@ export class RestoreEngine {
1757
1782
  private async assertCompleteVisibleSubset(
1758
1783
  topology: RootTopology,
1759
1784
  allowedManifests: readonly SnapshotManifest[],
1760
- mutationJournal?: MutationJournal,
1761
- extraExclusions: readonly string[] = [],
1762
- ownedPaths?: readonly (ReadonlyMap<string, OwnedPath> | undefined)[],
1785
+ mutationJournal: MutationJournal | undefined,
1786
+ check: VisibleSubsetCheck,
1763
1787
  ): Promise<void> {
1764
1788
  if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
1765
1789
  return;
@@ -1769,7 +1793,7 @@ export class RestoreEngine {
1769
1793
  for (const path of ignoredWorkspacePaths(manifest)) {
1770
1794
  allowedPaths.add(path);
1771
1795
  }
1772
- const paths = ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1796
+ const paths = check.ownedPaths?.[index] ?? await this.readOwnedPaths(manifest);
1773
1797
  for (const [path, owned] of paths) {
1774
1798
  if (owned.entry.kind !== "directory") {
1775
1799
  allowedPaths.add(path);
@@ -1777,12 +1801,13 @@ export class RestoreEngine {
1777
1801
  }
1778
1802
  }
1779
1803
 
1780
- const exclusions = new Set(extraExclusions);
1804
+ const exclusions = new Set(check.extraExclusions ?? []);
1781
1805
  if (mutationJournal !== undefined) {
1782
1806
  for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
1783
1807
  }
1784
1808
  const livePaths = await this.store.listVisibleLeafPaths(topology, {
1785
1809
  excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
1810
+ topologyAlreadyValidated: check.topologyValidated,
1786
1811
  });
1787
1812
  for (const path of livePaths) {
1788
1813
  if (!allowedPaths.has(path)) {
@@ -1933,7 +1958,7 @@ export class RestoreEngine {
1933
1958
  if (owned.entry.blobId === null) {
1934
1959
  throw new Error(`普通文件缺少 blob:${path}`);
1935
1960
  }
1936
- const [actual, expected] = await Promise.all([
1961
+ const [actual, expected] = await allCompleted([
1937
1962
  readFile(this.absolutePath(path)),
1938
1963
  this.store.readBlob(
1939
1964
  manifestId,
@@ -2262,6 +2287,7 @@ async function mapConcurrentOrdered<T, R>(
2262
2287
  const index = nextIndex;
2263
2288
  nextIndex += 1;
2264
2289
  try {
2290
+ checkOperation();
2265
2291
  results[index] = await operation(values[index]!);
2266
2292
  } catch (error) {
2267
2293
  if (!failed) failure = error;
@@ -2,8 +2,9 @@ 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";
5
- import { GitRunner } from "./git-runner.ts";
5
+ import { GitRunError, GitRunner } from "./git-runner.ts";
6
6
  import type { DiscoveryRoot } from "./model.ts";
7
+ import { allCompleted, checkOperation, OperationError, reportOperationProgress } from "./operation-context.ts";
7
8
 
8
9
  const DIRECTORY_SCAN_CONCURRENCY = 16;
9
10
  const GIT_POINTER_MAX_BYTES = 4096;
@@ -38,6 +39,19 @@ export interface RootTopology {
38
39
  readonly fingerprint: string;
39
40
  }
40
41
 
42
+ /**
43
+ * discover 调用原因:区分安全快照、恢复前、可见路径枚举、恢复后与补偿路径。
44
+ * 扫描行为不依赖该标签,它只作为调用方诊断与调用次数回归的归属标记。
45
+ */
46
+ export type RootDiscoveryReason =
47
+ | "safety-snapshot"
48
+ | "restore-pre"
49
+ | "visible-paths-pre"
50
+ | "visible-paths-post"
51
+ | "restore-post"
52
+ | "compensation"
53
+ | "unspecified";
54
+
41
55
  export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed";
42
56
 
43
57
  export class RootDiscoveryError extends Error {
@@ -51,7 +65,7 @@ export class RootDiscoveryError extends Error {
51
65
  }
52
66
 
53
67
  export interface RootDiscovery {
54
- discover(workspaceRoot: string): Promise<RootTopology>;
68
+ discover(workspaceRoot: string, reason?: RootDiscoveryReason): Promise<RootTopology>;
55
69
  }
56
70
 
57
71
  export class RootDiscovery {
@@ -61,8 +75,11 @@ export class RootDiscovery {
61
75
  this.git = git;
62
76
  }
63
77
 
64
- async discover(workspaceRoot: string): Promise<RootTopology> {
78
+ async discover(workspaceRoot: string, reason: RootDiscoveryReason = "unspecified"): Promise<RootTopology> {
79
+ checkOperation();
80
+ reportOperationProgress(`discover_roots:${reason}`);
65
81
  const workspaceIdentity = await canonicalWorkspaceRoot(workspaceRoot);
82
+ checkOperation();
66
83
  const activeRoots = new Map<string, DiscoveredRoot>();
67
84
  const outerRepository = await this.inspectRepository(workspaceIdentity, workspaceIdentity);
68
85
  if (outerRepository.kind === "active") {
@@ -77,7 +94,10 @@ export class RootDiscovery {
77
94
  }
78
95
 
79
96
  await this.scanDirectory(workspaceIdentity, workspaceIdentity, activeRoots);
97
+ checkOperation();
98
+ reportOperationProgress("discover_gitlinks");
80
99
  const gitlinkRoots = await this.discoverGitlinks(workspaceIdentity, activeRoots);
100
+ checkOperation();
81
101
  const roots = buildRoots([...activeRoots.values(), ...gitlinkRoots.values()]);
82
102
  return {
83
103
  workspaceIdentity,
@@ -92,13 +112,19 @@ export class RootDiscovery {
92
112
  activeRoots: Map<string, DiscoveredRoot>,
93
113
  ): Promise<void> {
94
114
  let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }];
115
+ let scanned = 0;
116
+ reportOperationProgress("scan_directories");
95
117
  while (level.length > 0) {
118
+ checkOperation();
96
119
  const next: Array<{ readonly path: string; readonly inspect: true }> = [];
97
120
  for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) {
98
- const children = await Promise.all(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
121
+ checkOperation();
122
+ const children = await allCompleted(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map(
99
123
  (candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots),
100
124
  ));
101
125
  for (const group of children) next.push(...group);
126
+ scanned += children.length;
127
+ reportOperationProgress(`scan_directories:${scanned}`);
102
128
  }
103
129
  level = next;
104
130
  }
@@ -109,6 +135,7 @@ export class RootDiscovery {
109
135
  candidate: { readonly path: string; readonly inspect: boolean },
110
136
  activeRoots: Map<string, DiscoveredRoot>,
111
137
  ): Promise<Array<{ readonly path: string; readonly inspect: true }>> {
138
+ checkOperation();
112
139
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
113
140
  if (candidate.inspect) {
114
141
  const inspection = await this.inspectRepository(candidate.path, workspaceIdentity);
@@ -124,6 +151,7 @@ export class RootDiscovery {
124
151
  }
125
152
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
126
153
  }
154
+ checkOperation();
127
155
  const entries = await readdir(candidate.path, { withFileTypes: true });
128
156
  if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return [];
129
157
  return entries
@@ -208,6 +236,7 @@ export class RootDiscovery {
208
236
  ): Promise<Map<string, DiscoveredRoot>> {
209
237
  const result = new Map<string, DiscoveredRoot>();
210
238
  for (const root of activeRoots.values()) {
239
+ checkOperation();
211
240
  if (!root.gitBacked || root.state !== "active") {
212
241
  continue;
213
242
  }
@@ -248,8 +277,19 @@ export class RootDiscovery {
248
277
  const result = await this.git.run(["-c", "core.fsmonitor=false", ...args], {
249
278
  env: cleanGitEnvironment(),
250
279
  });
280
+ if (result.aborted || result.timedOut) {
281
+ // 取消/超时不应被当成"仓库损坏";有 context 时在这里重新抛出具体原因。
282
+ checkOperation();
283
+ }
251
284
  return result.killed ? null : result.stdout;
252
- } catch {
285
+ } catch (error) {
286
+ if (error instanceof GitRunError && error.code === "git_termination_failed") {
287
+ // 无法证明子进程已停止:上层必须保留 lease 并走恢复,不能继续扫描。
288
+ throw error;
289
+ }
290
+ if (error instanceof OperationError) {
291
+ throw error;
292
+ }
253
293
  return null;
254
294
  }
255
295
  }
@@ -391,6 +431,7 @@ async function gitlinkState(absolutePath: string): Promise<DiscoveryRoot["state"
391
431
  const SKELETON_NOISE_FILES = new Set([".DS_Store", "desktop.ini", "Thumbs.db"]);
392
432
 
393
433
  async function directoryTreeContainsFile(directory: string): Promise<boolean> {
434
+ checkOperation();
394
435
  let entries;
395
436
  try {
396
437
  entries = await readdir(directory, { withFileTypes: true });