@gajae-code/coding-agent 0.11.8 → 0.11.10

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.
Files changed (101) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/dist/types/cli.d.ts +1 -0
  3. package/dist/types/config/model-profiles.d.ts +2 -3
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  6. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  7. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  8. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  9. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  10. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  11. package/dist/types/gjc-runtime/state-writer.d.ts +45 -0
  12. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  13. package/dist/types/modes/components/welcome.d.ts +2 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  15. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  16. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  17. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  18. package/dist/types/sdk/bus/index.d.ts +8 -1
  19. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  20. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  21. package/dist/types/sdk/bus/telegram-daemon.d.ts +74 -5
  22. package/dist/types/sdk/session.d.ts +7 -0
  23. package/dist/types/session/agent-session.d.ts +2 -0
  24. package/dist/types/session/internal/managed-session-scope.d.ts +13 -3
  25. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  26. package/dist/types/session/session-manager.d.ts +5 -1
  27. package/dist/types/session/session-storage.d.ts +13 -0
  28. package/dist/types/skill-state/active-state.d.ts +4 -0
  29. package/dist/types/task/provider-retry-status.d.ts +13 -0
  30. package/dist/types/task/types.d.ts +3 -0
  31. package/dist/types/tools/index.d.ts +3 -1
  32. package/package.json +8 -8
  33. package/scripts/generate-sdk-operation-inventory.ts +1 -0
  34. package/src/cli/mcp-cli.ts +4 -21
  35. package/src/cli/notify-cli.ts +1 -0
  36. package/src/cli.ts +32 -0
  37. package/src/commands/deep-interview.ts +9 -4
  38. package/src/commit/agentic/index.ts +3 -3
  39. package/src/commit/map-reduce/index.ts +2 -2
  40. package/src/config/model-profiles.ts +9 -10
  41. package/src/config/model-registry.ts +4 -2
  42. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  43. package/src/eval/js/tool-bridge.ts +1 -1
  44. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  45. package/src/gjc-runtime/deep-interview-draft.ts +843 -0
  46. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  47. package/src/gjc-runtime/deep-interview-recorder.ts +310 -227
  48. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  49. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  50. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  51. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  52. package/src/gjc-runtime/state-writer.ts +166 -2
  53. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  54. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  55. package/src/hooks/skill-state.ts +23 -1
  56. package/src/internal-urls/docs-index.generated.ts +6 -5
  57. package/src/modes/components/welcome.ts +10 -2
  58. package/src/modes/controllers/selector-controller.ts +38 -8
  59. package/src/modes/interactive-mode.ts +29 -9
  60. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  61. package/src/prompts/agents/architect.md +8 -1
  62. package/src/prompts/agents/critic.md +8 -1
  63. package/src/prompts/agents/planner.md +8 -1
  64. package/src/prompts/system/system-prompt.md +1 -0
  65. package/src/runtime-mcp/json-rpc.ts +7 -4
  66. package/src/runtime-mcp/redaction.ts +30 -0
  67. package/src/sdk/broker/identity.ts +22 -12
  68. package/src/sdk/broker/session-index.ts +11 -1
  69. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  70. package/src/sdk/bus/daemon-paths.ts +2 -0
  71. package/src/sdk/bus/index.ts +60 -5
  72. package/src/sdk/bus/notification-service.ts +19 -5
  73. package/src/sdk/bus/operator-runtime.ts +31 -5
  74. package/src/sdk/bus/telegram-cli.ts +6 -13
  75. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  76. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  77. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  78. package/src/sdk/bus/telegram-daemon.ts +685 -120
  79. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  80. package/src/sdk/session.ts +12 -0
  81. package/src/session/agent-session.ts +140 -10
  82. package/src/session/internal/managed-session-scope.ts +339 -228
  83. package/src/session/internal/managed-session-storage.ts +17 -4
  84. package/src/session/session-manager.ts +96 -33
  85. package/src/session/session-storage.ts +23 -0
  86. package/src/skill-state/active-state.ts +10 -0
  87. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  88. package/src/task/executor.ts +154 -44
  89. package/src/task/provider-retry-status.ts +88 -0
  90. package/src/task/render.ts +45 -26
  91. package/src/task/types.ts +3 -0
  92. package/src/tools/ask.ts +1 -0
  93. package/src/tools/bash-allowed-prefixes.ts +45 -1
  94. package/src/tools/index.ts +3 -1
  95. package/src/tools/subagent-render.ts +74 -16
  96. package/src/tools/subagent.ts +2 -0
  97. package/vendor/insane-search/MANIFEST.json +4 -1
  98. package/vendor/insane-search/engine/bias_check.py +5 -0
  99. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  100. package/vendor/insane-search/engine/transport.py +13 -2
  101. package/vendor/insane-search/engine/validators.py +4 -1
@@ -8,7 +8,7 @@ import {
8
8
  verifyOwnerOnlyPathSecurity,
9
9
  verifyOwnerOnlyPathSecurityExpected,
10
10
  } from "@gajae-code/natives";
11
- import { pathIsWithin } from "@gajae-code/utils";
11
+ import { logger, pathIsWithin } from "@gajae-code/utils";
12
12
  import type { ResumeSessionIdentity } from "../session-manager";
13
13
  import {
14
14
  FileSessionStorage,
@@ -24,6 +24,9 @@ import {
24
24
  copyManagedFileNoReplace,
25
25
  ensureManagedDirectory,
26
26
  fsyncManagedArtifactTree,
27
+ MANAGED_ARTIFACT_COPY_BATCH_SIZE,
28
+ MANAGED_ARTIFACT_MAX_FILES,
29
+ MANAGED_ARTIFACT_MAX_TOTAL_BYTES,
27
30
  type ManagedDirectoryRoot,
28
31
  type ManagedFileSnapshot,
29
32
  ManagedPublishError,
@@ -199,6 +202,7 @@ export type ManagedOpenFailure =
199
202
  | "destination_conflict"
200
203
  | "source_changed"
201
204
  | "unsafe_artifacts"
205
+ | "artifact_capacity_exceeded"
202
206
  | "durability_failed"
203
207
  | "atomic_unavailable"
204
208
  | "invalid_request"
@@ -569,7 +573,11 @@ function fsyncManagedParent(pathname: string): void {
569
573
  }
570
574
  }
571
575
 
572
- function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
576
+ export function canonicalBindingOpenFlags(platform: NodeJS.Platform = process.platform): number {
577
+ return (platform === "win32" ? fs.constants.O_RDWR : fs.constants.O_RDONLY) | fs.constants.O_NOFOLLOW;
578
+ }
579
+
580
+ export function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
573
581
  let captured: ManagedFileSnapshot;
574
582
  try {
575
583
  captured = captureManagedFileNoFollow(bindingPath);
@@ -579,7 +587,9 @@ function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
579
587
  if (captured.bytes.toString("utf8") !== expected) throw new Error("binding_invalid");
580
588
  let descriptor: number | undefined;
581
589
  try {
582
- descriptor = fs.openSync(bindingPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
590
+ // Bun on Windows rejects fsync on a read-only file descriptor with EPERM.
591
+ // The managed binding is owner-writable, so reopen it read/write only for the durability fence.
592
+ descriptor = fs.openSync(bindingPath, canonicalBindingOpenFlags());
583
593
  const before = fs.fstatSync(descriptor, { bigint: true });
584
594
  if (
585
595
  before.dev !== captured.identity.dev ||
@@ -1075,6 +1085,7 @@ function expectedFailure(error: unknown): ManagedOpenFailure {
1075
1085
  message === "destination_conflict" ||
1076
1086
  message === "source_changed" ||
1077
1087
  message === "unsafe_artifacts" ||
1088
+ message === "artifact_capacity_exceeded" ||
1078
1089
  message === "durability_failed" ||
1079
1090
  message === "atomic_unavailable" ||
1080
1091
  message === "invalid_request" ||
@@ -2278,7 +2289,7 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
2278
2289
  dev: stat.dev,
2279
2290
  ino: stat.ino,
2280
2291
  size: process.platform === "win32" ? BigInt(root.size) : stat.size,
2281
- mtimeNs: stat.mtimeNs,
2292
+ mtimeNs: process.platform === "win32" ? BigInt(root.mtimeNs) : stat.mtimeNs,
2282
2293
  },
2283
2294
  tree,
2284
2295
  };
@@ -2301,30 +2312,59 @@ function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
2301
2312
  }
2302
2313
  }
2303
2314
 
2304
- function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
2315
+ export function matchesMigrationArtifactRoot(
2316
+ pathname: string,
2317
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint },
2318
+ expectedTree: NativeDirectoryTreeSnapshot,
2319
+ platform: NodeJS.Platform = process.platform,
2320
+ ): boolean {
2305
2321
  try {
2306
2322
  const stat = fs.lstatSync(pathname, { bigint: true });
2307
- const snapshot = native.snapshotDirectoryTree(pathname);
2308
- const root = snapshot.snapshot?.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2309
- return (
2310
- stat.isDirectory() &&
2311
- !stat.isSymbolicLink() &&
2312
- stat.dev === plan.identity.dev &&
2313
- stat.ino === plan.identity.ino &&
2314
- stat.mtimeNs === plan.identity.mtimeNs &&
2315
- (process.platform === "win32"
2316
- ? snapshot.ok && !!root && BigInt(root.size) === plan.identity.size
2317
- : stat.size === plan.identity.size) &&
2318
- sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
2323
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== identity.dev || stat.ino !== identity.ino)
2324
+ return false;
2325
+ const observed = native.snapshotDirectoryTree(pathname);
2326
+ const expectedRoot = expectedTree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2327
+ const observedRoot = observed.snapshot?.entries.find(
2328
+ entry => entry.relativePath === "" && entry.kind === "directory",
2319
2329
  );
2330
+ if (
2331
+ !observed.ok ||
2332
+ !observed.snapshot ||
2333
+ !expectedRoot ||
2334
+ !observedRoot ||
2335
+ (platform === "win32" &&
2336
+ (observedRoot.dev !== identity.dev.toString() ||
2337
+ observedRoot.ino !== identity.ino.toString() ||
2338
+ BigInt(observedRoot.size) !== identity.size ||
2339
+ BigInt(observedRoot.mtimeNs) !== identity.mtimeNs)) ||
2340
+ (platform !== "win32" && (stat.size !== identity.size || stat.mtimeNs !== identity.mtimeNs))
2341
+ )
2342
+ return false;
2343
+ return sameArtifactTree(expectedTree, observed.snapshot, { platform, phase: "migration" });
2320
2344
  } catch {
2321
2345
  return false;
2322
2346
  }
2323
2347
  }
2324
2348
 
2325
- function sameArtifactTree(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
2326
- const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
2327
- JSON.stringify([
2349
+ type ArtifactTreeComparisonPolicy = {
2350
+ platform?: NodeJS.Platform;
2351
+ phase?: "migration" | "strict";
2352
+ };
2353
+
2354
+ function sameArtifactTree(
2355
+ left: NativeDirectoryTreeSnapshot,
2356
+ right: NativeDirectoryTreeSnapshot,
2357
+ policy: ArtifactTreeComparisonPolicy = {},
2358
+ ): boolean {
2359
+ const { platform = process.platform, phase = "strict" } = policy;
2360
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string => {
2361
+ // Detaching the root directory changes its ctime without changing the preserved artifact tree;
2362
+ // root identity and mtime are checked above. pi-iso also accepts Windows plain directories by
2363
+ // stable file id and kind only because NTFS can lazily update their metadata while enumeration
2364
+ // trails an open handle. All other platforms and nested directories compare every captured field.
2365
+ if (phase === "migration" && entry.kind === "directory" && (entry.relativePath === "" || platform === "win32"))
2366
+ return JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino]);
2367
+ return JSON.stringify([
2328
2368
  entry.relativePath,
2329
2369
  entry.kind,
2330
2370
  entry.dev,
@@ -2334,14 +2374,21 @@ function sameArtifactTree(left: NativeDirectoryTreeSnapshot, right: NativeDirect
2334
2374
  entry.ctimeNs,
2335
2375
  entry.sha256,
2336
2376
  ]);
2377
+ };
2378
+ const leftEntries = left.entries.map(entryKey).sort();
2379
+ const rightEntries = right.entries.map(entryKey).sort();
2337
2380
  return (
2338
2381
  left.rootDev === right.rootDev &&
2339
2382
  left.rootIno === right.rootIno &&
2340
- left.entries.length === right.entries.length &&
2341
- left.entries
2342
- .map(entryKey)
2343
- .sort()
2344
- .every((entry, index) => entry === right.entries.map(entryKey).sort()[index])
2383
+ leftEntries.length === rightEntries.length &&
2384
+ leftEntries.every((entry, index) => entry === rightEntries[index])
2385
+ );
2386
+ }
2387
+
2388
+ function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
2389
+ return (
2390
+ matchesMigrationArtifactRoot(pathname, plan.identity, plan.tree) &&
2391
+ sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
2345
2392
  );
2346
2393
  }
2347
2394
 
@@ -2380,10 +2427,11 @@ function cleanupAuthorityMatches(cleanup: SourceArtifactCleanup, parent: string)
2380
2427
  }
2381
2428
  }
2382
2429
 
2383
- function detachArtifactRootForMigration(plan: DetachedArtifactRoot): {
2384
- detached: DetachedArtifactRoot;
2385
- cleanup?: SourceArtifactCleanup;
2386
- } {
2430
+ function detachArtifactRootForMigration(
2431
+ plan: DetachedArtifactRoot,
2432
+ ):
2433
+ | { detached: DetachedArtifactRoot; detachOutcome: "clean" }
2434
+ | { detached: DetachedArtifactRoot; detachOutcome: "cleanup_pending"; cleanup: SourceArtifactCleanup } {
2387
2435
  const result = native.exactUnlink(plan.originalPath, {
2388
2436
  ...plan.identity,
2389
2437
  directory: true,
@@ -2410,7 +2458,7 @@ function detachArtifactRootForMigration(plan: DetachedArtifactRoot): {
2410
2458
  process.platform === "win32" ? fs.realpathSync.native(result.detachedPath) : result.detachedPath;
2411
2459
  if (!matchesDetachedArtifactRoot(detachedPath, plan)) throw new Error("durability_failed");
2412
2460
  const detached = { ...plan, detachedPath };
2413
- if (!cleanupPending) return { detached };
2461
+ if (!cleanupPending) return { detached, detachOutcome: "clean" };
2414
2462
  const placeholder = result.retainedPlaceholderPath!;
2415
2463
  const stat = fs.lstatSync(placeholder, { bigint: true });
2416
2464
  if (!stat.isDirectory() || stat.isSymbolicLink() || path.dirname(placeholder) !== path.dirname(plan.originalPath))
@@ -2425,10 +2473,14 @@ function detachArtifactRootForMigration(plan: DetachedArtifactRoot): {
2425
2473
  };
2426
2474
  if (!snapshot.ok || !snapshot.snapshot || !cleanupAuthorityMatches(cleanup, path.dirname(plan.originalPath)))
2427
2475
  throw new Error("durability_failed");
2428
- return { detached, cleanup };
2476
+ return { detached, detachOutcome: "cleanup_pending", cleanup };
2429
2477
  }
2430
2478
 
2431
- function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
2479
+ export function restorePreparedArtifactRoot(
2480
+ scope: ManagedScope,
2481
+ source: ManagedCandidate,
2482
+ lock?: ManagedStorageLock,
2483
+ ): void {
2432
2484
  const preparedReceipt = receiptPathFor(scope, source, "prepared");
2433
2485
  const detachedReceipt = receiptPathFor(scope, source, "detached");
2434
2486
  const receipt = fs.existsSync(detachedReceipt) ? detachedReceipt : preparedReceipt;
@@ -2447,6 +2499,7 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
2447
2499
  identity?: Record<string, unknown>;
2448
2500
  tree?: unknown;
2449
2501
  };
2502
+ detachOutcome?: unknown;
2450
2503
  };
2451
2504
  try {
2452
2505
  record = JSON.parse(captureManagedFileNoFollow(receipt).bytes.toString("utf8")) as typeof record;
@@ -2470,57 +2523,87 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
2470
2523
  typeof identity.mtimeNs !== "string"
2471
2524
  )
2472
2525
  throw new Error("durability_failed");
2473
- if (receipt === detachedReceipt && quarantine.role !== "detached_artifact_root")
2474
- throw new Error("durability_failed");
2475
- if (receipt === detachedReceipt) {
2476
- const cleanup = record.sourceArtifactCleanup;
2477
- const cleanupIdentity = cleanup?.identity;
2478
- const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
2526
+ const artifactIdentity = {
2527
+ dev: BigInt(identity.dev),
2528
+ ino: BigInt(identity.ino),
2529
+ size: BigInt(identity.size),
2530
+ mtimeNs: BigInt(identity.mtimeNs),
2531
+ };
2532
+ const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
2533
+ const assertPreparedTree = (pathname: string): void => {
2479
2534
  if (
2480
- cleanup?.state !== "cleanup_pending" ||
2481
- cleanup.role !== "exchange_placeholder" ||
2482
- typeof cleanup.retainedPath !== "string" ||
2483
- !cleanupIdentity ||
2484
- typeof cleanupIdentity.dev !== "string" ||
2485
- typeof cleanupIdentity.ino !== "string" ||
2486
- typeof cleanupIdentity.size !== "string" ||
2487
- typeof cleanupIdentity.mtimeNs !== "string" ||
2488
- !cleanupTree ||
2489
- !cleanupAuthorityMatches(
2535
+ !matchesMigrationArtifactRoot(
2536
+ pathname,
2490
2537
  {
2491
- state: "cleanup_pending",
2492
- role: "exchange_placeholder",
2493
- retainedPath: cleanup.retainedPath,
2494
- identity: {
2495
- dev: BigInt(cleanupIdentity.dev),
2496
- ino: BigInt(cleanupIdentity.ino),
2497
- size: BigInt(cleanupIdentity.size),
2498
- mtimeNs: BigInt(cleanupIdentity.mtimeNs),
2499
- },
2500
- tree: cleanupTree,
2538
+ ...artifactIdentity,
2501
2539
  },
2502
- path.dirname(source.path),
2540
+ expectedTree,
2503
2541
  )
2504
2542
  )
2505
2543
  throw new Error("durability_failed");
2544
+ };
2545
+ if (receipt === detachedReceipt) {
2546
+ if (quarantine.role !== "detached_artifact_root") throw new Error("durability_failed");
2547
+ if (record.detachOutcome === "clean") {
2548
+ if (record.sourceArtifactCleanup !== undefined) throw new Error("durability_failed");
2549
+ const originalExists = fs.existsSync(quarantine.path);
2550
+ const detachedExists = fs.existsSync(quarantine.detachedPath);
2551
+ if (originalExists) {
2552
+ if (detachedExists) throw new Error("durability_failed");
2553
+ assertPreparedTree(quarantine.path);
2554
+ if (lock) {
2555
+ lock.assertOwned();
2556
+ fs.unlinkSync(receipt);
2557
+ fsyncManagedParent(receipt);
2558
+ }
2559
+ return;
2560
+ }
2561
+ } else if (record.detachOutcome === "cleanup_pending") {
2562
+ const cleanup = record.sourceArtifactCleanup;
2563
+ if (!cleanup) throw new Error("durability_failed");
2564
+ const cleanupIdentity = cleanup.identity;
2565
+ const cleanupTree = artifactTreeSnapshot(cleanup.tree);
2566
+ if (
2567
+ cleanup.state !== "cleanup_pending" ||
2568
+ cleanup.role !== "exchange_placeholder" ||
2569
+ typeof cleanup.retainedPath !== "string" ||
2570
+ !cleanupIdentity ||
2571
+ typeof cleanupIdentity.dev !== "string" ||
2572
+ typeof cleanupIdentity.ino !== "string" ||
2573
+ typeof cleanupIdentity.size !== "string" ||
2574
+ typeof cleanupIdentity.mtimeNs !== "string" ||
2575
+ !cleanupTree ||
2576
+ !cleanupAuthorityMatches(
2577
+ {
2578
+ state: "cleanup_pending",
2579
+ role: "exchange_placeholder",
2580
+ retainedPath: cleanup.retainedPath,
2581
+ identity: {
2582
+ dev: BigInt(cleanupIdentity.dev),
2583
+ ino: BigInt(cleanupIdentity.ino),
2584
+ size: BigInt(cleanupIdentity.size),
2585
+ mtimeNs: BigInt(cleanupIdentity.mtimeNs),
2586
+ },
2587
+ tree: cleanupTree,
2588
+ },
2589
+ path.dirname(source.path),
2590
+ )
2591
+ )
2592
+ throw new Error("durability_failed");
2593
+ } else {
2594
+ throw new Error("durability_failed");
2595
+ }
2506
2596
  }
2507
2597
 
2508
- const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
2509
- const assertPreparedTree = (pathname: string): void => {
2510
- validateManagedArtifactTree(pathname);
2511
- const observed = native.snapshotDirectoryTree(pathname);
2512
- if (!observed.ok || !observed.snapshot || !sameArtifactTree(observed.snapshot, expectedTree))
2513
- throw new Error("durability_failed");
2514
- };
2515
2598
  if (fs.existsSync(quarantine.path)) {
2516
- const existing = fs.lstatSync(quarantine.path, { bigint: true });
2517
2599
  if (
2518
- !existing.isSymbolicLink() &&
2519
- existing.isDirectory() &&
2520
- existing.dev === BigInt(identity.dev) &&
2521
- existing.ino === BigInt(identity.ino) &&
2522
- existing.size === BigInt(identity.size) &&
2523
- existing.mtimeNs === BigInt(identity.mtimeNs)
2600
+ matchesMigrationArtifactRoot(
2601
+ quarantine.path,
2602
+ {
2603
+ ...artifactIdentity,
2604
+ },
2605
+ expectedTree,
2606
+ )
2524
2607
  ) {
2525
2608
  assertPreparedTree(quarantine.path);
2526
2609
  }
@@ -2530,10 +2613,7 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
2530
2613
  }
2531
2614
  assertPreparedTree(quarantine.detachedPath);
2532
2615
  const result = native.exactRestore(quarantine.detachedPath, quarantine.path, {
2533
- dev: BigInt(identity.dev),
2534
- ino: BigInt(identity.ino),
2535
- size: BigInt(identity.size),
2536
- mtimeNs: BigInt(identity.mtimeNs),
2616
+ ...artifactIdentity,
2537
2617
  directory: true,
2538
2618
  });
2539
2619
  if (!result.ok) throw new Error("durability_failed");
@@ -2564,40 +2644,41 @@ async function copyArtifacts(
2564
2644
  const sourceRoot = sourceRootOverride ?? sourceTranscript.slice(0, -6);
2565
2645
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot)) throw new Error("source_changed");
2566
2646
  const destinationRoot = destinationTranscript.slice(0, -6);
2567
- for (const entry of manifest) {
2647
+ for (let start = 0; start < manifest.length; start += MANAGED_ARTIFACT_COPY_BATCH_SIZE) {
2648
+ const batch = manifest.slice(start, start + MANAGED_ARTIFACT_COPY_BATCH_SIZE);
2568
2649
  revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2569
2650
  lock.assertOwned();
2570
- const source = path.join(sourceRoot, entry.path);
2571
- const destination = path.join(destinationRoot, entry.path);
2572
- if (entry.kind === "directory") {
2573
- if (entry.path === "") {
2574
- revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2575
- ensureManagedDirectory(destinationRoot, root);
2576
- } else {
2577
- revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2578
- ensureManagedDirectory(destination, root);
2651
+ for (const entry of batch) {
2652
+ const source = path.join(sourceRoot, entry.path);
2653
+ const destination = path.join(destinationRoot, entry.path);
2654
+ if (entry.kind === "directory") {
2655
+ if (entry.path === "") {
2656
+ ensureManagedDirectory(destinationRoot, root);
2657
+ } else {
2658
+ ensureManagedDirectory(destination, root);
2659
+ }
2660
+ continue;
2579
2661
  }
2580
- continue;
2581
- }
2582
- const snapshot = captureManagedFileNoFollow(source);
2583
- if (
2584
- snapshot.bytes.byteLength !== entry.size ||
2585
- createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
2586
- )
2587
- throw new Error("source_changed");
2588
- try {
2589
- revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2590
- await copyManagedFileNoReplace(source, destination, snapshot, root);
2591
- } catch (error) {
2592
- if ((error as Error).message !== "destination_conflict") throw error;
2662
+ const snapshot = captureManagedFileNoFollow(source);
2663
+ if (
2664
+ snapshot.bytes.byteLength !== entry.size ||
2665
+ createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
2666
+ )
2667
+ throw new Error("source_changed");
2668
+ try {
2669
+ await copyManagedFileNoReplace(source, destination, snapshot, root);
2670
+ } catch (error) {
2671
+ if ((error as Error).message !== "destination_conflict") throw error;
2672
+ }
2673
+ lock.assertOwned();
2674
+ const copied = captureManagedFileNoFollow(destination);
2675
+ if (
2676
+ copied.bytes.byteLength !== entry.size ||
2677
+ createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
2678
+ )
2679
+ throw new Error("durability_failed");
2593
2680
  }
2594
- lock.assertOwned();
2595
- const copied = captureManagedFileNoFollow(destination);
2596
- if (
2597
- copied.bytes.byteLength !== entry.size ||
2598
- createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
2599
- )
2600
- throw new Error("durability_failed");
2681
+ if (start + batch.length < manifest.length) await new Promise<void>(resolve => setTimeout(resolve, 0));
2601
2682
  }
2602
2683
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot) || !manifestMatches(destinationTranscript, manifest))
2603
2684
  throw new Error("durability_failed");
@@ -2618,6 +2699,7 @@ function migrationReceipt(
2618
2699
  role?: "detached_artifact_root";
2619
2700
  },
2620
2701
  sourceArtifactCleanup?: SourceArtifactCleanup,
2702
+ detachOutcome?: "clean" | "cleanup_pending",
2621
2703
  ): Uint8Array {
2622
2704
  lock.assertOwned();
2623
2705
  const destinationRecord =
@@ -2635,7 +2717,7 @@ function migrationReceipt(
2635
2717
  header: { id: destination.sessionId, cwd: destination.cwd },
2636
2718
  };
2637
2719
  return new TextEncoder().encode(
2638
- `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2720
+ `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}), ...(detachOutcome ? { detachOutcome } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2639
2721
  );
2640
2722
  }
2641
2723
 
@@ -2652,7 +2734,10 @@ async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandi
2652
2734
  const pathname = receiptPathFor(scope, candidate, state);
2653
2735
  try {
2654
2736
  const stat = fs.lstatSync(pathname);
2655
- if (stat.isFile() || stat.isSymbolicLink()) await fs.promises.unlink(pathname);
2737
+ if (stat.isFile() || stat.isSymbolicLink()) {
2738
+ await fs.promises.unlink(pathname);
2739
+ fsyncManagedParent(pathname);
2740
+ }
2656
2741
  } catch (error) {
2657
2742
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2658
2743
  }
@@ -2704,7 +2789,10 @@ function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandid
2704
2789
  }
2705
2790
 
2706
2791
  /** Resume tombstoned cleanup under its original operation lease without restoring retired candidates. */
2707
- export async function reconcileManagedTombstones(scope: ManagedScope): Promise<void> {
2792
+ export async function reconcileManagedTombstones(
2793
+ scope: ManagedScope,
2794
+ expectedCandidate?: ManagedCandidate,
2795
+ ): Promise<void> {
2708
2796
  const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY);
2709
2797
  for (const name of fs.readdirSync(directory)) {
2710
2798
  const tombstone = path.join(directory, name);
@@ -2722,135 +2810,148 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2722
2810
  for (const target of lockedTargets) {
2723
2811
  lock.assertOwned();
2724
2812
  if (cleanupCompleted(scope, tombstone, target)) continue;
2725
- const pending = pendingCleanupReceipt(scope, tombstone, target);
2726
- const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
2727
2813
  try {
2728
- fs.lstatSync(target.path);
2729
- } catch (error) {
2730
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2731
- if (!observedPending) continue;
2732
- if (
2733
- cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2734
- cleanupRootsAbsent(tombstone, target, observedPending)
2735
- ) {
2736
- fsyncManagedParent(target.path);
2737
- await publishCleanupCompleted(scope, tombstone, target, lock);
2738
- continue;
2739
- }
2740
- } else throw error;
2741
- }
2742
- const verified = observedPending ? target : validateCandidateForScope(scope, target);
2743
- if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
2744
- const discoveredDetach =
2745
- !!observedPending &&
2746
- (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
2747
- observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
2748
- const active =
2749
- discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
2750
- ? nextCleanupReceipt(target, observedPending)
2751
- : (observedPending ?? nextCleanupReceipt(target, undefined));
2752
- if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
2753
- await publishCleanupPending(scope, tombstone, active, lock);
2754
- let deletion = await new FileSessionStorage().deleteSessionVerified({
2755
- sessionsRoot: scope.sessionsRoot,
2756
- transcriptPath: target.path,
2757
- sessionId: target.sessionId,
2758
- cwd: target.cwd,
2759
- transcriptIdentity: target.identity,
2760
- expectedArtifactsIdentity: active.expectedArtifactsIdentity,
2761
- expectedArtifactsTree: active.expectedArtifactsTree,
2762
- detachedArtifactsPath:
2763
- active.detachedArtifactsPath ??
2764
- observedPending?.detachedArtifactsPath ??
2765
- (fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
2766
- detachedTranscriptPath:
2767
- active.detachedTranscriptPath ??
2768
- observedPending?.detachedTranscriptPath ??
2769
- (pending && fs.existsSync(pending.plannedTranscriptPath)
2770
- ? pending.plannedTranscriptPath
2771
- : undefined) ??
2772
- (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2773
- retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2774
- retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2775
- retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2776
- retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2777
- retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2778
- retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2779
- plannedArtifactsPath: active.plannedArtifactsPath,
2780
- plannedTranscriptPath: active.plannedTranscriptPath,
2781
- ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
2782
- ? { artifactsRemoved: true as const }
2783
- : {}),
2784
- });
2785
- if (deletion.kind === "artifacts_removed") {
2786
- await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
2787
- deletion = await new FileSessionStorage().deleteSessionVerified({
2814
+ const pending = pendingCleanupReceipt(scope, tombstone, target);
2815
+ const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
2816
+ try {
2817
+ fs.lstatSync(target.path);
2818
+ } catch (error) {
2819
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
2820
+ if (!observedPending) continue;
2821
+ if (
2822
+ cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2823
+ cleanupRootsAbsent(tombstone, target, observedPending)
2824
+ ) {
2825
+ fsyncManagedParent(target.path);
2826
+ await publishCleanupCompleted(scope, tombstone, target, lock);
2827
+ continue;
2828
+ }
2829
+ } else throw error;
2830
+ }
2831
+ const verified = observedPending ? target : validateCandidateForScope(scope, target);
2832
+ if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
2833
+ const discoveredDetach =
2834
+ !!observedPending &&
2835
+ (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
2836
+ observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
2837
+ const active =
2838
+ discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
2839
+ ? nextCleanupReceipt(target, observedPending)
2840
+ : (observedPending ?? nextCleanupReceipt(target, undefined));
2841
+ if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
2842
+ await publishCleanupPending(scope, tombstone, active, lock);
2843
+ let deletion = await new FileSessionStorage().deleteSessionVerified({
2788
2844
  sessionsRoot: scope.sessionsRoot,
2789
2845
  transcriptPath: target.path,
2790
2846
  sessionId: target.sessionId,
2791
2847
  cwd: target.cwd,
2792
2848
  transcriptIdentity: target.identity,
2793
- plannedArtifactsPath: active.plannedArtifactsPath,
2794
- plannedTranscriptPath: active.plannedTranscriptPath,
2795
- detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2796
-
2849
+ expectedArtifactsIdentity: active.expectedArtifactsIdentity,
2850
+ expectedArtifactsTree: active.expectedArtifactsTree,
2851
+ detachedArtifactsPath:
2852
+ active.detachedArtifactsPath ??
2853
+ observedPending?.detachedArtifactsPath ??
2854
+ (fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
2855
+ detachedTranscriptPath:
2856
+ active.detachedTranscriptPath ??
2857
+ observedPending?.detachedTranscriptPath ??
2858
+ (pending && fs.existsSync(pending.plannedTranscriptPath)
2859
+ ? pending.plannedTranscriptPath
2860
+ : undefined) ??
2861
+ (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2797
2862
  retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2798
2863
  retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2799
2864
  retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2800
2865
  retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2801
2866
  retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2802
2867
  retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2803
- artifactsRemoved: true,
2868
+ plannedArtifactsPath: active.plannedArtifactsPath,
2869
+ plannedTranscriptPath: active.plannedTranscriptPath,
2870
+ ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
2871
+ ? { artifactsRemoved: true as const }
2872
+ : {}),
2804
2873
  });
2805
- }
2806
- if (deletion.kind === "cleanup_pending") {
2807
- const retry = nextCleanupReceipt(target, active);
2808
- await publishCleanupPending(
2809
- scope,
2874
+ if (deletion.kind === "artifacts_removed") {
2875
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
2876
+ deletion = await new FileSessionStorage().deleteSessionVerified({
2877
+ sessionsRoot: scope.sessionsRoot,
2878
+ transcriptPath: target.path,
2879
+ sessionId: target.sessionId,
2880
+ cwd: target.cwd,
2881
+ transcriptIdentity: target.identity,
2882
+ plannedArtifactsPath: active.plannedArtifactsPath,
2883
+ plannedTranscriptPath: active.plannedTranscriptPath,
2884
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2885
+
2886
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2887
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2888
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2889
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2890
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2891
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2892
+ artifactsRemoved: true,
2893
+ });
2894
+ }
2895
+ if (deletion.kind === "cleanup_pending") {
2896
+ const retry = nextCleanupReceipt(target, active);
2897
+ await publishCleanupPending(
2898
+ scope,
2899
+ tombstone,
2900
+ {
2901
+ ...retry,
2902
+ expectedArtifactsIdentity:
2903
+ deletion.phase === "artifacts"
2904
+ ? deletion.artifactsIdentity
2905
+ : active.expectedArtifactsIdentity,
2906
+ expectedArtifactsTree:
2907
+ deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2908
+ detachedArtifactsPath:
2909
+ deletion.phase === "artifacts"
2910
+ ? deletion.detachedArtifactsPath
2911
+ : active.detachedArtifactsPath,
2912
+ detachedTranscriptPath:
2913
+ deletion.phase === "transcript"
2914
+ ? deletion.detachedTranscriptPath
2915
+ : active.detachedTranscriptPath,
2916
+ retainedArtifactsSuccessorPath:
2917
+ deletion.phase === "artifacts"
2918
+ ? deletion.retainedSuccessorPath
2919
+ : active.retainedArtifactsSuccessorPath,
2920
+ retainedArtifactsPlaceholderPath:
2921
+ deletion.phase === "artifacts"
2922
+ ? deletion.retainedPlaceholderPath
2923
+ : active.retainedArtifactsPlaceholderPath,
2924
+ retainedArtifactsUnknownPath:
2925
+ deletion.phase === "artifacts"
2926
+ ? deletion.retainedUnknownPath
2927
+ : active.retainedArtifactsUnknownPath,
2928
+ retainedTranscriptSuccessorPath:
2929
+ deletion.phase === "transcript"
2930
+ ? deletion.retainedSuccessorPath
2931
+ : active.retainedTranscriptSuccessorPath,
2932
+ retainedTranscriptPlaceholderPath:
2933
+ deletion.phase === "transcript"
2934
+ ? deletion.retainedPlaceholderPath
2935
+ : active.retainedTranscriptPlaceholderPath,
2936
+ retainedTranscriptUnknownPath:
2937
+ deletion.phase === "transcript"
2938
+ ? deletion.retainedUnknownPath
2939
+ : active.retainedTranscriptUnknownPath,
2940
+ },
2941
+ lock,
2942
+ );
2943
+ continue;
2944
+ }
2945
+ fsyncManagedParent(target.path);
2946
+ await publishCleanupCompleted(scope, tombstone, target, lock);
2947
+ } catch (error) {
2948
+ if (!expectedCandidate || target.sessionId === expectedCandidate.sessionId) throw error;
2949
+ logger.warn("Tombstone reconciliation failed for one target; will retry on a future scope open", {
2810
2950
  tombstone,
2811
- {
2812
- ...retry,
2813
- expectedArtifactsIdentity:
2814
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2815
- expectedArtifactsTree:
2816
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2817
- detachedArtifactsPath:
2818
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2819
- detachedTranscriptPath:
2820
- deletion.phase === "transcript"
2821
- ? deletion.detachedTranscriptPath
2822
- : active.detachedTranscriptPath,
2823
- retainedArtifactsSuccessorPath:
2824
- deletion.phase === "artifacts"
2825
- ? deletion.retainedSuccessorPath
2826
- : active.retainedArtifactsSuccessorPath,
2827
- retainedArtifactsPlaceholderPath:
2828
- deletion.phase === "artifacts"
2829
- ? deletion.retainedPlaceholderPath
2830
- : active.retainedArtifactsPlaceholderPath,
2831
- retainedArtifactsUnknownPath:
2832
- deletion.phase === "artifacts"
2833
- ? deletion.retainedUnknownPath
2834
- : active.retainedArtifactsUnknownPath,
2835
- retainedTranscriptSuccessorPath:
2836
- deletion.phase === "transcript"
2837
- ? deletion.retainedSuccessorPath
2838
- : active.retainedTranscriptSuccessorPath,
2839
- retainedTranscriptPlaceholderPath:
2840
- deletion.phase === "transcript"
2841
- ? deletion.retainedPlaceholderPath
2842
- : active.retainedTranscriptPlaceholderPath,
2843
- retainedTranscriptUnknownPath:
2844
- deletion.phase === "transcript"
2845
- ? deletion.retainedUnknownPath
2846
- : active.retainedTranscriptUnknownPath,
2847
- },
2848
- lock,
2849
- );
2850
- continue;
2951
+ sessionId: target.sessionId,
2952
+ error: String(error),
2953
+ });
2851
2954
  }
2852
- fsyncManagedParent(target.path);
2853
- await publishCleanupCompleted(scope, tombstone, target, lock);
2854
2955
  }
2855
2956
  } finally {
2856
2957
  if (lock) await lock.release().catch(() => undefined);
@@ -2877,7 +2978,7 @@ export async function prepareManagedSessionScopeForWrite(
2877
2978
  ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY), root, policy);
2878
2979
  ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY), root, policy);
2879
2980
  ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
2880
- await reconcileManagedTombstones(scope);
2981
+ await reconcileManagedTombstones(scope, expectedCandidate);
2881
2982
  return { kind: "resolved", scope };
2882
2983
  } catch (error) {
2883
2984
  const publication = error instanceof ManagedPublishError ? error : undefined;
@@ -2979,7 +3080,7 @@ export async function openManagedCandidateForWrite(
2979
3080
 
2980
3081
  scopeRoot(scope);
2981
3082
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2982
- restorePreparedArtifactRoot(scope, afterLock);
3083
+ restorePreparedArtifactRoot(scope, afterLock, heldLock);
2983
3084
  scopeRoot(scope);
2984
3085
 
2985
3086
  let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
@@ -3056,7 +3157,7 @@ export async function openManagedCandidateForWrite(
3056
3157
  if (artifactPlan) {
3057
3158
  const detached = detachArtifactRootForMigration(artifactPlan);
3058
3159
  detachedArtifacts = detached.detached;
3059
- sourceArtifactCleanup = detached.cleanup;
3160
+ sourceArtifactCleanup = detached.detachOutcome === "cleanup_pending" ? detached.cleanup : undefined;
3060
3161
  const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
3061
3162
  const detachedRecord = migrationReceipt(
3062
3163
  scope,
@@ -3073,6 +3174,7 @@ export async function openManagedCandidateForWrite(
3073
3174
  role: "detached_artifact_root",
3074
3175
  },
3075
3176
  sourceArtifactCleanup,
3177
+ detached.detachOutcome,
3076
3178
  );
3077
3179
  try {
3078
3180
  await publishManagedFileNoReplace(
@@ -3215,7 +3317,16 @@ export async function openManagedCandidateForWrite(
3215
3317
  };
3216
3318
  }
3217
3319
  const code = expectedFailure(error);
3218
- return { kind: "error", code, message: error instanceof Error ? error.message : "Managed migration failed." };
3320
+ return {
3321
+ kind: "error",
3322
+ code,
3323
+ message:
3324
+ code === "artifact_capacity_exceeded"
3325
+ ? `Legacy session artifacts exceed the migration capacity (${MANAGED_ARTIFACT_MAX_FILES.toLocaleString()} files or ${MANAGED_ARTIFACT_MAX_TOTAL_BYTES / 1024 / 1024} MiB).`
3326
+ : error instanceof Error
3327
+ ? error.message
3328
+ : "Managed migration failed.",
3329
+ };
3219
3330
  } finally {
3220
3331
  if (lock) await lock.release().catch(() => undefined);
3221
3332
  }