@gajae-code/coding-agent 0.11.8 → 0.11.9

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 (57) hide show
  1. package/CHANGELOG.md +18 -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/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  5. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  6. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  7. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  8. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  9. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  10. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  11. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  12. package/dist/types/modes/components/welcome.d.ts +2 -0
  13. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  14. package/dist/types/sdk/bus/chat-daemon-control.d.ts +3 -1
  15. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  16. package/dist/types/session/internal/managed-session-scope.d.ts +12 -2
  17. package/dist/types/session/internal/managed-session-storage.d.ts +8 -3
  18. package/dist/types/session/session-manager.d.ts +5 -1
  19. package/dist/types/session/session-storage.d.ts +13 -0
  20. package/dist/types/skill-state/active-state.d.ts +4 -0
  21. package/package.json +8 -8
  22. package/src/cli/mcp-cli.ts +4 -21
  23. package/src/cli.ts +32 -0
  24. package/src/commands/deep-interview.ts +9 -4
  25. package/src/commit/agentic/index.ts +3 -3
  26. package/src/commit/map-reduce/index.ts +2 -2
  27. package/src/config/model-profiles.ts +2 -3
  28. package/src/defaults/gjc/skills/deep-interview/SKILL.md +78 -163
  29. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  30. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  31. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  32. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  33. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  34. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  35. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  36. package/src/gjc-runtime/ralplan-runtime.ts +3 -1
  37. package/src/gjc-runtime/state-writer.ts +166 -2
  38. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  39. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  40. package/src/internal-urls/docs-index.generated.ts +3 -2
  41. package/src/modes/components/welcome.ts +10 -2
  42. package/src/modes/interactive-mode.ts +29 -9
  43. package/src/prompts/system/system-prompt.md +1 -0
  44. package/src/runtime-mcp/json-rpc.ts +7 -4
  45. package/src/runtime-mcp/redaction.ts +30 -0
  46. package/src/sdk/broker/session-index.ts +11 -1
  47. package/src/sdk/bus/chat-daemon-control.ts +5 -3
  48. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  49. package/src/sdk/bus/telegram-daemon.ts +18 -2
  50. package/src/session/agent-session.ts +86 -0
  51. package/src/session/internal/managed-session-scope.ts +206 -111
  52. package/src/session/internal/managed-session-storage.ts +17 -4
  53. package/src/session/session-manager.ts +96 -33
  54. package/src/session/session-storage.ts +23 -0
  55. package/src/skill-state/active-state.ts +10 -0
  56. package/src/task/executor.ts +13 -5
  57. package/src/tools/ask.ts +1 -0
@@ -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
  }
@@ -2979,7 +3064,7 @@ export async function openManagedCandidateForWrite(
2979
3064
 
2980
3065
  scopeRoot(scope);
2981
3066
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2982
- restorePreparedArtifactRoot(scope, afterLock);
3067
+ restorePreparedArtifactRoot(scope, afterLock, heldLock);
2983
3068
  scopeRoot(scope);
2984
3069
 
2985
3070
  let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
@@ -3056,7 +3141,7 @@ export async function openManagedCandidateForWrite(
3056
3141
  if (artifactPlan) {
3057
3142
  const detached = detachArtifactRootForMigration(artifactPlan);
3058
3143
  detachedArtifacts = detached.detached;
3059
- sourceArtifactCleanup = detached.cleanup;
3144
+ sourceArtifactCleanup = detached.detachOutcome === "cleanup_pending" ? detached.cleanup : undefined;
3060
3145
  const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
3061
3146
  const detachedRecord = migrationReceipt(
3062
3147
  scope,
@@ -3073,6 +3158,7 @@ export async function openManagedCandidateForWrite(
3073
3158
  role: "detached_artifact_root",
3074
3159
  },
3075
3160
  sourceArtifactCleanup,
3161
+ detached.detachOutcome,
3076
3162
  );
3077
3163
  try {
3078
3164
  await publishManagedFileNoReplace(
@@ -3215,7 +3301,16 @@ export async function openManagedCandidateForWrite(
3215
3301
  };
3216
3302
  }
3217
3303
  const code = expectedFailure(error);
3218
- return { kind: "error", code, message: error instanceof Error ? error.message : "Managed migration failed." };
3304
+ return {
3305
+ kind: "error",
3306
+ code,
3307
+ message:
3308
+ code === "artifact_capacity_exceeded"
3309
+ ? `Legacy session artifacts exceed the migration capacity (${MANAGED_ARTIFACT_MAX_FILES.toLocaleString()} files or ${MANAGED_ARTIFACT_MAX_TOTAL_BYTES / 1024 / 1024} MiB).`
3310
+ : error instanceof Error
3311
+ ? error.message
3312
+ : "Managed migration failed.",
3313
+ };
3219
3314
  } finally {
3220
3315
  if (lock) await lock.release().catch(() => undefined);
3221
3316
  }
@@ -26,9 +26,10 @@ import {
26
26
  } from "./native-publish-outcome";
27
27
 
28
28
  export const MANAGED_ARTIFACT_MAX_DEPTH = 32;
29
- export const MANAGED_ARTIFACT_MAX_FILES = 10_000;
29
+ export const MANAGED_ARTIFACT_MAX_FILES = 50_000;
30
30
  export const MANAGED_ARTIFACT_MAX_FILE_BYTES = 64 * 1024 * 1024;
31
31
  export const MANAGED_ARTIFACT_MAX_TOTAL_BYTES = 512 * 1024 * 1024;
32
+ export const MANAGED_ARTIFACT_COPY_BATCH_SIZE = 256;
32
33
  const LOCK_LEASE_MS = 60_000;
33
34
  const LOCK_HEARTBEAT_MS = 10_000;
34
35
  const LOCK_WAIT_MS = 5_000;
@@ -120,6 +121,7 @@ export type ManagedStorageFailure =
120
121
  | "destination_conflict"
121
122
  | "source_changed"
122
123
  | "unsafe_artifacts"
124
+ | "artifact_capacity_exceeded"
123
125
  | "durability_failed"
124
126
  | "atomic_unavailable"
125
127
  | "durability_not_provable"
@@ -1638,8 +1640,20 @@ export async function acquireManagedLock(
1638
1640
  }
1639
1641
  }
1640
1642
 
1643
+ export interface ManagedArtifactTreeLimits {
1644
+ maxFiles?: number;
1645
+ maxTotalBytes?: number;
1646
+ }
1647
+
1641
1648
  /** Bounds a no-follow artifact tree before it can be copied or deleted. */
1642
- export function validateManagedArtifactTree(root: string): void {
1649
+ export function validateManagedArtifactTree(root: string, limits: ManagedArtifactTreeLimits = {}): void {
1650
+ const clampLimit = (limit: number | undefined, maximum: number): number => {
1651
+ if (limit === undefined) return maximum;
1652
+ if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit <= 0) throw new Error("unsafe_artifacts");
1653
+ return Math.min(limit, maximum);
1654
+ };
1655
+ const maxFiles = clampLimit(limits.maxFiles, MANAGED_ARTIFACT_MAX_FILES);
1656
+ const maxTotalBytes = clampLimit(limits.maxTotalBytes, MANAGED_ARTIFACT_MAX_TOTAL_BYTES);
1643
1657
  let files = 0;
1644
1658
  let bytes = 0;
1645
1659
  const visit = (directory: string, depth: number): void => {
@@ -1656,8 +1670,7 @@ export function validateManagedArtifactTree(root: string): void {
1656
1670
  if (!stat.isFile() || stat.size > MANAGED_ARTIFACT_MAX_FILE_BYTES) throw new Error("unsafe_artifacts");
1657
1671
  files++;
1658
1672
  bytes += stat.size;
1659
- if (files > MANAGED_ARTIFACT_MAX_FILES || bytes > MANAGED_ARTIFACT_MAX_TOTAL_BYTES)
1660
- throw new Error("unsafe_artifacts");
1673
+ if (files > maxFiles || bytes > maxTotalBytes) throw new Error("artifact_capacity_exceeded");
1661
1674
  }
1662
1675
  };
1663
1676
  const rootStat = fs.lstatSync(root);