@gajae-code/coding-agent 0.11.7 → 0.11.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/dist/types/cli/args.d.ts +0 -2
  3. package/dist/types/cli.d.ts +1 -0
  4. package/dist/types/config/keybindings.d.ts +33 -3
  5. package/dist/types/config/model-profile-activation.d.ts +4 -3
  6. package/dist/types/config/model-profile-contract.d.ts +38 -0
  7. package/dist/types/config/model-profiles.d.ts +2 -3
  8. package/dist/types/config/settings-schema.d.ts +15 -2
  9. package/dist/types/edit/modes/patch.d.ts +13 -0
  10. package/dist/types/edit/path-mutation-lock.d.ts +15 -0
  11. package/dist/types/eval/py/executor.d.ts +1 -1
  12. package/dist/types/eval/py/tool-bridge.d.ts +1 -2
  13. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +12 -5
  14. package/dist/types/gjc-runtime/deep-interview-draft.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-payload.d.ts +41 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +32 -17
  17. package/dist/types/gjc-runtime/deep-interview-repair.d.ts +17 -0
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +135 -4
  19. package/dist/types/gjc-runtime/repository-binding.d.ts +63 -0
  20. package/dist/types/gjc-runtime/state-writer.d.ts +44 -0
  21. package/dist/types/gjc-runtime/ultragoal-guard.d.ts +1 -0
  22. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +24 -0
  23. package/dist/types/gjc-runtime/workflow-manifest.d.ts +5 -3
  24. package/dist/types/main.d.ts +8 -0
  25. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +2 -1
  26. package/dist/types/modes/components/tool-status-header.d.ts +3 -2
  27. package/dist/types/modes/components/welcome.d.ts +4 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  30. package/dist/types/modes/interactive-mode.d.ts +5 -2
  31. package/dist/types/modes/prompt-action-autocomplete.d.ts +6 -1
  32. package/dist/types/modes/prompt-suggestion-controller.d.ts +35 -0
  33. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -2
  34. package/dist/types/modes/types.d.ts +3 -0
  35. package/dist/types/modes/utils/hotkeys-markdown.d.ts +2 -1
  36. package/dist/types/runtime-mcp/manager.d.ts +1 -1
  37. package/dist/types/runtime-mcp/oauth-flow.d.ts +0 -5
  38. package/dist/types/runtime-mcp/plugin-network-boundary.d.ts +14 -0
  39. package/dist/types/runtime-mcp/redaction.d.ts +2 -0
  40. package/dist/types/sdk/broker/broker.d.ts +2 -0
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +7 -1
  43. package/dist/types/sdk/bus/config-commands.d.ts +2 -2
  44. package/dist/types/sdk/bus/config.d.ts +2 -0
  45. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  46. package/dist/types/sdk/bus/index.d.ts +25 -0
  47. package/dist/types/sdk/bus/notification-orchestration.d.ts +1 -0
  48. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +105 -0
  49. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +15 -3
  50. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -1
  51. package/dist/types/sdk/bus/telegram-daemon.d.ts +128 -1
  52. package/dist/types/sdk/bus/telegram-reference.d.ts +2 -0
  53. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  54. package/dist/types/sdk/host/host.d.ts +1 -1
  55. package/dist/types/sdk/host/query/handlers.d.ts +9 -0
  56. package/dist/types/sdk/index.d.ts +2 -0
  57. package/dist/types/sdk/prompt-status.d.ts +88 -0
  58. package/dist/types/sdk/session-directory.d.ts +1 -1
  59. package/dist/types/sdk/startup-capability.d.ts +3 -0
  60. package/dist/types/session/agent-session.d.ts +2 -0
  61. package/dist/types/session/internal/managed-session-scope.d.ts +22 -3
  62. package/dist/types/session/internal/managed-session-storage.d.ts +24 -4
  63. package/dist/types/session/internal/native-publish-outcome.d.ts +48 -0
  64. package/dist/types/session/session-manager.d.ts +5 -1
  65. package/dist/types/session/session-storage.d.ts +31 -0
  66. package/dist/types/skill-state/active-state.d.ts +4 -0
  67. package/dist/types/task/index.d.ts +3 -1
  68. package/dist/types/task/receipt.d.ts +2 -0
  69. package/dist/types/task/types.d.ts +94 -0
  70. package/dist/types/tools/ask.d.ts +454 -174
  71. package/dist/types/tools/image-gen.d.ts +47 -1
  72. package/dist/types/tools/index.d.ts +4 -0
  73. package/dist/types/utils/prompt-suggestion.d.ts +33 -0
  74. package/package.json +8 -8
  75. package/scripts/capture-platform-shortcut-labels-showcase.ts +342 -0
  76. package/scripts/dogfood-repository-binding.ts +209 -0
  77. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  78. package/scripts/run-test-manifest.ts +4 -4
  79. package/src/cli/args.ts +2 -92
  80. package/src/cli/mcp-cli.ts +4 -21
  81. package/src/cli/notify-cli.ts +5 -38
  82. package/src/cli.ts +50 -0
  83. package/src/commands/deep-interview.ts +9 -4
  84. package/src/commands/sdk.ts +2 -2
  85. package/src/commit/agentic/index.ts +3 -3
  86. package/src/commit/map-reduce/index.ts +2 -2
  87. package/src/config/keybindings.ts +157 -24
  88. package/src/config/model-profile-activation.ts +7 -18
  89. package/src/config/model-profile-contract.ts +161 -0
  90. package/src/config/model-profiles.ts +2 -3
  91. package/src/config/model-registry.ts +11 -1
  92. package/src/config/settings-schema.ts +17 -2
  93. package/src/coordinator-mcp/model-preset.ts +21 -74
  94. package/src/defaults/gjc/skills/deep-interview/SKILL.md +80 -165
  95. package/src/defaults/gjc/skills/ralplan/SKILL.md +3 -1
  96. package/src/edit/index.ts +3 -6
  97. package/src/edit/modes/patch.ts +38 -1
  98. package/src/edit/modes/replace.ts +42 -0
  99. package/src/edit/path-mutation-lock.ts +112 -0
  100. package/src/eval/py/executor.ts +30 -11
  101. package/src/eval/py/prelude.py +2 -2
  102. package/src/eval/py/tool-bridge.ts +34 -18
  103. package/src/extensibility/gjc-plugins/runtime-adapters.ts +2 -1
  104. package/src/gjc-runtime/deep-interview-ambiguity.ts +68 -47
  105. package/src/gjc-runtime/deep-interview-draft.ts +842 -0
  106. package/src/gjc-runtime/deep-interview-payload.ts +262 -0
  107. package/src/gjc-runtime/deep-interview-recorder.ts +294 -226
  108. package/src/gjc-runtime/deep-interview-repair.ts +1674 -0
  109. package/src/gjc-runtime/deep-interview-runtime.ts +264 -103
  110. package/src/gjc-runtime/deep-interview-state.ts +916 -4
  111. package/src/gjc-runtime/launch-tmux.ts +1 -1
  112. package/src/gjc-runtime/managed-owner-supervisor.ts +83 -4
  113. package/src/gjc-runtime/ralplan-runtime.ts +78 -4
  114. package/src/gjc-runtime/repository-binding.ts +267 -0
  115. package/src/gjc-runtime/session-state-sidecar.ts +25 -9
  116. package/src/gjc-runtime/state-writer.ts +166 -2
  117. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -1
  118. package/src/gjc-runtime/tmux-sessions.ts +45 -6
  119. package/src/gjc-runtime/ultragoal-guard.ts +5 -5
  120. package/src/gjc-runtime/ultragoal-runtime.ts +181 -19
  121. package/src/gjc-runtime/workflow-manifest.generated.json +386 -1
  122. package/src/gjc-runtime/workflow-manifest.ts +191 -5
  123. package/src/internal-urls/docs-index.generated.ts +8 -6
  124. package/src/internal-urls/local-protocol.ts +33 -16
  125. package/src/internal-urls/mcp-protocol.ts +73 -11
  126. package/src/main.ts +25 -10
  127. package/src/modes/DESIGN.md +5 -0
  128. package/src/modes/acp/acp-agent.ts +50 -16
  129. package/src/modes/components/model-selector.ts +42 -8
  130. package/src/modes/components/plan-preview-overlay.ts +1 -1
  131. package/src/modes/components/runtime-mcp-add-wizard.ts +5 -0
  132. package/src/modes/components/tool-status-header.ts +20 -4
  133. package/src/modes/components/welcome.ts +32 -20
  134. package/src/modes/controllers/command-controller.ts +97 -54
  135. package/src/modes/controllers/event-controller.ts +10 -0
  136. package/src/modes/controllers/input-controller.ts +7 -2
  137. package/src/modes/controllers/runtime-mcp-command-controller.ts +29 -6
  138. package/src/modes/controllers/selector-controller.ts +46 -54
  139. package/src/modes/interactive-mode.ts +70 -36
  140. package/src/modes/prompt-action-autocomplete.ts +32 -13
  141. package/src/modes/prompt-suggestion-controller.ts +96 -0
  142. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -2
  143. package/src/modes/types.ts +3 -0
  144. package/src/modes/utils/hotkeys-markdown.ts +64 -36
  145. package/src/modes/utils/ui-helpers.ts +5 -3
  146. package/src/prompts/agents/architect.md +0 -1
  147. package/src/prompts/agents/critic.md +0 -1
  148. package/src/prompts/system/prompt-suggestion-system.md +32 -0
  149. package/src/prompts/system/system-prompt.md +7 -4
  150. package/src/prompts/tools/cron.md +3 -1
  151. package/src/runtime-mcp/json-rpc.ts +7 -4
  152. package/src/runtime-mcp/manager.ts +51 -53
  153. package/src/runtime-mcp/oauth-flow.ts +0 -72
  154. package/src/runtime-mcp/plugin-network-boundary.ts +101 -0
  155. package/src/runtime-mcp/redaction.ts +30 -0
  156. package/src/runtime-mcp/transports/http.ts +13 -6
  157. package/src/sdk/broker/broker.ts +34 -20
  158. package/src/sdk/broker/lifecycle.ts +185 -36
  159. package/src/sdk/broker/session-index.ts +11 -1
  160. package/src/sdk/bus/chat-daemon-control.ts +20 -5
  161. package/src/sdk/bus/config-commands.ts +2 -2
  162. package/src/sdk/bus/config.ts +9 -1
  163. package/src/sdk/bus/conversation-store.ts +40 -6
  164. package/src/sdk/bus/index.ts +240 -65
  165. package/src/sdk/bus/lifecycle-commands.ts +47 -3
  166. package/src/sdk/bus/lifecycle-control-runtime.ts +11 -2
  167. package/src/sdk/bus/lifecycle-orchestrator.ts +32 -1
  168. package/src/sdk/bus/notification-orchestration.ts +12 -0
  169. package/src/sdk/bus/notification-service.ts +27 -2
  170. package/src/sdk/bus/prompt-reconciliation.ts +239 -0
  171. package/src/sdk/bus/recent-activity.ts +90 -26
  172. package/src/sdk/bus/telegram-daemon-contract.ts +15 -3
  173. package/src/sdk/bus/telegram-daemon-control.ts +145 -33
  174. package/src/sdk/bus/telegram-daemon.ts +853 -112
  175. package/src/sdk/bus/telegram-reference.ts +20 -2
  176. package/src/sdk/cli/session-cli.ts +15 -2
  177. package/src/sdk/host/control/dispatch.ts +1 -1
  178. package/src/sdk/host/control/operations.ts +1 -1
  179. package/src/sdk/host/host.ts +2 -6
  180. package/src/sdk/host/query/handlers.ts +70 -5
  181. package/src/sdk/index.ts +12 -0
  182. package/src/sdk/prompt-status.ts +97 -0
  183. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  184. package/src/sdk/protocol/operation-registry.ts +18 -4
  185. package/src/sdk/session-directory.ts +5 -1
  186. package/src/sdk/session.ts +2 -0
  187. package/src/sdk/startup-capability.ts +9 -1
  188. package/src/session/agent-session.ts +138 -5
  189. package/src/session/blob-store.ts +7 -2
  190. package/src/session/internal/managed-session-scope.ts +998 -133
  191. package/src/session/internal/managed-session-storage.ts +268 -203
  192. package/src/session/internal/native-publish-outcome.ts +266 -0
  193. package/src/session/session-manager.ts +406 -360
  194. package/src/session/session-storage.ts +151 -11
  195. package/src/skill-state/active-state.ts +10 -0
  196. package/src/task/executor.ts +13 -5
  197. package/src/task/gjc-command.ts +2 -2
  198. package/src/task/index.ts +121 -15
  199. package/src/task/receipt.ts +3 -0
  200. package/src/task/types.ts +34 -3
  201. package/src/tools/ask.ts +199 -88
  202. package/src/tools/ast-edit.ts +2 -2
  203. package/src/tools/bash-pty-selection.ts +2 -2
  204. package/src/tools/browser/screenshot-format.ts +6 -1
  205. package/src/tools/cron.ts +1 -1
  206. package/src/tools/image-gen.ts +178 -6
  207. package/src/tools/index.ts +4 -0
  208. package/src/tools/render-utils.ts +7 -1
  209. package/src/utils/edit-mode.ts +2 -2
  210. package/src/utils/prompt-suggestion.ts +265 -0
  211. package/src/utils/sixel.ts +3 -3
  212. package/src/web/insane/url-guard.ts +3 -1
  213. package/src/web/scrapers/docs-rs.ts +30 -5
@@ -14,6 +14,7 @@ import {
14
14
  FileSessionStorage,
15
15
  type NativeDirectoryTreeSnapshot,
16
16
  type SessionStorageFileIdentity,
17
+ type VerifiedSessionDeleteResult,
17
18
  } from "../session-storage";
18
19
  import {
19
20
  acquireManagedLock,
@@ -23,11 +24,16 @@ import {
23
24
  copyManagedFileNoReplace,
24
25
  ensureManagedDirectory,
25
26
  fsyncManagedArtifactTree,
27
+ MANAGED_ARTIFACT_COPY_BATCH_SIZE,
28
+ MANAGED_ARTIFACT_MAX_FILES,
29
+ MANAGED_ARTIFACT_MAX_TOTAL_BYTES,
26
30
  type ManagedDirectoryRoot,
31
+ type ManagedFileSnapshot,
32
+ ManagedPublishError,
27
33
  ManagedSessionDescendantStore,
28
34
  type ManagedSessionSecurityPolicy,
29
35
  type ManagedStorageLock,
30
- managedDirectoryRoot,
36
+ prepareManagedDirectoryRoot,
31
37
  publishManagedFileNoReplace,
32
38
  publishManagedTombstone,
33
39
  retainManagedDirectoryAuthority,
@@ -63,13 +69,12 @@ export interface ManagedCandidateWriteAuthority {
63
69
  readonly retainedDirectory?: string;
64
70
  }
65
71
 
66
- const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof managedDirectoryRoot>>();
72
+ const managedRoots = new WeakMap<ManagedScope, ReturnType<typeof prepareManagedDirectoryRoot>>();
67
73
  const managedDirectoryIdentities = new WeakMap<ManagedScope, { dev: bigint; ino: bigint }>();
68
74
  const managedDirectoryAuthorities = new WeakMap<ManagedScope, native.RecoveryFsRoot | undefined>();
69
75
  const boundManagedWriteAuthorities = new WeakMap<ManagedScope, ManagedCandidateWriteAuthority>();
70
76
 
71
77
  function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandidateWriteAuthority): void {
72
- assertManagedDirectoryRoot(authority.rootAuthority);
73
78
  if (
74
79
  authority.retainedAuthority &&
75
80
  authority.retainedDirectory !== undefined &&
@@ -79,11 +84,27 @@ function bindManagedWriteAuthority(scope: ManagedScope, authority: ManagedCandid
79
84
  authority: authority.retainedAuthority,
80
85
  authorityBaseDir: scope.directoryPath,
81
86
  }).assertBound();
87
+ } else {
88
+ assertManagedDirectoryRoot(authority.rootAuthority);
82
89
  }
83
90
  managedRoots.set(scope, authority.rootAuthority);
84
91
  boundManagedWriteAuthorities.set(scope, authority);
85
92
  }
86
93
 
94
+ /** A prepared scope is a retained authority boundary, not a path that may be re-adopted. */
95
+ function assertRetainedManagedDirectoryIdentity(scope: ManagedScope): void {
96
+ const expected = managedDirectoryIdentities.get(scope);
97
+ if (!expected) return;
98
+ const current = fs.lstatSync(scope.directoryPath, { bigint: true });
99
+ if (
100
+ !current.isDirectory() ||
101
+ current.isSymbolicLink() ||
102
+ current.dev !== expected.dev ||
103
+ current.ino !== expected.ino
104
+ )
105
+ throw new Error("Managed session directory changed");
106
+ }
107
+
87
108
  export function managedDirectoryAuthorityForScope(scope: ManagedScope): native.RecoveryFsRoot | undefined {
88
109
  if (!managedDirectoryAuthorities.has(scope)) throw new Error("Managed session directory authority was not prepared");
89
110
  return managedDirectoryAuthorities.get(scope);
@@ -97,21 +118,24 @@ export function managedDirectoryIdentityForScope(scope: ManagedScope): { dev: bi
97
118
 
98
119
  function configuredRootPath(scope: ManagedScope): string {
99
120
  let candidate = pathIsWithin(scope.agentDir, scope.sessionsRoot) ? scope.agentDir : path.dirname(scope.sessionsRoot);
121
+ const suffix: string[] = [];
100
122
  for (;;) {
101
123
  try {
102
124
  const stat = fs.lstatSync(candidate);
103
125
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Unsafe configured root: ${candidate}`);
104
- return fs.realpathSync.native(candidate);
126
+ const canonical = fs.realpathSync.native(candidate);
127
+ return suffix.length === 0 ? canonical : path.join(canonical, ...suffix);
105
128
  } catch (error) {
106
129
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
107
130
  const parent = path.dirname(candidate);
108
131
  if (parent === candidate) throw new Error("Configured managed root is unavailable.");
132
+ suffix.unshift(path.basename(candidate));
109
133
  candidate = parent;
110
134
  }
111
135
  }
112
136
  }
113
137
 
114
- function scopeRoot(scope: ManagedScope) {
138
+ function scopeRoot(scope: ManagedScope, policy: ManagedSessionSecurityPolicy = "default") {
115
139
  const bound = boundManagedWriteAuthorities.get(scope);
116
140
  if (bound) {
117
141
  bindManagedWriteAuthority(scope, bound);
@@ -119,7 +143,7 @@ function scopeRoot(scope: ManagedScope) {
119
143
  }
120
144
  const retained = managedRoots.get(scope);
121
145
  if (retained) return retained;
122
- const root = managedDirectoryRoot(configuredRootPath(scope));
146
+ const root = prepareManagedDirectoryRoot(configuredRootPath(scope), policy);
123
147
  managedRoots.set(scope, root);
124
148
  return root;
125
149
  }
@@ -137,11 +161,20 @@ export type ManagedScopeErrorCode =
137
161
  | "network_unsupported"
138
162
  | "sessions_root_unavailable"
139
163
  | "binding_conflict"
140
- | "binding_invalid";
164
+ | "binding_invalid"
165
+ | "atomic_unavailable"
166
+ | "invalid_request"
167
+ | "durability_failed"
168
+ | "durability_not_provable";
141
169
 
142
170
  export type ManagedScopeResolution =
143
171
  | { kind: "resolved"; scope: ManagedScope }
144
- | { kind: "error"; code: ManagedScopeErrorCode; message: string };
172
+ | {
173
+ kind: "error";
174
+ code: ManagedScopeErrorCode;
175
+ message: string;
176
+ cause?: { readonly classification: string; readonly diagnostic?: string };
177
+ };
145
178
 
146
179
  export interface ManagedCandidate {
147
180
  sessionId: string;
@@ -169,7 +202,11 @@ export type ManagedOpenFailure =
169
202
  | "destination_conflict"
170
203
  | "source_changed"
171
204
  | "unsafe_artifacts"
205
+ | "artifact_capacity_exceeded"
172
206
  | "durability_failed"
207
+ | "atomic_unavailable"
208
+ | "invalid_request"
209
+ | "durability_not_provable"
173
210
  | "migration_retired"
174
211
  | "legacy_migration_disabled"
175
212
  | "managed_storage_unsupported";
@@ -181,6 +218,7 @@ export type ManagedOpenCandidateResult =
181
218
  export type ManagedDeleteCandidateResult =
182
219
  | { kind: "deleted"; tombstonePath: string }
183
220
  | { kind: "already_deleted"; tombstonePath: string }
221
+ | { kind: "cleanup_pending"; tombstonePath: string; phase: "artifacts" | "transcript"; message: string }
184
222
  | { kind: "error"; code: ManagedOpenFailure; message: string };
185
223
 
186
224
  type NativeIdentity =
@@ -535,6 +573,63 @@ function fsyncManagedParent(pathname: string): void {
535
573
  }
536
574
  }
537
575
 
576
+ export function canonicalBindingOpenFlags(platform: NodeJS.Platform = process.platform): number {
577
+ return (platform === "win32" ? fs.constants.O_RDWR : fs.constants.O_RDONLY) | fs.constants.O_NOFOLLOW;
578
+ }
579
+
580
+ export function fsyncCanonicalBinding(bindingPath: string, expected: string): void {
581
+ let captured: ManagedFileSnapshot;
582
+ try {
583
+ captured = captureManagedFileNoFollow(bindingPath);
584
+ } catch {
585
+ throw new Error("binding_invalid");
586
+ }
587
+ if (captured.bytes.toString("utf8") !== expected) throw new Error("binding_invalid");
588
+ let descriptor: number | undefined;
589
+ try {
590
+ // Bun on Windows rejects fsync on a read-only file descriptor with EPERM.
591
+ // The managed binding is owner-writable, so reopen it read/write only for the durability fence.
592
+ descriptor = fs.openSync(bindingPath, canonicalBindingOpenFlags());
593
+ const before = fs.fstatSync(descriptor, { bigint: true });
594
+ if (
595
+ before.dev !== captured.identity.dev ||
596
+ before.ino !== captured.identity.ino ||
597
+ before.size !== BigInt(captured.identity.size) ||
598
+ before.mtimeNs !== captured.identity.mtimeNs
599
+ )
600
+ throw new Error("binding_invalid");
601
+ fs.fsyncSync(descriptor);
602
+ const after = fs.fstatSync(descriptor, { bigint: true });
603
+ if (
604
+ after.dev !== captured.identity.dev ||
605
+ after.ino !== captured.identity.ino ||
606
+ after.size !== BigInt(captured.identity.size) ||
607
+ after.mtimeNs !== captured.identity.mtimeNs
608
+ )
609
+ throw new Error("binding_invalid");
610
+ } catch (error) {
611
+ if (error instanceof Error && error.message === "binding_invalid") throw error;
612
+ throw new Error("durability_failed", { cause: error });
613
+ } finally {
614
+ if (descriptor !== undefined) fs.closeSync(descriptor);
615
+ }
616
+ fsyncManagedParent(bindingPath);
617
+ let recaptured: ManagedFileSnapshot;
618
+ try {
619
+ recaptured = captureManagedFileNoFollow(bindingPath);
620
+ } catch {
621
+ throw new Error("binding_invalid");
622
+ }
623
+ if (
624
+ recaptured.bytes.toString("utf8") !== expected ||
625
+ recaptured.identity.dev !== captured.identity.dev ||
626
+ recaptured.identity.ino !== captured.identity.ino ||
627
+ recaptured.identity.size !== captured.identity.size ||
628
+ recaptured.identity.mtimeNs !== captured.identity.mtimeNs
629
+ )
630
+ throw new Error("binding_invalid");
631
+ }
632
+
538
633
  type CandidatePreflight =
539
634
  | { kind: "capture"; identity: { dev: bigint; ino: bigint; size: number; mtimeNs: bigint } }
540
635
  | {
@@ -673,22 +768,48 @@ export async function ensureManagedScope(
673
768
  policy: ManagedSessionSecurityPolicy = "default",
674
769
  ): Promise<ManagedScopeResolution> {
675
770
  try {
676
- const root = scopeRoot(scope);
771
+ assertRetainedManagedDirectoryIdentity(scope);
772
+ const root = scopeRoot(scope, policy);
677
773
  ensureManagedDirectory(scope.sessionsRoot, root, policy);
678
774
  ensureManagedDirectory(scope.directoryPath, root, policy);
679
775
  const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
680
776
  const binding = `${JSON.stringify(bindingFor(scope))}\n`;
777
+ let bindingCollision = false;
681
778
  try {
682
779
  await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding), undefined, root, policy);
683
780
  } catch (error) {
684
781
  if ((error as Error).message !== "destination_conflict") throw error;
782
+ bindingCollision = true;
685
783
  }
686
- return validateExistingBinding(scope) ?? { kind: "resolved", scope };
784
+ const validated = validateExistingBinding(scope);
785
+ if (validated) return validated;
786
+ if (bindingCollision) fsyncCanonicalBinding(bindingPath, binding);
787
+
788
+ const preparedDirectory = fs.lstatSync(scope.directoryPath, { bigint: true });
789
+ if (!preparedDirectory.isDirectory() || preparedDirectory.isSymbolicLink())
790
+ throw new Error("Managed session directory changed");
791
+ managedDirectoryIdentities.set(scope, { dev: preparedDirectory.dev, ino: preparedDirectory.ino });
792
+ return { kind: "resolved", scope };
687
793
  } catch (error) {
794
+ const publication = error instanceof ManagedPublishError ? error : undefined;
795
+ const message =
796
+ publication?.classification ??
797
+ (error instanceof Error ? error.message : "The managed scope could not be initialized.");
798
+ const code =
799
+ message === "atomic_unavailable" ||
800
+ message === "invalid_request" ||
801
+ message === "durability_failed" ||
802
+ message === "durability_not_provable"
803
+ ? message
804
+ : "binding_invalid";
805
+
688
806
  return {
689
807
  kind: "error",
690
- code: "binding_invalid",
691
- message: error instanceof Error ? error.message : "The managed scope could not be initialized.",
808
+ code,
809
+ message,
810
+ ...(publication
811
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
812
+ : { cause: { classification: code } }),
692
813
  };
693
814
  }
694
815
  }
@@ -759,7 +880,8 @@ export function prepareManagedSessionScopeForWriteSync(
759
880
  authority?: ManagedCandidateWriteAuthority,
760
881
  ): ManagedScopeResolution {
761
882
  try {
762
- const root = authority?.rootAuthority ?? scopeRoot(scope);
883
+ assertRetainedManagedDirectoryIdentity(scope);
884
+ const root = authority?.rootAuthority ?? scopeRoot(scope, policy);
763
885
  if (authority) bindManagedWriteAuthority(scope, authority);
764
886
  ensureManagedDirectory(scope.sessionsRoot, root, policy);
765
887
  ensureManagedDirectory(scope.directoryPath, root, policy);
@@ -821,10 +943,26 @@ export function prepareManagedSessionScopeForWriteSync(
821
943
  ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY), root, policy);
822
944
  return { kind: "resolved", scope };
823
945
  } catch (error) {
946
+ const publication = error instanceof ManagedPublishError ? error : undefined;
947
+ const message =
948
+ publication?.classification ??
949
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
950
+ const code =
951
+ message === "atomic_unavailable" ||
952
+ message === "invalid_request" ||
953
+ message === "durability_failed" ||
954
+ message === "durability_not_provable"
955
+ ? message
956
+ : "binding_invalid";
824
957
  return {
825
958
  kind: "error",
826
- code: "binding_invalid",
827
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
959
+ code,
960
+ message,
961
+ ...(publication
962
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
963
+ : code === "binding_invalid"
964
+ ? {}
965
+ : { cause: { classification: code } }),
828
966
  };
829
967
  }
830
968
  }
@@ -947,7 +1085,11 @@ function expectedFailure(error: unknown): ManagedOpenFailure {
947
1085
  message === "destination_conflict" ||
948
1086
  message === "source_changed" ||
949
1087
  message === "unsafe_artifacts" ||
1088
+ message === "artifact_capacity_exceeded" ||
950
1089
  message === "durability_failed" ||
1090
+ message === "atomic_unavailable" ||
1091
+ message === "invalid_request" ||
1092
+ message === "durability_not_provable" ||
951
1093
  message === "migration_retired"
952
1094
  ? message
953
1095
  : "managed_storage_unsupported";
@@ -990,7 +1132,7 @@ function revalidatePickerConsent(
990
1132
  function receiptPathFor(
991
1133
  scope: ManagedScope,
992
1134
  source: ManagedCandidate,
993
- state: "prepared" | "published" | "committed" = "committed",
1135
+ state: "prepared" | "detached" | "published" | "committed" = "committed",
994
1136
  ): string {
995
1137
  const suffix = state === "committed" ? "" : `.${state}`;
996
1138
  return path.join(
@@ -1029,6 +1171,14 @@ function receiptMatches(
1029
1171
  identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1030
1172
  };
1031
1173
  artifactManifest?: unknown;
1174
+ sourceArtifactQuarantine?: { role?: unknown };
1175
+ sourceArtifactCleanup?: {
1176
+ state?: unknown;
1177
+ role?: unknown;
1178
+ retainedPath?: unknown;
1179
+ identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
1180
+ tree?: unknown;
1181
+ };
1032
1182
  };
1033
1183
  const exact = (
1034
1184
  recorded: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown } | undefined,
@@ -1069,12 +1219,43 @@ function receiptMatches(
1069
1219
  item.size >= 0
1070
1220
  );
1071
1221
  });
1222
+ const quarantine = record.sourceArtifactQuarantine;
1223
+ const cleanup = record.sourceArtifactCleanup;
1224
+ const cleanupIdentity = cleanup?.identity;
1225
+ const cleanupTree = artifactTreeSnapshot(cleanup?.tree);
1226
+ const validCleanup =
1227
+ cleanup === undefined ||
1228
+ (quarantine?.role === "detached_artifact_root" &&
1229
+ cleanup.state === "cleanup_pending" &&
1230
+ cleanup.role === "exchange_placeholder" &&
1231
+ typeof cleanup.retainedPath === "string" &&
1232
+ cleanupIdentity?.dev !== undefined &&
1233
+ cleanupIdentity.ino !== undefined &&
1234
+ cleanupIdentity.size !== undefined &&
1235
+ cleanupIdentity.mtimeNs !== undefined &&
1236
+ !!cleanupTree &&
1237
+ cleanupAuthorityMatches(
1238
+ {
1239
+ state: "cleanup_pending",
1240
+ role: "exchange_placeholder",
1241
+ retainedPath: cleanup.retainedPath,
1242
+ identity: {
1243
+ dev: BigInt(String(cleanupIdentity.dev)),
1244
+ ino: BigInt(String(cleanupIdentity.ino)),
1245
+ size: BigInt(String(cleanupIdentity.size)),
1246
+ mtimeNs: BigInt(String(cleanupIdentity.mtimeNs)),
1247
+ },
1248
+ tree: cleanupTree,
1249
+ },
1250
+ path.dirname(source.path),
1251
+ ));
1072
1252
  return (
1073
1253
  record.schemaVersion === 2 &&
1074
1254
  record.state === "committed" &&
1075
1255
  record.policy === "copy-retain" &&
1076
1256
  record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
1077
1257
  validManifest &&
1258
+ validCleanup &&
1078
1259
  manifestContains(destination.path, manifest as readonly ArtifactManifestEntry[]) &&
1079
1260
  record.source?.path === source.path &&
1080
1261
  record.source?.sessionId === source.sessionId &&
@@ -1217,6 +1398,65 @@ function retiredTargets(scope: ManagedScope, pathname: string): readonly Retired
1217
1398
  * a returned partial result appends `pending(N + 1)` with the observed detached
1218
1399
  * identity/path. Restart accepts only a contiguous, target-bound sequence.
1219
1400
  */
1401
+ type RetainedArtifactsRootReceipt = {
1402
+ path: string;
1403
+ identity: SessionStorageFileIdentity;
1404
+ tree: NativeDirectoryTreeSnapshot;
1405
+ };
1406
+
1407
+ function retainedArtifactsRootReceipt(receipt: CleanupReceipt): RetainedArtifactsRootReceipt | undefined {
1408
+ const pathname = deterministicRemovalRoot(receipt.plannedArtifactsPath);
1409
+ if (!fs.existsSync(pathname)) return undefined;
1410
+ if (!receipt.expectedArtifactsIdentity) throw new Error("durability_failed");
1411
+ const identity = artifactIdentityAt(pathname);
1412
+ if (!identity || !sameArtifactRootIdentity(identity, receipt.expectedArtifactsIdentity))
1413
+ throw new Error("durability_failed");
1414
+ const tree = snapshotArtifactTree(pathname);
1415
+ return { path: pathname, identity, tree };
1416
+ }
1417
+
1418
+ function retainedArtifactsRootMatches(record: Record<string, unknown>): boolean {
1419
+ if (record.retainedArtifactsRoot === undefined) return true;
1420
+ if (!record.retainedArtifactsRoot || typeof record.retainedArtifactsRoot !== "object")
1421
+ throw new Error("durability_failed");
1422
+ const retained = record.retainedArtifactsRoot as Record<string, unknown>;
1423
+ const identity = retained.identity;
1424
+ const tree = artifactTreeSnapshot(retained.tree);
1425
+ if (
1426
+ typeof record.plannedArtifactsPath !== "string" ||
1427
+ retained.path !== deterministicRemovalRoot(record.plannedArtifactsPath) ||
1428
+ !identity ||
1429
+ typeof identity !== "object" ||
1430
+ Array.isArray(identity) ||
1431
+ !tree
1432
+ )
1433
+ throw new Error("durability_failed");
1434
+ const artifactIdentity = identity as Record<string, unknown>;
1435
+ if (
1436
+ typeof artifactIdentity.dev !== "string" ||
1437
+ typeof artifactIdentity.ino !== "string" ||
1438
+ typeof artifactIdentity.size !== "number" ||
1439
+ typeof artifactIdentity.mtimeNs !== "string" ||
1440
+ typeof artifactIdentity.sha256 !== "string"
1441
+ )
1442
+ throw new Error("durability_failed");
1443
+ if (!fs.existsSync(retained.path)) return true;
1444
+ const observed = artifactIdentityAt(retained.path);
1445
+ if (
1446
+ !observed ||
1447
+ !sameArtifactRootIdentity(observed, {
1448
+ dev: BigInt(artifactIdentity.dev),
1449
+ ino: BigInt(artifactIdentity.ino),
1450
+ size: artifactIdentity.size,
1451
+ mtimeNs: BigInt(artifactIdentity.mtimeNs),
1452
+ sha256: artifactIdentity.sha256,
1453
+ }) ||
1454
+ JSON.stringify(snapshotArtifactTree(retained.path)) !== JSON.stringify(tree)
1455
+ )
1456
+ throw new Error("durability_failed");
1457
+ return true;
1458
+ }
1459
+
1220
1460
  type CleanupReceipt = {
1221
1461
  attempt: number;
1222
1462
  target: RetiredTarget;
@@ -1224,6 +1464,13 @@ type CleanupReceipt = {
1224
1464
  expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
1225
1465
  detachedArtifactsPath?: string;
1226
1466
  detachedTranscriptPath?: string;
1467
+ retainedArtifactsSuccessorPath?: string;
1468
+ retainedArtifactsPlaceholderPath?: string;
1469
+ retainedArtifactsUnknownPath?: string;
1470
+ retainedTranscriptSuccessorPath?: string;
1471
+ retainedTranscriptPlaceholderPath?: string;
1472
+ retainedTranscriptUnknownPath?: string;
1473
+
1227
1474
  plannedArtifactsPath: string;
1228
1475
  plannedTranscriptPath: string;
1229
1476
  };
@@ -1257,6 +1504,24 @@ function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: Cleanup
1257
1504
  ...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
1258
1505
  ...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
1259
1506
  ...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
1507
+ ...(receipt.retainedArtifactsSuccessorPath
1508
+ ? { retainedArtifactsSuccessorPath: receipt.retainedArtifactsSuccessorPath }
1509
+ : {}),
1510
+ ...(receipt.retainedArtifactsPlaceholderPath
1511
+ ? { retainedArtifactsPlaceholderPath: receipt.retainedArtifactsPlaceholderPath }
1512
+ : {}),
1513
+ ...(receipt.retainedArtifactsUnknownPath
1514
+ ? { retainedArtifactsUnknownPath: receipt.retainedArtifactsUnknownPath }
1515
+ : {}),
1516
+ ...(receipt.retainedTranscriptSuccessorPath
1517
+ ? { retainedTranscriptSuccessorPath: receipt.retainedTranscriptSuccessorPath }
1518
+ : {}),
1519
+ ...(receipt.retainedTranscriptPlaceholderPath
1520
+ ? { retainedTranscriptPlaceholderPath: receipt.retainedTranscriptPlaceholderPath }
1521
+ : {}),
1522
+ ...(receipt.retainedTranscriptUnknownPath
1523
+ ? { retainedTranscriptUnknownPath: receipt.retainedTranscriptUnknownPath }
1524
+ : {}),
1260
1525
  plannedArtifactsPath: receipt.plannedArtifactsPath,
1261
1526
  plannedTranscriptPath: receipt.plannedTranscriptPath,
1262
1527
  };
@@ -1277,6 +1542,7 @@ function cleanupArtifactsRemoved(
1277
1542
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1278
1543
  const record = value as Record<string, unknown>;
1279
1544
  const recorded = record.target as Record<string, unknown> | undefined;
1545
+ const identity = recorded?.identity as Record<string, unknown> | undefined;
1280
1546
  return (
1281
1547
  record.schemaVersion === 2 &&
1282
1548
  record.state === "artifacts_removed" &&
@@ -1285,9 +1551,17 @@ function cleanupArtifactsRemoved(
1285
1551
  record.attempt === attempt &&
1286
1552
  recorded?.path === target.path &&
1287
1553
  recorded.sessionId === target.sessionId &&
1288
- recorded.cwd === target.cwd
1554
+ recorded.cwd === target.cwd &&
1555
+ identity?.canonicalPath === target.identity.canonicalPath &&
1556
+ identity.dev === String(target.identity.dev) &&
1557
+ identity.ino === String(target.identity.ino) &&
1558
+ identity.size === target.identity.size &&
1559
+ identity.mtimeNs === String(target.identity.mtimeNs) &&
1560
+ identity.sha256 === target.identity.sha256 &&
1561
+ retainedArtifactsRootMatches(record)
1289
1562
  );
1290
- } catch {
1563
+ } catch (error) {
1564
+ if ((error as Error).message === "durability_failed") throw error;
1291
1565
  return false;
1292
1566
  }
1293
1567
  }
@@ -1298,9 +1572,14 @@ async function publishCleanupArtifactsRemoved(
1298
1572
  receipt: CleanupReceipt,
1299
1573
  lock: ManagedStorageLock,
1300
1574
  ): Promise<void> {
1575
+ const retainedArtifactsRoot = retainedArtifactsRootReceipt(receipt);
1301
1576
  await publishManagedTombstone(
1302
1577
  cleanupReceiptPath(tombstone, receipt.target, "artifacts_removed", receipt.attempt),
1303
- { ...cleanupReceipt(scope, tombstone, receipt), state: "artifacts_removed" },
1578
+ {
1579
+ ...cleanupReceipt(scope, tombstone, receipt),
1580
+ state: "artifacts_removed",
1581
+ ...(retainedArtifactsRoot ? { retainedArtifactsRoot } : {}),
1582
+ },
1304
1583
  lock.assertOwned,
1305
1584
  ).catch(error => {
1306
1585
  if ((error as Error).message !== "destination_conflict") throw error;
@@ -1317,6 +1596,14 @@ function isQuarantinePath(target: RetiredTarget, pathname: unknown): pathname is
1317
1596
  );
1318
1597
  }
1319
1598
 
1599
+ function isRetainedNativePath(target: RetiredTarget, pathname: unknown): pathname is string {
1600
+ return (
1601
+ typeof pathname === "string" &&
1602
+ path.dirname(pathname) === path.dirname(target.path) &&
1603
+ path.basename(pathname).startsWith(".gjc-")
1604
+ );
1605
+ }
1606
+
1320
1607
  function deterministicRemovalRoot(plannedRoot: string): string {
1321
1608
  return `${plannedRoot}.removing`;
1322
1609
  }
@@ -1328,17 +1615,44 @@ function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pa
1328
1615
  );
1329
1616
  }
1330
1617
 
1331
- function artifactRootsAbsent(
1332
- _scope: ManagedScope,
1333
- tombstone: string,
1334
- target: RetiredTarget,
1335
- pending: CleanupReceipt,
1336
- ): boolean {
1618
+ function transcriptRootMatchesTarget(pathname: string, target: RetiredTarget): boolean {
1619
+ try {
1620
+ const observed = captureManagedFileNoFollow(pathname);
1621
+ const digest = createHash("sha256").update(observed.bytes).digest("hex");
1622
+ return (
1623
+ observed.identity.dev === target.identity.dev &&
1624
+ observed.identity.ino === target.identity.ino &&
1625
+ observed.identity.size === target.identity.size &&
1626
+ observed.identity.mtimeNs === target.identity.mtimeNs &&
1627
+ digest === target.identity.sha256
1628
+ );
1629
+ } catch (error) {
1630
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
1631
+ throw new Error("durability_failed");
1632
+ }
1633
+ }
1634
+
1635
+ function cleanupRootsAbsent(tombstone: string, target: RetiredTarget, pending: CleanupReceipt): boolean {
1337
1636
  const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
1637
+ const activeTranscriptRoots = new Set(
1638
+ [pending.plannedTranscriptPath, pending.detachedTranscriptPath].filter((pathname): pathname is string =>
1639
+ isQuarantinePath(target, pathname),
1640
+ ),
1641
+ );
1338
1642
  const roots = new Set<string>([
1643
+ target.path,
1644
+ target.path.slice(0, -6),
1339
1645
  pending.plannedArtifactsPath,
1340
1646
  deterministicRemovalRoot(pending.plannedArtifactsPath),
1341
1647
  ...(pending.detachedArtifactsPath ? [pending.detachedArtifactsPath] : []),
1648
+ ...[
1649
+ pending.retainedArtifactsSuccessorPath,
1650
+ pending.retainedArtifactsPlaceholderPath,
1651
+ pending.retainedArtifactsUnknownPath,
1652
+ pending.retainedTranscriptSuccessorPath,
1653
+ pending.retainedTranscriptPlaceholderPath,
1654
+ pending.retainedTranscriptUnknownPath,
1655
+ ].filter((pathname): pathname is string => isRetainedNativePath(target, pathname)),
1342
1656
  ]);
1343
1657
  for (const name of fs.readdirSync(path.dirname(tombstone))) {
1344
1658
  if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
@@ -1347,26 +1661,91 @@ function artifactRootsAbsent(
1347
1661
  ) as {
1348
1662
  plannedArtifactsPath?: unknown;
1349
1663
  detachedArtifactsPath?: unknown;
1664
+ plannedTranscriptPath?: unknown;
1665
+ detachedTranscriptPath?: unknown;
1666
+ retainedArtifactsSuccessorPath?: unknown;
1667
+ retainedArtifactsPlaceholderPath?: unknown;
1668
+ retainedArtifactsUnknownPath?: unknown;
1669
+ retainedTranscriptSuccessorPath?: unknown;
1670
+ retainedTranscriptPlaceholderPath?: unknown;
1671
+ retainedTranscriptUnknownPath?: unknown;
1350
1672
  };
1351
1673
  if (isQuarantinePath(target, record.plannedArtifactsPath)) {
1352
1674
  roots.add(record.plannedArtifactsPath);
1353
1675
  roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
1354
1676
  }
1355
1677
  if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
1678
+ for (const pathname of [record.plannedTranscriptPath, record.detachedTranscriptPath]) {
1679
+ if (isQuarantinePath(target, pathname) && !activeTranscriptRoots.has(pathname)) roots.add(pathname);
1680
+ }
1681
+ for (const pathname of [
1682
+ record.retainedArtifactsSuccessorPath,
1683
+ record.retainedArtifactsPlaceholderPath,
1684
+ record.retainedArtifactsUnknownPath,
1685
+ record.retainedTranscriptSuccessorPath,
1686
+ record.retainedTranscriptPlaceholderPath,
1687
+ record.retainedTranscriptUnknownPath,
1688
+ ]) {
1689
+ if (isRetainedNativePath(target, pathname)) roots.add(pathname);
1690
+ }
1691
+ }
1692
+ for (const pathname of roots) {
1693
+ try {
1694
+ fs.lstatSync(pathname);
1695
+ throw new Error("durability_failed");
1696
+ } catch (error) {
1697
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1698
+ throw error;
1699
+ }
1700
+ }
1701
+ for (const pathname of activeTranscriptRoots) {
1702
+ try {
1703
+ fs.lstatSync(pathname);
1704
+ } catch (error) {
1705
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
1706
+ throw error;
1707
+ }
1708
+ if (!transcriptRootMatchesTarget(pathname, target)) throw new Error("durability_failed");
1709
+ return false;
1356
1710
  }
1357
- return [...roots].every(pathname => !fs.existsSync(pathname));
1711
+ return true;
1358
1712
  }
1359
1713
 
1360
1714
  function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
1361
1715
  return left.dev === right.dev && left.ino === right.ino;
1362
1716
  }
1363
1717
 
1364
- /**
1365
- * A pending receipt is written before the native no-replace detach. If a crash
1366
- * happens after that detach, the next receipt may only inherit the exact
1367
- * planned quarantine pathname and identity from its predecessor.
1368
- */
1718
+ function sameArtifactTreeSnapshot(left: NativeDirectoryTreeSnapshot, right: NativeDirectoryTreeSnapshot): boolean {
1719
+ if (left.rootDev !== right.rootDev || left.rootIno !== right.rootIno || left.entries.length !== right.entries.length)
1720
+ return false;
1721
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string =>
1722
+ JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino, entry.size, entry.mtimeNs, entry.sha256]);
1723
+ const leftEntries = left.entries.map(entryKey).sort();
1724
+ const rightEntries = right.entries.map(entryKey).sort();
1725
+ return leftEntries.every((entry, index) => entry === rightEntries[index]);
1726
+ }
1727
+
1728
+ function assertRetainedArtifactsAuthority(pending: CleanupReceipt): void {
1729
+ if (!pending.detachedArtifactsPath) return;
1730
+ if (!pending.expectedArtifactsIdentity || !pending.expectedArtifactsTree) throw new Error("durability_failed");
1731
+ try {
1732
+ fs.lstatSync(pending.detachedArtifactsPath);
1733
+ } catch (error) {
1734
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
1735
+ throw new Error("durability_failed");
1736
+ }
1737
+ const observed = artifactIdentityAt(pending.detachedArtifactsPath);
1738
+ if (
1739
+ !observed ||
1740
+ !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity) ||
1741
+ !sameArtifactTreeSnapshot(snapshotArtifactTree(pending.detachedArtifactsPath), pending.expectedArtifactsTree)
1742
+ )
1743
+ throw new Error("binding_invalid");
1744
+ }
1745
+
1369
1746
  function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceipt): CleanupReceipt {
1747
+ assertRetainedArtifactsAuthority(pending);
1748
+
1370
1749
  let detachedArtifactsPath = pending.detachedArtifactsPath;
1371
1750
  let detachedTranscriptPath = pending.detachedTranscriptPath;
1372
1751
  for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
@@ -1430,6 +1809,16 @@ function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | und
1430
1809
  })
1431
1810
  )
1432
1811
  return undefined;
1812
+ const roots = snapshot.entries.filter(entry => {
1813
+ const item = entry as Record<string, unknown>;
1814
+ return (
1815
+ item.relativePath === "" &&
1816
+ item.kind === "directory" &&
1817
+ item.dev === snapshot.rootDev &&
1818
+ item.ino === snapshot.rootIno
1819
+ );
1820
+ });
1821
+ if (roots.length !== 1) return undefined;
1433
1822
  return snapshot as unknown as NativeDirectoryTreeSnapshot;
1434
1823
  }
1435
1824
 
@@ -1481,7 +1870,19 @@ function pendingCleanupReceipt(
1481
1870
  plannedPaths.has(record.plannedArtifactsPath as string) ||
1482
1871
  plannedPaths.has(record.plannedTranscriptPath as string) ||
1483
1872
  (record.detachedArtifactsPath !== undefined && !isQuarantinePath(target, record.detachedArtifactsPath)) ||
1484
- (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath))
1873
+ (record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath)) ||
1874
+ (record.retainedArtifactsSuccessorPath !== undefined &&
1875
+ !isRetainedNativePath(target, record.retainedArtifactsSuccessorPath)) ||
1876
+ (record.retainedArtifactsPlaceholderPath !== undefined &&
1877
+ !isRetainedNativePath(target, record.retainedArtifactsPlaceholderPath)) ||
1878
+ (record.retainedArtifactsUnknownPath !== undefined &&
1879
+ !isRetainedNativePath(target, record.retainedArtifactsUnknownPath)) ||
1880
+ (record.retainedTranscriptSuccessorPath !== undefined &&
1881
+ !isRetainedNativePath(target, record.retainedTranscriptSuccessorPath)) ||
1882
+ (record.retainedTranscriptPlaceholderPath !== undefined &&
1883
+ !isRetainedNativePath(target, record.retainedTranscriptPlaceholderPath)) ||
1884
+ (record.retainedTranscriptUnknownPath !== undefined &&
1885
+ !isRetainedNativePath(target, record.retainedTranscriptUnknownPath))
1485
1886
  )
1486
1887
  throw new Error("durability_failed");
1487
1888
  const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
@@ -1504,7 +1905,7 @@ function pendingCleanupReceipt(
1504
1905
  const expectedArtifactsTree =
1505
1906
  record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
1506
1907
  if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1507
- if (record.detachedArtifactsPath !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
1908
+
1508
1909
  if (
1509
1910
  latest &&
1510
1911
  ((record.detachedArtifactsPath !== undefined &&
@@ -1523,6 +1924,12 @@ function pendingCleanupReceipt(
1523
1924
  expectedArtifactsTree,
1524
1925
  detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
1525
1926
  detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
1927
+ retainedArtifactsSuccessorPath: record.retainedArtifactsSuccessorPath as string | undefined,
1928
+ retainedArtifactsPlaceholderPath: record.retainedArtifactsPlaceholderPath as string | undefined,
1929
+ retainedArtifactsUnknownPath: record.retainedArtifactsUnknownPath as string | undefined,
1930
+ retainedTranscriptSuccessorPath: record.retainedTranscriptSuccessorPath as string | undefined,
1931
+ retainedTranscriptPlaceholderPath: record.retainedTranscriptPlaceholderPath as string | undefined,
1932
+ retainedTranscriptUnknownPath: record.retainedTranscriptUnknownPath as string | undefined,
1526
1933
  plannedArtifactsPath: record.plannedArtifactsPath as string,
1527
1934
  plannedTranscriptPath: record.plannedTranscriptPath as string,
1528
1935
  };
@@ -1572,7 +1979,13 @@ function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | und
1572
1979
  expectedArtifactsIdentity,
1573
1980
  expectedArtifactsTree,
1574
1981
  detachedArtifactsPath: pending?.detachedArtifactsPath,
1982
+ retainedArtifactsSuccessorPath: pending?.retainedArtifactsSuccessorPath,
1983
+ retainedArtifactsPlaceholderPath: pending?.retainedArtifactsPlaceholderPath,
1984
+ retainedArtifactsUnknownPath: pending?.retainedArtifactsUnknownPath,
1575
1985
  detachedTranscriptPath: pending?.detachedTranscriptPath,
1986
+ retainedTranscriptSuccessorPath: pending?.retainedTranscriptSuccessorPath,
1987
+ retainedTranscriptPlaceholderPath: pending?.retainedTranscriptPlaceholderPath,
1988
+ retainedTranscriptUnknownPath: pending?.retainedTranscriptUnknownPath,
1576
1989
  plannedArtifactsPath: path.join(directory, `.gjc-delete-${operation}-artifacts-${attempt}`),
1577
1990
  plannedTranscriptPath: path.join(directory, `.gjc-delete-${operation}-transcript-${attempt}`),
1578
1991
  };
@@ -1585,6 +1998,36 @@ function requiresFreshCleanupPlan(pending: CleanupReceipt): boolean {
1585
1998
  );
1586
1999
  }
1587
2000
 
2001
+ /** Evidence-carrier for a verified `cleanup_pending` deletion, bound to the active plan. */
2002
+ function cleanupPendingEvidence(
2003
+ retry: CleanupReceipt,
2004
+ active: CleanupReceipt,
2005
+ deletion: Extract<VerifiedSessionDeleteResult, { kind: "cleanup_pending" }>,
2006
+ ): CleanupReceipt {
2007
+ return {
2008
+ ...retry,
2009
+ expectedArtifactsIdentity:
2010
+ deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2011
+ expectedArtifactsTree: deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2012
+ detachedArtifactsPath:
2013
+ deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2014
+ detachedTranscriptPath:
2015
+ deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2016
+ retainedArtifactsSuccessorPath:
2017
+ deletion.phase === "artifacts" ? deletion.retainedSuccessorPath : active.retainedArtifactsSuccessorPath,
2018
+ retainedArtifactsPlaceholderPath:
2019
+ deletion.phase === "artifacts" ? deletion.retainedPlaceholderPath : active.retainedArtifactsPlaceholderPath,
2020
+ retainedArtifactsUnknownPath:
2021
+ deletion.phase === "artifacts" ? deletion.retainedUnknownPath : active.retainedArtifactsUnknownPath,
2022
+ retainedTranscriptSuccessorPath:
2023
+ deletion.phase === "transcript" ? deletion.retainedSuccessorPath : active.retainedTranscriptSuccessorPath,
2024
+ retainedTranscriptPlaceholderPath:
2025
+ deletion.phase === "transcript" ? deletion.retainedPlaceholderPath : active.retainedTranscriptPlaceholderPath,
2026
+ retainedTranscriptUnknownPath:
2027
+ deletion.phase === "transcript" ? deletion.retainedUnknownPath : active.retainedTranscriptUnknownPath,
2028
+ };
2029
+ }
2030
+
1588
2031
  async function publishCleanupPending(
1589
2032
  scope: ManagedScope,
1590
2033
  tombstone: string,
@@ -1846,7 +2289,7 @@ function planArtifactRootForMigration(sourceTranscript: string, operation: strin
1846
2289
  dev: stat.dev,
1847
2290
  ino: stat.ino,
1848
2291
  size: process.platform === "win32" ? BigInt(root.size) : stat.size,
1849
- mtimeNs: stat.mtimeNs,
2292
+ mtimeNs: process.platform === "win32" ? BigInt(root.mtimeNs) : stat.mtimeNs,
1850
2293
  },
1851
2294
  tree,
1852
2295
  };
@@ -1869,47 +2312,194 @@ function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
1869
2312
  }
1870
2313
  }
1871
2314
 
1872
- function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
2315
+ export function matchesMigrationArtifactRoot(
2316
+ pathname: string,
2317
+ identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint },
2318
+ expectedTree: NativeDirectoryTreeSnapshot,
2319
+ platform: NodeJS.Platform = process.platform,
2320
+ ): boolean {
1873
2321
  try {
1874
2322
  const stat = fs.lstatSync(pathname, { bigint: true });
2323
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.dev !== identity.dev || stat.ino !== identity.ino)
2324
+ return false;
2325
+ const observed = native.snapshotDirectoryTree(pathname);
2326
+ const expectedRoot = expectedTree.entries.find(entry => entry.relativePath === "" && entry.kind === "directory");
2327
+ const observedRoot = observed.snapshot?.entries.find(
2328
+ entry => entry.relativePath === "" && entry.kind === "directory",
2329
+ );
2330
+ if (
2331
+ !observed.ok ||
2332
+ !observed.snapshot ||
2333
+ !expectedRoot ||
2334
+ !observedRoot ||
2335
+ (platform === "win32" &&
2336
+ (observedRoot.dev !== identity.dev.toString() ||
2337
+ observedRoot.ino !== identity.ino.toString() ||
2338
+ BigInt(observedRoot.size) !== identity.size ||
2339
+ BigInt(observedRoot.mtimeNs) !== identity.mtimeNs)) ||
2340
+ (platform !== "win32" && (stat.size !== identity.size || stat.mtimeNs !== identity.mtimeNs))
2341
+ )
2342
+ return false;
2343
+ return sameArtifactTree(expectedTree, observed.snapshot, { platform, phase: "migration" });
2344
+ } catch {
2345
+ return false;
2346
+ }
2347
+ }
2348
+
2349
+ type ArtifactTreeComparisonPolicy = {
2350
+ platform?: NodeJS.Platform;
2351
+ phase?: "migration" | "strict";
2352
+ };
2353
+
2354
+ function sameArtifactTree(
2355
+ left: NativeDirectoryTreeSnapshot,
2356
+ right: NativeDirectoryTreeSnapshot,
2357
+ policy: ArtifactTreeComparisonPolicy = {},
2358
+ ): boolean {
2359
+ const { platform = process.platform, phase = "strict" } = policy;
2360
+ const entryKey = (entry: NativeDirectoryTreeSnapshot["entries"][number]): string => {
2361
+ // Detaching the root directory changes its ctime without changing the preserved artifact tree;
2362
+ // root identity and mtime are checked above. pi-iso also accepts Windows plain directories by
2363
+ // stable file id and kind only because NTFS can lazily update their metadata while enumeration
2364
+ // trails an open handle. All other platforms and nested directories compare every captured field.
2365
+ if (phase === "migration" && entry.kind === "directory" && (entry.relativePath === "" || platform === "win32"))
2366
+ return JSON.stringify([entry.relativePath, entry.kind, entry.dev, entry.ino]);
2367
+ return JSON.stringify([
2368
+ entry.relativePath,
2369
+ entry.kind,
2370
+ entry.dev,
2371
+ entry.ino,
2372
+ entry.size,
2373
+ entry.mtimeNs,
2374
+ entry.ctimeNs,
2375
+ entry.sha256,
2376
+ ]);
2377
+ };
2378
+ const leftEntries = left.entries.map(entryKey).sort();
2379
+ const rightEntries = right.entries.map(entryKey).sort();
2380
+ return (
2381
+ left.rootDev === right.rootDev &&
2382
+ left.rootIno === right.rootIno &&
2383
+ leftEntries.length === rightEntries.length &&
2384
+ leftEntries.every((entry, index) => entry === rightEntries[index])
2385
+ );
2386
+ }
2387
+
2388
+ function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
2389
+ return (
2390
+ matchesMigrationArtifactRoot(pathname, plan.identity, plan.tree) &&
2391
+ sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
2392
+ );
2393
+ }
2394
+
2395
+ type SourceArtifactCleanup = {
2396
+ state: "cleanup_pending";
2397
+ role: "exchange_placeholder";
2398
+ retainedPath: string;
2399
+ identity: DetachedArtifactRoot["identity"];
2400
+ tree: NativeDirectoryTreeSnapshot;
2401
+ };
2402
+
2403
+ function cleanupAuthorityMatches(cleanup: SourceArtifactCleanup, parent: string): boolean {
2404
+ try {
2405
+ const stat = fs.lstatSync(cleanup.retainedPath, { bigint: true });
2406
+ if (
2407
+ path.dirname(cleanup.retainedPath) !== parent ||
2408
+ !stat.isDirectory() ||
2409
+ stat.isSymbolicLink() ||
2410
+ stat.dev !== cleanup.identity.dev ||
2411
+ stat.ino !== cleanup.identity.ino ||
2412
+ stat.size !== cleanup.identity.size ||
2413
+ stat.mtimeNs !== cleanup.identity.mtimeNs
2414
+ )
2415
+ return false;
2416
+ const snapshot = native.snapshotDirectoryTree(cleanup.retainedPath);
1875
2417
  return (
1876
- stat.isDirectory() &&
1877
- !stat.isSymbolicLink() &&
1878
- stat.dev === plan.identity.dev &&
1879
- stat.ino === plan.identity.ino &&
1880
- stat.size === plan.identity.size &&
1881
- stat.mtimeNs === plan.identity.mtimeNs &&
1882
- sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
2418
+ !!snapshot.ok &&
2419
+ !!snapshot.snapshot &&
2420
+ sameArtifactTree(snapshot.snapshot, cleanup.tree) &&
2421
+ cleanup.tree.entries.length === 1 &&
2422
+ cleanup.tree.entries[0]?.relativePath === "" &&
2423
+ cleanup.tree.entries[0]?.kind === "directory"
1883
2424
  );
1884
2425
  } catch {
1885
2426
  return false;
1886
2427
  }
1887
2428
  }
1888
2429
 
1889
- function detachArtifactRootForMigration(plan: DetachedArtifactRoot): DetachedArtifactRoot {
2430
+ function detachArtifactRootForMigration(
2431
+ plan: DetachedArtifactRoot,
2432
+ ):
2433
+ | { detached: DetachedArtifactRoot; detachOutcome: "clean" }
2434
+ | { detached: DetachedArtifactRoot; detachOutcome: "cleanup_pending"; cleanup: SourceArtifactCleanup } {
1890
2435
  const result = native.exactUnlink(plan.originalPath, {
1891
2436
  ...plan.identity,
1892
2437
  directory: true,
1893
2438
  detachOnly: true,
1894
2439
  quarantineName: path.basename(plan.detachedPath),
1895
2440
  });
1896
- if (!result.ok || !result.detachedPath || !matchesDetachedArtifactRoot(result.detachedPath, plan))
2441
+ const cleanSuccess =
2442
+ result.ok && !result.retainedSuccessorPath && !result.retainedPlaceholderPath && !result.retainedUnknownPath;
2443
+ const cleanupPending =
2444
+ !result.ok &&
2445
+ result.code === "cleanup_pending" &&
2446
+ !!result.detachedPath &&
2447
+ !!result.retainedPlaceholderPath &&
2448
+ !result.retainedSuccessorPath &&
2449
+ !result.retainedUnknownPath;
2450
+ if (
2451
+ (!cleanSuccess && !cleanupPending) ||
2452
+ !result.detachedPath ||
2453
+ !matchesDetachedArtifactRoot(result.detachedPath, plan)
2454
+ )
1897
2455
  throw new Error("durability_failed");
2456
+
1898
2457
  const detachedPath =
1899
2458
  process.platform === "win32" ? fs.realpathSync.native(result.detachedPath) : result.detachedPath;
1900
2459
  if (!matchesDetachedArtifactRoot(detachedPath, plan)) throw new Error("durability_failed");
1901
- return { ...plan, detachedPath };
2460
+ const detached = { ...plan, detachedPath };
2461
+ if (!cleanupPending) return { detached, detachOutcome: "clean" };
2462
+ const placeholder = result.retainedPlaceholderPath!;
2463
+ const stat = fs.lstatSync(placeholder, { bigint: true });
2464
+ if (!stat.isDirectory() || stat.isSymbolicLink() || path.dirname(placeholder) !== path.dirname(plan.originalPath))
2465
+ throw new Error("durability_failed");
2466
+ const snapshot = native.snapshotDirectoryTree(placeholder);
2467
+ const cleanup: SourceArtifactCleanup = {
2468
+ state: "cleanup_pending",
2469
+ role: "exchange_placeholder",
2470
+ retainedPath: placeholder,
2471
+ identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
2472
+ tree: snapshot.snapshot!,
2473
+ };
2474
+ if (!snapshot.ok || !snapshot.snapshot || !cleanupAuthorityMatches(cleanup, path.dirname(plan.originalPath)))
2475
+ throw new Error("durability_failed");
2476
+ return { detached, detachOutcome: "cleanup_pending", cleanup };
1902
2477
  }
1903
2478
 
1904
- function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
1905
- const receipt = receiptPathFor(scope, source, "prepared");
2479
+ export function restorePreparedArtifactRoot(
2480
+ scope: ManagedScope,
2481
+ source: ManagedCandidate,
2482
+ lock?: ManagedStorageLock,
2483
+ ): void {
2484
+ const preparedReceipt = receiptPathFor(scope, source, "prepared");
2485
+ const detachedReceipt = receiptPathFor(scope, source, "detached");
2486
+ const receipt = fs.existsSync(detachedReceipt) ? detachedReceipt : preparedReceipt;
1906
2487
  let record: {
1907
2488
  sourceArtifactQuarantine?: {
1908
2489
  path?: unknown;
1909
2490
  detachedPath?: unknown;
1910
2491
  identity?: Record<string, unknown>;
1911
2492
  tree?: unknown;
2493
+ role?: unknown;
2494
+ };
2495
+ sourceArtifactCleanup?: {
2496
+ state?: unknown;
2497
+ role?: unknown;
2498
+ retainedPath?: unknown;
2499
+ identity?: Record<string, unknown>;
2500
+ tree?: unknown;
1912
2501
  };
2502
+ detachOutcome?: unknown;
1913
2503
  };
1914
2504
  try {
1915
2505
  record = JSON.parse(captureManagedFileNoFollow(receipt).bytes.toString("utf8")) as typeof record;
@@ -1933,39 +2523,105 @@ function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandida
1933
2523
  typeof identity.mtimeNs !== "string"
1934
2524
  )
1935
2525
  throw new Error("durability_failed");
2526
+ const artifactIdentity = {
2527
+ dev: BigInt(identity.dev),
2528
+ ino: BigInt(identity.ino),
2529
+ size: BigInt(identity.size),
2530
+ mtimeNs: BigInt(identity.mtimeNs),
2531
+ };
1936
2532
  const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
1937
2533
  const assertPreparedTree = (pathname: string): void => {
1938
- validateManagedArtifactTree(pathname);
1939
- const observed = native.snapshotDirectoryTree(pathname);
1940
- if (!observed.ok || !observed.snapshot || JSON.stringify(observed.snapshot) !== JSON.stringify(expectedTree))
2534
+ if (
2535
+ !matchesMigrationArtifactRoot(
2536
+ pathname,
2537
+ {
2538
+ ...artifactIdentity,
2539
+ },
2540
+ expectedTree,
2541
+ )
2542
+ )
1941
2543
  throw new Error("durability_failed");
1942
2544
  };
2545
+ if (receipt === detachedReceipt) {
2546
+ if (quarantine.role !== "detached_artifact_root") throw new Error("durability_failed");
2547
+ if (record.detachOutcome === "clean") {
2548
+ if (record.sourceArtifactCleanup !== undefined) throw new Error("durability_failed");
2549
+ const originalExists = fs.existsSync(quarantine.path);
2550
+ const detachedExists = fs.existsSync(quarantine.detachedPath);
2551
+ if (originalExists) {
2552
+ if (detachedExists) throw new Error("durability_failed");
2553
+ assertPreparedTree(quarantine.path);
2554
+ if (lock) {
2555
+ lock.assertOwned();
2556
+ fs.unlinkSync(receipt);
2557
+ fsyncManagedParent(receipt);
2558
+ }
2559
+ return;
2560
+ }
2561
+ } else if (record.detachOutcome === "cleanup_pending") {
2562
+ const cleanup = record.sourceArtifactCleanup;
2563
+ if (!cleanup) throw new Error("durability_failed");
2564
+ const cleanupIdentity = cleanup.identity;
2565
+ const cleanupTree = artifactTreeSnapshot(cleanup.tree);
2566
+ if (
2567
+ cleanup.state !== "cleanup_pending" ||
2568
+ cleanup.role !== "exchange_placeholder" ||
2569
+ typeof cleanup.retainedPath !== "string" ||
2570
+ !cleanupIdentity ||
2571
+ typeof cleanupIdentity.dev !== "string" ||
2572
+ typeof cleanupIdentity.ino !== "string" ||
2573
+ typeof cleanupIdentity.size !== "string" ||
2574
+ typeof cleanupIdentity.mtimeNs !== "string" ||
2575
+ !cleanupTree ||
2576
+ !cleanupAuthorityMatches(
2577
+ {
2578
+ state: "cleanup_pending",
2579
+ role: "exchange_placeholder",
2580
+ retainedPath: cleanup.retainedPath,
2581
+ identity: {
2582
+ dev: BigInt(cleanupIdentity.dev),
2583
+ ino: BigInt(cleanupIdentity.ino),
2584
+ size: BigInt(cleanupIdentity.size),
2585
+ mtimeNs: BigInt(cleanupIdentity.mtimeNs),
2586
+ },
2587
+ tree: cleanupTree,
2588
+ },
2589
+ path.dirname(source.path),
2590
+ )
2591
+ )
2592
+ throw new Error("durability_failed");
2593
+ } else {
2594
+ throw new Error("durability_failed");
2595
+ }
2596
+ }
2597
+
1943
2598
  if (fs.existsSync(quarantine.path)) {
1944
- const existing = fs.lstatSync(quarantine.path, { bigint: true });
1945
2599
  if (
1946
- existing.isSymbolicLink() ||
1947
- !existing.isDirectory() ||
1948
- existing.dev !== BigInt(identity.dev) ||
1949
- existing.ino !== BigInt(identity.ino) ||
1950
- existing.size !== BigInt(identity.size) ||
1951
- existing.mtimeNs !== BigInt(identity.mtimeNs)
1952
- )
1953
- throw new Error("durability_failed");
1954
- assertPreparedTree(quarantine.path);
2600
+ matchesMigrationArtifactRoot(
2601
+ quarantine.path,
2602
+ {
2603
+ ...artifactIdentity,
2604
+ },
2605
+ expectedTree,
2606
+ )
2607
+ ) {
2608
+ assertPreparedTree(quarantine.path);
2609
+ }
2610
+ // A changed source pathname is independent retained authority. Do not replace
2611
+ // it with the detached original; retain both roots for recovery.
1955
2612
  return;
1956
2613
  }
1957
2614
  assertPreparedTree(quarantine.detachedPath);
1958
2615
  const result = native.exactRestore(quarantine.detachedPath, quarantine.path, {
1959
- dev: BigInt(identity.dev),
1960
- ino: BigInt(identity.ino),
1961
- size: BigInt(identity.size),
1962
- mtimeNs: BigInt(identity.mtimeNs),
2616
+ ...artifactIdentity,
1963
2617
  directory: true,
1964
2618
  });
1965
2619
  if (!result.ok) throw new Error("durability_failed");
1966
2620
  }
1967
2621
 
1968
- function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot): void {
2622
+ function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot, cleanup?: SourceArtifactCleanup): void {
2623
+ if (cleanup && !cleanupAuthorityMatches(cleanup, path.dirname(detached.originalPath)))
2624
+ throw new Error("durability_failed");
1969
2625
  const result = native.exactRestore(detached.detachedPath, detached.originalPath, {
1970
2626
  ...detached.identity,
1971
2627
  directory: true,
@@ -1988,40 +2644,41 @@ async function copyArtifacts(
1988
2644
  const sourceRoot = sourceRootOverride ?? sourceTranscript.slice(0, -6);
1989
2645
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot)) throw new Error("source_changed");
1990
2646
  const destinationRoot = destinationTranscript.slice(0, -6);
1991
- for (const entry of manifest) {
2647
+ for (let start = 0; start < manifest.length; start += MANAGED_ARTIFACT_COPY_BATCH_SIZE) {
2648
+ const batch = manifest.slice(start, start + MANAGED_ARTIFACT_COPY_BATCH_SIZE);
1992
2649
  revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
1993
2650
  lock.assertOwned();
1994
- const source = path.join(sourceRoot, entry.path);
1995
- const destination = path.join(destinationRoot, entry.path);
1996
- if (entry.kind === "directory") {
1997
- if (entry.path === "") {
1998
- revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
1999
- ensureManagedDirectory(destinationRoot, root);
2000
- } else {
2001
- revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2002
- ensureManagedDirectory(destination, root);
2651
+ for (const entry of batch) {
2652
+ const source = path.join(sourceRoot, entry.path);
2653
+ const destination = path.join(destinationRoot, entry.path);
2654
+ if (entry.kind === "directory") {
2655
+ if (entry.path === "") {
2656
+ ensureManagedDirectory(destinationRoot, root);
2657
+ } else {
2658
+ ensureManagedDirectory(destination, root);
2659
+ }
2660
+ continue;
2003
2661
  }
2004
- continue;
2005
- }
2006
- const snapshot = captureManagedFileNoFollow(source);
2007
- if (
2008
- snapshot.bytes.byteLength !== entry.size ||
2009
- createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
2010
- )
2011
- throw new Error("source_changed");
2012
- try {
2013
- revalidatePickerConsent(scope, expectedCandidate, expectedIdentity);
2014
- await copyManagedFileNoReplace(source, destination, snapshot, root);
2015
- } catch (error) {
2016
- if ((error as Error).message !== "destination_conflict") throw error;
2662
+ const snapshot = captureManagedFileNoFollow(source);
2663
+ if (
2664
+ snapshot.bytes.byteLength !== entry.size ||
2665
+ createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
2666
+ )
2667
+ throw new Error("source_changed");
2668
+ try {
2669
+ await copyManagedFileNoReplace(source, destination, snapshot, root);
2670
+ } catch (error) {
2671
+ if ((error as Error).message !== "destination_conflict") throw error;
2672
+ }
2673
+ lock.assertOwned();
2674
+ const copied = captureManagedFileNoFollow(destination);
2675
+ if (
2676
+ copied.bytes.byteLength !== entry.size ||
2677
+ createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
2678
+ )
2679
+ throw new Error("durability_failed");
2017
2680
  }
2018
- lock.assertOwned();
2019
- const copied = captureManagedFileNoFollow(destination);
2020
- if (
2021
- copied.bytes.byteLength !== entry.size ||
2022
- createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
2023
- )
2024
- throw new Error("durability_failed");
2681
+ if (start + batch.length < manifest.length) await new Promise<void>(resolve => setTimeout(resolve, 0));
2025
2682
  }
2026
2683
  if (!manifestMatches(sourceTranscript, manifest, sourceRoot) || !manifestMatches(destinationTranscript, manifest))
2027
2684
  throw new Error("durability_failed");
@@ -2030,7 +2687,7 @@ async function copyArtifacts(
2030
2687
  function migrationReceipt(
2031
2688
  scope: ManagedScope,
2032
2689
  lock: ManagedStorageLock,
2033
- state: "prepared" | "published" | "committed",
2690
+ state: "prepared" | "artifact_detached" | "published" | "committed",
2034
2691
  source: ManagedCandidate,
2035
2692
  destination: ManagedCandidate | { path: string; sessionId: string; cwd: string },
2036
2693
  manifest: readonly ArtifactManifestEntry[],
@@ -2039,7 +2696,10 @@ function migrationReceipt(
2039
2696
  detachedPath: string;
2040
2697
  identity: DetachedArtifactRoot["identity"];
2041
2698
  tree: NativeDirectoryTreeSnapshot;
2699
+ role?: "detached_artifact_root";
2042
2700
  },
2701
+ sourceArtifactCleanup?: SourceArtifactCleanup,
2702
+ detachOutcome?: "clean" | "cleanup_pending",
2043
2703
  ): Uint8Array {
2044
2704
  lock.assertOwned();
2045
2705
  const destinationRecord =
@@ -2057,16 +2717,27 @@ function migrationReceipt(
2057
2717
  header: { id: destination.sessionId, cwd: destination.cwd },
2058
2718
  };
2059
2719
  return new TextEncoder().encode(
2060
- `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2720
+ `${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}), ...(sourceArtifactCleanup ? { sourceArtifactCleanup } : {}), ...(detachOutcome ? { detachOutcome } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
2061
2721
  );
2062
2722
  }
2063
2723
 
2724
+ function detachedReceiptMatches(receipt: string, expected: Uint8Array): boolean {
2725
+ try {
2726
+ return captureManagedFileNoFollow(receipt).bytes.equals(expected);
2727
+ } catch {
2728
+ return false;
2729
+ }
2730
+ }
2731
+
2064
2732
  async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
2065
- for (const state of ["prepared", "published"] as const) {
2733
+ for (const state of ["prepared", "detached", "published"] as const) {
2066
2734
  const pathname = receiptPathFor(scope, candidate, state);
2067
2735
  try {
2068
2736
  const stat = fs.lstatSync(pathname);
2069
- if (stat.isFile() || stat.isSymbolicLink()) await fs.promises.unlink(pathname);
2737
+ if (stat.isFile() || stat.isSymbolicLink()) {
2738
+ await fs.promises.unlink(pathname);
2739
+ fsyncManagedParent(pathname);
2740
+ }
2070
2741
  } catch (error) {
2071
2742
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
2072
2743
  }
@@ -2145,8 +2816,7 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2145
2816
  if (!observedPending) continue;
2146
2817
  if (
2147
2818
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2148
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
2149
- !fs.existsSync(observedPending.plannedTranscriptPath)
2819
+ cleanupRootsAbsent(tombstone, target, observedPending)
2150
2820
  ) {
2151
2821
  fsyncManagedParent(target.path);
2152
2822
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2185,6 +2855,12 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2185
2855
  ? pending.plannedTranscriptPath
2186
2856
  : undefined) ??
2187
2857
  (fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
2858
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2859
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2860
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2861
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2862
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2863
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2188
2864
  plannedArtifactsPath: active.plannedArtifactsPath,
2189
2865
  plannedTranscriptPath: active.plannedTranscriptPath,
2190
2866
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -2201,6 +2877,14 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2201
2877
  transcriptIdentity: target.identity,
2202
2878
  plannedArtifactsPath: active.plannedArtifactsPath,
2203
2879
  plannedTranscriptPath: active.plannedTranscriptPath,
2880
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
2881
+
2882
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
2883
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
2884
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
2885
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
2886
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
2887
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2204
2888
  artifactsRemoved: true,
2205
2889
  });
2206
2890
  }
@@ -2221,10 +2905,34 @@ export async function reconcileManagedTombstones(scope: ManagedScope): Promise<v
2221
2905
  deletion.phase === "transcript"
2222
2906
  ? deletion.detachedTranscriptPath
2223
2907
  : active.detachedTranscriptPath,
2908
+ retainedArtifactsSuccessorPath:
2909
+ deletion.phase === "artifacts"
2910
+ ? deletion.retainedSuccessorPath
2911
+ : active.retainedArtifactsSuccessorPath,
2912
+ retainedArtifactsPlaceholderPath:
2913
+ deletion.phase === "artifacts"
2914
+ ? deletion.retainedPlaceholderPath
2915
+ : active.retainedArtifactsPlaceholderPath,
2916
+ retainedArtifactsUnknownPath:
2917
+ deletion.phase === "artifacts"
2918
+ ? deletion.retainedUnknownPath
2919
+ : active.retainedArtifactsUnknownPath,
2920
+ retainedTranscriptSuccessorPath:
2921
+ deletion.phase === "transcript"
2922
+ ? deletion.retainedSuccessorPath
2923
+ : active.retainedTranscriptSuccessorPath,
2924
+ retainedTranscriptPlaceholderPath:
2925
+ deletion.phase === "transcript"
2926
+ ? deletion.retainedPlaceholderPath
2927
+ : active.retainedTranscriptPlaceholderPath,
2928
+ retainedTranscriptUnknownPath:
2929
+ deletion.phase === "transcript"
2930
+ ? deletion.retainedUnknownPath
2931
+ : active.retainedTranscriptUnknownPath,
2224
2932
  },
2225
2933
  lock,
2226
2934
  );
2227
- throw new Error("durability_failed");
2935
+ continue;
2228
2936
  }
2229
2937
  fsyncManagedParent(target.path);
2230
2938
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2257,11 +2965,24 @@ export async function prepareManagedSessionScopeForWrite(
2257
2965
  await reconcileManagedTombstones(scope);
2258
2966
  return { kind: "resolved", scope };
2259
2967
  } catch (error) {
2260
- if (error instanceof Error && error.message === "durability_failed") return { kind: "resolved", scope };
2968
+ const publication = error instanceof ManagedPublishError ? error : undefined;
2969
+ const message =
2970
+ publication?.classification ??
2971
+ (error instanceof Error ? error.message : "Managed write protocol setup failed.");
2972
+ const code =
2973
+ message === "atomic_unavailable" ||
2974
+ message === "invalid_request" ||
2975
+ message === "durability_failed" ||
2976
+ message === "durability_not_provable"
2977
+ ? message
2978
+ : "binding_invalid";
2261
2979
  return {
2262
2980
  kind: "error",
2263
- code: "binding_invalid",
2264
- message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
2981
+ code,
2982
+ message,
2983
+ ...(publication
2984
+ ? { cause: { classification: publication.classification, diagnostic: publication.diagnostic } }
2985
+ : { cause: { classification: code } }),
2265
2986
  };
2266
2987
  }
2267
2988
  }
@@ -2300,7 +3021,8 @@ export async function openManagedCandidateForWrite(
2300
3021
  if (prepared.kind === "error")
2301
3022
  return {
2302
3023
  kind: "error",
2303
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
3024
+ code: expectedFailure(new Error(prepared.code)),
3025
+
2304
3026
  message: prepared.message,
2305
3027
  };
2306
3028
  current = revalidatePickerConsent(scope, candidate, expectedIdentity);
@@ -2319,11 +3041,15 @@ export async function openManagedCandidateForWrite(
2319
3041
  const internal = managedInternalDirectory(scope);
2320
3042
  let lock: ManagedStorageLock | undefined;
2321
3043
  let detachedArtifacts: DetachedArtifactRoot | undefined;
3044
+ let sourceArtifactCleanup: SourceArtifactCleanup | undefined;
3045
+
2322
3046
  try {
2323
3047
  revalidatePickerConsent(scope, current, expectedIdentity);
2324
3048
  lock = await acquireManagedLock(path.join(internal, MANAGED_LOCKS_DIRECTORY), operation, scopeRoot(scope));
2325
3049
  const heldLock = lock;
3050
+
2326
3051
  const afterLock = revalidatePickerConsent(scope, current, expectedIdentity);
3052
+
2327
3053
  const listing = listManagedCandidates(scope);
2328
3054
  if (listing.kind === "error") return { kind: "error", code: "binding_invalid", message: listing.message };
2329
3055
  const destination = path.join(scope.directoryPath, path.basename(afterLock.path));
@@ -2335,11 +3061,22 @@ export async function openManagedCandidateForWrite(
2335
3061
  code: "destination_conflict",
2336
3062
  message: "A distinct v2 transcript already owns this session id.",
2337
3063
  };
3064
+
2338
3065
  scopeRoot(scope);
2339
3066
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2340
- restorePreparedArtifactRoot(scope, afterLock);
3067
+ restorePreparedArtifactRoot(scope, afterLock, heldLock);
2341
3068
  scopeRoot(scope);
2342
- const sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3069
+
3070
+ let sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3071
+ if (
3072
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
3073
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
3074
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
3075
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
3076
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
3077
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
3078
+ )
3079
+ throw new Error("source_changed");
2343
3080
  let manifest: readonly ArtifactManifestEntry[] = [];
2344
3081
  const artifactPlan = planArtifactRootForMigration(afterLock.path, operation);
2345
3082
  const intendedDestination = { path: destination, sessionId: afterLock.sessionId, cwd: afterLock.cwd };
@@ -2361,6 +3098,7 @@ export async function openManagedCandidateForWrite(
2361
3098
  message: "The migration destination already exists without validated ownership.",
2362
3099
  };
2363
3100
  }
3101
+
2364
3102
  const preparedReceipt = receiptPathFor(scope, afterLock, "prepared");
2365
3103
  try {
2366
3104
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
@@ -2389,14 +3127,53 @@ export async function openManagedCandidateForWrite(
2389
3127
  } catch (error) {
2390
3128
  if ((error as Error).message !== "destination_conflict") throw error;
2391
3129
  }
3130
+
3131
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
2392
3132
  lock.assertOwned();
2393
3133
  if (!preparedReceiptMatches(preparedReceipt, scope, afterLock, intendedDestination, artifactPlan))
2394
3134
  throw new Error("durability_failed");
2395
3135
  if (artifactPlan && fs.existsSync(artifactPlan.detachedPath)) throw new Error("destination_conflict");
3136
+
2396
3137
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
3138
+
2397
3139
  scopeRoot(scope);
2398
- detachedArtifacts = artifactPlan ? detachArtifactRootForMigration(artifactPlan) : undefined;
3140
+
3141
+ if (artifactPlan) {
3142
+ const detached = detachArtifactRootForMigration(artifactPlan);
3143
+ detachedArtifacts = detached.detached;
3144
+ sourceArtifactCleanup = detached.detachOutcome === "cleanup_pending" ? detached.cleanup : undefined;
3145
+ const detachedReceipt = receiptPathFor(scope, afterLock, "detached");
3146
+ const detachedRecord = migrationReceipt(
3147
+ scope,
3148
+ lock,
3149
+ "artifact_detached",
3150
+ afterLock,
3151
+ intendedDestination,
3152
+ [],
3153
+ {
3154
+ path: detachedArtifacts.originalPath,
3155
+ detachedPath: detachedArtifacts.detachedPath,
3156
+ identity: detachedArtifacts.identity,
3157
+ tree: detachedArtifacts.tree,
3158
+ role: "detached_artifact_root",
3159
+ },
3160
+ sourceArtifactCleanup,
3161
+ detached.detachOutcome,
3162
+ );
3163
+ try {
3164
+ await publishManagedFileNoReplace(
3165
+ detachedReceipt,
3166
+ detachedRecord,
3167
+ assertPublicationConsent,
3168
+ scopeRoot(scope),
3169
+ );
3170
+ } catch (error) {
3171
+ if ((error as Error).message !== "destination_conflict") throw error;
3172
+ }
3173
+ if (!detachedReceiptMatches(detachedReceipt, detachedRecord)) throw new Error("durability_failed");
3174
+ }
2399
3175
  manifest = detachedArtifacts ? artifactManifestFromSnapshot(detachedArtifacts.tree) : [];
3176
+
2400
3177
  await copyArtifacts(
2401
3178
  scope,
2402
3179
  afterLock.path,
@@ -2407,6 +3184,22 @@ export async function openManagedCandidateForWrite(
2407
3184
  expectedIdentity,
2408
3185
  detachedArtifacts?.detachedPath,
2409
3186
  );
3187
+
3188
+ if (!existing) {
3189
+ revalidatePickerConsent(scope, afterLock, expectedIdentity);
3190
+ lock.assertOwned();
3191
+ sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
3192
+ if (
3193
+ sourceSnapshot.identity.dev !== afterLock.identity.dev ||
3194
+ sourceSnapshot.identity.ino !== afterLock.identity.ino ||
3195
+ sourceSnapshot.identity.size !== afterLock.identity.size ||
3196
+ sourceSnapshot.identity.mtimeNs !== afterLock.identity.mtimeNs ||
3197
+ sourceSnapshot.identity.sha256 !== afterLock.identity.sha256 ||
3198
+ sourceSnapshot.bytes.byteLength !== afterLock.identity.size
3199
+ )
3200
+ throw new Error("source_changed");
3201
+ }
3202
+
2410
3203
  if (!existing) {
2411
3204
  try {
2412
3205
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
@@ -2416,6 +3209,7 @@ export async function openManagedCandidateForWrite(
2416
3209
  if ((error as Error).message !== "destination_conflict") throw error;
2417
3210
  }
2418
3211
  }
3212
+
2419
3213
  // Artifact files, directories, and the transcript must be durable before a receipt can grant shadow authority.
2420
3214
  scopeRoot(scope);
2421
3215
  if (manifest.length > 0) fsyncManagedArtifactTree(destination.slice(0, -6));
@@ -2429,12 +3223,14 @@ export async function openManagedCandidateForWrite(
2429
3223
  !manifestMatches(destination, manifest)
2430
3224
  )
2431
3225
  throw new Error("durability_failed");
3226
+
2432
3227
  if (detachedArtifacts) {
2433
3228
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2434
3229
  scopeRoot(scope);
2435
- restoreDetachedArtifactRoot(detachedArtifacts);
3230
+ restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2436
3231
  detachedArtifacts = undefined;
2437
3232
  }
3233
+
2438
3234
  const latest = validateCandidateForScope(scope, afterLock);
2439
3235
  if (!latest || !sameCandidate(latest, afterLock) || !manifestMatches(afterLock.path, manifest))
2440
3236
  return {
@@ -2442,6 +3238,7 @@ export async function openManagedCandidateForWrite(
2442
3238
  code: "source_changed",
2443
3239
  message: "The legacy candidate or its artifacts changed before migration commit.",
2444
3240
  };
3241
+
2445
3242
  for (const state of ["published", "committed"] as const) {
2446
3243
  const receipt = receiptPathFor(scope, afterLock, state);
2447
3244
  try {
@@ -2449,7 +3246,24 @@ export async function openManagedCandidateForWrite(
2449
3246
  lock.assertOwned();
2450
3247
  await publishManagedFileNoReplace(
2451
3248
  receipt,
2452
- migrationReceipt(scope, lock, state, afterLock, migrated, manifest),
3249
+ migrationReceipt(
3250
+ scope,
3251
+ lock,
3252
+ state,
3253
+ afterLock,
3254
+ migrated,
3255
+ manifest,
3256
+ artifactPlan
3257
+ ? {
3258
+ path: artifactPlan.originalPath,
3259
+ detachedPath: artifactPlan.detachedPath,
3260
+ identity: artifactPlan.identity,
3261
+ tree: artifactPlan.tree,
3262
+ role: "detached_artifact_root",
3263
+ }
3264
+ : undefined,
3265
+ sourceArtifactCleanup,
3266
+ ),
2453
3267
  assertPublicationConsent,
2454
3268
  scopeRoot(scope),
2455
3269
  );
@@ -2457,6 +3271,7 @@ export async function openManagedCandidateForWrite(
2457
3271
  if ((error as Error).message !== "destination_conflict") throw error;
2458
3272
  }
2459
3273
  }
3274
+
2460
3275
  const receipt = receiptPathFor(scope, afterLock);
2461
3276
  lock.assertOwned();
2462
3277
  if (!receiptMatches(receipt, afterLock, migrated, scope))
@@ -2465,6 +3280,7 @@ export async function openManagedCandidateForWrite(
2465
3280
  code: "durability_failed",
2466
3281
  message: "The migration receipt does not bind the copied v2 transcript and artifacts.",
2467
3282
  };
3283
+
2468
3284
  revalidatePickerConsent(scope, afterLock, expectedIdentity);
2469
3285
  scopeRoot(scope);
2470
3286
  await removeStagedReceipts(scope, afterLock);
@@ -2476,7 +3292,7 @@ export async function openManagedCandidateForWrite(
2476
3292
  };
2477
3293
  } catch (error) {
2478
3294
  try {
2479
- if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts);
3295
+ if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts, sourceArtifactCleanup);
2480
3296
  } catch {
2481
3297
  return {
2482
3298
  kind: "error",
@@ -2485,7 +3301,16 @@ export async function openManagedCandidateForWrite(
2485
3301
  };
2486
3302
  }
2487
3303
  const code = expectedFailure(error);
2488
- return { kind: "error", code, message: error instanceof Error ? error.message : "Managed migration failed." };
3304
+ return {
3305
+ kind: "error",
3306
+ code,
3307
+ message:
3308
+ code === "artifact_capacity_exceeded"
3309
+ ? `Legacy session artifacts exceed the migration capacity (${MANAGED_ARTIFACT_MAX_FILES.toLocaleString()} files or ${MANAGED_ARTIFACT_MAX_TOTAL_BYTES / 1024 / 1024} MiB).`
3310
+ : error instanceof Error
3311
+ ? error.message
3312
+ : "Managed migration failed.",
3313
+ };
2489
3314
  } finally {
2490
3315
  if (lock) await lock.release().catch(() => undefined);
2491
3316
  }
@@ -2500,7 +3325,7 @@ export async function deleteManagedSessionCandidate(
2500
3325
  if (prepared.kind === "error")
2501
3326
  return {
2502
3327
  kind: "error",
2503
- code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
3328
+ code: expectedFailure(new Error(prepared.code)),
2504
3329
  message: prepared.message,
2505
3330
  };
2506
3331
  const current = validateCandidateForScope(scope, candidate);
@@ -2560,8 +3385,7 @@ export async function deleteManagedSessionCandidate(
2560
3385
  if (!observedPending) continue;
2561
3386
  if (
2562
3387
  cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
2563
- artifactRootsAbsent(scope, tombstone, target, observedPending) &&
2564
- !fs.existsSync(observedPending.plannedTranscriptPath)
3388
+ cleanupRootsAbsent(tombstone, target, observedPending)
2565
3389
  ) {
2566
3390
  fsyncManagedParent(target.path);
2567
3391
  await publishCleanupCompleted(scope, tombstone, target, lock);
@@ -2596,6 +3420,12 @@ export async function deleteManagedSessionCandidate(
2596
3420
  active.detachedTranscriptPath ??
2597
3421
  observedPending?.detachedTranscriptPath ??
2598
3422
  (pending && fs.existsSync(pending.plannedTranscriptPath) ? pending.plannedTranscriptPath : undefined),
3423
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3424
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3425
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3426
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3427
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3428
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2599
3429
  plannedArtifactsPath: active.plannedArtifactsPath,
2600
3430
  plannedTranscriptPath: active.plannedTranscriptPath,
2601
3431
  ...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
@@ -2612,6 +3442,13 @@ export async function deleteManagedSessionCandidate(
2612
3442
  transcriptIdentity: target.identity,
2613
3443
  plannedArtifactsPath: active.plannedArtifactsPath,
2614
3444
  plannedTranscriptPath: active.plannedTranscriptPath,
3445
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3446
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3447
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3448
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3449
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3450
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3451
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
2615
3452
  artifactsRemoved: true,
2616
3453
  });
2617
3454
  }
@@ -2627,23 +3464,51 @@ export async function deleteManagedSessionCandidate(
2627
3464
  )
2628
3465
  throw new Error("durability_failed");
2629
3466
  const retry = nextCleanupReceipt(target, active);
2630
- await publishCleanupPending(
2631
- scope,
2632
- tombstone,
2633
- {
2634
- ...retry,
2635
- expectedArtifactsIdentity:
2636
- deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
2637
- expectedArtifactsTree:
2638
- deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
2639
- detachedArtifactsPath:
2640
- deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
2641
- detachedTranscriptPath:
2642
- deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
2643
- },
2644
- lock,
2645
- );
2646
- throw new Error("durability_failed");
3467
+ await publishCleanupPending(scope, tombstone, cleanupPendingEvidence(retry, active, deletion), lock);
3468
+ if (deletion.phase === "artifacts") {
3469
+ // A typed retained artifact root proves durable canonical absence; advance
3470
+ // to the transcript phase instead of stalling on the retained evidence.
3471
+ await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
3472
+ deletion = await new FileSessionStorage().deleteSessionVerified({
3473
+ sessionsRoot: scope.sessionsRoot,
3474
+ transcriptPath: target.path,
3475
+ sessionId: target.sessionId,
3476
+ cwd: target.cwd,
3477
+ transcriptIdentity: target.identity,
3478
+ plannedArtifactsPath: active.plannedArtifactsPath,
3479
+ plannedTranscriptPath: active.plannedTranscriptPath,
3480
+ detachedTranscriptPath: active.detachedTranscriptPath ?? observedPending?.detachedTranscriptPath,
3481
+ retainedArtifactsSuccessorPath: active.retainedArtifactsSuccessorPath,
3482
+ retainedArtifactsPlaceholderPath: active.retainedArtifactsPlaceholderPath,
3483
+ retainedArtifactsUnknownPath: active.retainedArtifactsUnknownPath,
3484
+ retainedTranscriptSuccessorPath: active.retainedTranscriptSuccessorPath,
3485
+ retainedTranscriptPlaceholderPath: active.retainedTranscriptPlaceholderPath,
3486
+ retainedTranscriptUnknownPath: active.retainedTranscriptUnknownPath,
3487
+ artifactsRemoved: true,
3488
+ });
3489
+ if (deletion.kind === "cleanup_pending") {
3490
+ const followup = nextCleanupReceipt(target, active);
3491
+ await publishCleanupPending(
3492
+ scope,
3493
+ tombstone,
3494
+ cleanupPendingEvidence(followup, active, deletion),
3495
+ lock,
3496
+ );
3497
+ if (
3498
+ deletion.phase !== "transcript" ||
3499
+ deletion.detachedTranscriptPath !== active.plannedTranscriptPath
3500
+ )
3501
+ return {
3502
+ kind: "cleanup_pending",
3503
+ tombstonePath: tombstone,
3504
+ phase: deletion.phase,
3505
+ message:
3506
+ "Exact cleanup remains pending because descriptor-bound final deletion is unavailable.",
3507
+ };
3508
+ }
3509
+ }
3510
+ // A typed retained transcript quarantine is durable canonical-absence
3511
+ // evidence — never a terminal byte-deletion claim; complete the receipt.
2647
3512
  }
2648
3513
  fsyncManagedParent(target.path);
2649
3514
  await publishCleanupCompleted(scope, tombstone, target, lock);