mixdog 1.3.2 → 1.3.3

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 (171) hide show
  1. package/package.json +7 -7
  2. package/scripts/verify-runtime-dependency-versions.mjs +158 -0
  3. package/src/agents/reviewer/AGENT.md +5 -7
  4. package/src/agents/reviewer/agent.json +1 -1
  5. package/src/defaults/shipped-history.json +4 -0
  6. package/src/defaults/skills/browser-use/SKILL.md +6 -3
  7. package/src/defaults/skills/local-provider/SKILL.md +33 -6
  8. package/src/rules/lead/LEAD.md +5 -8
  9. package/src/rules/shared/45-destructive.md +5 -1
  10. package/src/rules/shared/60-verification.md +6 -4
  11. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +4 -1
  12. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +38 -11
  13. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +15 -3
  14. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-client-version.mjs +1 -1
  15. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +17 -4
  16. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +14 -1
  17. package/src/runtime/agent/orchestrator/providers/cursor-client-version.mjs +1 -1
  18. package/src/runtime/agent/orchestrator/providers/custom-provider-config.mjs +14 -1
  19. package/src/runtime/agent/orchestrator/providers/grok-client-version.mjs +1 -1
  20. package/src/runtime/agent/orchestrator/providers/registry.mjs +10 -0
  21. package/src/runtime/agent/orchestrator/providers/wire-gateway.mjs +13 -1
  22. package/src/runtime/agent/orchestrator/runtime-core/goal-runtime.mjs +5 -0
  23. package/src/runtime/agent/orchestrator/runtime-core/goal-turns.mjs +10 -1
  24. package/src/runtime/agent/orchestrator/runtime-core/tool-catalog.mjs +40 -5
  25. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +32 -11
  26. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +6 -3
  27. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +1 -1
  28. package/src/runtime/agent/orchestrator/session/agent-room.d.mts +13 -0
  29. package/src/runtime/agent/orchestrator/session/agent-room.mjs +68 -12
  30. package/src/runtime/agent/orchestrator/session/compact/continuation.mjs +15 -6
  31. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +31 -8
  32. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +44 -0
  33. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +117 -19
  34. package/src/runtime/agent/orchestrator/session/compact/text-utils.mjs +1 -1
  35. package/src/runtime/agent/orchestrator/session/compact/user-message-kind.mjs +3 -0
  36. package/src/runtime/agent/orchestrator/session/human-gate/delegation.mjs +147 -137
  37. package/src/runtime/agent/orchestrator/session/human-gate/gate.mjs +37 -74
  38. package/src/runtime/agent/orchestrator/session/human-gate/requests.mjs +0 -2
  39. package/src/runtime/agent/orchestrator/session/human-gate/run-policy.mjs +2 -2
  40. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +13 -7
  41. package/src/runtime/agent/orchestrator/session/loop/loop-state.mjs +5 -0
  42. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/continuation.mjs +2 -0
  43. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/empty-nudge.mjs +1 -1
  44. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn/output-limit.mjs +62 -13
  45. package/src/runtime/agent/orchestrator/session/loop/no-tool-turn.mjs +22 -6
  46. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +7 -0
  47. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +12 -20
  48. package/src/runtime/agent/orchestrator/session/loop/tool-exec/call-prep.mjs +5 -0
  49. package/src/runtime/agent/orchestrator/session/loop/tool-exec/dispatch.mjs +11 -5
  50. package/src/runtime/agent/orchestrator/session/manager/agent-listing-notice.mjs +102 -0
  51. package/src/runtime/agent/orchestrator/session/manager/ask-queue.mjs +2 -0
  52. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +13 -1
  53. package/src/runtime/agent/orchestrator/session/manager/ask-turn-execute.mjs +24 -15
  54. package/src/runtime/agent/orchestrator/session/manager/ask-turn-prepare.mjs +8 -0
  55. package/src/runtime/agent/orchestrator/session/manager/ask-turn-steering-drain.mjs +1 -0
  56. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +32 -1
  57. package/src/runtime/agent/orchestrator/session/manager/orchestration-mode-switch-notice.mjs +17 -1
  58. package/src/runtime/agent/orchestrator/session/manager/pending-message-entry.mjs +6 -0
  59. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +36 -0
  60. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +28 -9
  61. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +1 -1
  62. package/src/runtime/agent/orchestrator/session/manager/session-record.mjs +7 -5
  63. package/src/runtime/agent/orchestrator/session/manager/session-tool-surface.mjs +7 -3
  64. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +10 -1
  65. package/src/runtime/agent/orchestrator/session/routine-result.mjs +7 -4
  66. package/src/runtime/agent/orchestrator/session/runtime-user-context.mjs +3 -1
  67. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +11 -2
  68. package/src/runtime/agent/orchestrator/session/store/sweep/stale-sweep.mjs +5 -4
  69. package/src/runtime/agent/orchestrator/session/store/sweep/sub-agent-retention.mjs +140 -0
  70. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-plan.mjs +5 -0
  71. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-record.mjs +7 -2
  72. package/src/runtime/agent/orchestrator/session/store/sweep/sweep-row.mjs +13 -0
  73. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +76 -0
  74. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +33 -1
  75. package/src/runtime/agent/orchestrator/session/store-summary-visibility.d.mts +5 -0
  76. package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +37 -0
  77. package/src/runtime/agent/orchestrator/session/store.mjs +1 -0
  78. package/src/runtime/agent/orchestrator/tools/builtin/advisory-lock.mjs +18 -7
  79. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/backgrounded-result.mjs +2 -0
  80. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +127 -20
  81. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +557 -0
  82. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-records.mjs +6 -0
  83. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +8 -2
  84. package/src/runtime/attachments/legacy-office.mjs +110 -0
  85. package/src/runtime/attachments/prompt-restore.mjs +70 -0
  86. package/src/runtime/attachments/store.mjs +60 -3
  87. package/src/runtime/effort-judge/judge-client.mjs +61 -14
  88. package/src/runtime/effort-judge/model-install.mjs +34 -2
  89. package/src/runtime/memory/lib/memory-chunk-quality.mjs +14 -2
  90. package/src/runtime/office/portable/portable-soffice.mjs +132 -30
  91. package/src/runtime/shared/agent-avatar-sprites.mjs +23 -8
  92. package/src/runtime/shared/agent-avatar.mjs +3 -2
  93. package/src/runtime/shared/agent-room-host.d.mts +31 -0
  94. package/src/runtime/shared/agent-room-host.mjs +58 -0
  95. package/src/runtime/shared/bot-hops.mjs +16 -11
  96. package/src/runtime/shared/redact-directories.mjs +18 -0
  97. package/src/runtime/shared/remove-directory.mjs +28 -0
  98. package/src/runtime/shared/session-sidebar-host.d.mts +11 -2
  99. package/src/runtime/shared/session-sidebar-host.mjs +16 -3
  100. package/src/runtime/shared/tool-execution-contract.mjs +4 -0
  101. package/src/runtime/shared/tool-work-units.mjs +12 -9
  102. package/src/runtime/shared/turn-snapshot.mjs +233 -122
  103. package/src/runtime/shared/user-data-guard.mjs +51 -19
  104. package/src/runtime/shared/worktrees/activity-gate.mjs +209 -227
  105. package/src/runtime/shared/worktrees/contract.mjs +11 -3
  106. package/src/runtime/shared/worktrees/maintenance.mjs +11 -2
  107. package/src/runtime/shared/worktrees/manager.mjs +419 -52
  108. package/src/runtime/shared/worktrees/registry.mjs +3 -1
  109. package/src/runtime/shared/worktrees/tool-host.mjs +39 -0
  110. package/src/runtime/tidy/process.mjs +41 -8
  111. package/src/runtime/tidy/resolve.mjs +10 -7
  112. package/src/session-runtime/agent-home-delivery.mjs +73 -91
  113. package/src/session-runtime/agent-profile-changes.mjs +16 -0
  114. package/src/session-runtime/agent-room-delivery.mjs +2 -63
  115. package/src/session-runtime/agent-task-sessions.mjs +194 -64
  116. package/src/session-runtime/boot/apis.mjs +3 -0
  117. package/src/session-runtime/boot/facade.mjs +5 -6
  118. package/src/session-runtime/boot/tools.mjs +21 -1
  119. package/src/session-runtime/held-requests.mjs +20 -25
  120. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +5 -22
  121. package/src/session-runtime/model-recency.mjs +1 -0
  122. package/src/session-runtime/provider-models/row-hydration.mjs +8 -1
  123. package/src/session-runtime/quick-model-rows/model-meta.mjs +2 -0
  124. package/src/session-runtime/quick-model-rows/provider-rows.mjs +7 -1
  125. package/src/session-runtime/runtime-review-api.mjs +4 -1
  126. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +34 -19
  127. package/src/session-runtime/services/agent-tool/spawn-flow/job-start.mjs +3 -0
  128. package/src/session-runtime/services/provider-admin.mjs +12 -2
  129. package/src/session-runtime/services/worktree-access.mjs +27 -0
  130. package/src/session-runtime/session-hooks.mjs +9 -17
  131. package/src/session-runtime/settings-builtin-tools-api.mjs +2 -0
  132. package/src/session-runtime/setup-tool/executor.mjs +4 -0
  133. package/src/session-runtime/setup-tool/settings-contract.mjs +3 -0
  134. package/src/session-runtime/tool-policy/agent-profile-fold.mjs +98 -14
  135. package/src/session-runtime/tool-policy/policy-stage.mjs +23 -5
  136. package/src/session-runtime/tool-policy-refresh.mjs +1 -0
  137. package/src/session-runtime/tool-surface.mjs +9 -5
  138. package/src/session-runtime/turn/session-ops.mjs +8 -0
  139. package/src/session-runtime/turn/turn-run.mjs +5 -1
  140. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +10 -2
  141. package/src/session-runtime/workflow-agents-api/agent-route.mjs +39 -17
  142. package/src/session-runtime/workspace/transition-steps.mjs +1 -18
  143. package/src/session-runtime/workspace/transitions.mjs +279 -67
  144. package/src/session-runtime/workspace/worktree-tool.mjs +194 -0
  145. package/src/standalone/daemon-lag-profiler.mjs +3 -1
  146. package/src/standalone/daemon-session-control.mjs +140 -23
  147. package/src/standalone/daemon-telemetry.mjs +9 -0
  148. package/src/standalone/daemon.mjs +5 -0
  149. package/src/standalone/local-session-runtime.mjs +11 -2
  150. package/src/standalone/session-protocol.mjs +1 -0
  151. package/src/standalone/session-runtime-inline-host/boot-loaders.mjs +2 -0
  152. package/src/standalone/session-service/agent-route-follow.mjs +69 -0
  153. package/src/standalone/session-service/session-calls/session-turns.mjs +33 -4
  154. package/src/standalone/session-service.mjs +10 -0
  155. package/src/standalone/session-tool-output.mjs +1 -0
  156. package/src/standalone/staged-import.mjs +47 -0
  157. package/src/tui/App.jsx +2 -0
  158. package/src/tui/app/message-selector.mjs +15 -2
  159. package/src/tui/app/model-options.mjs +16 -2
  160. package/src/tui/app/model-picker.mjs +2 -1
  161. package/src/tui/app/route-pickers.mjs +3 -1
  162. package/src/tui/dist/index.mjs +92 -40
  163. package/src/tui/paste-attachments.mjs +0 -1
  164. package/src/tui/paste-text-policy.mjs +4 -5
  165. package/src/tui/session/agent-job-feed/execution-delivery.mjs +4 -1
  166. package/src/tui/session/notice-surface.mjs +3 -0
  167. package/src/tui/session/session-api/intake/abort.mjs +33 -17
  168. package/src/tui/session/session-api/intake/rewind.mjs +39 -4
  169. package/src/tui/session/session-api/routes-agent-definitions.mjs +1 -0
  170. package/src/tui/session/tool-approval.mjs +11 -48
  171. package/src/session-runtime/room-member-gate.mjs +0 -61
@@ -26,6 +26,10 @@
26
26
  * reachable at `refs/mixdog/removed/<id>` (returned as `recoveryRef`/`tip`;
27
27
  * `git branch <name> refs/mixdog/removed/<id>` restores it, `git update-ref
28
28
  * -d` forgets it).
29
+ *
30
+ * A snapshot (createSnapshotWorktree) is a read-only detached checkout in the
31
+ * same store and registry, bound to no session and removed with the turn
32
+ * that made it (or by the prune / startup reconcile once its holder is gone).
29
33
  */
30
34
  import { existsSync } from 'node:fs';
31
35
  import {
@@ -42,6 +46,7 @@ import {
42
46
  normalizeWorktreeOwner,
43
47
  runRootCleanups,
44
48
  runRootReleasers,
49
+ WORKTREE_SNAPSHOT_KIND,
45
50
  worktreeBranchName,
46
51
  worktreeError,
47
52
  } from './contract.mjs';
@@ -79,6 +84,8 @@ import {
79
84
  } from './include.mjs';
80
85
  import { linkWorktreeDependencies } from './dependencies.mjs';
81
86
  import { withRepoLock as withRepoMutationLock } from './lock.mjs';
87
+ import { holdWorkspaceActivity } from './activity-gate.mjs';
88
+ import { withAdvisoryLocks } from '../../agent/orchestrator/tools/builtin/advisory-lock.mjs';
82
89
  import { mergeBranchIntoCheckout, operationInProgress } from './merge.mjs';
83
90
  import { readOwnerLease, removeOwnerLease, sameOwner, writeOwnerLease } from './owner.mjs';
84
91
  import { canonicalPath, checkoutFolder, isWithin, pathKey, projectSubdirOf, samePath } from './paths.mjs';
@@ -318,6 +325,8 @@ async function recordInclude(record, ledger) {
318
325
  }
319
326
 
320
327
  async function unmergedCommitCount(record) {
328
+ // A snapshot has no branch, so nothing of it can be unmerged.
329
+ if (!record.branch) return 0;
321
330
  const cwd = repoCwd(record);
322
331
  const tip = await branchCommit(cwd, record.branch);
323
332
  if (!tip) return 0;
@@ -591,6 +600,20 @@ async function rollbackCreation(record, cause) {
591
600
  return { complete: false, remaining };
592
601
  }
593
602
 
603
+ /** finishDeletion's folder step; what is left over goes to `remaining`. */
604
+ async function removeFolder(record, remaining) {
605
+ await removeOwnerLease(record);
606
+ const ctx = { reason: 'worktree-removed', entryId: record.id };
607
+ const results = await runRootReleasers(record.path, ctx);
608
+ for (const root of new Set([record.path, sessionRootOf(record)])) {
609
+ results.push(...(await runRootCleanups(root, ctx)));
610
+ }
611
+ for (const result of results) {
612
+ if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
613
+ }
614
+ remaining.push(...(await (record.deleting?.safeOnly ? removeCheckoutSafely(record) : removeCheckout(record))));
615
+ }
616
+
594
617
  /**
595
618
  * Finish (or resume) a decided deletion, in order: release the live handles
596
619
  * on the folder (desktop terminals), drop the caches keyed by it (the checkout
@@ -602,16 +625,14 @@ async function finishDeletion(record) {
602
625
  await assertRecoveryStoresClear(record);
603
626
  const remaining = [];
604
627
  try {
605
- await removeOwnerLease(record);
606
- const ctx = { reason: 'worktree-removed', entryId: record.id };
607
- const results = await runRootReleasers(record.path, ctx);
608
- for (const root of new Set([record.path, sessionRootOf(record)])) {
609
- results.push(...(await runRootCleanups(root, ctx)));
628
+ // A snapshot is deleted under the cross-process write lock the git tool
629
+ // takes on the worktree it writes in (withGitWriteLocks: its advisory
630
+ // lock), so no git write of another process runs while the folder goes.
631
+ if (record.kind === WORKTREE_SNAPSHOT_KIND) {
632
+ await withAdvisoryLocks([record.path], () => removeFolder(record, remaining));
633
+ } else {
634
+ await removeFolder(record, remaining);
610
635
  }
611
- for (const result of results) {
612
- if (!result.ok) remaining.push({ kind: 'cleanup', name: result.name, error: result.error.message });
613
- }
614
- remaining.push(...(await (record.deleting?.safeOnly ? removeCheckoutSafely(record) : removeCheckout(record))));
615
636
  } catch (error) {
616
637
  remaining.push({ kind: 'error', error: String(error?.message || error) });
617
638
  }
@@ -623,7 +644,7 @@ async function finishDeletion(record) {
623
644
  let branch = { deleted: false, reason: 'kept' };
624
645
  let kept = { tip: null, recoveryRef: null };
625
646
  const merged = (await unmergedCommitCount(record)) === 0;
626
- if (!record.deleting?.preserveBranch && (record.deleting?.force || merged)) {
647
+ if (record.branch && !record.deleting?.preserveBranch && (record.deleting?.force || merged)) {
627
648
  // Forced past unmerged commits: the tip stays reachable before `branch -D`.
628
649
  if (!merged) kept = await keepRemovedTip(record);
629
650
  else kept.tip = await branchCommit(repoCwd(record), record.branch);
@@ -672,11 +693,16 @@ export async function getWorktreeByPath(path) {
672
693
  return record ? entryView(record) : null;
673
694
  }
674
695
 
675
- /** Resolve a branch to its checkout without switching any existing folder. */
696
+ /**
697
+ * Resolve a branch to its checkout without switching any existing folder.
698
+ * `startPoint` (with `create`): the new branch starts at that commit-ish
699
+ * instead of the project folder's branch.
700
+ */
676
701
  export async function resolveBranchWorkspace({
677
702
  projectPath,
678
703
  branch,
679
704
  create = false,
705
+ startPoint = null,
680
706
  sessionId,
681
707
  runtimeToken,
682
708
  txId,
@@ -684,6 +710,7 @@ export async function resolveBranchWorkspace({
684
710
  signal,
685
711
  } = {}) {
686
712
  requireText(branch, 'branch');
713
+ if (startPoint !== null && create !== true) throw new TypeError('startPoint needs create.');
687
714
  const repo = await inspectRepo(projectPath, { signal });
688
715
  return withRepoLock(repo.repoCommonDir, async () => {
689
716
  if (!(await validBranchName(repo.projectPath, branch))) throw new TypeError('Invalid branch name.');
@@ -738,6 +765,7 @@ export async function resolveBranchWorkspace({
738
765
  branch,
739
766
  existingBranch: !create,
740
767
  baseBranch: create ? repo.currentBranch : branch,
768
+ startCommit: startPoint,
741
769
  nameHint: branch,
742
770
  confirmLarge,
743
771
  signal,
@@ -901,6 +929,53 @@ async function diskFullFailure(error, { needed, root }) {
901
929
  });
902
930
  }
903
931
 
932
+ /**
933
+ * The size preflight of every managed checkout of `baseCommit`:
934
+ * WORKTREE_DISK_INSUFFICIENT when it cannot fit, WORKTREE_LARGE_REPO_CONFIRM
935
+ * (`details: { estimateBytes, thresholdBytes }`) above the warning size until
936
+ * `confirmLarge`. `extraBytes`: include copies on top of the tracked files.
937
+ */
938
+ async function assertCheckoutFits({ repo, baseCommit, extraBytes = 0, settings, confirmLarge, signal }) {
939
+ const tracked = await trackedCheckoutBytes(repo.topLevel, baseCommit, { signal });
940
+ const contentBytes = tracked.bytes + extraBytes;
941
+ const root = managedWorktreeRoot(repo.repoCommonDir);
942
+ const needed = requiredBytes(contentBytes);
943
+ const free = await config.freeBytes(root);
944
+ if (free < needed) {
945
+ throw worktreeError('WORKTREE_DISK_INSUFFICIENT', undefined, {
946
+ requiredBytes: needed,
947
+ freeBytes: free,
948
+ path: root,
949
+ });
950
+ }
951
+ if (settings.sizeWarnBytes > 0 && contentBytes > settings.sizeWarnBytes && confirmLarge !== true) {
952
+ throw worktreeError('WORKTREE_LARGE_REPO_CONFIRM', undefined, {
953
+ estimateBytes: contentBytes,
954
+ thresholdBytes: settings.sizeWarnBytes,
955
+ });
956
+ }
957
+ return { tracked, root, needed };
958
+ }
959
+
960
+ /** The full id of commit-ish `value` in `cwd`; TypeError when it names no commit. */
961
+ async function resolveCommit(cwd, value, signal) {
962
+ const name = String(value ?? '').trim();
963
+ if (!name || name.startsWith('-')) throw new TypeError('A commit is required.');
964
+ const result = await runGit(cwd, ['rev-parse', '--verify', '--quiet', `${name}^{commit}`], {
965
+ signal,
966
+ allowFailure: true,
967
+ });
968
+ if (result.code !== 0) throw new TypeError(`Commit ${name} does not exist.`);
969
+ return firstLine(result.stdout);
970
+ }
971
+
972
+ /** A snapshot is in use while its holder session is live, or its lease holder runs in another process. */
973
+ async function snapshotHolderLive(record, live) {
974
+ if (record.holderSessionId && live.has(record.holderSessionId)) return true;
975
+ const lease = await readOwnerLease(record);
976
+ return !!lease && lease.pid !== process.pid && leaseProcessAlive(lease);
977
+ }
978
+
904
979
  /**
905
980
  * `carryChanges` decides the project folder's uncommitted changes (tracked
906
981
  * and untracked, ignored excluded), judged under the repository lock:
@@ -913,9 +988,12 @@ async function diskFullFailure(error, { needed, root }) {
913
988
  * untracked files. The result's `changes` (`{ mode: 'carried'|'left',
914
989
  * count, recoveryRef? }`) reports what happened to them; a carried move's
915
990
  * `recoveryRef` stays until dropCarryRecoveryRef / restoreCarriedChanges.
991
+ * `startCommit` (a new exact `branch` only) starts the branch at that
992
+ * commit-ish instead of the base branch's tip.
916
993
  * @param {{ projectPath: string, sessionId: string, runtimeToken: string, baseBranch?: string|null,
917
994
  * nameHint?: string, confirmLarge?: boolean, txId: string, signal?: AbortSignal,
918
- * carryChanges?: 'ask'|'leave'|'carry'|null }} input
995
+ * carryChanges?: 'ask'|'leave'|'carry'|null, branch?: string|null, existingBranch?: boolean,
996
+ * startCommit?: string|null }} input
919
997
  */
920
998
  export async function createManagedWorktree({
921
999
  projectPath,
@@ -929,9 +1007,13 @@ export async function createManagedWorktree({
929
1007
  carryChanges = null,
930
1008
  branch: requestedBranch = null,
931
1009
  existingBranch = false,
1010
+ startCommit = null,
932
1011
  } = {}) {
933
1012
  requireText(projectPath, 'projectPath');
934
1013
  requireText(txId, 'txId');
1014
+ if (startCommit !== null && (existingBranch || requestedBranch === null)) {
1015
+ throw new TypeError('startCommit applies to a new exact branch only.');
1016
+ }
935
1017
  if (![null, 'ask', 'leave', 'carry'].includes(carryChanges)) {
936
1018
  throw new TypeError('carryChanges must be ask, leave or carry.');
937
1019
  }
@@ -970,30 +1052,22 @@ export async function createManagedWorktree({
970
1052
  }
971
1053
  mergeTarget = baseBranch;
972
1054
  }
1055
+ // A new branch at an explicit start point (`git worktree add -b <b> <path> <commit-ish>`).
1056
+ if (startCommit) baseCommit = await resolveCommit(repo.projectPath, startCommit, signal);
973
1057
 
974
1058
  // The main checkout's local env files come along on their own;
975
1059
  // `.worktreeinclude` applies on top.
976
1060
  const includePlan = await planIncludes(repo.topLevel, await readIncludeEntries(repo.topLevel), {
977
1061
  envFiles: await untrackedEnvFiles(repo.topLevel),
978
1062
  });
979
- const tracked = await trackedCheckoutBytes(repo.topLevel, baseCommit, { signal });
980
- const contentBytes = tracked.bytes + includePlan.copyBytes;
981
- const root = managedWorktreeRoot(repo.repoCommonDir);
982
- const needed = requiredBytes(contentBytes);
983
- const free = await config.freeBytes(root);
984
- if (free < needed) {
985
- throw worktreeError('WORKTREE_DISK_INSUFFICIENT', undefined, {
986
- requiredBytes: needed,
987
- freeBytes: free,
988
- path: root,
989
- });
990
- }
991
- if (settings.sizeWarnBytes > 0 && contentBytes > settings.sizeWarnBytes && confirmLarge !== true) {
992
- throw worktreeError('WORKTREE_LARGE_REPO_CONFIRM', undefined, {
993
- estimateBytes: contentBytes,
994
- thresholdBytes: settings.sizeWarnBytes,
995
- });
996
- }
1063
+ const { tracked, root, needed } = await assertCheckoutFits({
1064
+ repo,
1065
+ baseCommit,
1066
+ extraBytes: includePlan.copyBytes,
1067
+ settings,
1068
+ confirmLarge,
1069
+ signal,
1070
+ });
997
1071
  throwIfAborted(signal);
998
1072
 
999
1073
  const branches = new Set(
@@ -1085,11 +1159,9 @@ export async function createManagedWorktree({
1085
1159
  });
1086
1160
  throwIfAborted(signal);
1087
1161
  record.warnings = [...record.warnings, ...applied.warnings];
1088
- record.state = 'ready';
1089
- await putRecord(record);
1090
1162
  const made = (record.resources.includes || []).filter((item) => item.status === 'created');
1091
1163
  const rels = (predicate) => made.filter(predicate).map((item) => item.rel);
1092
- // What creation did, for the agent's note at the first turn (transitions.mjs).
1164
+ // What creation did, for the agent's note at the first turn there (transitions.mjs).
1093
1165
  const prepared = {
1094
1166
  root: repo.topLevel,
1095
1167
  copied: rels((item) => item.mode === 'copy'),
@@ -1097,6 +1169,12 @@ export async function createManagedWorktree({
1097
1169
  dependencies: linkedDependencies,
1098
1170
  skipped: [...includePlan.warnings, ...applied.warnings],
1099
1171
  };
1172
+ // Whichever path made it (a worktree task, a branch selection, `git
1173
+ // worktree add`), the first turn a session runs here prepares its
1174
+ // dependencies (claimWorktreeSetup).
1175
+ record.setup = { pending: true, prepared };
1176
+ record.state = 'ready';
1177
+ await putRecord(record);
1100
1178
  return { ...entryView(record), prepared, ...(changes ? { changes } : {}) };
1101
1179
  } catch (error) {
1102
1180
  // Carried already: the changes go back to the project folder before the
@@ -1158,6 +1236,213 @@ export async function createManagedWorktree({
1158
1236
  });
1159
1237
  }
1160
1238
 
1239
+ /**
1240
+ * A read-only snapshot: a detached checkout of `commit` (any commit-ish of
1241
+ * the repository) in the managed store, bound to no session. Its record
1242
+ * (`kind: 'snapshot'`, `branch` and `ownerSessionId` null) names the session
1243
+ * whose turn made it (`holderSessionId`, with an owner lease); it is removed
1244
+ * when that turn settles (removeSessionSnapshots), when the session is
1245
+ * deleted, and by the manual prune / startup reconcile once its holder is
1246
+ * gone. No include copies, dependency links or carried changes: a snapshot
1247
+ * only shows the commit. `txId` makes a retry return the same snapshot.
1248
+ * @param {{ projectPath: string, sessionId: string, runtimeToken: string, commit?: string,
1249
+ * txId: string, confirmLarge?: boolean, signal?: AbortSignal }} input
1250
+ */
1251
+ export async function createSnapshotWorktree({
1252
+ projectPath,
1253
+ sessionId,
1254
+ runtimeToken,
1255
+ commit = 'HEAD',
1256
+ txId,
1257
+ confirmLarge = false,
1258
+ signal,
1259
+ } = {}) {
1260
+ requireText(projectPath, 'projectPath');
1261
+ requireText(txId, 'txId');
1262
+ const holder = normalizeWorktreeOwner({ sessionId, runtimeToken, pid: process.pid });
1263
+ throwIfAborted(signal);
1264
+ const repo = await inspectRepo(projectPath, { signal });
1265
+ return withRepoLock(repo.repoCommonDir, async () => {
1266
+ const prior = findRecord(await readRegistry(), (record) => record.txId === txId);
1267
+ if (prior) {
1268
+ if (prior.kind !== WORKTREE_SNAPSHOT_KIND || prior.holderSessionId !== holder.sessionId) {
1269
+ throw worktreeError('WORKTREE_REVISION_CONFLICT', 'This transaction belongs to another workspace.', { txId });
1270
+ }
1271
+ if (prior.state === 'ready') return entryView(prior);
1272
+ const rollback = await rollbackCreation(prior);
1273
+ if (!rollback.complete) {
1274
+ throw worktreeError('WORKTREE_REVISION_CONFLICT', 'A previous snapshot is still being cleaned up; try again.', {
1275
+ entryId: prior.id,
1276
+ remaining: rollback.remaining,
1277
+ });
1278
+ }
1279
+ }
1280
+ const baseCommit = await resolveCommit(repo.projectPath, commit, signal);
1281
+ const settings = await currentWorktreeSettings();
1282
+ const { tracked, root, needed } = await assertCheckoutFits({ repo, baseCommit, settings, confirmLarge, signal });
1283
+ throwIfAborted(signal);
1284
+ let record = null;
1285
+ for (let attempt = 0; attempt < 5 && !record; attempt++) {
1286
+ const ids = new Set(Object.keys((await readRegistry()).entries).map((id) => id.toLowerCase()));
1287
+ const slug = uniqueSlug(
1288
+ `snapshot-${baseCommit.slice(0, 8)}`,
1289
+ (candidate) => ids.has(candidate.toLowerCase()) || existsSync(join(root, candidate))
1290
+ );
1291
+ const candidate = {
1292
+ id: slug,
1293
+ txId,
1294
+ kind: WORKTREE_SNAPSHOT_KIND,
1295
+ repoCommonDir: repo.repoCommonDir,
1296
+ projectPath: repo.projectPath,
1297
+ projectSubdir: projectSubdirOf(repo.topLevel, repo.projectPath),
1298
+ path: managedWorktreePath(repo.repoCommonDir, slug),
1299
+ branch: null,
1300
+ baseCommit,
1301
+ mergeTarget: null,
1302
+ createdAt: Date.now(),
1303
+ ownerSessionId: null,
1304
+ holderSessionId: holder.sessionId,
1305
+ // No branch of its own: nothing is ever deleted from refs/heads.
1306
+ existingBranch: true,
1307
+ exactBranch: false,
1308
+ state: 'creating',
1309
+ disposable: true,
1310
+ warnings: tracked.submodules ? ['submodules'] : [],
1311
+ resources: { worktree: null, lock: null, owner: null, includes: [] },
1312
+ deleting: null,
1313
+ recovery: null,
1314
+ };
1315
+ if (await insertRecord(candidate)) record = candidate;
1316
+ }
1317
+ if (!record) throw worktreeError('WORKTREE_REVISION_CONFLICT', 'Could not reserve a worktree name; try again.');
1318
+ try {
1319
+ await mkdir(root, { recursive: true });
1320
+ await setResource(record, 'worktree', 'pending');
1321
+ await runGit(repo.projectPath, ['worktree', 'add', '--detach', record.path, baseCommit], {
1322
+ signal,
1323
+ timeoutMs: WORKTREE_ADD_TIMEOUT_MS,
1324
+ });
1325
+ await setResource(record, 'worktree', 'created');
1326
+ throwIfAborted(signal);
1327
+ await setResource(record, 'lock', 'pending');
1328
+ await runGit(repo.projectPath, [
1329
+ 'worktree',
1330
+ 'lock',
1331
+ '--reason',
1332
+ `mixdog snapshot session=${holder.sessionId}`,
1333
+ record.path,
1334
+ ]);
1335
+ await setResource(record, 'lock', 'created');
1336
+ await setResource(record, 'owner', 'pending');
1337
+ await writeOwnerLease(record, holder);
1338
+ await setResource(record, 'owner', 'created');
1339
+ record.state = 'ready';
1340
+ await putRecord(record);
1341
+ return entryView(record);
1342
+ } catch (error) {
1343
+ const rollback = await rollbackCreation(record, error);
1344
+ const failure = isDiskFullError(error) ? await diskFullFailure(error, { needed, root }) : error;
1345
+ if (!rollback.complete) failure.rollback = rollback;
1346
+ throw failure;
1347
+ }
1348
+ });
1349
+ }
1350
+
1351
+ /**
1352
+ * Hold a snapshot's folder against its consumers on every deletion attempt,
1353
+ * a resumed (`deleting`) one included: WORKTREE_BUSY_JOBS (`folder-in-use`)
1354
+ * while any process's turn (a worker running there) or command (a background
1355
+ * shell, a git tool call) holds a workspace lease inside it; otherwise no new
1356
+ * one is admitted there until the returned release. A folder no longer a
1357
+ * checkout admits no consumer (activity-gate tracks git checkouts only).
1358
+ */
1359
+ function holdSnapshotFolder(record) {
1360
+ if (!existsSync(join(record.path, '.git'))) return () => {};
1361
+ return holdWorkspaceActivity([record.path]);
1362
+ }
1363
+
1364
+ /** A refusal that only says "something still runs there" (retry once it ends). */
1365
+ function snapshotInUse(error) {
1366
+ return (
1367
+ error?.code === 'WORKTREE_BUSY_JOBS' &&
1368
+ ['folder-in-use', 'workspace-admission-unreadable'].includes(error.details?.reason)
1369
+ );
1370
+ }
1371
+
1372
+ /**
1373
+ * Remove snapshot `entryId` once nothing runs in it (removeManagedWorktree
1374
+ * holds its folder); changes made in it only with `force`.
1375
+ */
1376
+ export async function removeSnapshotWorktree({ entryId, force = false, expected = null } = {}) {
1377
+ if (!expected) {
1378
+ const record = await getRecord(entryId);
1379
+ if (record?.kind !== WORKTREE_SNAPSHOT_KIND) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1380
+ }
1381
+ // With `expected`, removeManagedWorktree checks identity (kind included) and skips a stale selection.
1382
+ return removeManagedWorktree({ entryId, force, expected });
1383
+ }
1384
+
1385
+ /**
1386
+ * Remove the snapshots `sessionId` holds (`txIds`: only the ones those
1387
+ * creation transactions made — a settled turn's; an id alone could name a
1388
+ * later snapshot reusing a freed slug), changes and all: snapshots are
1389
+ * read-only by contract.
1390
+ * One a worker or background shell still runs in is kept with reason
1391
+ * `in-use` (its caller retries after they end); any other failure stays for
1392
+ * the prune / startup reconcile.
1393
+ * @param {string} sessionId
1394
+ * @param {{ txIds?: string[]|null }} [options]
1395
+ * @returns {Promise<{ removed: string[], kept: Array<{ id: string, txId: string, reason?: 'in-use', error?: string, remaining?: object[] }> }>}
1396
+ */
1397
+ export async function removeSessionSnapshots(sessionId, { txIds = null } = {}) {
1398
+ const report = { removed: [], kept: [] };
1399
+ if (typeof sessionId !== 'string' || !sessionId) return report;
1400
+ const only = txIds ? new Set(txIds) : null;
1401
+ const held = Object.values((await readRegistry()).entries).filter(
1402
+ (record) =>
1403
+ record.kind === WORKTREE_SNAPSHOT_KIND &&
1404
+ record.holderSessionId === sessionId &&
1405
+ (!only || only.has(record.txId))
1406
+ );
1407
+ for (const record of held) {
1408
+ const row = { id: record.id, txId: record.txId };
1409
+ try {
1410
+ const result = await removeSnapshotWorktree({
1411
+ entryId: record.id,
1412
+ force: true,
1413
+ expected: { kind: record.kind, txId: record.txId, holderSessionId: record.holderSessionId },
1414
+ });
1415
+ // Skipped: the record is no longer the snapshot selected above (its slug was reused).
1416
+ if (result.skipped) continue;
1417
+ if (result.removed) report.removed.push(record.id);
1418
+ else report.kept.push({ ...row, remaining: result.remaining });
1419
+ } catch (error) {
1420
+ report.kept.push(
1421
+ snapshotInUse(error) ? { ...row, reason: 'in-use' } : { ...row, error: String(error?.message || error) }
1422
+ );
1423
+ }
1424
+ }
1425
+ return report;
1426
+ }
1427
+
1428
+ /**
1429
+ * The first turn a session runs in managed worktree `entryId` claims its
1430
+ * dependency preparation, once: `{ prepared }` (what creation did) while it
1431
+ * is still pending, else null. Claimed before it runs, so a failed or
1432
+ * cancelled preparation is never repeated.
1433
+ */
1434
+ export async function claimWorktreeSetup(entryId) {
1435
+ const record = await getRecord(entryId);
1436
+ if (!record?.setup?.pending) return null;
1437
+ return withRepoLock(record.repoCommonDir, async () => {
1438
+ const current = await getRecord(entryId);
1439
+ if (current?.state !== 'ready' || !current.setup?.pending) return null;
1440
+ current.setup = { ...current.setup, pending: false, claimedAt: Date.now() };
1441
+ await putRecord(current);
1442
+ return { prepared: current.setup.prepared ?? null };
1443
+ });
1444
+ }
1445
+
1161
1446
  /**
1162
1447
  * After a carry whose session move failed: put the changes kept at
1163
1448
  * `recoveryRef` back into the project folder (refused, ref kept, when the
@@ -1538,8 +1823,11 @@ export async function assertWorktreeRemovable({ entryId, force = false, sessionI
1538
1823
  * Remove a managed worktree. Refuses dirty (incl. untracked and modified
1539
1824
  * include copies) or unmerged worktrees unless `force`. Retained file recovery
1540
1825
  * data blocks removal regardless of force.
1541
- * @param {{ entryId: string, force?: boolean }} input
1542
- * @returns {Promise<{ removed: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
1826
+ * `expected` ({ kind, txId, holderSessionId }) pins the record the caller
1827
+ * selected: one that no longer matches under the repository lock (removed,
1828
+ * its slug reused) is left alone and reported `{ removed: false, skipped: true }`.
1829
+ * @param {{ entryId: string, force?: boolean, expected?: { kind?: string, txId?: string, holderSessionId?: string }|null }} input
1830
+ * @returns {Promise<{ removed: boolean, skipped?: boolean, retryable?: boolean, remaining?: object[], branchDeleted?: boolean }>}
1543
1831
  */
1544
1832
  export async function removeManagedWorktree({
1545
1833
  entryId,
@@ -1547,29 +1835,54 @@ export async function removeManagedWorktree({
1547
1835
  sessionId,
1548
1836
  preserveBranch = false,
1549
1837
  safeOnly = false,
1838
+ expected = null,
1550
1839
  } = {}) {
1840
+ const skipped = { removed: false, skipped: true };
1841
+ const matches = (record) =>
1842
+ !expected ||
1843
+ (record.kind === expected.kind &&
1844
+ record.txId === expected.txId &&
1845
+ record.holderSessionId === expected.holderSessionId);
1551
1846
  const record = await getRecord(entryId);
1552
- if (!record) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1847
+ if (!record) {
1848
+ if (expected) return skipped;
1849
+ throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1850
+ }
1553
1851
  return withRepoLock(record.repoCommonDir, async () => {
1554
1852
  const current = await getRecord(entryId);
1555
- if (!current) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1853
+ if (!current || !matches(current)) {
1854
+ if (!current && !expected) throw worktreeError('WORKTREE_MISSING', undefined, { entryId });
1855
+ return skipped;
1856
+ }
1556
1857
  assertNotShared(current, sessionId);
1557
1858
  if (current.state === 'creating' || current.state === 'recovery') {
1558
1859
  const rollback = await rollbackCreation(current);
1559
1860
  return rollback.complete ? { removed: true } : { removed: false, retryable: true, remaining: rollback.remaining };
1560
1861
  }
1561
- if (current.state === 'ready') {
1562
- await assertRecoveryStoresClear(current);
1563
- await assertManagedCheckoutPath(current);
1564
- if (!force) await assertRemovable(current);
1565
- current.state = 'deleting';
1566
- current.deleting = { force: force === true, preserveBranch, safeOnly, startedAt: Date.now(), remaining: [] };
1567
- await putRecord(current);
1862
+ // Every deletion attempt of a snapshot, a resumed one included, waits
1863
+ // for (refuses on) its consumers.
1864
+ const release = current.kind === WORKTREE_SNAPSHOT_KIND ? holdSnapshotFolder(current) : () => {};
1865
+ try {
1866
+ return await decideAndFinishDeletion(current, { force, preserveBranch, safeOnly });
1867
+ } finally {
1868
+ release();
1568
1869
  }
1569
- return finishDeletion(current);
1570
1870
  });
1571
1871
  }
1572
1872
 
1873
+ /** Mark a ready worktree `deleting` (after its guards), then finish the deletion. */
1874
+ async function decideAndFinishDeletion(current, { force, preserveBranch, safeOnly }) {
1875
+ if (current.state === 'ready') {
1876
+ await assertRecoveryStoresClear(current);
1877
+ await assertManagedCheckoutPath(current);
1878
+ if (!force) await assertRemovable(current);
1879
+ current.state = 'deleting';
1880
+ current.deleting = { force: force === true, preserveBranch, safeOnly, startedAt: Date.now(), remaining: [] };
1881
+ await putRecord(current);
1882
+ }
1883
+ return finishDeletion(current);
1884
+ }
1885
+
1573
1886
  /** A lease whose recorded process still runs. */
1574
1887
  function leaseProcessAlive(lease) {
1575
1888
  return !!lease?.pid && config.processAlive(lease.pid);
@@ -1587,12 +1900,17 @@ export async function listManagedWorktrees({ liveSessionIds = [], retainedSessio
1587
1900
  const retained = new Set(retainedSessionIds);
1588
1901
  const rows = [];
1589
1902
  for (const record of Object.values((await readRegistry()).entries)) {
1590
- const ownerLive =
1591
- (!!record.ownerSessionId && live.has(record.ownerSessionId)) || leaseProcessAlive(await readOwnerLease(record));
1903
+ const snapshot = record.kind === WORKTREE_SNAPSHOT_KIND;
1904
+ const ownerLive = snapshot
1905
+ ? await snapshotHolderLive(record, live)
1906
+ : (!!record.ownerSessionId && live.has(record.ownerSessionId)) ||
1907
+ leaseProcessAlive(await readOwnerLease(record));
1592
1908
  const isRetained = !!record.ownerSessionId && retained.has(record.ownerSessionId);
1593
- let removable =
1594
- !record.sharedSessionIds?.length &&
1595
- (record.state !== 'ready' || (!ownerLive && (record.disposable === true || !isRetained)));
1909
+ // A snapshot outlives no holder: the prune removes it, changes and all.
1910
+ let removable = snapshot
1911
+ ? !ownerLive
1912
+ : !record.sharedSessionIds?.length &&
1913
+ (record.state !== 'ready' || (!ownerLive && (record.disposable === true || !isRetained)));
1596
1914
  if (removable) {
1597
1915
  try {
1598
1916
  await assertRecoveryStoresClear(record);
@@ -1620,13 +1938,16 @@ export async function listManagedWorktrees({ liveSessionIds = [], retainedSessio
1620
1938
  * autoCleanup setting at startup; true for a manual prune) also removes
1621
1939
  * clean, merged worktrees that no stored session retains (or that a deleted
1622
1940
  * session left marked disposable) and whose owner is not live — never one a
1623
- * session in the store still references.
1624
- * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean }} input
1941
+ * session in the store still references. A snapshot whose holder is neither
1942
+ * live nor running in another process is removed in either mode.
1943
+ * `repoCommonDir` limits the whole reconcile to that repository.
1944
+ * @param {{ liveSessionIds?: string[], retainedSessionIds?: string[], removeOrphans?: boolean, repoCommonDir?: string|null }} input
1625
1945
  */
1626
1946
  export async function reconcileManagedWorktrees({
1627
1947
  liveSessionIds = [],
1628
1948
  retainedSessionIds = [],
1629
1949
  removeOrphans = false,
1950
+ repoCommonDir: onlyRepo = null,
1630
1951
  } = {}) {
1631
1952
  const live = new Set(liveSessionIds);
1632
1953
  const retained = new Set(retainedSessionIds);
@@ -1638,6 +1959,7 @@ export async function reconcileManagedWorktrees({
1638
1959
  const byRepo = new Map();
1639
1960
  for (const record of Object.values((await readRegistry()).entries)) {
1640
1961
  const key = pathKey(record.repoCommonDir);
1962
+ if (onlyRepo && key !== pathKey(onlyRepo)) continue;
1641
1963
  if (!byRepo.has(key)) byRepo.set(key, record.repoCommonDir);
1642
1964
  }
1643
1965
  for (const [key, repoCommonDir] of byRepo) {
@@ -1668,11 +1990,56 @@ export async function reconcileManagedWorktrees({
1668
1990
  else report.kept.push({ id: record.id, reason: 'recovery', remaining: result.remaining });
1669
1991
  continue;
1670
1992
  }
1993
+ if (record.state === 'deleting' && record.kind === WORKTREE_SNAPSHOT_KIND) {
1994
+ // An unfinished snapshot deletion: whatever entered the
1995
+ // surviving folder since still keeps it.
1996
+ let release;
1997
+ try {
1998
+ release = holdSnapshotFolder(record);
1999
+ } catch (error) {
2000
+ if (!snapshotInUse(error)) throw error;
2001
+ report.kept.push({ id: record.id, reason: 'snapshot-in-use' });
2002
+ continue;
2003
+ }
2004
+ try {
2005
+ reportDeletion(record, await finishDeletion(record));
2006
+ } finally {
2007
+ release();
2008
+ }
2009
+ continue;
2010
+ }
1671
2011
  if (record.state === 'deleting') {
1672
2012
  // A decided deletion: its session already left the folder.
1673
2013
  reportDeletion(record, await finishDeletion(record));
1674
2014
  continue;
1675
2015
  }
2016
+ if (record.kind === WORKTREE_SNAPSHOT_KIND) {
2017
+ // Read-only and turn-scoped: once its holder is gone it goes,
2018
+ // whatever autoCleanup says and whatever was changed in it.
2019
+ // A worker or background shell still running in it keeps it too.
2020
+ let release = null;
2021
+ if (!(await snapshotHolderLive(record, live))) {
2022
+ try {
2023
+ release = holdSnapshotFolder(record);
2024
+ } catch (error) {
2025
+ if (!snapshotInUse(error)) throw error;
2026
+ }
2027
+ }
2028
+ if (!release) {
2029
+ report.kept.push({ id: record.id, reason: 'snapshot-in-use' });
2030
+ continue;
2031
+ }
2032
+ try {
2033
+ await assertRecoveryStoresClear(record);
2034
+ record.state = 'deleting';
2035
+ record.deleting = { force: true, startedAt: Date.now(), remaining: [] };
2036
+ await putRecord(record);
2037
+ reportDeletion(record, await finishDeletion(record));
2038
+ } finally {
2039
+ release();
2040
+ }
2041
+ continue;
2042
+ }
1676
2043
  const lease = await readOwnerLease(record);
1677
2044
  const ownerLive = (!!record.ownerSessionId && live.has(record.ownerSessionId)) || leaseProcessAlive(lease);
1678
2045
  if (!ownerLive && lease) {