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
@@ -75,7 +75,11 @@ import { createLineDiffBudget, lineDiffHunks, unifiedHunksBody } from './bounded
75
75
  // baseline of the session-wide review.
76
76
  //
77
77
  // Worker trackers remain separate until the worker completes: then its
78
- // segments, owned paths and counts join the owning Lead turn's.
78
+ // segments, owned paths and counts join the Lead turn that is running at that
79
+ // moment, or — when the Lead's turn already ended (sealed or sealing) — the
80
+ // next Lead turn that opens or reopens: the turn that receives the worker's
81
+ // completion and reports it. A worker's edits so land in exactly one turn
82
+ // record, and never in a record that was already frozen.
79
83
 
80
84
  const DISABLED = /^(0|false|off)$/i.test(String(process.env.MIXDOG_TURN_SNAPSHOT || ''));
81
85
  const TURN_CACHE_MAX = 32;
@@ -99,18 +103,46 @@ const _changeMutations = new Map();
99
103
  // `${sessionId}\0${checkpointId}` → path keys whose worktree state is unknown
100
104
  // and whose record could not even be marked so (the store refused every write).
101
105
  const _unknownChangeStates = new Map();
106
+ // Owner session → worker key → completed worker review waiting for the
107
+ // owner's next turn (its turn had ended when they completed), in completion
108
+ // order. Bounded like a turn's agents (trimWorkerReviews).
109
+ const _parkedWorkerReviews = new Map();
110
+ // Owner session → how many times its change history was deleted: a worker
111
+ // bound before a delete never writes into the history after it.
112
+ const _historyEpochs = new Map();
113
+
114
+ /** A parked review owns its worker's bound tracker content (`release`). */
115
+ function releaseParkedReview(review) {
116
+ if (review.release) releaseDiffTrackerContent(review.source);
117
+ }
118
+
119
+ function dropParkedWorkerReviews(ownerSessionId) {
120
+ for (const review of _parkedWorkerReviews.get(ownerSessionId)?.values() || []) releaseParkedReview(review);
121
+ _parkedWorkerReviews.delete(ownerSessionId);
122
+ }
102
123
 
103
124
  function trimTurnCache() {
104
125
  while (_turnsBySession.size > TURN_CACHE_MAX) {
105
126
  const oldest = _turnsBySession.keys().next().value;
106
127
  if (oldest === undefined) break;
107
128
  _turnsBySession.delete(oldest);
129
+ dropParkedWorkerReviews(oldest);
108
130
  for (const [sessionId, tracker] of _diffTrackersBySession) {
109
131
  if (sessionId === oldest || tracker.ownerSessionId === oldest) {
110
132
  _diffTrackersBySession.delete(sessionId);
133
+ // A bound worker's content is read by nothing else; the Lead's own
134
+ // tracker may still be sealing, so it is only unregistered.
135
+ if (sessionId !== oldest) releaseDiffTrackerContent(tracker);
111
136
  }
112
137
  }
113
138
  }
139
+ // Parked reviews of a Lead with no turn in memory (a worker resumed after a
140
+ // restart) are bounded on their own: oldest owner first.
141
+ while (_parkedWorkerReviews.size > TURN_CACHE_MAX) {
142
+ const oldest = _parkedWorkerReviews.keys().next().value;
143
+ if (oldest === undefined) break;
144
+ dropParkedWorkerReviews(oldest);
145
+ }
114
146
  }
115
147
 
116
148
  function mergePatches(values) {
@@ -695,13 +727,17 @@ function mergeTrackedContent(target, source) {
695
727
  }
696
728
  }
697
729
 
698
- function trimAgentReviews(turn) {
730
+ /** Bound worker reviews keyed by worker (a turn's agents, an owner's parked
731
+ * reviews): oldest out first, by count and retained patch bytes. */
732
+ function trimWorkerReviews(reviews, evicted = () => {}) {
699
733
  const retainedBytes = () =>
700
- [...turn.agents.values()].reduce((total, review) => total + Buffer.byteLength(review.patch || '', 'utf8'), 0);
701
- while (turn.agents.size > MAX_AGENT_REVIEWS_PER_TURN || retainedBytes() > MAX_AGENT_REVIEW_BYTES_PER_TURN) {
702
- const oldest = turn.agents.keys().next().value;
734
+ [...reviews.values()].reduce((total, review) => total + Buffer.byteLength(review.patch || '', 'utf8'), 0);
735
+ while (reviews.size > MAX_AGENT_REVIEWS_PER_TURN || retainedBytes() > MAX_AGENT_REVIEW_BYTES_PER_TURN) {
736
+ const oldest = reviews.keys().next().value;
703
737
  if (oldest === undefined) break;
704
- turn.agents.delete(oldest);
738
+ const review = reviews.get(oldest);
739
+ reviews.delete(oldest);
740
+ evicted(review);
705
741
  }
706
742
  }
707
743
 
@@ -741,6 +777,7 @@ function continueTurnSnapshot(ownerSessionId) {
741
777
  tracker.sealed = false;
742
778
  tracker.sealEpoch += 1;
743
779
  markWorktreeContention(ownerSessionId, tracker);
780
+ adoptParkedWorkerReviews(ownerSessionId, turn, tracker);
744
781
  return true;
745
782
  }
746
783
 
@@ -757,16 +794,8 @@ export async function beginTurnSnapshot(worktree, sessionId, options = {}) {
757
794
  // Turn start and Undo/Reapply share the per-session lock: a start waits for
758
795
  // an in-flight mutation, and a mutation queued after this start re-checks
759
796
  // under the lock and finds the turn running.
760
- // Late-worker re-seals still queued for the previous turn run first, in
761
- // this same step — including one a worker queued while an earlier one ran —
762
- // so the workers' edits stay in that turn's record and nothing the new turn
763
- // writes can reach it. (A queued re-seal implies a chain.)
764
797
  const { token, tracker } = _changeMutations.has(ownerSessionId)
765
- ? await serializeChangeMutation(ownerSessionId, async () => {
766
- const previous = _diffTrackersBySession.get(ownerSessionId);
767
- while (previous?.resealQueued) await runQueuedReseal(ownerSessionId, previous).catch(() => {});
768
- return openTurnSnapshot(worktree, ownerSessionId, options);
769
- })
798
+ ? await serializeChangeMutation(ownerSessionId, async () => openTurnSnapshot(worktree, ownerSessionId, options))
770
799
  : openTurnSnapshot(worktree, ownerSessionId, options);
771
800
  if (!tracker) return token;
772
801
  try {
@@ -796,15 +825,15 @@ function openTurnSnapshot(worktree, ownerSessionId, options) {
796
825
  // worktree lock ahead of this turn's baseline.
797
826
  const prior = _diffTrackersBySession.get(ownerSessionId);
798
827
  if (prior && prior.ownerGeneration > 0 && !prior.sealed) void sealTurn(ownerSessionId, prior).catch(() => {});
799
- for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
800
- if (tracker.ownerSessionId === ownerSessionId) _diffTrackersBySession.delete(trackedSessionId);
801
- }
828
+ // Workers still running keep their bound trackers across the new turn:
829
+ // their edits join whichever turn is running when they complete.
802
830
  _turnsBySession.delete(ownerSessionId);
803
- _turnsBySession.set(ownerSessionId, {
831
+ const turn = {
804
832
  generation,
805
833
  checkpointId,
806
834
  agents: new Map(),
807
- });
835
+ };
836
+ _turnsBySession.set(ownerSessionId, turn);
808
837
  const worktreeRequest = pathKey(worktree);
809
838
  const tracker = resetDiffTracker(ownerSessionId, {
810
839
  ownerGeneration: generation,
@@ -813,7 +842,10 @@ function openTurnSnapshot(worktree, ownerSessionId, options) {
813
842
  worktreeRoot: resolve(clean(worktree) || '.'),
814
843
  });
815
844
  trimTurnCache();
816
- if (tracker) markWorktreeContention(ownerSessionId, tracker);
845
+ if (tracker) {
846
+ markWorktreeContention(ownerSessionId, tracker);
847
+ adoptParkedWorkerReviews(ownerSessionId, turn, tracker);
848
+ }
817
849
  _turnOpenedHookForTest?.(ownerSessionId, checkpointId);
818
850
  return { token: { generation, checkpointId }, tracker };
819
851
  }
@@ -825,7 +857,9 @@ export function _setTurnOpenedHookForTest(hook) {
825
857
  _turnOpenedHookForTest = typeof hook === 'function' ? hook : null;
826
858
  }
827
859
 
828
- /** Bind one worker execution to the Lead turn that launched it. */
860
+ /** Bind one worker execution to its Lead. A Lead with no turn in memory (a
861
+ * worker resumed after a restart) binds generation 0: its completion waits
862
+ * for the Lead's next turn. */
829
863
  export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {}) {
830
864
  if (DISABLED) return null;
831
865
  const owner = clean(ownerSessionId);
@@ -833,24 +867,21 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
833
867
  // Durable: the worker's edits join the Lead's session review even when it
834
868
  // runs its own session turn, and whatever the turn's agent list keeps.
835
869
  recordReviewOwner(child, owner);
836
- const turn = _turnsBySession.get(owner);
837
- if (!owner || !child || !turn) return null;
870
+ if (!owner || !child) return null;
871
+ const generation = _turnsBySession.get(owner)?.generation || 0;
838
872
  const existingTracker = _diffTrackersBySession.get(child);
839
- if (
840
- !existingTracker ||
841
- existingTracker.ownerSessionId !== owner ||
842
- existingTracker.ownerGeneration !== turn.generation
843
- ) {
873
+ if (!existingTracker || existingTracker.ownerSessionId !== owner || existingTracker.ownerGeneration !== generation) {
844
874
  resetDiffTracker(child, {
845
875
  ownerSessionId: owner,
846
- ownerGeneration: turn.generation,
876
+ ownerGeneration: generation,
847
877
  });
848
878
  }
849
879
  _agentTurnSeq += 1;
850
880
  return {
851
881
  id: `agent-review-${_agentTurnSeq}`,
852
882
  ownerSessionId: owner,
853
- generation: turn.generation,
883
+ generation,
884
+ historyEpoch: _historyEpochs.get(owner) || 0,
854
885
  sessionId: child,
855
886
  agent: clean(meta.agent) || null,
856
887
  tag: clean(meta.tag) || null,
@@ -858,62 +889,145 @@ export function beginAgentTurnReview(ownerSessionId, childSessionId, meta = {})
858
889
  };
859
890
  }
860
891
 
861
- /** Freeze successful worker apply_patch diffs into their owning Lead turn.
862
- * Everything a seal reads — the agent entry, the worker's owned paths and a
863
- * late re-seal — is published before this returns, so a seal started right
864
- * after it already includes the worker. */
892
+ /** The Lead turn is running: not sealed, not sealing. */
893
+ const turnRunning = (turn, tracker) =>
894
+ Boolean(turn && tracker) &&
895
+ tracker.ownerGeneration === turn.generation &&
896
+ !tracker.sealed &&
897
+ !tracker.freezing &&
898
+ !tracker.discarded;
899
+
900
+ /** Attribute one completed worker review to `turn` and fold its own segments,
901
+ * owned paths and counts into the turn's tracker. */
902
+ function joinWorkerReview(turn, ownerTracker, review) {
903
+ const { key, source, tracked, patch } = review;
904
+ const prior = turn.agents.get(key);
905
+ if (!patch) {
906
+ if (!(tracked && prior)) return false;
907
+ turn.agents.delete(key);
908
+ return true;
909
+ }
910
+ turn.agents.delete(key);
911
+ turn.agents.set(key, {
912
+ sessionId: key,
913
+ agent: review.agent || prior?.agent || null,
914
+ tag: review.tag || prior?.tag || null,
915
+ patch: tracked ? patch : mergePatches([prior?.patch, patch]),
916
+ });
917
+ if (source && ownerTracker !== source) foldWorkerTracker(ownerTracker, source);
918
+ trimWorkerReviews(turn.agents);
919
+ return true;
920
+ }
921
+
922
+ /** Fold a finished worker's owned paths, counts and own segments into
923
+ * `target`. */
924
+ function foldWorkerTracker(target, source) {
925
+ for (const path of source.ownedPaths) target.ownedPaths.add(path);
926
+ mergeOwnStats(target.ownStats, source.ownStats);
927
+ mergeTrackedContent(target, source);
928
+ }
929
+
930
+ /** One parked review of a worker that completed again before its Lead's next
931
+ * turn: both runs' sources fold into one tracker this review owns — the
932
+ * older run's own bound tracker, else a fresh one, never a worker session's
933
+ * own tracker — exactly as both would have joined a running turn. The patch
934
+ * is the merged tracker's cumulative view when tracked, else the merged
935
+ * apply_patch diffs. */
936
+ function mergeParkedReview(prior, review) {
937
+ const sources = [prior.source, review.source].filter(Boolean);
938
+ let source = null;
939
+ if (sources.length > 0) {
940
+ source = prior.release && prior.source ? prior.source : createDiffTracker();
941
+ for (const other of sources) if (other !== source) foldWorkerTracker(source, other);
942
+ }
943
+ releaseParkedReview(review);
944
+ const tracked = prior.tracked || review.tracked;
945
+ return {
946
+ key: review.key,
947
+ source,
948
+ tracked,
949
+ patch: tracked ? trackerView(source).patch : mergePatches([prior.patch, review.patch]),
950
+ agent: review.agent || prior.agent,
951
+ tag: review.tag || prior.tag,
952
+ release: Boolean(source),
953
+ };
954
+ }
955
+
956
+ /** Park a completed worker review for its Lead's next turn. A newer
957
+ * completion of the same worker merges into the parked one (every run's
958
+ * edits are kept, as a running turn keeps them); the parked reviews are
959
+ * bounded by the turn's own limits, and an evicted one releases its tracker
960
+ * content. */
961
+ function parkWorkerReview(ownerSessionId, review) {
962
+ const parked = _parkedWorkerReviews.get(ownerSessionId) || new Map();
963
+ const prior = parked.get(review.key);
964
+ if (!prior && !review.patch) {
965
+ releaseParkedReview(review);
966
+ return false;
967
+ }
968
+ parked.delete(review.key);
969
+ parked.set(review.key, prior ? mergeParkedReview(prior, review) : review);
970
+ trimWorkerReviews(parked, releaseParkedReview);
971
+ _parkedWorkerReviews.set(ownerSessionId, parked);
972
+ trimTurnCache();
973
+ return true;
974
+ }
975
+
976
+ /** A turn that just opened (or reopened) takes the worker reviews that
977
+ * completed while its Lead had no running turn. */
978
+ function adoptParkedWorkerReviews(ownerSessionId, turn, tracker) {
979
+ const parked = _parkedWorkerReviews.get(ownerSessionId);
980
+ if (!parked) return;
981
+ _parkedWorkerReviews.delete(ownerSessionId);
982
+ for (const review of parked.values()) {
983
+ joinWorkerReview(turn, tracker, review);
984
+ releaseParkedReview(review);
985
+ }
986
+ }
987
+
988
+ /** Hand a finished worker's own edits (its tracker, else its apply_patch
989
+ * diffs) to its Lead: into the Lead turn running now, or — when none runs —
990
+ * parked for the Lead's next turn to open or reopen. Everything a seal
991
+ * reads is published before this returns, so a seal started right after it
992
+ * already includes the worker. */
865
993
  export function completeAgentTurnReview(handle, patches = []) {
866
994
  if (!handle || handle.completed === true) return false;
867
995
  handle.completed = true;
868
996
  const ownerSessionId = clean(handle.ownerSessionId);
869
997
  const key = clean(handle.sessionId) || handle.id;
870
- const turn = _turnsBySession.get(ownerSessionId);
871
998
  const tracker = _diffTrackersBySession.get(key);
872
999
  const matchingTracker =
873
1000
  tracker && tracker.ownerSessionId === ownerSessionId && tracker.ownerGeneration === handle.generation;
1001
+ // This execution is over: its bound tracker leaves the registry, so a next
1002
+ // execution of the worker starts its own.
1003
+ if (matchingTracker) _diffTrackersBySession.delete(key);
1004
+ // The Lead's change history was deleted after this worker was bound.
1005
+ if ((_historyEpochs.get(ownerSessionId) || 0) !== (handle.historyEpoch || 0)) {
1006
+ if (matchingTracker) releaseDiffTrackerContent(tracker);
1007
+ return false;
1008
+ }
874
1009
  // A worker that runs its own session turn replaces the bound tracker with
875
1010
  // its turn's own one (no owner). beginAgentTurnReview reset any older
876
1011
  // tracker, so an unowned one here holds this execution's edits.
877
1012
  const source = matchingTracker || (tracker && !tracker.ownerSessionId) ? tracker : null;
1013
+ const tracked = Boolean(source) && source.revision > 0;
1014
+ const review = {
1015
+ key,
1016
+ source,
1017
+ tracked,
1018
+ patch: tracked ? trackerView(source).patch : mergePatches(patches),
1019
+ agent: handle.agent,
1020
+ tag: handle.tag,
1021
+ release: Boolean(matchingTracker),
1022
+ };
1023
+ const turn = _turnsBySession.get(ownerSessionId);
878
1024
  const ownerTracker = _diffTrackersBySession.get(ownerSessionId);
879
- try {
880
- if (!turn || turn.generation !== handle.generation) return false;
881
- const prior = turn.agents.get(key);
882
- const tracked = Boolean(source) && source.revision > 0;
883
- const patch = tracked ? trackerView(source).patch : mergePatches(patches);
884
- if (!patch) {
885
- if (tracked && prior) {
886
- turn.agents.delete(key);
887
- return true;
888
- }
889
- return false;
890
- }
891
- turn.agents.delete(key);
892
- turn.agents.set(key, {
893
- sessionId: key,
894
- agent: handle.agent || prior?.agent || null,
895
- tag: handle.tag || prior?.tag || null,
896
- patch: tracked ? patch : mergePatches([prior?.patch, patch]),
897
- });
898
- if (source && ownerTracker && ownerTracker !== source) {
899
- for (const path of source.ownedPaths) ownerTracker.ownedPaths.add(path);
900
- mergeOwnStats(ownerTracker.ownStats, source.ownStats);
901
- mergeTrackedContent(ownerTracker, source);
902
- }
903
- trimAgentReviews(turn);
904
- // A worker that finishes after its Lead turn sealed (or while it seals)
905
- // re-seals that turn's change record (its edits are part of the turn's net
906
- // worktree diff).
907
- if (ownerTracker && ownerTracker !== tracker && ownerTracker.ownerGeneration === handle.generation) {
908
- resealTurn(ownerSessionId, ownerTracker);
909
- }
910
- return true;
911
- } finally {
912
- if (matchingTracker && _diffTrackersBySession.get(key) === tracker) {
913
- releaseDiffTrackerContent(tracker);
914
- _diffTrackersBySession.delete(key);
915
- }
1025
+ if (turnRunning(turn, ownerTracker)) {
1026
+ const joined = joinWorkerReview(turn, ownerTracker, review);
1027
+ releaseParkedReview(review);
1028
+ return joined;
916
1029
  }
1030
+ return parkWorkerReview(ownerSessionId, review);
917
1031
  }
918
1032
 
919
1033
  /** Persist the durable half of a finished review: the baseline tree hash plus
@@ -1092,15 +1206,12 @@ const pendingFreezeKey = (ownerSessionId, checkpointId) => `${ownerSessionId}\0$
1092
1206
  * Seal a Lead turn: capture the worktree one last time, persist the review
1093
1207
  * baseline and freeze the change record. Single-flight per tracker; a seal
1094
1208
  * requested while another runs waits for it and then seals again only if a
1095
- * completion wake reopened the turn meanwhile. `reseal` (a late worker) only
1096
- * re-freezes a turn that is still sealed, after any seal in flight: that one
1097
- * captured its agents before the worker was attributed.
1209
+ * completion wake reopened the turn meanwhile.
1098
1210
  */
1099
- function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1211
+ function sealTurn(ownerSessionId, tracker) {
1100
1212
  // Its history was deleted (clearSessionChangeHistory): never written again.
1101
1213
  if (tracker.discarded) return Promise.resolve();
1102
1214
  if (tracker.freezing) {
1103
- if (reseal) return tracker.freezing.then(() => sealTurn(ownerSessionId, tracker, { reseal: true }));
1104
1215
  return tracker.freezing.then(() => (tracker.sealed ? undefined : sealTurn(ownerSessionId, tracker)));
1105
1216
  }
1106
1217
  const epoch = tracker.sealEpoch;
@@ -1115,9 +1226,7 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1115
1226
  // Best-effort refresh: seal with the last captured worktree state.
1116
1227
  }
1117
1228
  }
1118
- if (tracker.discarded || tracker.sealEpoch !== epoch || (reseal && !tracker.sealed)) return;
1119
- // A re-seal never rewrites a turn once the next one opened.
1120
- if (reseal && turnSuperseded(ownerSessionId, tracker)) return;
1229
+ if (tracker.discarded || tracker.sealEpoch !== epoch) return;
1121
1230
  tracker.sealed = true;
1122
1231
  await persistTurnSnapshotRecord(ownerSessionId, tracker);
1123
1232
  try {
@@ -1125,8 +1234,8 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1125
1234
  } catch {
1126
1235
  /* the change record adds Undo history; it never fails a turn */
1127
1236
  }
1128
- // The bounded own before/after content stays until the next turn: a late
1129
- // worker's re-seal and an Undo/Reapply of this turn fold into it.
1237
+ // The bounded own before/after content stays until the next turn: a
1238
+ // completion wake reopening it and an Undo/Reapply of this turn fold into it.
1130
1239
  })().finally(() => {
1131
1240
  if (tracker.freezing === run) tracker.freezing = null;
1132
1241
  const count = (_pendingFreezes.get(key) || 1) - 1;
@@ -1137,31 +1246,6 @@ function sealTurn(ownerSessionId, tracker, { reseal = false } = {}) {
1137
1246
  return run;
1138
1247
  }
1139
1248
 
1140
- function resealTurn(ownerSessionId, tracker) {
1141
- // A turn still running seals with every published agent; one sealed or
1142
- // sealing queues its re-seal (sealTurn runs it after a seal in flight).
1143
- if (!(tracker.sealed || tracker.freezing) || !clean(tracker.checkpointId) || tracker.resealQueued) return;
1144
- // On the session lock: an in-flight Undo/Reapply lands before the re-freeze
1145
- // reads the worktree. A turn start queued ahead of it runs it before opening
1146
- // (beginTurnSnapshot), so one queued re-seal covers every late worker.
1147
- tracker.resealQueued = true;
1148
- void serializeChangeMutation(ownerSessionId, () => runQueuedReseal(ownerSessionId, tracker)).catch(() => {});
1149
- }
1150
-
1151
- /** True once a newer turn of the session opened over `tracker`'s. */
1152
- function turnSuperseded(ownerSessionId, tracker) {
1153
- const turn = _turnsBySession.get(ownerSessionId);
1154
- return Boolean(turn) && turn.generation !== tracker.ownerGeneration;
1155
- }
1156
-
1157
- /** Run `tracker`'s queued re-seal once, whichever lock step reaches it first. */
1158
- function runQueuedReseal(ownerSessionId, tracker) {
1159
- if (!tracker.resealQueued) return undefined;
1160
- tracker.resealQueued = false;
1161
- if (turnSuperseded(ownerSessionId, tracker)) return undefined;
1162
- return sealTurn(ownerSessionId, tracker, { reseal: true });
1163
- }
1164
-
1165
1249
  /** Freeze a completed (or cancelled) Lead turn while retaining only its
1166
1250
  * bounded review data. `expected` — the `{ generation }` beginTurnSnapshot
1167
1251
  * resolved to — makes a delayed call a no-op once a newer turn began. */
@@ -1947,20 +2031,8 @@ export async function clearSessionChangeHistory(sessionId) {
1947
2031
  throw new Error('change history cannot be deleted while this session is running a turn');
1948
2032
  }
1949
2033
  // Invalidate every producer of the old history BEFORE the awaited
1950
- // deletion: a worker finishing meanwhile finds no turn to fold into, and
1951
- // a re-seal it (or anything earlier) already queued finds its tracker
1952
- // discarded, so nothing begun before the delete can write it back.
1953
- for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
1954
- if (trackedSessionId !== ownerSessionId && tracker.ownerSessionId !== ownerSessionId) continue;
1955
- tracker.discarded = true;
1956
- tracker.resealQueued = false;
1957
- releaseDiffTrackerContent(tracker);
1958
- _diffTrackersBySession.delete(trackedSessionId);
1959
- }
1960
- _turnsBySession.delete(ownerSessionId);
1961
- for (const key of _unknownChangeStates.keys()) {
1962
- if (key.startsWith(`${ownerSessionId}\0`)) _unknownChangeStates.delete(key);
1963
- }
2034
+ // deletion, so nothing begun before the delete can write it back.
2035
+ discardSessionChangeState(ownerSessionId);
1964
2036
  // The session's first-seen baselines go too: later edits start fresh ones.
1965
2037
  const [turns] = await Promise.all([
1966
2038
  clearStoredChangeHistory(ownerSessionId),
@@ -1970,12 +2042,51 @@ export async function clearSessionChangeHistory(sessionId) {
1970
2042
  });
1971
2043
  }
1972
2044
 
2045
+ /** Release a Lead session's in-memory change state: a worker bound before
2046
+ * this finds its history epoch moved on (its completion parks nothing), its
2047
+ * parked worker reviews are dropped, and its own and bound worker trackers
2048
+ * are discarded (anything earlier that seals writes nothing). */
2049
+ function discardSessionChangeState(ownerSessionId) {
2050
+ _historyEpochs.set(ownerSessionId, (_historyEpochs.get(ownerSessionId) || 0) + 1);
2051
+ dropParkedWorkerReviews(ownerSessionId);
2052
+ for (const [trackedSessionId, tracker] of _diffTrackersBySession) {
2053
+ if (trackedSessionId !== ownerSessionId && tracker.ownerSessionId !== ownerSessionId) continue;
2054
+ tracker.discarded = true;
2055
+ releaseDiffTrackerContent(tracker);
2056
+ _diffTrackersBySession.delete(trackedSessionId);
2057
+ }
2058
+ _turnsBySession.delete(ownerSessionId);
2059
+ for (const key of _unknownChangeStates.keys()) {
2060
+ if (key.startsWith(`${ownerSessionId}\0`)) _unknownChangeStates.delete(key);
2061
+ }
2062
+ }
2063
+
2064
+ /** The session was deleted: release its turn, parked worker reviews and
2065
+ * bound worker trackers (its stored records go with the session purge). */
2066
+ export function forgetTurnSnapshotSession(sessionId) {
2067
+ const ownerSessionId = clean(sessionId);
2068
+ if (ownerSessionId) discardSessionChangeState(ownerSessionId);
2069
+ }
2070
+
2071
+ export function _diffTrackerForTest(sessionId) {
2072
+ return _diffTrackersBySession.get(clean(sessionId));
2073
+ }
2074
+
2075
+ export function _parkedWorkerReviewsForTest(ownerSessionId) {
2076
+ return [...(_parkedWorkerReviews.get(clean(ownerSessionId))?.values() || [])].map(({ key, patch }) => ({
2077
+ key,
2078
+ patch,
2079
+ }));
2080
+ }
2081
+
1973
2082
  export function _resetTurnSnapshotForTest() {
1974
2083
  _turnsBySession.clear();
1975
2084
  _diffTrackersBySession.clear();
1976
2085
  _pendingFreezes.clear();
1977
2086
  _changeMutations.clear();
1978
2087
  _unknownChangeStates.clear();
2088
+ _parkedWorkerReviews.clear();
2089
+ _historyEpochs.clear();
1979
2090
  _turnOpenedHookForTest = null;
1980
2091
  _agentTurnSeq = 0;
1981
2092
  _resetTurnWorktreeSnapshotsForTest();
@@ -389,9 +389,20 @@ function copyTree(src, dst, copied) {
389
389
  copied.push(dst);
390
390
  }
391
391
 
392
- /** Prune one data dir's scope only; other scopes and legacy entries stay. */
392
+ // Only a published manifest makes a backup count toward retention.
393
+ const manifestPath = (scopeDir, name) => join(scopeDir, name, BACKUP_MANIFEST);
394
+
395
+ /** Prune one data dir's scope only; other scopes, legacy entries and
396
+ * unfinished reservations stay. */
393
397
  function pruneBackups(scopeDir, names, own) {
394
- for (const name of backupsToPrune(names, own)) {
398
+ const completed = names.filter((name) => {
399
+ try {
400
+ return statSync(manifestPath(scopeDir, name)).isFile();
401
+ } catch {
402
+ return false;
403
+ }
404
+ });
405
+ for (const name of backupsToPrune(completed, own)) {
395
406
  try {
396
407
  rmSync(join(scopeDir, name), { recursive: true, force: true });
397
408
  } catch {}
@@ -461,16 +472,24 @@ export function backupUserData(dataDir, reason = 'snapshot', { dirs = [] } = {})
461
472
  const scopeDir = scopeDirFor(dataDir);
462
473
  const { backupDir, names } = reserveBackupDir(scopeDir, reason);
463
474
  const copied = [];
464
- for (const rel of USER_DATA_FILES) {
465
- const src = join(dataDir, rel);
466
- if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
467
- }
468
- for (const rel of [...USER_DATA_DIRS, ...dirs]) {
469
- const src = join(dataDir, rel);
470
- if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
475
+ try {
476
+ for (const rel of USER_DATA_FILES) {
477
+ const src = join(dataDir, rel);
478
+ if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
479
+ }
480
+ for (const rel of [...USER_DATA_DIRS, ...dirs]) {
481
+ const src = join(dataDir, rel);
482
+ if (existsSync(src)) copyTree(src, join(backupDir, rel), copied);
483
+ }
484
+ if (copied.length > 0) finishBackup(dataDir, backupDir, reason);
485
+ } catch (error) {
486
+ // Drop only this call's reservation; a failed cleanup must not mask the cause.
487
+ try {
488
+ rmSync(backupDir, { recursive: true, force: true });
489
+ } catch {}
490
+ throw error;
471
491
  }
472
492
  if (copied.length > 0) {
473
- finishBackup(dataDir, backupDir, reason);
474
493
  pruneBackups(scopeDir, names, basename(backupDir));
475
494
  if (process.env.MIXDOG_SETUP_QUIET !== '1') {
476
495
  process.stderr.write(`[user-data-backup] ${reason}: copied ${copied.length} file(s) to ${backupDir}\n`);
@@ -499,7 +518,13 @@ async function copyTreeAsync(src, dst, copied) {
499
518
  }
500
519
 
501
520
  async function pruneBackupsAsync(scopeDir, names, own) {
502
- for (const name of backupsToPrune(names, own)) {
521
+ const completed = [];
522
+ for (const name of names) {
523
+ try {
524
+ if ((await statP(manifestPath(scopeDir, name))).isFile()) completed.push(name);
525
+ } catch {}
526
+ }
527
+ for (const name of backupsToPrune(completed, own)) {
503
528
  try {
504
529
  await rmP(join(scopeDir, name), { recursive: true, force: true });
505
530
  } catch {}
@@ -512,16 +537,23 @@ export async function backupUserDataAsync(dataDir, reason = 'snapshot') {
512
537
  const scopeDir = scopeDirFor(dataDir);
513
538
  const { backupDir, names } = await reserveBackupDirAsync(scopeDir, reason);
514
539
  const copied = [];
515
- for (const rel of USER_DATA_FILES) {
516
- const src = join(dataDir, rel);
517
- if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
518
- }
519
- for (const rel of USER_DATA_DIRS) {
520
- const src = join(dataDir, rel);
521
- if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
540
+ try {
541
+ for (const rel of USER_DATA_FILES) {
542
+ const src = join(dataDir, rel);
543
+ if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
544
+ }
545
+ for (const rel of USER_DATA_DIRS) {
546
+ const src = join(dataDir, rel);
547
+ if (existsSync(src)) await copyTreeAsync(src, join(backupDir, rel), copied);
548
+ }
549
+ if (copied.length > 0) finishBackup(dataDir, backupDir, reason);
550
+ } catch (error) {
551
+ try {
552
+ await rmP(backupDir, { recursive: true, force: true });
553
+ } catch {}
554
+ throw error;
522
555
  }
523
556
  if (copied.length > 0) {
524
- finishBackup(dataDir, backupDir, reason);
525
557
  await pruneBackupsAsync(scopeDir, names, basename(backupDir));
526
558
  if (process.env.MIXDOG_SETUP_QUIET !== '1') {
527
559
  process.stderr.write(`[user-data-backup] ${reason}: copied ${copied.length} file(s) to ${backupDir}\n`);