@gajae-code/coding-agent 0.12.7 → 0.12.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 (117) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/notify-cli.d.ts +1 -0
  3. package/dist/types/commands/notify.d.ts +4 -0
  4. package/dist/types/commands/sdk.d.ts +23 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/config/model-discovery-manager.d.ts +6 -1
  7. package/dist/types/config/model-registry.d.ts +2 -0
  8. package/dist/types/config/resolve-config-value.d.ts +2 -1
  9. package/dist/types/config/settings-schema.d.ts +17 -1
  10. package/dist/types/discovery/agents-md.d.ts +11 -1
  11. package/dist/types/main.d.ts +1 -0
  12. package/dist/types/modes/components/hook-selector.d.ts +20 -0
  13. package/dist/types/modes/components/notifications-settings-editor.d.ts +53 -4
  14. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  15. package/dist/types/modes/controllers/selector-controller.d.ts +4 -2
  16. package/dist/types/modes/types.d.ts +6 -0
  17. package/dist/types/sdk/broker/broker.d.ts +5 -1
  18. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +1 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/config.d.ts +75 -113
  21. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  22. package/dist/types/sdk/bus/discord-live-provider.d.ts +8 -2
  23. package/dist/types/sdk/bus/discord-provider.d.ts +19 -0
  24. package/dist/types/sdk/bus/index.d.ts +1 -1
  25. package/dist/types/sdk/bus/notification-orchestration.d.ts +127 -18
  26. package/dist/types/sdk/bus/notification-service.d.ts +54 -4
  27. package/dist/types/sdk/bus/session-control.d.ts +8 -3
  28. package/dist/types/sdk/bus/slack-live-provider.d.ts +9 -2
  29. package/dist/types/sdk/bus/slack-provider.d.ts +22 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  31. package/dist/types/sdk/client/discovery.d.ts +2 -1
  32. package/dist/types/sdk/host/query/handlers.d.ts +2 -0
  33. package/dist/types/sdk/index.d.ts +1 -0
  34. package/dist/types/sdk/providers.d.ts +22 -0
  35. package/dist/types/sdk/session-directory.d.ts +1 -1
  36. package/dist/types/session/internal/managed-session-scope.d.ts +17 -1
  37. package/dist/types/session/internal/managed-session-storage.d.ts +19 -1
  38. package/dist/types/session/internal/native-publish-outcome.d.ts +3 -2
  39. package/dist/types/session/internal/session-open-errors.d.ts +5 -0
  40. package/dist/types/session/session-manager.d.ts +3 -1
  41. package/dist/types/session/session-storage.d.ts +2 -0
  42. package/dist/types/system-prompt.d.ts +12 -1
  43. package/dist/types/tools/composer-bash-policy.d.ts +9 -1
  44. package/package.json +10 -8
  45. package/scripts/build-sdk-package-smoke.ts +51 -0
  46. package/scripts/capture-notifications-settings-showcase.ts +1 -1
  47. package/scripts/verify-gjc-sdk-canonicalization.ts +14 -1
  48. package/src/cli/notify-cli.ts +215 -99
  49. package/src/commands/notify.ts +19 -8
  50. package/src/commands/sdk.ts +54 -0
  51. package/src/config/file-lock.ts +6 -0
  52. package/src/config/model-discovery-manager.ts +50 -8
  53. package/src/config/model-profiles.ts +8 -0
  54. package/src/config/model-registry.ts +866 -76
  55. package/src/config/resolve-config-value.ts +10 -8
  56. package/src/config/settings-schema.ts +25 -15
  57. package/src/config/settings.ts +15 -15
  58. package/src/discovery/agents-md.ts +76 -19
  59. package/src/gjc-runtime/psmux-detect.ts +12 -16
  60. package/src/gjc-runtime/session-state-sidecar.ts +12 -2
  61. package/src/gjc-runtime/tmux-sessions.ts +8 -1
  62. package/src/internal-urls/docs-index.generated.ts +10 -9
  63. package/src/main.ts +43 -7
  64. package/src/modes/DESIGN.md +11 -11
  65. package/src/modes/components/extensions/inspector-panel.ts +9 -5
  66. package/src/modes/components/hook-selector.ts +155 -44
  67. package/src/modes/components/model-selector.ts +242 -49
  68. package/src/modes/components/notifications-settings-editor.ts +631 -85
  69. package/src/modes/controllers/extension-ui-controller.ts +114 -15
  70. package/src/modes/controllers/selector-controller.ts +525 -93
  71. package/src/modes/print-mode.ts +3 -0
  72. package/src/modes/types.ts +6 -0
  73. package/src/prompts/system/system-prompt.md +1 -0
  74. package/src/sdk/broker/broker.ts +54 -10
  75. package/src/sdk/broker/lifecycle-ledger.ts +34 -1
  76. package/src/sdk/broker/lifecycle.ts +47 -50
  77. package/src/sdk/bus/chat-daemon-cli.ts +12 -5
  78. package/src/sdk/bus/chat-daemon-control.ts +35 -6
  79. package/src/sdk/bus/chat-daemon-runtime.ts +16 -2
  80. package/src/sdk/bus/config.ts +534 -286
  81. package/src/sdk/bus/daemon-paths.ts +2 -0
  82. package/src/sdk/bus/discord-live-provider.ts +57 -2
  83. package/src/sdk/bus/discord-provider.ts +21 -0
  84. package/src/sdk/bus/index.ts +97 -35
  85. package/src/sdk/bus/notification-orchestration.ts +371 -68
  86. package/src/sdk/bus/notification-service.ts +571 -137
  87. package/src/sdk/bus/session-control.ts +119 -44
  88. package/src/sdk/bus/slack-live-provider.ts +78 -2
  89. package/src/sdk/bus/slack-provider.ts +24 -0
  90. package/src/sdk/bus/telegram-daemon-cli.ts +9 -2
  91. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  92. package/src/sdk/bus/telegram-daemon-control.ts +3 -3
  93. package/src/sdk/bus/telegram-daemon.ts +23 -15
  94. package/src/sdk/client/discovery.ts +10 -4
  95. package/src/sdk/host/query/handlers.ts +20 -1
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  98. package/src/sdk/protocol/operation-registry.ts +5 -2
  99. package/src/sdk/providers.ts +51 -0
  100. package/src/sdk/session-directory.ts +1 -0
  101. package/src/sdk/session.ts +34 -14
  102. package/src/session/agent-session.ts +109 -37
  103. package/src/session/internal/managed-session-scope.ts +981 -450
  104. package/src/session/internal/managed-session-storage.ts +418 -47
  105. package/src/session/internal/native-publish-outcome.ts +15 -3
  106. package/src/session/internal/session-open-errors.ts +11 -0
  107. package/src/session/session-manager.ts +27 -3
  108. package/src/session/session-storage.ts +72 -5
  109. package/src/slash-commands/builtin-registry.ts +117 -15
  110. package/src/system-prompt.ts +28 -15
  111. package/src/task/index.ts +9 -3
  112. package/src/tools/ask.ts +1 -1
  113. package/src/tools/bash.ts +4 -2
  114. package/src/tools/composer-bash-policy.ts +21 -5
  115. package/src/tools/telegram-send.ts +4 -2
  116. package/src/utils/command-args.ts +19 -30
  117. package/src/utils/lang-from-path.ts +2 -4
@@ -8,13 +8,14 @@ import {
8
8
  verifyOwnerOnlyPathSecurity,
9
9
  verifyOwnerOnlyPathSecurityExpected,
10
10
  } from "@gajae-code/natives";
11
- import { logger, pathIsWithin } from "@gajae-code/utils";
11
+ import { hasFsCode, logger, pathIsWithin } from "@gajae-code/utils";
12
12
  import type { ResumeSessionIdentity } from "../session-manager";
13
13
  import {
14
14
  FileSessionStorage,
15
15
  type NativeDirectoryTreeSnapshot,
16
16
  type SessionStorageFileIdentity,
17
17
  type VerifiedSessionDeleteResult,
18
+ type VerifiedSessionDeleteTarget,
18
19
  } from "../session-storage";
19
20
  import {
20
21
  acquireManagedLock,
@@ -127,7 +128,7 @@ function configuredRootPath(scope: ManagedScope): string {
127
128
  const canonical = fs.realpathSync.native(candidate);
128
129
  return suffix.length === 0 ? canonical : path.join(canonical, ...suffix);
129
130
  } catch (error) {
130
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
131
+ if (!hasFsCode(error, "ENOENT")) throw error;
131
132
  const parent = path.dirname(candidate);
132
133
  if (parent === candidate) throw new Error("Configured managed root is unavailable.");
133
134
  suffix.unshift(path.basename(candidate));
@@ -163,6 +164,7 @@ export type ManagedScopeErrorCode =
163
164
  | "sessions_root_unavailable"
164
165
  | "binding_conflict"
165
166
  | "binding_invalid"
167
+ | "migration_busy"
166
168
  | "atomic_unavailable"
167
169
  | "invalid_request"
168
170
  | "durability_failed"
@@ -177,6 +179,24 @@ export type ManagedScopeResolution =
177
179
  cause?: { readonly classification: string; readonly diagnostic?: string };
178
180
  };
179
181
 
182
+ function managedScopeFailureCause(error: unknown): { readonly classification: string } {
183
+ return { classification: managedSecurityFailureClassification(error) ?? "binding_invalid" };
184
+ }
185
+
186
+ const managedScopeFailureCodes = new Set([
187
+ "atomic_unavailable",
188
+ "invalid_request",
189
+ "durability_failed",
190
+ "durability_not_provable",
191
+ "migration_busy",
192
+ ]);
193
+
194
+ function managedScopeFailureMessage(error: unknown, fallback: string): string {
195
+ const classification = managedSecurityFailureClassification(error);
196
+ if (classification) return classification;
197
+ return error instanceof Error && managedScopeFailureCodes.has(error.message) ? error.message : fallback;
198
+ }
199
+
180
200
  export interface ManagedCandidate {
181
201
  sessionId: string;
182
202
  path: string;
@@ -222,6 +242,38 @@ export type ManagedDeleteCandidateResult =
222
242
  | { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string }
223
243
  | { kind: "error"; code: ManagedOpenFailure; message: string };
224
244
 
245
+ export interface ManagedVerifiedDeleteTestEvent {
246
+ readonly flow: "direct" | "reconcile";
247
+ readonly stage: "initial" | "artifact-finalization" | "transcript-after-artifacts-removed";
248
+ }
249
+
250
+ export interface ManagedLockReleaseTestEvent {
251
+ readonly path: string;
252
+ readonly attemptId: string;
253
+ }
254
+
255
+ /** Test-only ordering seams for verified deletion and managed-lock release. */
256
+ export const ManagedSessionScopeTestHooks: {
257
+ beforeVerifiedDelete?: (event: ManagedVerifiedDeleteTestEvent) => void | Promise<void>;
258
+ beforeManagedLockRelease?: (event: ManagedLockReleaseTestEvent) => void | Promise<void>;
259
+ } = {};
260
+
261
+ async function deleteSessionVerifiedWithFence(
262
+ flow: ManagedVerifiedDeleteTestEvent["flow"],
263
+ stage: ManagedVerifiedDeleteTestEvent["stage"],
264
+ lock: ManagedStorageLock,
265
+ target: VerifiedSessionDeleteTarget,
266
+ verifyAuthority?: () => void,
267
+ ): Promise<VerifiedSessionDeleteResult> {
268
+ lock.assertOwned();
269
+ verifyAuthority?.();
270
+ const hook = ManagedSessionScopeTestHooks.beforeVerifiedDelete;
271
+ if (hook) await hook({ flow, stage });
272
+ lock.assertOwned();
273
+ verifyAuthority?.();
274
+ return new FileSessionStorage().deleteSessionVerified(target);
275
+ }
276
+
225
277
  type NativeIdentity =
226
278
  | { ok: true; platform: "posix" | "win32"; canonicalPath: string }
227
279
  | { ok: false; code: NativeIdentityFailureCode };
@@ -277,7 +329,7 @@ function identityFor(cwd: string): NativeIdentity {
277
329
  function verifyExistingManagedScopeDirectory(pathname: string) {
278
330
  if (process.platform !== "win32") return verifyOwnerOnlyPathSecurity(pathname, "directory");
279
331
  const expected = fs.lstatSync(pathname, { bigint: true });
280
- if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("Unsafe managed directory");
332
+ if (!expected.isDirectory() || expected.isSymbolicLink()) throw new Error("reparse_point");
281
333
  const verified = verifyOwnerOnlyPathSecurityExpected(pathname, "directory", expected.dev, expected.ino);
282
334
  const current = fs.lstatSync(pathname, { bigint: true });
283
335
  if (
@@ -286,7 +338,7 @@ function verifyExistingManagedScopeDirectory(pathname: string) {
286
338
  current.dev !== expected.dev ||
287
339
  current.ino !== expected.ino
288
340
  )
289
- throw new Error("Managed session directory changed");
341
+ throw new Error("identity_mismatch");
290
342
  return verified;
291
343
  }
292
344
 
@@ -405,8 +457,18 @@ function validateExistingBinding(scope: ManagedScope): ManagedScopeResolution |
405
457
  try {
406
458
  raw = captureManagedFileNoFollow(bindingPath).bytes.toString("utf8");
407
459
  } catch (error) {
408
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
409
- return { kind: "error", code: "binding_invalid", message: "The managed scope binding is invalid JSON." };
460
+ if (hasFsCode(error, "ENOENT")) return undefined;
461
+ const classification = hasFsCode(error, "EACCES")
462
+ ? "EACCES"
463
+ : hasFsCode(error, "EPERM")
464
+ ? "EPERM"
465
+ : "binding_invalid";
466
+ return {
467
+ kind: "error",
468
+ code: "binding_invalid",
469
+ message: "The managed scope binding is invalid JSON.",
470
+ cause: { classification },
471
+ };
410
472
  }
411
473
  return validateBindingRaw(scope, raw);
412
474
  }
@@ -429,10 +491,11 @@ function resolveManagedScopeInternal(
429
491
  kind: "error",
430
492
  code: "sessions_root_unavailable",
431
493
  message: "The sessions root is not a safe directory.",
494
+ cause: { classification: "reparse_point" },
432
495
  };
433
496
  }
434
497
  } catch (error) {
435
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
498
+ if (!hasFsCode(error, "ENOENT")) {
436
499
  return {
437
500
  kind: "error",
438
501
  code: "sessions_root_unavailable",
@@ -462,10 +525,11 @@ function resolveManagedScopeInternal(
462
525
  kind: "error",
463
526
  code: "sessions_root_unavailable",
464
527
  message: "The sessions root is not a safe directory.",
528
+ cause: { classification: "reparse_point" },
465
529
  };
466
530
  }
467
531
  } catch (error) {
468
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
532
+ if (!hasFsCode(error, "ENOENT")) {
469
533
  return {
470
534
  kind: "error",
471
535
  code: "sessions_root_unavailable",
@@ -476,7 +540,12 @@ function resolveManagedScopeInternal(
476
540
  try {
477
541
  const directory = fs.lstatSync(scope.directoryPath);
478
542
  if (!directory.isDirectory() || directory.isSymbolicLink()) {
479
- return { kind: "error", code: "binding_invalid", message: "The managed scope path is not a safe directory." };
543
+ return {
544
+ kind: "error",
545
+ code: "binding_invalid",
546
+ message: "The managed scope path is not a safe directory.",
547
+ cause: { classification: "reparse_point" },
548
+ };
480
549
  }
481
550
  const security = validateNativeSecurityResult(
482
551
  verifyExistingManagedScopeDirectory(scope.directoryPath),
@@ -491,8 +560,12 @@ function resolveManagedScopeInternal(
491
560
  };
492
561
  }
493
562
  } catch (error) {
494
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
495
- return { kind: "error", code: "binding_invalid", message: "The managed scope path could not be inspected." };
563
+ if (!hasFsCode(error, "ENOENT")) {
564
+ return {
565
+ kind: "error",
566
+ code: "binding_invalid",
567
+ message: "The managed scope path could not be inspected.",
568
+ };
496
569
  }
497
570
  }
498
571
  return validateExistingBinding(scope) ?? { kind: "resolved", scope };
@@ -557,7 +630,7 @@ function fsyncManagedParent(pathname: string): void {
557
630
  try {
558
631
  descriptor = fs.openSync(parent, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
559
632
  } catch (error) {
560
- if ((error as NodeJS.ErrnoException).code === "ENOENT" && path.dirname(parent) !== parent) {
633
+ if (hasFsCode(error, "ENOENT") && path.dirname(parent) !== parent) {
561
634
  parent = path.dirname(parent);
562
635
  continue;
563
636
  }
@@ -692,7 +765,6 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage
692
765
  named.isSymbolicLink() ||
693
766
  named.dev !== snapshot.identity.dev ||
694
767
  named.ino !== snapshot.identity.ino ||
695
- named.nlink !== 1n ||
696
768
  Number(named.size) !== snapshot.identity.size ||
697
769
  named.mtimeNs !== snapshot.identity.mtimeNs
698
770
  )
@@ -707,7 +779,7 @@ function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): Manage
707
779
  canonicalPath: path.resolve(filePath),
708
780
  sessionId: header.id,
709
781
  ...snapshot.identity,
710
- nlink: named.nlink,
782
+ nlink: snapshot.identity.nlink,
711
783
  mtimeMs: Number(named.mtimeMs),
712
784
  sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
713
785
  },
@@ -781,7 +853,7 @@ export async function ensureManagedScope(
781
853
  try {
782
854
  await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy);
783
855
  } catch (error) {
784
- if ((error as Error).message !== "destination_conflict") throw error;
856
+ if (!(error instanceof Error) || error.message !== "destination_conflict") throw error;
785
857
  bindingCollision = true;
786
858
  }
787
859
  const validated = validateExistingBinding(scope);
@@ -789,15 +861,14 @@ export async function ensureManagedScope(
789
861
  if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding);
790
862
 
791
863
  const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
792
- if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
793
- throw new Error("Managed session directory changed");
864
+ if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink()) throw new Error("reparse_point");
794
865
  managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
795
866
  return { kind: "resolved", scope };
796
867
  } catch (error) {
797
868
  const publication = error instanceof ManagedPublishError ? error : undefined;
798
869
  const message =
799
870
  publication?.classification ??
800
- (error instanceof Error ? error.message : "The managed scope could not be initialized.");
871
+ managedScopeFailureMessage(error, "The managed scope could not be initialized.");
801
872
  const code =
802
873
  message === "atomic_unavailable" ||
803
874
  message === "invalid_request" ||
@@ -805,14 +876,13 @@ export async function ensureManagedScope(
805
876
  message === "durability_not_provable"
806
877
  ? message
807
878
  : "binding_invalid";
808
-
809
879
  return {
810
880
  kind: "error",
811
881
  code,
812
882
  message,
813
- ...(publication
814
- ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
815
- : { cause: { classification: code } }),
883
+ cause: publication
884
+ ? { classification: publication.classification, diagnostic: publication.diagnostic }
885
+ : managedScopeFailureCause(error),
816
886
  };
817
887
  }
818
888
  }
@@ -948,7 +1018,7 @@ export function prepareManagedSessionScopeForWriteSync(
948
1018
  try {
949
1019
  store.publishNoReplaceSync(MANAGED_SESSION_BINDING_FILE, binding);
950
1020
  } catch (error) {
951
- if ((error as Error).message !== "destination_conflict") throw error;
1021
+ if (!(error instanceof Error) || error.message !== "destination_conflict") throw error;
952
1022
  }
953
1023
  stage = "binding_read";
954
1024
  const capturedBinding = store.readExpected(MANAGED_SESSION_BINDING_FILE);
@@ -986,8 +1056,7 @@ export function prepareManagedSessionScopeForWriteSync(
986
1056
  } catch (error) {
987
1057
  const publication = error instanceof ManagedPublishError ? error : undefined;
988
1058
  const message =
989
- publication?.classification ??
990
- (error instanceof Error ? error.message : "Managed write protocol setup failed.");
1059
+ publication?.classification ?? managedScopeFailureMessage(error, "Managed write protocol setup failed.");
991
1060
  const code =
992
1061
  message === "atomic_unavailable" ||
993
1062
  message === "invalid_request" ||
@@ -995,14 +1064,14 @@ export function prepareManagedSessionScopeForWriteSync(
995
1064
  message === "durability_not_provable"
996
1065
  ? message
997
1066
  : "binding_invalid";
998
- const securityClassification = managedSecurityFailureClassification(error);
1067
+
999
1068
  return {
1000
1069
  kind: "error",
1001
1070
  code,
1002
1071
  message,
1003
1072
  cause: publication
1004
1073
  ? { classification: publication.classification, diagnostic: publication.diagnostic }
1005
- : { classification: securityClassification ?? code, diagnostic: `prepare:${stage}` },
1074
+ : { ...managedScopeFailureCause(error), diagnostic: `prepare:${stage}` },
1006
1075
  };
1007
1076
  }
1008
1077
  }
@@ -1140,7 +1209,6 @@ function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean
1140
1209
  left.path === right.path &&
1141
1210
  left.identity.dev === right.identity.dev &&
1142
1211
  left.identity.ino === right.identity.ino &&
1143
- left.identity.nlink === right.identity.nlink &&
1144
1212
  left.identity.size === right.identity.size &&
1145
1213
  left.identity.mtimeNs === right.identity.mtimeNs &&
1146
1214
  left.identity.sha256 === right.identity.sha256
@@ -1153,7 +1221,6 @@ function matchesExpectedResumeIdentity(candidate: ManagedCandidate, expected: Re
1153
1221
  candidate.identity.sessionId === expected.sessionId &&
1154
1222
  candidate.identity.dev === expected.dev &&
1155
1223
  candidate.identity.ino === expected.ino &&
1156
- candidate.identity.nlink === expected.nlink &&
1157
1224
  candidate.identity.size === expected.size &&
1158
1225
  candidate.identity.mtimeMs === expected.mtimeMs &&
1159
1226
  candidate.identity.mtimeNs === expected.mtimeNs &&
@@ -1218,7 +1285,14 @@ function receiptMatches(
1218
1285
  state?: unknown;
1219
1286
  role?: unknown;
1220
1287
  retainedPath?: unknown;
1221
- identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1288
+ identity?: {
1289
+ dev?: unknown;
1290
+ ino?: unknown;
1291
+ size?: unknown;
1292
+ mtimeNs?: unknown;
1293
+ parentDev?: unknown;
1294
+ parentIno?: unknown;
1295
+ };
1222
1296
  tree?: unknown;
1223
1297
  };
1224
1298
  };
@@ -1275,6 +1349,8 @@ function receiptMatches(
1275
1349
  cleanupIdentity.ino !== undefined &&
1276
1350
  cleanupIdentity.size !== undefined &&
1277
1351
  cleanupIdentity.mtimeNs !== undefined &&
1352
+ cleanupIdentity.parentDev !== undefined &&
1353
+ cleanupIdentity.parentIno !== undefined &&
1278
1354
  !!cleanupTree &&
1279
1355
  cleanupAuthorityMatches(
1280
1356
  {
@@ -1286,6 +1362,8 @@ function receiptMatches(
1286
1362
  ino: BigInt(String(cleanupIdentity.ino)),
1287
1363
  size: BigInt(String(cleanupIdentity.size)),
1288
1364
  mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)),
1365
+ parentDev: BigInt(String(cleanupIdentity.parentDev)),
1366
+ parentIno: BigInt(String(cleanupIdentity.parentIno)),
1289
1367
  },
1290
1368
  tree: cleanupTree,
1291
1369
  },
@@ -1368,27 +1446,7 @@ function preparedReceiptMatches(
1368
1446
  }
1369
1447
  }
1370
1448
 
1371
- type RetiredTarget = ManagedCandidate & { transcriptParentIdentity: { dev: bigint; ino: bigint } };
1372
-
1373
- function authorizeRetiredTarget(scope: ManagedScope, candidate: ManagedCandidate): RetiredTarget | undefined {
1374
- try {
1375
- const before = fs.lstatSync(path.dirname(candidate.path), { bigint: true });
1376
- if (!before.isDirectory()) return undefined;
1377
- const verified = validateCandidateForScope(scope, candidate);
1378
- const after = fs.lstatSync(path.dirname(candidate.path), { bigint: true });
1379
- if (
1380
- !verified ||
1381
- !sameCandidate(verified, candidate) ||
1382
- !after.isDirectory() ||
1383
- after.dev !== before.dev ||
1384
- after.ino !== before.ino
1385
- )
1386
- return undefined;
1387
- return { ...candidate, transcriptParentIdentity: { dev: before.dev, ino: before.ino } };
1388
- } catch {
1389
- return undefined;
1390
- }
1391
- }
1449
+ type RetiredTarget = ManagedCandidate;
1392
1450
 
1393
1451
  function retiredTargets(scope: ManagedScope, pathname: string): readonly RetiredTarget[] | undefined {
1394
1452
  try {
@@ -1409,14 +1467,6 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1409
1467
  const identity = item.identity;
1410
1468
  if (!identity || typeof identity !== "object" || Array.isArray(identity)) return undefined;
1411
1469
  const fields = identity as Record<string, unknown>;
1412
- const transcriptParentIdentity = item.transcriptParentIdentity;
1413
- if (
1414
- !transcriptParentIdentity ||
1415
- typeof transcriptParentIdentity !== "object" ||
1416
- Array.isArray(transcriptParentIdentity)
1417
- )
1418
- return undefined;
1419
- const parentFields = transcriptParentIdentity as Record<string, unknown>;
1420
1470
  if (
1421
1471
  typeof item.path !== "string" ||
1422
1472
  typeof item.sessionId !== "string" ||
@@ -1426,13 +1476,10 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1426
1476
  typeof fields.canonicalPath !== "string" ||
1427
1477
  typeof fields.dev !== "string" ||
1428
1478
  typeof fields.ino !== "string" ||
1429
- typeof fields.nlink !== "string" ||
1430
1479
  typeof fields.size !== "number" ||
1431
1480
  typeof fields.mtimeMs !== "number" ||
1432
1481
  typeof fields.mtimeNs !== "string" ||
1433
- typeof fields.sha256 !== "string" ||
1434
- typeof parentFields.dev !== "string" ||
1435
- typeof parentFields.ino !== "string"
1482
+ typeof fields.sha256 !== "string"
1436
1483
  )
1437
1484
  return undefined;
1438
1485
  const provenance =
@@ -1451,17 +1498,12 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1451
1498
  canonicalPath: fields.canonicalPath,
1452
1499
  dev: BigInt(fields.dev),
1453
1500
  ino: BigInt(fields.ino),
1454
- nlink: BigInt(fields.nlink as string),
1455
1501
  size: fields.size,
1456
1502
  mtimeMs: fields.mtimeMs,
1457
1503
  mtimeNs: BigInt(fields.mtimeNs),
1458
1504
  sha256: fields.sha256,
1459
1505
  sessionId: item.sessionId,
1460
1506
  },
1461
- transcriptParentIdentity: {
1462
- dev: BigInt(parentFields.dev as string),
1463
- ino: BigInt(parentFields.ino as string),
1464
- },
1465
1507
  });
1466
1508
  }
1467
1509
  return targets;
@@ -1483,13 +1525,19 @@ type RetainedArtifactsRootReceipt = {
1483
1525
  };
1484
1526
 
1485
1527
  function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined {
1486
- const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath);
1528
+ const pathname = receipt.detachedArtifactsPath ?? deterministicRemovalRoot(receipt.plannedArtifactsPath);
1487
1529
  if (!fs.existsSync(pathname)) return undefined;
1488
- if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed");
1530
+ if (!isQuarantinePath(receipt.target, pathname) || !receipt.expectedArtifactsIdentity)
1531
+ throw new Error("durability_failed");
1489
1532
  const identity = artifactIdentityAt(pathname);
1490
- if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity))
1533
+ if (
1534
+ !identity ||
1535
+ identity.dev !== receipt.expectedArtifactsIdentity.dev ||
1536
+ identity.ino !== receipt.expectedArtifactsIdentity.ino
1537
+ )
1491
1538
  throw new Error("durability_failed");
1492
1539
  const tree = snapshotArtifactTree(pathname);
1540
+ if (!artifactTreePayloadAbsent(tree)) throw new Error("durability_failed");
1493
1541
  return { path: pathname, identity, tree };
1494
1542
  }
1495
1543
 
@@ -1500,13 +1548,21 @@ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean
1500
1548
  const retained = record.retainedArtifactsRoot as Record<string, unknown>;
1501
1549
  const identity = retained.identity;
1502
1550
  const tree = artifactTreeSnapshot(retained.tree);
1551
+ const expectedRetainedPath =
1552
+ typeof record.detachedArtifactsPath === "string"
1553
+ ? record.detachedArtifactsPath
1554
+ : typeof record.plannedArtifactsPath === "string"
1555
+ ? deterministicRemovalRoot(record.plannedArtifactsPath)
1556
+ : undefined;
1503
1557
  if (
1504
- typeof record.plannedArtifactsPath !== "string" ||
1505
- retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) ||
1558
+ !expectedRetainedPath ||
1559
+ retained.path !== expectedRetainedPath ||
1560
+ record.artifactsPayloadDurable !== true ||
1506
1561
  !identity ||
1507
1562
  typeof identity !== "object" ||
1508
1563
  Array.isArray(identity) ||
1509
- !tree
1564
+ !tree ||
1565
+ !artifactTreePayloadAbsent(tree)
1510
1566
  )
1511
1567
  throw new Error("durability_failed");
1512
1568
  const artifactIdentity = identity as Record<string, unknown>;
@@ -1522,14 +1578,9 @@ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean
1522
1578
  const observed = artifactIdentityAt(retained.path);
1523
1579
  if (
1524
1580
  !observed ||
1525
- !sameArtifactRootIdentity(observed, {
1526
- dev: BigInt(artifactIdentity.dev),
1527
- ino: BigInt(artifactIdentity.ino),
1528
- size: artifactIdentity.size,
1529
- mtimeNs: BigInt(artifactIdentity.mtimeNs),
1530
- sha256: artifactIdentity.sha256,
1531
- }) ||
1532
- JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree)
1581
+ observed.dev !== BigInt(artifactIdentity.dev) ||
1582
+ observed.ino !== BigInt(artifactIdentity.ino) ||
1583
+ !artifactTreePayloadAbsent(snapshotArtifactTree(retained.path))
1533
1584
  )
1534
1585
  throw new Error("durability_failed");
1535
1586
  return true;
@@ -1538,12 +1589,13 @@ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean
1538
1589
  type CleanupReceipt = {
1539
1590
  attempt: number;
1540
1591
  target: RetiredTarget;
1541
- transcriptParentIdentity: { dev: bigint; ino: bigint };
1542
1592
  expectedArtifactsIdentity?: SessionStorageFileIdentity;
1543
- artifactsAbsentAtAuthorization?: true;
1544
1593
  expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
1594
+ artifactsPayloadDurable?: true;
1595
+ artifactsRemovedAttempt?: number;
1545
1596
  detachedArtifactsPath?: string;
1546
1597
  detachedTranscriptPath?: string;
1598
+ transcriptPayloadDurable?: true;
1547
1599
  retainedArtifactsSuccessorPath?: string;
1548
1600
  retainedArtifactsPlaceholderPath?: string;
1549
1601
  retainedArtifactsUnknownPath?: string;
@@ -1580,15 +1632,15 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
1580
1632
  cwd: receipt.target.cwd,
1581
1633
  identity: receipt.target.identity,
1582
1634
  },
1583
- transcriptParentIdentity: {
1584
- dev: receipt.transcriptParentIdentity.dev.toString(),
1585
- ino: receipt.transcriptParentIdentity.ino.toString(),
1586
- },
1587
1635
  ...(receipt.expectedArtifactsIdentity ? { expectedArtifactsIdentity: receipt.expectedArtifactsIdentity } : {}),
1588
- ...(receipt.artifactsAbsentAtAuthorization ? { artifactsAbsentAtAuthorization: true as const } : {}),
1589
1636
  ...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
1637
+ ...(receipt.artifactsPayloadDurable === true ? { artifactsPayloadDurable: true } : {}),
1638
+ ...(receipt.artifactsRemovedAttempt !== undefined
1639
+ ? { artifactsRemovedAttempt: receipt.artifactsRemovedAttempt }
1640
+ : {}),
1590
1641
  ...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
1591
1642
  ...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
1643
+ ...(receipt.transcriptPayloadDurable === true ? { transcriptPayloadDurable: true } : {}),
1592
1644
  ...(receipt.retainedArtifactsSuccessorPath
1593
1645
  ? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath }
1594
1646
  : {}),
@@ -1612,45 +1664,102 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
1612
1664
  };
1613
1665
  }
1614
1666
 
1615
- function cleanupArtifactsRemoved(
1667
+ type CleanupArtifactsRemovedEvidence = { retainedArtifactsRootPath?: string };
1668
+
1669
+ function cleanupArtifactsRemovedEvidence(
1616
1670
  scope: ManagedScope,
1617
1671
  tombstone: string,
1618
1672
  target: RetiredTarget,
1619
1673
  attempt: number,
1620
- ): boolean {
1674
+ ): CleanupArtifactsRemovedEvidence | undefined {
1621
1675
  try {
1622
1676
  const value: unknown = JSON.parse(
1623
1677
  captureManagedFileNoFollow(cleanupReceiptPath(tombstone, target, "artifacts_removed", attempt)).bytes.toString(
1624
1678
  "utf8",
1625
1679
  ),
1626
1680
  );
1627
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1681
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
1628
1682
  const record = value as Record<string, unknown>;
1629
1683
  const recorded = record.target as Record<string, unknown> | undefined;
1630
1684
  const identity = recorded?.identity as Record<string, unknown> | undefined;
1631
- return (
1632
- record.schemaVersion === 2 &&
1633
- record.state === "artifacts_removed" &&
1634
- record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
1635
- record.tombstone === tombstone &&
1636
- record.attempt === attempt &&
1637
- recorded?.path === target.path &&
1638
- recorded.sessionId === target.sessionId &&
1639
- recorded.cwd === target.cwd &&
1640
- identity?.canonicalPath === target.identity.canonicalPath &&
1641
- identity.dev === String(target.identity.dev) &&
1642
- identity.ino === String(target.identity.ino) &&
1643
- identity.size === target.identity.size &&
1644
- identity.mtimeNs === String(target.identity.mtimeNs) &&
1645
- identity.sha256 === target.identity.sha256 &&
1646
- retainedArtifactsRootMatches(record)
1647
- );
1685
+ if (
1686
+ record.schemaVersion !== 2 ||
1687
+ record.state !== "artifacts_removed" ||
1688
+ record.scope !== scopeDigest(scope.platform, scope.canonicalCwd) ||
1689
+ record.tombstone !== tombstone ||
1690
+ record.attempt !== attempt ||
1691
+ recorded?.path !== target.path ||
1692
+ recorded.sessionId !== target.sessionId ||
1693
+ recorded.cwd !== target.cwd ||
1694
+ identity?.canonicalPath !== target.identity.canonicalPath ||
1695
+ identity.dev !== String(target.identity.dev) ||
1696
+ identity.ino !== String(target.identity.ino) ||
1697
+ identity.size !== target.identity.size ||
1698
+ identity.mtimeNs !== String(target.identity.mtimeNs) ||
1699
+ identity.sha256 !== target.identity.sha256 ||
1700
+ !retainedArtifactsRootMatches(record)
1701
+ )
1702
+ return undefined;
1703
+ const retained = record.retainedArtifactsRoot as Record<string, unknown> | undefined;
1704
+ return { retainedArtifactsRootPath: typeof retained?.path === "string" ? retained.path : undefined };
1648
1705
  } catch (error) {
1649
1706
  if ((error as Error).message === "durability_failed") throw error;
1650
- return false;
1707
+ return undefined;
1651
1708
  }
1652
1709
  }
1653
1710
 
1711
+ function cleanupArtifactsRemovedReceipt(
1712
+ tombstone: string,
1713
+ target: RetiredTarget,
1714
+ attempt: number,
1715
+ ): RetainedArtifactsRootReceipt | undefined {
1716
+ const receiptPath = cleanupReceiptPath(tombstone, target, "artifacts_removed", attempt);
1717
+ if (!fs.existsSync(receiptPath)) return undefined;
1718
+ const record = JSON.parse(captureManagedFileNoFollow(receiptPath).bytes.toString("utf8")) as Record<string, unknown>;
1719
+ if (!retainedArtifactsRootMatches(record)) return undefined;
1720
+ const retained = record.retainedArtifactsRoot as Record<string, unknown> | undefined;
1721
+ const identity = retained?.identity;
1722
+ const tree = artifactTreeSnapshot(retained?.tree);
1723
+ if (
1724
+ !retained ||
1725
+ typeof retained.path !== "string" ||
1726
+ !identity ||
1727
+ typeof identity !== "object" ||
1728
+ Array.isArray(identity) ||
1729
+ !tree
1730
+ )
1731
+ return undefined;
1732
+ const typed = identity as Record<string, unknown>;
1733
+ if (
1734
+ typeof typed.dev !== "string" ||
1735
+ typeof typed.ino !== "string" ||
1736
+ typeof typed.size !== "number" ||
1737
+ typeof typed.mtimeNs !== "string" ||
1738
+ typeof typed.sha256 !== "string"
1739
+ )
1740
+ return undefined;
1741
+ return {
1742
+ path: retained.path,
1743
+ identity: {
1744
+ dev: BigInt(typed.dev),
1745
+ ino: BigInt(typed.ino),
1746
+ size: typed.size,
1747
+ mtimeNs: BigInt(typed.mtimeNs),
1748
+ sha256: typed.sha256,
1749
+ },
1750
+ tree,
1751
+ };
1752
+ }
1753
+
1754
+ function cleanupArtifactsRemoved(
1755
+ scope: ManagedScope,
1756
+ tombstone: string,
1757
+ target: RetiredTarget,
1758
+ attempt: number,
1759
+ ): boolean {
1760
+ return cleanupArtifactsRemovedEvidence(scope, tombstone, target, attempt) !== undefined;
1761
+ }
1762
+
1654
1763
  async function publishCleanupArtifactsRemoved(
1655
1764
  scope: ManagedScope,
1656
1765
  tombstone: string,
@@ -1700,6 +1809,23 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa
1700
1809
  );
1701
1810
  }
1702
1811
 
1812
+ function assertAuthorizedCleanupPending(
1813
+ target: RetiredTarget,
1814
+ active: CleanupReceipt,
1815
+ deletion: Extract<VerifiedSessionDeleteResult, { kind: "cleanup_pending" }>,
1816
+ ): void {
1817
+ if (
1818
+ (deletion.phase === "artifacts" &&
1819
+ !isAuthorizedArtifactRoot(
1820
+ target,
1821
+ active.detachedArtifactsPath ?? active.plannedArtifactsPath,
1822
+ deletion.detachedArtifactsPath,
1823
+ )) ||
1824
+ (deletion.phase === "transcript" && deletion.detachedTranscriptPath !== active.plannedTranscriptPath)
1825
+ )
1826
+ throw new Error("durability_failed");
1827
+ }
1828
+
1703
1829
  function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean {
1704
1830
  try {
1705
1831
  const observed = captureManagedFileNoFollow(pathname);
@@ -1717,16 +1843,36 @@ function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): b
1717
1843
  }
1718
1844
  }
1719
1845
 
1720
- function cleanupTranscriptParentMatches(target: RetiredTarget, pending: CleanupReceipt): boolean {
1846
+ function reconcileScrubbedTranscriptPlaceholder(pathname: string): boolean {
1721
1847
  try {
1722
- const stat = fs.lstatSync(path.dirname(target.path), { bigint: true });
1848
+ const stat = fs.lstatSync(pathname);
1849
+ return !stat.isSymbolicLink() && stat.isFile() && stat.size === 0 && stat.nlink === 1;
1850
+ } catch (error) {
1851
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
1852
+ }
1853
+ }
1854
+
1855
+ function managedPathPresentNoFollow(pathname: string): boolean {
1856
+ try {
1857
+ fs.lstatSync(pathname);
1858
+ return true;
1859
+ } catch (error) {
1860
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1861
+ throw new Error("durability_failed");
1862
+ }
1863
+ }
1864
+
1865
+ function isScrubbedTranscriptPlaceholder(pathname: string): boolean {
1866
+ try {
1867
+ const stat = fs.lstatSync(pathname);
1868
+ if (stat.isSymbolicLink()) return false;
1723
1869
  return (
1724
- stat.isDirectory() &&
1725
- stat.dev === pending.transcriptParentIdentity.dev &&
1726
- stat.ino === pending.transcriptParentIdentity.ino
1870
+ (stat.isFile() && stat.size === 0 && stat.nlink === 1) ||
1871
+ (stat.isDirectory() && fs.readdirSync(pathname).length === 0)
1727
1872
  );
1728
- } catch {
1729
- return false;
1873
+ } catch (error) {
1874
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1875
+ throw new Error("durability_failed");
1730
1876
  }
1731
1877
  }
1732
1878
 
@@ -1734,32 +1880,16 @@ function cleanupRootsAbsent(
1734
1880
  tombstone: string,
1735
1881
  target: RetiredTarget,
1736
1882
  pending: CleanupReceipt,
1737
- includeTranscriptAuthority = true,
1883
+ allowedRetainedArtifactsRoot?: string,
1738
1884
  ): boolean {
1739
- const transcriptParent = path.dirname(target.path);
1740
- const transcriptParentMatches = (): boolean => {
1741
- try {
1742
- const stat = fs.lstatSync(transcriptParent, { bigint: true });
1743
- return (
1744
- stat.isDirectory() &&
1745
- stat.dev === pending.transcriptParentIdentity.dev &&
1746
- stat.ino === pending.transcriptParentIdentity.ino
1747
- );
1748
- } catch {
1749
- return false;
1750
- }
1751
- };
1752
- if (!transcriptParentMatches()) return false;
1753
1885
  const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
1754
- const activeTranscriptRoots = includeTranscriptAuthority
1755
- ? new Set(
1756
- [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
1757
- isQuarantinePath(target, pathname),
1758
- ),
1759
- )
1760
- : new Set<string>();
1886
+ const activeTranscriptRoots = new Set(
1887
+ [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
1888
+ isQuarantinePath(target, pathname),
1889
+ ),
1890
+ );
1761
1891
  const roots = new Set<string>([
1762
- ...(includeTranscriptAuthority ? [target.path] : []),
1892
+ target.path,
1763
1893
  target.path.slice(0, -6),
1764
1894
  pending.plannedArtifactsPath,
1765
1895
  deterministicRemovalRoot(pending.plannedArtifactsPath),
@@ -1788,25 +1918,80 @@ function cleanupRootsAbsent(
1788
1918
  retainedTranscriptSuccessorPath?: unknown;
1789
1919
  retainedTranscriptPlaceholderPath?: unknown;
1790
1920
  retainedTranscriptUnknownPath?: unknown;
1921
+ transcriptPayloadDurable?: unknown;
1791
1922
  };
1792
1923
  if (isQuarantinePath(target, record.plannedArtifactsPath)) {
1793
1924
  roots.add(record.plannedArtifactsPath);
1794
1925
  roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
1795
1926
  }
1796
1927
  if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
1928
+ const historicalTranscriptDurable = record.transcriptPayloadDurable === true;
1797
1929
  for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) {
1798
- if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname);
1930
+ if (!isQuarantinePath(target, pathname) || activeTranscriptRoots.has(pathname)) continue;
1931
+ if (historicalTranscriptDurable || pending.transcriptPayloadDurable === true) {
1932
+ if (!reconcileScrubbedTranscriptPlaceholder(pathname)) return false;
1933
+ roots.delete(pathname);
1934
+ } else roots.add(pathname);
1799
1935
  }
1800
- for (const pathname of [
1801
- record.retainedArtifactsSuccessorPath,
1802
- record.retainedArtifactsPlaceholderPath,
1803
- record.retainedArtifactsUnknownPath,
1804
- record.retainedTranscriptSuccessorPath,
1805
- record.retainedTranscriptPlaceholderPath,
1806
- record.retainedTranscriptUnknownPath,
1807
- ]) {
1936
+ for (const pathname of [record.retainedArtifactsSuccessorPath, record.retainedArtifactsUnknownPath]) {
1808
1937
  if (isRetainedNativePath(target, pathname)) roots.add(pathname);
1809
1938
  }
1939
+ if (isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) {
1940
+ if (!reconcileScrubbedTranscriptPlaceholder(record.retainedArtifactsPlaceholderPath))
1941
+ roots.add(record.retainedArtifactsPlaceholderPath);
1942
+ }
1943
+ for (const pathname of [record.retainedTranscriptSuccessorPath, record.retainedTranscriptUnknownPath]) {
1944
+ if (isRetainedNativePath(target, pathname)) roots.add(pathname);
1945
+ }
1946
+ if (isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) {
1947
+ if (historicalTranscriptDurable) {
1948
+ if (!reconcileScrubbedTranscriptPlaceholder(record.retainedTranscriptPlaceholderPath)) return false;
1949
+ roots.delete(record.retainedTranscriptPlaceholderPath);
1950
+ } else roots.add(record.retainedTranscriptPlaceholderPath);
1951
+ }
1952
+ }
1953
+ for (const blocker of [pending.retainedArtifactsSuccessorPath, pending.retainedArtifactsUnknownPath]) {
1954
+ if (!blocker) continue;
1955
+ try {
1956
+ fs.lstatSync(blocker);
1957
+ return false;
1958
+ } catch (error) {
1959
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1960
+ }
1961
+ }
1962
+ if (pending.retainedArtifactsPlaceholderPath) {
1963
+ if (!reconcileScrubbedTranscriptPlaceholder(pending.retainedArtifactsPlaceholderPath)) return false;
1964
+ roots.delete(pending.retainedArtifactsPlaceholderPath);
1965
+ }
1966
+ if (pending.transcriptPayloadDurable === true) {
1967
+ for (const blocker of [pending.retainedTranscriptSuccessorPath, pending.retainedTranscriptUnknownPath]) {
1968
+ if (!blocker) continue;
1969
+ try {
1970
+ fs.lstatSync(blocker);
1971
+ return false;
1972
+ } catch (error) {
1973
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
1974
+ }
1975
+ }
1976
+ for (const placeholder of [
1977
+ target.path,
1978
+ pending.plannedTranscriptPath,
1979
+ pending.detachedTranscriptPath,
1980
+ pending.retainedTranscriptPlaceholderPath,
1981
+ ].filter((pathname): pathname is string => typeof pathname === "string")) {
1982
+ if (!reconcileScrubbedTranscriptPlaceholder(placeholder)) return false;
1983
+ roots.delete(placeholder);
1984
+ activeTranscriptRoots.delete(placeholder);
1985
+ }
1986
+ }
1987
+ if (allowedRetainedArtifactsRoot) {
1988
+ if (
1989
+ pending.detachedArtifactsPath !== allowedRetainedArtifactsRoot ||
1990
+ !retainedArtifactPayloadAbsent(allowedRetainedArtifactsRoot)
1991
+ )
1992
+ return false;
1993
+ assertRetainedArtifactsAuthority(pending);
1994
+ roots.delete(allowedRetainedArtifactsRoot);
1810
1995
  }
1811
1996
  for (const pathname of roots) {
1812
1997
  try {
@@ -1827,75 +2012,85 @@ function cleanupRootsAbsent(
1827
2012
  if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed");
1828
2013
  return false;
1829
2014
  }
1830
- if (!includeTranscriptAuthority) return true;
1831
- const pendingDirectories = [transcriptParent];
1832
- const snapshots: Array<{ path: string; stat: fs.BigIntStats }> = [];
1833
- let entryCount = 0;
1834
- try {
1835
- while (pendingDirectories.length > 0) {
1836
- const directory = pendingDirectories.pop();
1837
- if (!directory) return false;
1838
- const before = fs.lstatSync(directory, { bigint: true });
1839
- if (!before.isDirectory()) return false;
1840
- if (
1841
- directory === transcriptParent &&
1842
- (before.dev !== pending.transcriptParentIdentity.dev || before.ino !== pending.transcriptParentIdentity.ino)
1843
- )
1844
- return false;
1845
- snapshots.push({ path: directory, stat: before });
1846
- const entries = fs.readdirSync(directory);
1847
- entryCount += entries.length;
1848
- if (entryCount > MANAGED_ARTIFACT_MAX_FILES) return false;
1849
- for (const name of entries) {
1850
- const pathname = path.join(directory, name);
1851
- let stat: fs.BigIntStats;
1852
- try {
1853
- stat = fs.lstatSync(pathname, { bigint: true });
1854
- } catch {
1855
- return false;
1856
- }
1857
- if (stat.dev === target.identity.dev && stat.ino === target.identity.ino) return false;
1858
- if (stat.isDirectory()) pendingDirectories.push(pathname);
1859
- }
1860
- }
1861
- for (const snapshot of snapshots) {
1862
- const after = fs.lstatSync(snapshot.path, { bigint: true });
1863
- if (
1864
- !after.isDirectory() ||
1865
- after.dev !== snapshot.stat.dev ||
1866
- after.ino !== snapshot.stat.ino ||
1867
- after.mtimeNs !== snapshot.stat.mtimeNs ||
1868
- after.ctimeNs !== snapshot.stat.ctimeNs
1869
- )
1870
- return false;
1871
- }
1872
- return true;
1873
- } catch {
1874
- return false;
2015
+ if (allowedRetainedArtifactsRoot) {
2016
+ assertRetainedArtifactsAuthority(pending);
2017
+ if (!retainedArtifactPayloadAbsent(allowedRetainedArtifactsRoot)) return false;
1875
2018
  }
2019
+ return true;
1876
2020
  }
1877
2021
 
1878
2022
  function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
1879
2023
  return left.dev === right.dev && left.ino === right.ino;
1880
2024
  }
1881
2025
 
1882
- function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
1883
- if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length)
1884
- return false;
1885
- const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
1886
- JSON.stringify([
1887
- entry.relativePath,
1888
- entry.kind,
1889
- entry.dev,
1890
- entry.ino,
1891
- entry.nlink,
1892
- entry.size,
1893
- entry.mtimeNs,
1894
- entry.sha256,
1895
- ]);
1896
- const leftEntries = left.entries.map(entryKey).sort();
1897
- const rightEntries = right.entries.map(entryKey).sort();
1898
- return leftEntries.every((entry, index) => entry === rightEntries[index]);
2026
+ type ManagedArtifactTreeEntry = NativeDirectoryTreeSnapshot["entries"][number];
2027
+
2028
+ function artifactTreeQuarantineName(entry: ManagedArtifactTreeEntry): string {
2029
+ const material = Buffer.concat([
2030
+ Buffer.from(entry.relativePath),
2031
+ Buffer.from([0]),
2032
+ Buffer.from(entry.dev),
2033
+ Buffer.from([0]),
2034
+ Buffer.from(entry.ino),
2035
+ ]);
2036
+ return `.pi-tree-detached-${createHash("sha256").update(material).digest("hex")}`;
2037
+ }
2038
+
2039
+ function artifactTreeReplayPathCompatible(
2040
+ observedPath: string,
2041
+ expectedPath: string,
2042
+ expectedByPath: ReadonlyMap<string, ManagedArtifactTreeEntry>,
2043
+ ): boolean {
2044
+ if (expectedPath === "") return observedPath === "";
2045
+ const observedParts = observedPath.split("/");
2046
+ const expectedParts = expectedPath.split("/");
2047
+ if (observedParts.length !== expectedParts.length) return false;
2048
+ for (let index = 0; index < expectedParts.length; index += 1) {
2049
+ const logicalPath = expectedParts.slice(0, index + 1).join("/");
2050
+ const logicalEntry = expectedByPath.get(logicalPath);
2051
+ if (!logicalEntry) return false;
2052
+ const observedPart = observedParts[index];
2053
+ if (observedPart !== expectedParts[index] && observedPart !== artifactTreeQuarantineName(logicalEntry))
2054
+ return false;
2055
+ }
2056
+ return true;
2057
+ }
2058
+
2059
+ export function artifactTreeReplayCompatible(
2060
+ observed: NativeDirectoryTreeSnapshot,
2061
+ expected: NativeDirectoryTreeSnapshot,
2062
+ ): boolean {
2063
+ if (observed.rootDev !== expected.rootDev || observed.rootIno !== expected.rootIno) return false;
2064
+ const emptyDigest = createHash("sha256").update("").digest("hex");
2065
+ const expectedByIdentity = new Map(
2066
+ expected.entries.map(entry => [JSON.stringify([entry.kind, entry.dev, entry.ino]), entry] as const),
2067
+ );
2068
+ const expectedByPath = new Map(expected.entries.map(entry => [entry.relativePath, entry] as const));
2069
+ const seen = new Set<string>();
2070
+ let rootObserved = false;
2071
+ const compatible = observed.entries.every(entry => {
2072
+ const key = JSON.stringify([entry.kind, entry.dev, entry.ino]);
2073
+ if (seen.has(key)) return false;
2074
+ seen.add(key);
2075
+ const original = expectedByIdentity.get(key);
2076
+ if (!original || !artifactTreeReplayPathCompatible(entry.relativePath, original.relativePath, expectedByPath))
2077
+ return false;
2078
+ if (entry.relativePath === "" && entry.kind === "directory") rootObserved = true;
2079
+ if (entry.kind === "directory") return true;
2080
+ return (
2081
+ (entry.size === original.size && entry.mtimeNs === original.mtimeNs && entry.sha256 === original.sha256) ||
2082
+ (entry.size === "0" && entry.sha256 === emptyDigest)
2083
+ );
2084
+ });
2085
+ return compatible && rootObserved;
2086
+ }
2087
+
2088
+ function artifactTreePayloadAbsent(snapshot: NativeDirectoryTreeSnapshot): boolean {
2089
+ const emptyDigest = createHash("sha256").update("").digest("hex");
2090
+ return snapshot.entries.every(
2091
+ entry =>
2092
+ entry.kind === "directory" || (entry.kind === "file" && entry.size === "0" && entry.sha256 === emptyDigest),
2093
+ );
1899
2094
  }
1900
2095
 
1901
2096
  function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
@@ -1908,10 +2103,15 @@ function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
1908
2103
  throw new Error("durability_failed");
1909
2104
  }
1910
2105
  const observed = artifactIdentityAt(pending.detachedArtifactsPath);
2106
+ const observedTree = snapshotArtifactTree(pending.detachedArtifactsPath);
1911
2107
  if (
1912
2108
  !observed ||
1913
- !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
1914
- !sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree)
2109
+ (pending.artifactsPayloadDurable === true
2110
+ ? observed.dev !== pending.expectedArtifactsIdentity.dev ||
2111
+ observed.ino !== pending.expectedArtifactsIdentity.ino ||
2112
+ !artifactTreePayloadAbsent(observedTree)
2113
+ : !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
2114
+ !artifactTreeReplayCompatible(observedTree, pending.expectedArtifactsTree))
1915
2115
  )
1916
2116
  throw new Error("binding_invalid");
1917
2117
  }
@@ -1921,27 +2121,77 @@ function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceip
1921
2121
 
1922
2122
  let detachedArtifactsPath = pending.detachedArtifactsPath;
1923
2123
  let detachedTranscriptPath = pending.detachedTranscriptPath;
1924
- for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
2124
+ const detachedCandidates = new Set<string>([
2125
+ pending.plannedArtifactsPath,
2126
+ deterministicRemovalRoot(pending.plannedArtifactsPath),
2127
+ ...(pending.detachedArtifactsPath ? [deterministicRemovalRoot(pending.detachedArtifactsPath)] : []),
2128
+ ]);
2129
+ for (const pathname of detachedCandidates) {
1925
2130
  if (!fs.existsSync(pathname)) continue;
1926
- if (!pending.expectedArtifactsIdentity) throw new Error("durability_failed");
2131
+ if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed");
1927
2132
  const observed = artifactIdentityAt(pathname);
1928
- if (!observed || !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity))
1929
- throw new Error("durability_failed");
1930
- if (detachedArtifactsPath && detachedArtifactsPath !== pathname) throw new Error("durability_failed");
1931
- detachedArtifactsPath = pathname;
1932
- }
1933
- if (fs.existsSync(pending.plannedTranscriptPath)) {
1934
- const observed = captureManagedFileNoFollow(pending.plannedTranscriptPath);
1935
- const digest = createHash("sha256").update(observed.bytes).digest("hex");
2133
+ const observedTree = snapshotArtifactTree(pathname);
1936
2134
  if (
1937
- observed.identity.dev !== target.identity.dev ||
1938
- observed.identity.ino !== target.identity.ino ||
1939
- observed.identity.size !== target.identity.size ||
1940
- observed.identity.mtimeNs !== target.identity.mtimeNs ||
1941
- digest !== target.identity.sha256
2135
+ !observed ||
2136
+ (pending.artifactsPayloadDurable === true
2137
+ ? observed.dev !== pending.expectedArtifactsIdentity.dev ||
2138
+ observed.ino !== pending.expectedArtifactsIdentity.ino ||
2139
+ !artifactTreePayloadAbsent(observedTree)
2140
+ : !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
2141
+ !artifactTreeReplayCompatible(observedTree, pending.expectedArtifactsTree))
1942
2142
  )
1943
2143
  throw new Error("durability_failed");
1944
- detachedTranscriptPath = pending.plannedTranscriptPath;
2144
+ if (detachedArtifactsPath && detachedArtifactsPath !== pathname && fs.existsSync(detachedArtifactsPath))
2145
+ throw new Error("durability_failed");
2146
+ detachedArtifactsPath = pathname;
2147
+ }
2148
+ if (pending.transcriptPayloadDurable === true) {
2149
+ for (const blocker of [pending.retainedTranscriptSuccessorPath, pending.retainedTranscriptUnknownPath]) {
2150
+ if (!blocker) continue;
2151
+ try {
2152
+ fs.lstatSync(blocker);
2153
+ throw new Error("durability_failed");
2154
+ } catch (error) {
2155
+ if ((error as Error).message === "durability_failed") throw error;
2156
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw new Error("durability_failed");
2157
+ }
2158
+ }
2159
+ for (const placeholder of [
2160
+ target.path,
2161
+ pending.plannedTranscriptPath,
2162
+ pending.detachedTranscriptPath,
2163
+ pending.retainedTranscriptPlaceholderPath,
2164
+ ].filter((pathname): pathname is string => typeof pathname === "string"))
2165
+ if (!reconcileScrubbedTranscriptPlaceholder(placeholder)) throw new Error("durability_failed");
2166
+ return { ...pending, detachedArtifactsPath, detachedTranscriptPath: undefined };
2167
+ }
2168
+ const retainedBlockers = [pending.retainedTranscriptSuccessorPath, pending.retainedTranscriptUnknownPath].filter(
2169
+ (pathname): pathname is string => typeof pathname === "string" && managedPathPresentNoFollow(pathname),
2170
+ );
2171
+ if (retainedBlockers.length > 0) throw new Error("durability_failed");
2172
+ const transcriptCandidates = [
2173
+ target.path,
2174
+ pending.plannedTranscriptPath,
2175
+ pending.detachedTranscriptPath,
2176
+ pending.retainedTranscriptPlaceholderPath,
2177
+ ].filter(
2178
+ (pathname, index, values): pathname is string =>
2179
+ typeof pathname === "string" && managedPathPresentNoFollow(pathname) && values.indexOf(pathname) === index,
2180
+ );
2181
+ const boundCandidates = transcriptCandidates.filter(pathname => transcriptRootMatchesTarget(pathname, target));
2182
+ if (boundCandidates.length > 1) throw new Error("durability_failed");
2183
+ if (boundCandidates.length === 1) {
2184
+ detachedTranscriptPath = boundCandidates[0] === target.path ? undefined : boundCandidates[0];
2185
+ } else if (transcriptCandidates.length > 0) {
2186
+ if (!transcriptCandidates.every(isScrubbedTranscriptPlaceholder)) throw new Error("durability_failed");
2187
+ for (const pathname of transcriptCandidates)
2188
+ if (!reconcileScrubbedTranscriptPlaceholder(pathname)) throw new Error("durability_failed");
2189
+ return {
2190
+ ...pending,
2191
+ detachedArtifactsPath,
2192
+ detachedTranscriptPath: undefined,
2193
+ transcriptPayloadDurable: true,
2194
+ };
1945
2195
  }
1946
2196
  return { ...pending, detachedArtifactsPath, detachedTranscriptPath };
1947
2197
  }
@@ -1974,7 +2224,6 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und
1974
2224
  (item.kind === "file" || item.kind === "directory") &&
1975
2225
  typeof item.dev === "string" &&
1976
2226
  typeof item.ino === "string" &&
1977
- typeof item.nlink === "string" &&
1978
2227
  typeof item.size === "string" &&
1979
2228
  typeof item.mtimeNs === "string" &&
1980
2229
  typeof item.ctimeNs === "string" &&
@@ -2022,14 +2271,6 @@ function pendingCleanupReceipt(
2022
2271
  const attempt = record.attempt;
2023
2272
  const recorded = record.target as Record<string, unknown> | undefined;
2024
2273
  const identity = recorded?.identity as Record<string, unknown> | undefined;
2025
- const transcriptParentIdentity = record.transcriptParentIdentity as Record<string, unknown> | undefined;
2026
- if (
2027
- typeof transcriptParentIdentity?.dev === "string" &&
2028
- typeof transcriptParentIdentity?.ino === "string" &&
2029
- (transcriptParentIdentity.dev !== target.transcriptParentIdentity.dev.toString() ||
2030
- transcriptParentIdentity.ino !== target.transcriptParentIdentity.ino.toString())
2031
- )
2032
- throw new Error("durability_failed");
2033
2274
  if (
2034
2275
  record.schemaVersion !== 2 ||
2035
2276
  record.state !== "cleanup_pending" ||
@@ -2043,20 +2284,9 @@ function pendingCleanupReceipt(
2043
2284
  recorded.cwd !== target.cwd ||
2044
2285
  identity?.dev !== String(target.identity.dev) ||
2045
2286
  identity.ino !== String(target.identity.ino) ||
2046
- identity.nlink !== String(target.identity.nlink) ||
2047
2287
  identity.size !== target.identity.size ||
2048
2288
  identity.mtimeNs !== String(target.identity.mtimeNs) ||
2049
2289
  identity.sha256 !== target.identity.sha256 ||
2050
- !transcriptParentIdentity ||
2051
- typeof transcriptParentIdentity.dev !== "string" ||
2052
- typeof transcriptParentIdentity.ino !== "string" ||
2053
- !/^[0-9]+$/.test(transcriptParentIdentity.dev) ||
2054
- !/^[0-9]+$/.test(transcriptParentIdentity.ino) ||
2055
- transcriptParentIdentity.dev !== target.transcriptParentIdentity.dev.toString() ||
2056
- transcriptParentIdentity.ino !== target.transcriptParentIdentity.ino.toString() ||
2057
- (latest !== undefined &&
2058
- (transcriptParentIdentity.dev !== latest.transcriptParentIdentity.dev.toString() ||
2059
- transcriptParentIdentity.ino !== latest.transcriptParentIdentity.ino.toString())) ||
2060
2290
  !isQuarantinePath(target, record.plannedArtifactsPath) ||
2061
2291
  !isQuarantinePath(target, record.plannedTranscriptPath) ||
2062
2292
  record.plannedArtifactsPath === record.plannedTranscriptPath ||
@@ -2075,11 +2305,16 @@ function pendingCleanupReceipt(
2075
2305
  (record.retainedTranscriptPlaceholderPath !== undefined &&
2076
2306
  !isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) ||
2077
2307
  (record.retainedTranscriptUnknownPath !== undefined &&
2078
- !isRetainedNativePath(target, record.retainedTranscriptUnknownPath))
2308
+ !isRetainedNativePath(target, record.retainedTranscriptUnknownPath)) ||
2309
+ (record.artifactsPayloadDurable !== undefined && record.artifactsPayloadDurable !== true) ||
2310
+ (record.artifactsRemovedAttempt !== undefined &&
2311
+ (typeof record.artifactsRemovedAttempt !== "number" ||
2312
+ !Number.isSafeInteger(record.artifactsRemovedAttempt) ||
2313
+ record.artifactsRemovedAttempt < 1 ||
2314
+ record.artifactsRemovedAttempt > (attempt as number))) ||
2315
+ (record.transcriptPayloadDurable !== undefined && record.transcriptPayloadDurable !== true)
2079
2316
  )
2080
2317
  throw new Error("durability_failed");
2081
- if (record.artifactsAbsentAtAuthorization !== undefined && record.artifactsAbsentAtAuthorization !== true)
2082
- throw new Error("durability_failed");
2083
2318
  const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
2084
2319
  const expectedArtifactsIdentity = artifact
2085
2320
  ? typeof artifact.dev === "string" &&
@@ -2097,8 +2332,6 @@ function pendingCleanupReceipt(
2097
2332
  : undefined
2098
2333
  : undefined;
2099
2334
  if (artifact && !expectedArtifactsIdentity) throw new Error("durability_failed");
2100
- if (record.artifactsAbsentAtAuthorization === true && expectedArtifactsIdentity)
2101
- throw new Error("durability_failed");
2102
2335
  const expectedArtifactsTree =
2103
2336
  record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
2104
2337
  if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
@@ -2117,15 +2350,13 @@ function pendingCleanupReceipt(
2117
2350
  latest = {
2118
2351
  attempt,
2119
2352
  target,
2120
- transcriptParentIdentity: {
2121
- dev: BigInt(transcriptParentIdentity.dev as string),
2122
- ino: BigInt(transcriptParentIdentity.ino as string),
2123
- },
2124
2353
  expectedArtifactsIdentity,
2125
- artifactsAbsentAtAuthorization: record.artifactsAbsentAtAuthorization === true ? true : undefined,
2126
2354
  expectedArtifactsTree,
2355
+ artifactsPayloadDurable: record.artifactsPayloadDurable === true ? true : undefined,
2356
+ artifactsRemovedAttempt: record.artifactsRemovedAttempt as number | undefined,
2127
2357
  detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
2128
2358
  detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
2359
+ transcriptPayloadDurable: record.transcriptPayloadDurable === true ? true : undefined,
2129
2360
  retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined,
2130
2361
  retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined,
2131
2362
  retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined,
@@ -2147,13 +2378,7 @@ function artifactIdentityForCleanup(target: RetiredTarget): SessionStorageFileId
2147
2378
  try {
2148
2379
  const stat = fs.lstatSync(target.path.slice(0, -6), { bigint: true });
2149
2380
  if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("unsafe_artifacts");
2150
- return {
2151
- dev: stat.dev,
2152
- ino: stat.ino,
2153
- size: Number(stat.size),
2154
- mtimeNs: stat.mtimeNs,
2155
- sha256: "",
2156
- };
2381
+ return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs, sha256: "" };
2157
2382
  } catch (error) {
2158
2383
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
2159
2384
  throw error;
@@ -2172,10 +2397,18 @@ function snapshotArtifactTree(pathname: string): NativeDirectoryTreeSnapshot {
2172
2397
  return result.snapshot;
2173
2398
  }
2174
2399
 
2400
+ function retainedArtifactPayloadAbsent(pathname: string): boolean {
2401
+ try {
2402
+ return artifactTreePayloadAbsent(snapshotArtifactTree(pathname));
2403
+ } catch (error) {
2404
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
2405
+ throw error;
2406
+ }
2407
+ }
2408
+
2175
2409
  function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | undefined): CleanupReceipt {
2176
2410
  const attempt = (pending?.attempt ?? 0) + 1;
2177
2411
  const directory = path.dirname(target.path);
2178
- const transcriptParentIdentity = pending?.transcriptParentIdentity ?? target.transcriptParentIdentity;
2179
2412
  const operation = stableOperationName(target);
2180
2413
  const expectedArtifactsIdentity = pending?.expectedArtifactsIdentity ?? artifactIdentityForCleanup(target);
2181
2414
  const expectedArtifactsTree =
@@ -2185,16 +2418,16 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und
2185
2418
  return {
2186
2419
  attempt,
2187
2420
  target,
2188
- transcriptParentIdentity,
2189
2421
  expectedArtifactsIdentity,
2190
- artifactsAbsentAtAuthorization:
2191
- pending?.artifactsAbsentAtAuthorization ?? (expectedArtifactsIdentity ? undefined : true),
2192
2422
  expectedArtifactsTree,
2423
+ artifactsPayloadDurable: pending?.artifactsPayloadDurable,
2424
+ artifactsRemovedAttempt: pending?.artifactsRemovedAttempt,
2193
2425
  detachedArtifactsPath: pending?.detachedArtifactsPath,
2194
2426
  retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath,
2195
2427
  retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath,
2196
2428
  retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath,
2197
2429
  detachedTranscriptPath: pending?.detachedTranscriptPath,
2430
+ transcriptPayloadDurable: pending?.transcriptPayloadDurable,
2198
2431
  retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath,
2199
2432
  retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath,
2200
2433
  retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath,
@@ -2221,10 +2454,22 @@ function cleanupPendingEvidence(
2221
2454
  expectedArtifactsIdentity:
2222
2455
  deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2223
2456
  expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2457
+ artifactsPayloadDurable:
2458
+ deletion.phase === "artifacts"
2459
+ ? deletion.artifactsPayloadDurable
2460
+ ? true
2461
+ : undefined
2462
+ : active.artifactsPayloadDurable,
2224
2463
  detachedArtifactsPath:
2225
2464
  deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2226
2465
  detachedTranscriptPath:
2227
2466
  deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2467
+ transcriptPayloadDurable:
2468
+ deletion.phase === "transcript"
2469
+ ? deletion.transcriptPayloadDurable
2470
+ ? true
2471
+ : undefined
2472
+ : active.transcriptPayloadDurable,
2228
2473
  retainedArtifactsSuccessorPath:
2229
2474
  deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath,
2230
2475
  retainedArtifactsPlaceholderPath:
@@ -2240,6 +2485,57 @@ function cleanupPendingEvidence(
2240
2485
  };
2241
2486
  }
2242
2487
 
2488
+ async function continueDetachedArtifactCleanup(
2489
+ scope: ManagedScope,
2490
+ tombstone: string,
2491
+ target: RetiredTarget,
2492
+ pendingEvidence: CleanupReceipt,
2493
+ fallbackDetachedTranscriptPath: string | undefined,
2494
+ lock: ManagedStorageLock,
2495
+ flow: ManagedVerifiedDeleteTestEvent["flow"],
2496
+ ): Promise<{ deletion: VerifiedSessionDeleteResult; pendingEvidence: CleanupReceipt }> {
2497
+ const deletion = await deleteSessionVerifiedWithFence(flow, "artifact-finalization", lock, {
2498
+ sessionsRoot: scope.sessionsRoot,
2499
+ transcriptPath: target.path,
2500
+ sessionId: target.sessionId,
2501
+ cwd: target.cwd,
2502
+ transcriptIdentity: target.identity,
2503
+ transcriptParentIdentity: (() => {
2504
+ const parent = fs.lstatSync(path.dirname(target.path), { bigint: true });
2505
+ return { dev: parent.dev, ino: parent.ino };
2506
+ })(),
2507
+ expectedArtifactsIdentity: pendingEvidence.expectedArtifactsIdentity,
2508
+ expectedArtifactsTree: pendingEvidence.expectedArtifactsTree,
2509
+ detachedArtifactsPath: pendingEvidence.detachedArtifactsPath,
2510
+ retainedArtifactsSuccessorPath: pendingEvidence.retainedArtifactsSuccessorPath,
2511
+ retainedArtifactsPlaceholderPath: pendingEvidence.retainedArtifactsPlaceholderPath,
2512
+ retainedArtifactsUnknownPath: pendingEvidence.retainedArtifactsUnknownPath,
2513
+ detachedTranscriptPath: pendingEvidence.detachedTranscriptPath ?? fallbackDetachedTranscriptPath,
2514
+ retainedTranscriptSuccessorPath: pendingEvidence.retainedTranscriptSuccessorPath,
2515
+ retainedTranscriptPlaceholderPath: pendingEvidence.retainedTranscriptPlaceholderPath,
2516
+ retainedTranscriptUnknownPath: pendingEvidence.retainedTranscriptUnknownPath,
2517
+ plannedArtifactsPath: pendingEvidence.plannedArtifactsPath,
2518
+ plannedTranscriptPath: pendingEvidence.plannedTranscriptPath,
2519
+ });
2520
+ if (deletion.kind === "cleanup_pending") {
2521
+ if (
2522
+ deletion.phase !== "artifacts" ||
2523
+ !isAuthorizedArtifactRoot(
2524
+ target,
2525
+ pendingEvidence.detachedArtifactsPath ?? pendingEvidence.plannedArtifactsPath,
2526
+ deletion.detachedArtifactsPath,
2527
+ )
2528
+ )
2529
+ throw new Error("durability_failed");
2530
+ const followup = nextCleanupReceipt(target, pendingEvidence);
2531
+ pendingEvidence = cleanupPendingEvidence(followup, pendingEvidence, deletion);
2532
+ await publishCleanupPending(scope, tombstone, pendingEvidence, lock);
2533
+ } else if (deletion.kind !== "artifacts_removed") {
2534
+ throw new Error("durability_failed");
2535
+ }
2536
+ return { deletion, pendingEvidence };
2537
+ }
2538
+
2243
2539
  async function publishCleanupPending(
2244
2540
  scope: ManagedScope,
2245
2541
  tombstone: string,
@@ -2477,7 +2773,7 @@ function manifestContains(transcriptPath: string, manifest: readonly ArtifactMan
2477
2773
  type DetachedArtifactRoot = {
2478
2774
  originalPath: string;
2479
2775
  detachedPath: string;
2480
- identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint };
2776
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; parentDev: bigint; parentIno: bigint };
2481
2777
  tree: NativeDirectoryTreeSnapshot;
2482
2778
  };
2483
2779
 
@@ -2494,6 +2790,7 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
2494
2790
  const tree = snapshotArtifactTree(originalPath);
2495
2791
  const root = tree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2496
2792
  if (!root) throw new Error("unsafe_artifacts");
2793
+ const parent = fs.lstatSync(path.dirname(originalPath), { bigint: true });
2497
2794
  return {
2498
2795
  originalPath,
2499
2796
  detachedPath: path.join(path.dirname(originalPath), `.gjc-migrate-${operation}-artifacts`),
@@ -2502,6 +2799,8 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
2502
2799
  ino: stat.ino,
2503
2800
  size: process.platform === "win32" ? BigInt(root.size) : stat.size,
2504
2801
  mtimeNs: process.platform === "win32" ? BigInt(root.mtimeNs) : stat.mtimeNs,
2802
+ parentDev: parent.dev,
2803
+ parentIno: parent.ino,
2505
2804
  },
2506
2805
  tree,
2507
2806
  };
@@ -2581,7 +2880,6 @@ function sameArtifactTree(
2581
2880
  entry.kind,
2582
2881
  entry.dev,
2583
2882
  entry.ino,
2584
- entry.nlink,
2585
2883
  entry.size,
2586
2884
  entry.mtimeNs,
2587
2885
  entry.ctimeNs,
@@ -2620,12 +2918,15 @@ export function cleanupAuthorityMatches(
2620
2918
  ): boolean {
2621
2919
  try {
2622
2920
  const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true });
2921
+ const parentStat = fs.lstatSync(parent, { bigint: true });
2623
2922
  if (
2624
2923
  path.dirname(cleanup.retainedPath) !== parent ||
2625
2924
  !stat.isDirectory() ||
2626
2925
  stat.isSymbolicLink() ||
2627
2926
  stat.dev !== cleanup.identity.dev ||
2628
- stat.ino !== cleanup.identity.ino
2927
+ stat.ino !== cleanup.identity.ino ||
2928
+ parentStat.dev !== cleanup.identity.parentDev ||
2929
+ parentStat.ino !== cleanup.identity.parentIno
2629
2930
  )
2630
2931
  return false;
2631
2932
  const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath);
@@ -2701,6 +3002,7 @@ export function detachArtifactRootForMigration(
2701
3002
  entry => entry.relativePath === "" && entry.kind === "directory",
2702
3003
  );
2703
3004
  if (!placeholderRoot) throw new Error("durability_failed");
3005
+ const parent = fs.lstatSync(path.dirname(placeholder), { bigint: true });
2704
3006
  const cleanup: SourceArtifactCleanup = {
2705
3007
  state: "cleanup_pending",
2706
3008
  role: "exchange_placeholder",
@@ -2710,6 +3012,8 @@ export function detachArtifactRootForMigration(
2710
3012
  ino: stat.ino,
2711
3013
  size: platform === "win32" ? BigInt(placeholderRoot.size) : stat.size,
2712
3014
  mtimeNs: platform === "win32" ? BigInt(placeholderRoot.mtimeNs) : stat.mtimeNs,
3015
+ parentDev: parent.dev,
3016
+ parentIno: parent.ino,
2713
3017
  },
2714
3018
  tree: snapshot.snapshot,
2715
3019
  };
@@ -2762,7 +3066,9 @@ export function restorePreparedArtifactRoot(
2762
3066
  typeof identity.dev !== "string" ||
2763
3067
  typeof identity.ino !== "string" ||
2764
3068
  typeof identity.size !== "string" ||
2765
- typeof identity.mtimeNs !== "string"
3069
+ typeof identity.mtimeNs !== "string" ||
3070
+ typeof identity.parentDev !== "string" ||
3071
+ typeof identity.parentIno !== "string"
2766
3072
  )
2767
3073
  throw new Error("durability_failed");
2768
3074
  const artifactIdentity = {
@@ -2770,6 +3076,8 @@ export function restorePreparedArtifactRoot(
2770
3076
  ino: BigInt(identity.ino),
2771
3077
  size: BigInt(identity.size),
2772
3078
  mtimeNs: BigInt(identity.mtimeNs),
3079
+ parentDev: BigInt(identity.parentDev),
3080
+ parentIno: BigInt(identity.parentIno),
2773
3081
  };
2774
3082
  const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
2775
3083
  const assertPreparedTree = (pathname: string): void => {
@@ -2814,6 +3122,8 @@ export function restorePreparedArtifactRoot(
2814
3122
  typeof cleanupIdentity.ino !== "string" ||
2815
3123
  typeof cleanupIdentity.size !== "string" ||
2816
3124
  typeof cleanupIdentity.mtimeNs !== "string" ||
3125
+ typeof cleanupIdentity.parentDev !== "string" ||
3126
+ typeof cleanupIdentity.parentIno !== "string" ||
2817
3127
  !cleanupTree ||
2818
3128
  !cleanupAuthorityMatches(
2819
3129
  {
@@ -2825,6 +3135,8 @@ export function restorePreparedArtifactRoot(
2825
3135
  ino: BigInt(cleanupIdentity.ino),
2826
3136
  size: BigInt(cleanupIdentity.size),
2827
3137
  mtimeNs: BigInt(cleanupIdentity.mtimeNs),
3138
+ parentDev: BigInt(cleanupIdentity.parentDev),
3139
+ parentIno: BigInt(cleanupIdentity.parentIno),
2828
3140
  },
2829
3141
  tree: cleanupTree,
2830
3142
  },
@@ -2858,7 +3170,7 @@ export function restorePreparedArtifactRoot(
2858
3170
  ...artifactIdentity,
2859
3171
  directory: true,
2860
3172
  });
2861
- if (!result.ok) throw new Error("durability_failed");
3173
+ if (!result.ok && result.code !== "cleanup_pending") throw new Error("durability_failed");
2862
3174
  }
2863
3175
 
2864
3176
  function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void {
@@ -2868,7 +3180,7 @@ function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: S
2868
3180
  ...detached.identity,
2869
3181
  directory: true,
2870
3182
  });
2871
- if (!result.ok) throw new Error("durability_failed");
3183
+ if (!result.ok && result.code !== "cleanup_pending") throw new Error("durability_failed");
2872
3184
  }
2873
3185
 
2874
3186
  async function copyArtifacts(
@@ -2972,14 +3284,19 @@ function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean
2972
3284
  }
2973
3285
 
2974
3286
  async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
3287
+ const authority = boundManagedWriteAuthorities.get(scope);
3288
+ const rootAuthority = authority?.rootAuthority ?? scopeRoot(scope);
3289
+ const store = authority?.retainedAuthority
3290
+ ? new ManagedSessionDescendantStore(rootAuthority, scope.directoryPath, {
3291
+ authority: authority.retainedAuthority,
3292
+ authorityBaseDir: scope.directoryPath,
3293
+ })
3294
+ : new ManagedSessionDescendantStore(rootAuthority, scope.directoryPath);
2975
3295
  for (const state of ["prepared", "detached", "published"] as const) {
2976
3296
  const pathname = receiptPathFor(scope, candidate, state);
2977
3297
  try {
2978
- const stat = fs.lstatSync(pathname);
2979
- if (stat.isFile() || stat.isSymbolicLink()) {
2980
- await fs.promises.unlink(pathname);
2981
- fsyncManagedParent(pathname);
2982
- }
3298
+ const snapshot = captureManagedFileNoFollow(pathname);
3299
+ store.removeExpected(path.relative(scope.directoryPath, pathname), snapshot);
2983
3300
  } catch (error) {
2984
3301
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2985
3302
  }
@@ -3060,12 +3377,31 @@ export async function reconcileManagedTombstones(
3060
3377
  fs.lstatSync(target.path);
3061
3378
  } catch (error) {
3062
3379
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
3063
- if (!observedPending) continue;
3380
+ const replayReceipt = observedPending ?? nextCleanupReceipt(target, pending);
3381
+ const artifactsEvidence = cleanupArtifactsRemovedEvidence(
3382
+ scope,
3383
+ tombstone,
3384
+ target,
3385
+ pending?.artifactsRemovedAttempt ??
3386
+ replayReceipt.artifactsRemovedAttempt ??
3387
+ pending?.attempt ??
3388
+ replayReceipt.attempt,
3389
+ );
3064
3390
  if (
3065
- cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
3066
- cleanupRootsAbsent(tombstone, target, observedPending)
3067
- )
3391
+ artifactsEvidence &&
3392
+ cleanupRootsAbsent(
3393
+ tombstone,
3394
+ target,
3395
+ replayReceipt,
3396
+ artifactsEvidence.retainedArtifactsRootPath,
3397
+ )
3398
+ ) {
3399
+ fsyncManagedParent(target.path);
3400
+ await publishCleanupCompleted(scope, tombstone, target, lock);
3068
3401
  continue;
3402
+ }
3403
+ if (!observedPending) continue;
3404
+ if (!observedPending.detachedTranscriptPath) continue;
3069
3405
  } else throw error;
3070
3406
  }
3071
3407
  const verified = observedPending ? target : validateCandidateForScope(scope, target);
@@ -3074,22 +3410,27 @@ export async function reconcileManagedTombstones(
3074
3410
  !!observedPending &&
3075
3411
  (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
3076
3412
  observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
3077
- const active =
3413
+ let active =
3078
3414
  discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
3079
3415
  ? nextCleanupReceipt(target, observedPending)
3080
3416
  : (observedPending ?? nextCleanupReceipt(target, undefined));
3081
3417
  if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
3082
3418
  await publishCleanupPending(scope, tombstone, active, lock);
3083
- if (!cleanupTranscriptParentMatches(target, active)) continue;
3084
- let deletion = await new FileSessionStorage().deleteSessionVerified({
3419
+ const initialTarget = observedPending?.detachedTranscriptPath
3420
+ ? target
3421
+ : validateCandidateForScope(scope, target);
3422
+ if (!initialTarget) throw new Error("source_changed");
3423
+ let deletion = await deleteSessionVerifiedWithFence("reconcile", "initial", lock, {
3085
3424
  sessionsRoot: scope.sessionsRoot,
3086
3425
  transcriptPath: target.path,
3087
3426
  sessionId: target.sessionId,
3088
3427
  cwd: target.cwd,
3089
- transcriptIdentity: target.identity,
3090
- transcriptParentIdentity: target.transcriptParentIdentity,
3428
+ transcriptIdentity: {
3429
+ ...initialTarget.identity,
3430
+ nlink: fs.lstatSync(observedPending?.detachedTranscriptPath ?? target.path, { bigint: true })
3431
+ .nlink,
3432
+ },
3091
3433
  expectedArtifactsIdentity: active.expectedArtifactsIdentity,
3092
- artifactsAbsentAtAuthorization: active.artifactsAbsentAtAuthorization,
3093
3434
  expectedArtifactsTree: active.expectedArtifactsTree,
3094
3435
  detachedArtifactsPath:
3095
3436
  active.detachedArtifactsPath ??
@@ -3110,85 +3451,156 @@ export async function reconcileManagedTombstones(
3110
3451
  retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3111
3452
  plannedArtifactsPath: active.plannedArtifactsPath,
3112
3453
  plannedTranscriptPath: active.plannedTranscriptPath,
3113
- ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
3454
+ ...(cleanupArtifactsRemoved(
3455
+ scope,
3456
+ tombstone,
3457
+ target,
3458
+ pending?.artifactsRemovedAttempt ??
3459
+ active.artifactsRemovedAttempt ??
3460
+ pending?.attempt ??
3461
+ active.attempt,
3462
+ )
3114
3463
  ? { artifactsRemoved: true as const }
3115
3464
  : {}),
3116
3465
  });
3117
3466
  if (deletion.kind === "artifacts_removed") {
3118
- if (!cleanupRootsAbsent(tombstone, target, active, false)) continue;
3119
- if (!cleanupTranscriptParentMatches(target, active)) continue;
3120
3467
  await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
3121
- deletion = await new FileSessionStorage().deleteSessionVerified({
3122
- sessionsRoot: scope.sessionsRoot,
3123
- transcriptPath: target.path,
3124
- sessionId: target.sessionId,
3125
- cwd: target.cwd,
3126
- transcriptIdentity: target.identity,
3127
- transcriptParentIdentity: target.transcriptParentIdentity,
3128
- plannedArtifactsPath: active.plannedArtifactsPath,
3129
- plannedTranscriptPath: active.plannedTranscriptPath,
3130
- detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3131
-
3132
- retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3133
- retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3134
- retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3135
- retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3136
- retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3137
- retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3138
- artifactsRemoved: true,
3139
- });
3140
- }
3141
- if (deletion.kind === "cleanup_pending") {
3142
- const retry = nextCleanupReceipt(target, active);
3143
- await publishCleanupPending(
3144
- scope,
3145
- tombstone,
3468
+ active = { ...active, artifactsRemovedAttempt: active.attempt };
3469
+ const refreshedTarget = validateCandidateForScope(scope, target);
3470
+ if (!refreshedTarget) throw new Error("source_changed");
3471
+ deletion = await deleteSessionVerifiedWithFence(
3472
+ "reconcile",
3473
+ "transcript-after-artifacts-removed",
3474
+ lock,
3146
3475
  {
3147
- ...retry,
3148
- expectedArtifactsIdentity:
3149
- deletion.phase === "artifacts"
3150
- ? deletion.artifactsIdentity
3151
- : active.expectedArtifactsIdentity,
3152
- expectedArtifactsTree:
3153
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
3154
- detachedArtifactsPath:
3155
- deletion.phase === "artifacts"
3156
- ? deletion.detachedArtifactsPath
3157
- : active.detachedArtifactsPath,
3476
+ sessionsRoot: scope.sessionsRoot,
3477
+ transcriptPath: target.path,
3478
+ sessionId: target.sessionId,
3479
+ cwd: target.cwd,
3480
+ transcriptIdentity: refreshedTarget.identity,
3481
+ plannedArtifactsPath: active.plannedArtifactsPath,
3482
+ plannedTranscriptPath: active.plannedTranscriptPath,
3158
3483
  detachedTranscriptPath:
3159
- deletion.phase === "transcript"
3160
- ? deletion.detachedTranscriptPath
3161
- : active.detachedTranscriptPath,
3162
- retainedArtifactsSuccessorPath:
3163
- deletion.phase === "artifacts"
3164
- ? deletion.retainedSuccessorPath
3165
- : active.retainedArtifactsSuccessorPath,
3166
- retainedArtifactsPlaceholderPath:
3167
- deletion.phase === "artifacts"
3168
- ? deletion.retainedPlaceholderPath
3169
- : active.retainedArtifactsPlaceholderPath,
3170
- retainedArtifactsUnknownPath:
3171
- deletion.phase === "artifacts"
3172
- ? deletion.retainedUnknownPath
3173
- : active.retainedArtifactsUnknownPath,
3174
- retainedTranscriptSuccessorPath:
3175
- deletion.phase === "transcript"
3176
- ? deletion.retainedSuccessorPath
3177
- : active.retainedTranscriptSuccessorPath,
3178
- retainedTranscriptPlaceholderPath:
3179
- deletion.phase === "transcript"
3180
- ? deletion.retainedPlaceholderPath
3181
- : active.retainedTranscriptPlaceholderPath,
3182
- retainedTranscriptUnknownPath:
3183
- deletion.phase === "transcript"
3184
- ? deletion.retainedUnknownPath
3185
- : active.retainedTranscriptUnknownPath,
3484
+ active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3485
+
3486
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3487
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3488
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3489
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3490
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3491
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3492
+ artifactsRemoved: true,
3186
3493
  },
3187
- lock,
3188
3494
  );
3189
- continue;
3190
3495
  }
3191
- if (!cleanupRootsAbsent(tombstone, target, active)) continue;
3496
+ if (deletion.kind === "cleanup_pending") {
3497
+ assertAuthorizedCleanupPending(target, active, deletion);
3498
+ const retry = nextCleanupReceipt(target, active);
3499
+ let pendingEvidence = cleanupPendingEvidence(retry, active, deletion);
3500
+ await publishCleanupPending(scope, tombstone, pendingEvidence, lock);
3501
+ if (deletion.phase === "artifacts") {
3502
+ if (
3503
+ deletion.detachedArtifactsPath === active.plannedArtifactsPath &&
3504
+ !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath)
3505
+ ) {
3506
+ ({ deletion, pendingEvidence } = await continueDetachedArtifactCleanup(
3507
+ scope,
3508
+ tombstone,
3509
+ target,
3510
+ pendingEvidence,
3511
+ observedPending?.detachedTranscriptPath,
3512
+ lock,
3513
+ "reconcile",
3514
+ ));
3515
+ }
3516
+ if (
3517
+ deletion.kind === "cleanup_pending" &&
3518
+ deletion.phase === "artifacts" &&
3519
+ (deletion.artifactsPayloadDurable !== true ||
3520
+ !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath))
3521
+ )
3522
+ continue;
3523
+ await publishCleanupArtifactsRemoved(scope, tombstone, pendingEvidence, lock);
3524
+ pendingEvidence = { ...pendingEvidence, artifactsRemovedAttempt: pendingEvidence.attempt };
3525
+ const retainedProof = cleanupArtifactsRemovedReceipt(
3526
+ tombstone,
3527
+ target,
3528
+ pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt,
3529
+ );
3530
+ if (!retainedProof) throw new Error("durability_failed");
3531
+ deletion = await deleteSessionVerifiedWithFence(
3532
+ "reconcile",
3533
+ "transcript-after-artifacts-removed",
3534
+ lock,
3535
+ {
3536
+ sessionsRoot: scope.sessionsRoot,
3537
+ transcriptPath: target.path,
3538
+ sessionId: target.sessionId,
3539
+ cwd: target.cwd,
3540
+ transcriptIdentity: {
3541
+ ...target.identity,
3542
+ nlink: fs.lstatSync(target.path, { bigint: true }).nlink,
3543
+ },
3544
+ plannedArtifactsPath: pendingEvidence.plannedArtifactsPath,
3545
+ plannedTranscriptPath: pendingEvidence.plannedTranscriptPath,
3546
+ detachedTranscriptPath:
3547
+ pendingEvidence.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3548
+ expectedArtifactsIdentity: retainedProof.identity,
3549
+ expectedArtifactsTree: retainedProof.tree,
3550
+ detachedArtifactsPath: retainedProof.path,
3551
+ retainedArtifactsSuccessorPath: pendingEvidence.retainedArtifactsSuccessorPath,
3552
+ retainedArtifactsPlaceholderPath: pendingEvidence.retainedArtifactsPlaceholderPath,
3553
+ retainedArtifactsUnknownPath: pendingEvidence.retainedArtifactsUnknownPath,
3554
+ retainedTranscriptSuccessorPath: pendingEvidence.retainedTranscriptSuccessorPath,
3555
+ retainedTranscriptPlaceholderPath: pendingEvidence.retainedTranscriptPlaceholderPath,
3556
+ retainedTranscriptUnknownPath: pendingEvidence.retainedTranscriptUnknownPath,
3557
+ artifactsRemoved: true,
3558
+ },
3559
+ () => {
3560
+ if (
3561
+ !cleanupArtifactsRemoved(
3562
+ scope,
3563
+ tombstone,
3564
+ target,
3565
+ pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt,
3566
+ )
3567
+ )
3568
+ throw new Error("durability_failed");
3569
+ },
3570
+ );
3571
+ if (deletion.kind === "cleanup_pending") {
3572
+ if (
3573
+ deletion.phase !== "transcript" ||
3574
+ (deletion.detachedTranscriptPath !== pendingEvidence.plannedTranscriptPath &&
3575
+ deletion.transcriptPayloadDurable !== true)
3576
+ )
3577
+ throw new Error("durability_failed");
3578
+ const followup = nextCleanupReceipt(target, pendingEvidence);
3579
+ pendingEvidence = cleanupPendingEvidence(followup, pendingEvidence, deletion);
3580
+ await publishCleanupPending(scope, tombstone, pendingEvidence, lock);
3581
+ }
3582
+ if (deletion.kind === "deleted" && fs.existsSync(pendingEvidence.plannedTranscriptPath)) {
3583
+ if (!reconcileScrubbedTranscriptPlaceholder(pendingEvidence.plannedTranscriptPath))
3584
+ throw new Error("durability_failed");
3585
+ }
3586
+ }
3587
+ if (
3588
+ deletion.kind === "cleanup_pending" &&
3589
+ (deletion.phase !== "transcript" ||
3590
+ deletion.transcriptPayloadDurable !== true ||
3591
+ !cleanupRootsAbsent(
3592
+ tombstone,
3593
+ target,
3594
+ pendingEvidence,
3595
+ pendingEvidence.artifactsPayloadDurable === true &&
3596
+ pendingEvidence.detachedArtifactsPath &&
3597
+ retainedArtifactPayloadAbsent(pendingEvidence.detachedArtifactsPath)
3598
+ ? pendingEvidence.detachedArtifactsPath
3599
+ : undefined,
3600
+ ))
3601
+ )
3602
+ continue;
3603
+ }
3192
3604
  fsyncManagedParent(target.path);
3193
3605
  await publishCleanupCompleted(scope, tombstone, target, lock);
3194
3606
  } catch (error) {
@@ -3201,7 +3613,7 @@ export async function reconcileManagedTombstones(
3201
3613
  }
3202
3614
  }
3203
3615
  } finally {
3204
- if (lock) await lock.release().catch(() => undefined);
3616
+ if (lock) await lock.release();
3205
3617
  }
3206
3618
  }
3207
3619
  }
@@ -3230,31 +3642,27 @@ export async function prepareManagedSessionScopeForWrite(
3230
3642
  } catch (error) {
3231
3643
  const publication = error instanceof ManagedPublishError ? error : undefined;
3232
3644
  const message =
3233
- publication?.classification ??
3234
- (error instanceof Error ? error.message : "Managed write protocol setup failed.");
3645
+ publication?.classification ?? managedScopeFailureMessage(error, "Managed write protocol setup failed.");
3235
3646
  const code =
3236
3647
  message === "atomic_unavailable" ||
3237
3648
  message === "invalid_request" ||
3238
3649
  message === "durability_failed" ||
3239
- message === "durability_not_provable"
3650
+ message === "durability_not_provable" ||
3651
+ message === "migration_busy"
3240
3652
  ? message
3241
3653
  : "binding_invalid";
3242
3654
  return {
3243
3655
  kind: "error",
3244
3656
  code,
3245
3657
  message,
3246
- ...(publication
3247
- ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
3248
- : { cause: { classification: code } }),
3658
+ cause: publication
3659
+ ? { classification: publication.classification, diagnostic: publication.diagnostic }
3660
+ : managedScopeFailureCause(error),
3249
3661
  };
3250
3662
  }
3251
3663
  }
3252
3664
 
3253
- /**
3254
- * Open a validated candidate for mutation. Legacy transcripts are copied exactly once
3255
- * into v2 and retained at their original location; no transcript data is merged.
3256
- */
3257
- export async function openManagedCandidateForWrite(
3665
+ async function openManagedCandidateForWriteInternal(
3258
3666
  scope: ManagedScope,
3259
3667
  candidate: ManagedCandidate,
3260
3668
  expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain",
@@ -3575,12 +3983,43 @@ export async function openManagedCandidateForWrite(
3575
3983
  : "Managed migration failed.",
3576
3984
  };
3577
3985
  } finally {
3578
- if (lock) await lock.release().catch(() => undefined);
3986
+ if (lock) {
3987
+ await ManagedSessionScopeTestHooks.beforeManagedLockRelease?.({ path: lock.path, attemptId: lock.attemptId });
3988
+ await lock.release();
3989
+ }
3579
3990
  }
3580
3991
  }
3581
3992
 
3582
- /** Tombstone a verified managed candidate before exact-identity deletion. */
3583
- export async function deleteManagedSessionCandidate(
3993
+ /**
3994
+ * Open a validated candidate for mutation. Legacy transcripts are copied exactly once
3995
+ * into v2 and retained at their original location; no transcript data is merged.
3996
+ */
3997
+ export async function openManagedCandidateForWrite(
3998
+ scope: ManagedScope,
3999
+ candidate: ManagedCandidate,
4000
+ expectedIdentityOrMigrationPolicy: ResumeSessionIdentity | ManagedMigrationPolicy = "copy-retain",
4001
+ migrationPolicy: ManagedMigrationPolicy = typeof expectedIdentityOrMigrationPolicy === "string"
4002
+ ? expectedIdentityOrMigrationPolicy
4003
+ : "copy-retain",
4004
+ authority?: ManagedCandidateWriteAuthority,
4005
+ ): Promise<ManagedOpenCandidateResult> {
4006
+ try {
4007
+ return await openManagedCandidateForWriteInternal(
4008
+ scope,
4009
+ candidate,
4010
+ expectedIdentityOrMigrationPolicy,
4011
+ migrationPolicy,
4012
+ authority,
4013
+ );
4014
+ } catch (error) {
4015
+ const code = expectedFailure(error);
4016
+ if (code === "migration_busy")
4017
+ return { kind: "error", code, message: error instanceof Error ? error.message : "migration_busy" };
4018
+ throw error;
4019
+ }
4020
+ }
4021
+
4022
+ async function deleteManagedSessionCandidateInternal(
3584
4023
  scope: ManagedScope,
3585
4024
  candidate: ManagedCandidate,
3586
4025
  ): Promise<ManagedDeleteCandidateResult> {
@@ -3611,11 +4050,7 @@ export async function deleteManagedSessionCandidate(
3611
4050
  let targets = retiredTargets(scope, tombstone);
3612
4051
  if (!targets) {
3613
4052
  if (!current) throw new Error("source_changed");
3614
- const authorizedTargets = [current, ...(paired ? [paired] : [])].map(target =>
3615
- authorizeRetiredTarget(scope, target),
3616
- );
3617
- if (authorizedTargets.some(target => target === undefined)) throw new Error("source_changed");
3618
- targets = authorizedTargets as RetiredTarget[];
4053
+ targets = [current, ...(paired ? [paired] : [])];
3619
4054
  lock.assertOwned();
3620
4055
  try {
3621
4056
  await publishManagedTombstone(
@@ -3630,10 +4065,6 @@ export async function deleteManagedSessionCandidate(
3630
4065
  cwd: target.cwd,
3631
4066
  provenance: target.provenance,
3632
4067
  identity: target.identity,
3633
- transcriptParentIdentity: {
3634
- dev: target.transcriptParentIdentity.dev.toString(),
3635
- ino: target.transcriptParentIdentity.ino.toString(),
3636
- },
3637
4068
  })),
3638
4069
  },
3639
4070
  lock.assertOwned,
@@ -3649,25 +4080,28 @@ export async function deleteManagedSessionCandidate(
3649
4080
  lock.assertOwned();
3650
4081
  const pending = pendingCleanupReceipt(scope, tombstone, target);
3651
4082
  const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
3652
- if (cleanupCompleted(scope, tombstone, target)) continue;
3653
4083
  try {
3654
4084
  fs.lstatSync(target.path);
3655
4085
  } catch (error) {
3656
4086
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
3657
4087
  if (!observedPending) continue;
4088
+ const artifactsEvidence = cleanupArtifactsRemovedEvidence(
4089
+ scope,
4090
+ tombstone,
4091
+ target,
4092
+ observedPending.artifactsRemovedAttempt ?? observedPending.attempt,
4093
+ );
3658
4094
  if (
3659
- cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
3660
- cleanupRootsAbsent(tombstone, target, observedPending)
3661
- )
3662
- return {
3663
- kind: "cleanup_pending",
3664
- tombstonePath: tombstone,
3665
- phase: "transcript",
3666
- message:
3667
- "Exact cleanup remains pending because transcript authority disappeared without native deletion proof.",
3668
- };
4095
+ artifactsEvidence &&
4096
+ cleanupRootsAbsent(tombstone, target, observedPending, artifactsEvidence.retainedArtifactsRootPath)
4097
+ ) {
4098
+ fsyncManagedParent(target.path);
4099
+ await publishCleanupCompleted(scope, tombstone, target, lock);
4100
+ continue;
4101
+ }
3669
4102
  } else throw error;
3670
4103
  }
4104
+ if (cleanupCompleted(scope, tombstone, target)) continue;
3671
4105
  deletedAny = true;
3672
4106
  const verified = observedPending ? target : validateCandidateForScope(scope, target);
3673
4107
  if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
@@ -3675,30 +4109,25 @@ export async function deleteManagedSessionCandidate(
3675
4109
  !!observedPending &&
3676
4110
  (observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
3677
4111
  observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
3678
- const active =
4112
+ let active =
3679
4113
  discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
3680
4114
  ? nextCleanupReceipt(target, observedPending)
3681
4115
  : (observedPending ?? nextCleanupReceipt(target, undefined));
3682
4116
  if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
3683
4117
  await publishCleanupPending(scope, tombstone, active, lock);
3684
- if (!cleanupTranscriptParentMatches(target, active))
3685
- return {
3686
- kind: "cleanup_pending",
3687
- tombstonePath: tombstone,
3688
- phase: cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
3689
- ? "transcript"
3690
- : "artifacts",
3691
- message: "Exact cleanup remains pending because transcript parent identity changed before mutation.",
3692
- };
3693
- let deletion = await new FileSessionStorage().deleteSessionVerified({
4118
+ const initialTarget = validateCandidateForScope(scope, target);
4119
+ if (!initialTarget) throw new Error("source_changed");
4120
+ let deletion = await deleteSessionVerifiedWithFence("direct", "initial", lock, {
3694
4121
  sessionsRoot: scope.sessionsRoot,
3695
4122
  transcriptPath: target.path,
3696
4123
  sessionId: target.sessionId,
3697
4124
  cwd: target.cwd,
3698
- transcriptIdentity: target.identity,
3699
- transcriptParentIdentity: target.transcriptParentIdentity,
4125
+ transcriptIdentity: initialTarget.identity,
4126
+ transcriptParentIdentity: (() => {
4127
+ const parent = fs.lstatSync(path.dirname(target.path), { bigint: true });
4128
+ return { dev: parent.dev, ino: parent.ino };
4129
+ })(),
3700
4130
  expectedArtifactsIdentity: active.expectedArtifactsIdentity,
3701
- artifactsAbsentAtAuthorization: active.artifactsAbsentAtAuthorization,
3702
4131
  expectedArtifactsTree: active.expectedArtifactsTree,
3703
4132
  detachedArtifactsPath: active.detachedArtifactsPath ?? observedPending?.detachedArtifactsPath,
3704
4133
  detachedTranscriptPath:
@@ -3713,33 +4142,26 @@ export async function deleteManagedSessionCandidate(
3713
4142
  retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3714
4143
  plannedArtifactsPath: active.plannedArtifactsPath,
3715
4144
  plannedTranscriptPath: active.plannedTranscriptPath,
3716
- ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
4145
+ ...(cleanupArtifactsRemoved(
4146
+ scope,
4147
+ tombstone,
4148
+ target,
4149
+ pending?.artifactsRemovedAttempt ?? active.artifactsRemovedAttempt ?? pending?.attempt ?? active.attempt,
4150
+ )
3717
4151
  ? { artifactsRemoved: true as const }
3718
4152
  : {}),
3719
4153
  });
3720
4154
  if (deletion.kind === "artifacts_removed") {
3721
- if (!cleanupRootsAbsent(tombstone, target, active, false))
3722
- return {
3723
- kind: "cleanup_pending",
3724
- tombstonePath: tombstone,
3725
- phase: "artifacts",
3726
- message: "Exact cleanup remains pending because artifact or side-path authority is retained.",
3727
- };
3728
- if (!cleanupTranscriptParentMatches(target, active))
3729
- return {
3730
- kind: "cleanup_pending",
3731
- tombstonePath: tombstone,
3732
- phase: "transcript",
3733
- message: "Exact cleanup remains pending because transcript parent identity changed before mutation.",
3734
- };
3735
4155
  await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
3736
- deletion = await new FileSessionStorage().deleteSessionVerified({
4156
+ active = { ...active, artifactsRemovedAttempt: active.attempt };
4157
+ const refreshedTarget = validateCandidateForScope(scope, target);
4158
+ if (!refreshedTarget) throw new Error("source_changed");
4159
+ deletion = await deleteSessionVerifiedWithFence("direct", "transcript-after-artifacts-removed", lock, {
3737
4160
  sessionsRoot: scope.sessionsRoot,
3738
4161
  transcriptPath: target.path,
3739
4162
  sessionId: target.sessionId,
3740
4163
  cwd: target.cwd,
3741
- transcriptIdentity: target.identity,
3742
- transcriptParentIdentity: target.transcriptParentIdentity,
4164
+ transcriptIdentity: refreshedTarget.identity,
3743
4165
  plannedArtifactsPath: active.plannedArtifactsPath,
3744
4166
  plannedTranscriptPath: active.plannedTranscriptPath,
3745
4167
  detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
@@ -3753,32 +4175,123 @@ export async function deleteManagedSessionCandidate(
3753
4175
  });
3754
4176
  }
3755
4177
  if (deletion.kind === "cleanup_pending") {
4178
+ assertAuthorizedCleanupPending(target, active, deletion);
4179
+ const retry = nextCleanupReceipt(target, active);
4180
+ let pendingEvidence = cleanupPendingEvidence(retry, active, deletion);
4181
+ await publishCleanupPending(scope, tombstone, pendingEvidence, lock);
4182
+ if (deletion.phase === "artifacts") {
4183
+ if (
4184
+ (deletion.artifactsPayloadDurable === true &&
4185
+ retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath)) ||
4186
+ (deletion.detachedArtifactsPath === active.plannedArtifactsPath &&
4187
+ !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath))
4188
+ ) {
4189
+ ({ deletion, pendingEvidence } = await continueDetachedArtifactCleanup(
4190
+ scope,
4191
+ tombstone,
4192
+ target,
4193
+ pendingEvidence,
4194
+ observedPending?.detachedTranscriptPath,
4195
+ lock,
4196
+ "direct",
4197
+ ));
4198
+ }
4199
+ if (
4200
+ deletion.kind === "cleanup_pending" &&
4201
+ deletion.phase === "artifacts" &&
4202
+ (deletion.artifactsPayloadDurable !== true ||
4203
+ !retainedArtifactPayloadAbsent(deletion.detachedArtifactsPath))
4204
+ )
4205
+ return {
4206
+ kind: "cleanup_pending",
4207
+ tombstonePath: tombstone,
4208
+ phase: deletion.phase,
4209
+ message: "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
4210
+ };
4211
+ await publishCleanupArtifactsRemoved(scope, tombstone, pendingEvidence, lock);
4212
+ pendingEvidence = { ...pendingEvidence, artifactsRemovedAttempt: pendingEvidence.attempt };
4213
+ const retainedProof = cleanupArtifactsRemovedReceipt(
4214
+ tombstone,
4215
+ target,
4216
+ pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt,
4217
+ );
4218
+ if (!retainedProof) throw new Error("durability_failed");
4219
+ deletion = await deleteSessionVerifiedWithFence(
4220
+ "direct",
4221
+ "transcript-after-artifacts-removed",
4222
+ lock,
4223
+ {
4224
+ sessionsRoot: scope.sessionsRoot,
4225
+ transcriptPath: target.path,
4226
+ sessionId: target.sessionId,
4227
+ cwd: target.cwd,
4228
+ transcriptIdentity: {
4229
+ ...target.identity,
4230
+ nlink: fs.lstatSync(target.path, { bigint: true }).nlink,
4231
+ },
4232
+ plannedArtifactsPath: pendingEvidence.plannedArtifactsPath,
4233
+ plannedTranscriptPath: pendingEvidence.plannedTranscriptPath,
4234
+ detachedTranscriptPath:
4235
+ pendingEvidence.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
4236
+ expectedArtifactsIdentity: retainedProof.identity,
4237
+ expectedArtifactsTree: retainedProof.tree,
4238
+ detachedArtifactsPath: retainedProof.path,
4239
+ retainedArtifactsSuccessorPath: pendingEvidence.retainedArtifactsSuccessorPath,
4240
+ retainedArtifactsPlaceholderPath: pendingEvidence.retainedArtifactsPlaceholderPath,
4241
+ retainedArtifactsUnknownPath: pendingEvidence.retainedArtifactsUnknownPath,
4242
+ retainedTranscriptSuccessorPath: pendingEvidence.retainedTranscriptSuccessorPath,
4243
+ retainedTranscriptPlaceholderPath: pendingEvidence.retainedTranscriptPlaceholderPath,
4244
+ retainedTranscriptUnknownPath: pendingEvidence.retainedTranscriptUnknownPath,
4245
+ artifactsRemoved: true,
4246
+ },
4247
+ () => {
4248
+ if (
4249
+ !cleanupArtifactsRemoved(
4250
+ scope,
4251
+ tombstone,
4252
+ target,
4253
+ pendingEvidence.artifactsRemovedAttempt ?? pendingEvidence.attempt,
4254
+ )
4255
+ )
4256
+ throw new Error("durability_failed");
4257
+ },
4258
+ );
4259
+ if (deletion.kind === "cleanup_pending") {
4260
+ if (
4261
+ deletion.phase !== "transcript" ||
4262
+ (deletion.detachedTranscriptPath !== pendingEvidence.plannedTranscriptPath &&
4263
+ deletion.transcriptPayloadDurable !== true)
4264
+ )
4265
+ throw new Error("durability_failed");
4266
+ const followup = nextCleanupReceipt(target, pendingEvidence);
4267
+ pendingEvidence = cleanupPendingEvidence(followup, pendingEvidence, deletion);
4268
+ await publishCleanupPending(scope, tombstone, pendingEvidence, lock);
4269
+ }
4270
+ }
3756
4271
  if (
3757
- (deletion.phase === "artifacts" &&
3758
- !isAuthorizedArtifactRoot(
4272
+ deletion.kind === "cleanup_pending" &&
4273
+ (deletion.phase !== "transcript" ||
4274
+ deletion.transcriptPayloadDurable !== true ||
4275
+ !cleanupRootsAbsent(
4276
+ tombstone,
3759
4277
  target,
3760
- active.detachedArtifactsPath ?? active.plannedArtifactsPath,
3761
- deletion.detachedArtifactsPath,
3762
- )) ||
3763
- (deletion.phase === "transcript" && deletion.detachedTranscriptPath !== active.plannedTranscriptPath)
4278
+ pendingEvidence,
4279
+ pendingEvidence.artifactsPayloadDurable === true &&
4280
+ pendingEvidence.detachedArtifactsPath &&
4281
+ retainedArtifactPayloadAbsent(pendingEvidence.detachedArtifactsPath)
4282
+ ? pendingEvidence.detachedArtifactsPath
4283
+ : undefined,
4284
+ ))
3764
4285
  )
3765
- throw new Error("durability_failed");
3766
- const retry = nextCleanupReceipt(target, active);
3767
- await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock);
3768
- return {
3769
- kind: "cleanup_pending",
3770
- tombstonePath: tombstone,
3771
- phase: deletion.phase,
3772
- message: "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
3773
- };
4286
+ return {
4287
+ kind: "cleanup_pending",
4288
+ tombstonePath: tombstone,
4289
+ phase: deletion.phase,
4290
+ message: deletion.error.message,
4291
+ };
4292
+ // A retained transcript quarantine proves canonical absence and remains
4293
+ // identity-bound in the durable pending receipt.
3774
4294
  }
3775
- if (!cleanupRootsAbsent(tombstone, target, active))
3776
- return {
3777
- kind: "cleanup_pending",
3778
- tombstonePath: tombstone,
3779
- phase: "transcript",
3780
- message: "Exact cleanup remains pending because transcript or artifact authority is retained.",
3781
- };
3782
4295
  fsyncManagedParent(target.path);
3783
4296
  await publishCleanupCompleted(scope, tombstone, target, lock);
3784
4297
  }
@@ -3787,6 +4300,24 @@ export async function deleteManagedSessionCandidate(
3787
4300
  const code = expectedFailure(error);
3788
4301
  return { kind: "error", code, message: error instanceof Error ? error.message : "Managed deletion failed." };
3789
4302
  } finally {
3790
- if (lock) await lock.release().catch(() => undefined);
4303
+ if (lock) {
4304
+ await ManagedSessionScopeTestHooks.beforeManagedLockRelease?.({ path: lock.path, attemptId: lock.attemptId });
4305
+ await lock.release();
4306
+ }
4307
+ }
4308
+ }
4309
+
4310
+ /** Tombstone a verified managed candidate before exact-identity deletion. */
4311
+ export async function deleteManagedSessionCandidate(
4312
+ scope: ManagedScope,
4313
+ candidate: ManagedCandidate,
4314
+ ): Promise<ManagedDeleteCandidateResult> {
4315
+ try {
4316
+ return await deleteManagedSessionCandidateInternal(scope, candidate);
4317
+ } catch (error) {
4318
+ const code = expectedFailure(error);
4319
+ if (code === "migration_busy")
4320
+ return { kind: "error", code, message: error instanceof Error ? error.message : "migration_busy" };
4321
+ throw error;
3791
4322
  }
3792
4323
  }