openmausbot 0.1.95 → 0.1.97

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 (169) hide show
  1. package/dist/assets/{DesktopViewer-BwWpc4RK.js → DesktopViewer-CPidMCtt.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-jsQkIqTn.js → abnfDiagram-O67JEVCF-k1UCNXb7.js} +1 -1
  3. package/dist/assets/{arc-7kei4Hsu.js → arc-BUWGa9UU.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-CRWDFDMq.js → architectureDiagram-NJMV4G6O-DiPor3UT.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CTKW_IKs.js → blockDiagram-BEXU5L5S-Ag9nLW4H.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-BBTW7-6M.js → c4Diagram-YGBWAQC7-Bh0sjgUa.js} +1 -1
  7. package/dist/assets/channel-DR8eD6QW.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-M_Ox-MkD.js → chunk-2Q5K7J3B-BFBcIIqu.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-D2YiN-Ud.js → chunk-3FUC2YCW-Dvfpex-f.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BmVYJ0f8.js → chunk-5VM5RSS4-CMjP2YZW.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-BtiLVVnZ.js → chunk-GTNCS2PH-BryoeTc7.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-DFYSb6J6.js → chunk-GWA4HPMP-BhfvcchO.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-mknKBzgV.js → chunk-JWPE2WC7-bcCVp8rp.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-DgSYGiOu.js → chunk-NETBCI7D-yKIjIlkQ.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-C7ZvuguH.js → chunk-WEXAMYUT-D3Zqbmup.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-CPqxINpA.js → chunk-XXDRQBXY-CjVoIMG6.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-Cd6xfJaC.js → classDiagram-v2-NBCMYWYE-mUCWdVyH.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BSXY5qRh.js → cose-bilkent-JH36ORCC-BBmR1bbX.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-D_lhEMj1.js → cynefin-OW5HDTMX-BpRrxoO4.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-Da9FrFwk.js → cynefinDiagram-VND7K2PF-D3VBWCPK.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-T77jMOXe.js → dagre-6A5THRUB-DxEIACOO.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-Dkj96X5E.js → diagram-22UHCM2B-BANgZEOS.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-CMi9zlD_.js → diagram-3UASUU5V-Cnq7nJir.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-CXK6YUfS.js → diagram-ATOU4E4O-4TBDIWnz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-BE7B1u9Q.js → diagram-CDSNMT55-CD_B39D4.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CCMQphKr.js → diagram-MLGK6HIB-CzvnFhEC.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-iHJ_-tGH.js → diagram-MPIPVDR6-C4w2SNml.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-DONsCWQ7.js → ebnfDiagram-ZINNZB2B-gkUEBm2s.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-CU_WOaXk.js → elk-276RUBZZ-B_56X6mw.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BeYwM04G.js → erDiagram-OPXOYQCR-C_oByoDS.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-Bdw9DqEl.js → flowDiagram-KWPJA3E3-CR9xXJkC.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-C9iwq3qm.js → ganttDiagram-FUAMR5RP-gtYBTu0B.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-Dx-VWzUg.js → gitGraphDiagram-X574FWY7-VJ6-Tp_p.js} +1 -1
  34. package/dist/assets/{index-KygHRE3E.js → index-Cp4S4NOL.js} +1 -1
  35. package/dist/assets/index-CpaIXRLp.js +614 -0
  36. package/dist/assets/index-Z81SMsgR.css +1 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-BG7uaEd4.js → infoDiagram-VRGFBTTK-2Lu9te4D.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-D72WAXnM.js → ishikawaDiagram-OU5B5YK6-DR49XY-e.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DKH1gjYT.js → journeyDiagram-ZHPQQLJL-ZoR0M305.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-3upm9JUT.js → kanban-definition-PNTS6WVX-DLKHxrO-.js} +1 -1
  41. package/dist/assets/{linear-CcR4pc0-.js → linear-BASsfWly.js} +1 -1
  42. package/dist/assets/{mermaid.core-C6yU5FSS.js → mermaid.core-BVidiPkk.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-DOI2xEuv.js → mindmap-definition-NLK3R4M7-g_Qx0U5C.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-BVWJSgLd.js → pegDiagram-GJSIUBJH-DI9Cjs8w.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-jtbYezhq.js → pieDiagram-5QR66LMP-sRI0Uhr5.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-g0u8mtPX.js → quadrantDiagram-O4NWA36T-CamKTby3.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-k8689Wny.js → railroadDiagram-XR7U4H2S-DOLlkbf3.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-DsJ22PDt.js → requirementDiagram-PLB6GJNP-DrRQ5r97.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-CneWBRgG.js → sankeyDiagram-IPEJSGJF-DGS_lXcX.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-DWsFCWap.js → sequenceDiagram-PO4LG4MO-CkjWqwtf.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-Bvf0YE3S.js → sizeCapture-INFHLROL-CepRgful.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-Bxb3Iei8.js → stateDiagram-v2-GCMORJYK-hykr-9RS.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-DLkk7W4A.js → swimlanes-2SLR337P-CKOHEZbI.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-D-8vqZ4-.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-KR9bIHYC.js → timeline-definition-EJHVYXUP-CB8pJ6AC.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-DfsOrj8t.js → usecaseDiagram-POWQR4AR-DrclJHdl.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-DRSTzG3h.js → vennDiagram-UO4OBE2U-zS23qjv8.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DtEdhPgT.js → wardleyDiagram-VNRHLVJA-52jgeKxz.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-B-XhTxjG.js → xychartDiagram-PMCCYNJV-DSdl9rQ8.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/cloud-home-start.js +26 -13
  62. package/dist-server/companion/src/listener.js +39 -18
  63. package/dist-server/container-mcp.js +14 -27
  64. package/dist-server/drivers/agents-proxy.js +108 -49
  65. package/dist-server/harness-mcp-proxy.js +1 -2
  66. package/dist-server/index.js +4664 -4644
  67. package/dist-server/openmausbot.js +1425 -1437
  68. package/dist-server/pair-cli.js +1425 -1437
  69. package/dist-server/permission-proxy.js +0 -4
  70. package/dist-server/server/admin-activity.js +1 -4
  71. package/dist-server/server/auto-approve.js +20 -5
  72. package/dist-server/server/auto-vm-claims.js +13 -1
  73. package/dist-server/server/automatic-recovery.js +1 -2
  74. package/dist-server/server/boat.js +71 -87
  75. package/dist-server/server/bot-overview.js +1 -1
  76. package/dist-server/server/browser-engine.js +38 -1
  77. package/dist-server/server/browser-live.js +112 -25
  78. package/dist-server/server/browser-runtime.js +31 -1
  79. package/dist-server/server/checkpoints.js +340 -167
  80. package/dist-server/server/chief-of-staff.js +7 -6
  81. package/dist-server/server/cli-setup.js +3 -3
  82. package/dist-server/server/cli.js +33 -26
  83. package/dist-server/server/cloud-computer-tools.js +11 -4
  84. package/dist-server/server/cloud-home-start.js +13 -16
  85. package/dist-server/server/cloud-home.js +3 -13
  86. package/dist-server/server/cloud-move-http.js +44 -30
  87. package/dist-server/server/cloud-move.js +20 -18
  88. package/dist-server/server/cloud-owner.js +1 -1
  89. package/dist-server/server/composio.js +10 -3
  90. package/dist-server/server/computer-engine-removal.js +70 -0
  91. package/dist-server/server/computer-selection.js +18 -0
  92. package/dist-server/server/config.js +99 -75
  93. package/dist-server/server/connector-scope-hint.js +83 -0
  94. package/dist-server/server/container-computer.js +16 -5
  95. package/dist-server/server/contracts.js +0 -9
  96. package/dist-server/server/decider/jev.js +1 -4
  97. package/dist-server/server/decider/log.js +1 -3
  98. package/dist-server/server/decision-log.js +1 -4
  99. package/dist-server/server/default-model-selection.js +20 -14
  100. package/dist-server/server/delegations.js +14 -7
  101. package/dist-server/server/delta-context.js +6 -1
  102. package/dist-server/server/drivers/acp/core.js +5 -4
  103. package/dist-server/server/drivers/acp/opencode-go.js +19 -23
  104. package/dist-server/server/drivers/agents-call.js +47 -64
  105. package/dist-server/server/drivers/agents-catalog.js +69 -3
  106. package/dist-server/server/drivers/agents-proxy.js +2 -1
  107. package/dist-server/server/drivers/builtIn.js +0 -2
  108. package/dist-server/server/drivers/claude-login-auth.js +2 -11
  109. package/dist-server/server/drivers/claude.js +49 -30
  110. package/dist-server/server/drivers/codex-device-auth.js +3 -1
  111. package/dist-server/server/drivers/codex.js +7 -6
  112. package/dist-server/server/drivers/grok.js +1 -1
  113. package/dist-server/server/drivers/openai-chat-protocol.js +4 -2
  114. package/dist-server/server/drivers/openai-chat.js +113 -11
  115. package/dist-server/server/drivers/pi.js +184 -49
  116. package/dist-server/server/drivers/prompt-split.js +4 -4
  117. package/dist-server/server/harness/bus.js +49 -0
  118. package/dist-server/server/harness-mcp-proxy.js +1 -1
  119. package/dist-server/server/index.js +942 -980
  120. package/dist-server/server/live-call.js +11 -6
  121. package/dist-server/server/managed-policy.js +1 -1
  122. package/dist-server/server/mcp-registry.js +35 -0
  123. package/dist-server/server/message-file.js +4 -4
  124. package/dist-server/server/model-catalog/presets.js +8 -0
  125. package/dist-server/server/peer-delivery.js +4 -19
  126. package/dist-server/server/peer-roster.js +40 -6
  127. package/dist-server/server/profile-versions.js +0 -31
  128. package/dist-server/server/provider-key-check.js +2 -1
  129. package/dist-server/server/restart.js +40 -0
  130. package/dist-server/server/room-handoffs.js +12 -0
  131. package/dist-server/server/room-turn-end.js +15 -0
  132. package/dist-server/server/routes/bot-memory.js +0 -24
  133. package/dist-server/server/routes/live.js +3 -3
  134. package/dist-server/server/routine-requests.js +2 -5
  135. package/dist-server/server/routines.js +6 -5
  136. package/dist-server/server/screen-frame-gate.js +1 -3
  137. package/dist-server/server/server-launcher.js +53 -0
  138. package/dist-server/server/store.js +126 -2
  139. package/dist-server/server/surface.js +40 -64
  140. package/dist-server/server/system-prompt.js +23 -13
  141. package/dist-server/server/team-manifest.js +1 -6
  142. package/dist-server/server/tunnel.js +4 -1
  143. package/dist-server/server/turn-continuation.js +162 -0
  144. package/dist-server/server/turn-images.js +1 -21
  145. package/dist-server/server/turn-resources.js +19 -150
  146. package/dist-server/server/usage-ledger.js +3 -1
  147. package/dist-server/server/workspace.js +3 -4
  148. package/dist-server/server-launcher.js +72 -0
  149. package/dist-server/shared/ask-question.js +1 -71
  150. package/dist-server/shared/cloud-computer.js +10 -0
  151. package/dist-server/shared/credential-request.js +1 -1
  152. package/dist-server/shared/failed-turn.js +22 -0
  153. package/dist-server/shared/live-approval.js +2 -2
  154. package/dist-server/shared/package-format.js +3 -3
  155. package/dist-server/shared/pairing-link.js +144 -0
  156. package/dist-server/shared/place-view.js +409 -0
  157. package/dist-server/shared/tool-scope-support.js +1 -2
  158. package/dist-server/vps-container-mcp.js +14 -27
  159. package/dist-server/workspace-backup.worker.js +14 -27
  160. package/package.json +1 -1
  161. package/dist/assets/channel-DS2NbT5x.js +0 -1
  162. package/dist/assets/index-NF7XCHAy.css +0 -1
  163. package/dist/assets/index-X_5ydXIE.js +0 -610
  164. package/dist/assets/swimlanesDiagram-TC7HE7FX-C6bZtBXk.js +0 -8
  165. package/dist-server/server/claim-idle.js +0 -33
  166. package/dist-server/server/cloud-overflow.js +0 -167
  167. package/dist-server/server/drivers/boatagent.js +0 -443
  168. package/dist-server/server/tightening-requests.js +0 -435
  169. package/dist-server/shared/tightening-request.js +0 -87
@@ -2,43 +2,43 @@
2
2
  //
3
3
  // Before a turn's engine can touch files, the working folder is snapshotted
4
4
  // into a shadow repo at DATA_DIR/checkpoints/<botId>/<sha256(cwd)[..16]>/.git.
5
+ // When the turn settles, its digest diffs the folder against that snapshot to
6
+ // list the files the turn changed, added and deleted.
5
7
  // The user's own .git (if the folder is a repository) is never read, written,
6
8
  // or locked: every git call runs with GIT_DIR pointing at the shadow repo and
7
9
  // GIT_WORK_TREE pointing at the folder, so the shadow index is the only index
8
10
  // involved — and git always skips directories named .git when walking a work
9
11
  // tree, so the user's repository internals are invisible to the snapshot.
10
12
  //
11
- // Restore is redo-friendly on purpose: it commits a safety point, then moves
12
- // the index and work tree back with `git restore --source` + `git clean -fd`
13
- // while HEAD keeps the safety point — so the state just replaced is the one
14
- // retained checkpoint, and "undo the undo" is one more restore. Ignored/excluded files
15
- // (node_modules, .env, media) are never snapshotted and never removed by one.
13
+ // Ignored/excluded files (node_modules, .env, media) are never snapshotted.
16
14
  //
17
15
  // Failure policy: checkpointing is best-effort convenience, never load-bearing.
18
16
  // Any git failure disables the feature for that bot for the rest of the
19
- // session and logs — nothing here ever throws into the turn path.
17
+ // session and logs — nothing here ever throws into the turn path. A git call
18
+ // that hits the timeout disables only that bot+folder, so a slow folder costs
19
+ // one wait and one log line, not one wait per turn.
20
20
  //
21
21
  // Adapted from the checkpoint designs of Cline, Roo-Code, and Gemini CLI
22
22
  // (all Apache-2.0): the per-call GIT_DIR/GIT_WORK_TREE/GIT_CONFIG_* env
23
- // override and restore shape follow Gemini CLI's gitService, the sanitized
24
- // GIT_* env list and the exclude categories follow Roo-Code's checkpoint
25
- // service, and the snapshot-before-every-turn cadence follows Cline.
23
+ // override follows Gemini CLI's gitService, the sanitized GIT_* env list and
24
+ // the exclude categories follow Roo-Code's checkpoint service, and the
25
+ // snapshot-before-every-turn cadence follows Cline.
26
26
  import { execFile } from "node:child_process";
27
27
  import { createHash } from "node:crypto";
28
28
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
29
+ import { readdir } from "node:fs/promises";
29
30
  import { homedir } from "node:os";
30
31
  import { isAbsolute, join, parse, resolve } from "node:path";
31
32
  import { DATA_DIR } from "./config.js";
32
33
  export const CHECKPOINTS_DIR = join(DATA_DIR, "checkpoints");
33
- /** Full sha1 hex only. The API hands out full hashes; accepting anything
34
- * looser would let arbitrary revspecs ("HEAD~3", "main@{u}") reach git. */
34
+ /** Full sha1 hex only: accepting anything looser would let arbitrary
35
+ * revspecs ("HEAD~3", "main@{u}") reach git. */
35
36
  const COMMIT_HASH = /^[0-9a-f]{40}$/;
36
- // What a checkpoint deliberately does not carry. Restores must never delete
37
- // these either: `git clean -fd` (without -x) leaves ignored files alone, so
38
- // everything listed here survives a rollback untouched. Categories follow
39
- // Roo-Code's checkpoint excludes: VCS internals, dependency trees, build
40
- // output, caches, logs, secrets, media, archives, databases, model weights.
41
- const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted, never removed by restore
37
+ // What a checkpoint deliberately does not carry, so a digest never lists
38
+ // these either. Categories follow Roo-Code's checkpoint excludes: VCS
39
+ // internals, dependency trees, build output, caches, logs, secrets, media,
40
+ // archives, databases, model weights.
41
+ const EXCLUDES = `# OpenMausBot checkpoint excludes — never snapshotted
42
42
  .git/
43
43
  .svn/
44
44
  .hg/
@@ -120,9 +120,20 @@ Thumbs.db
120
120
  `;
121
121
  // gpgsign off: the user's global config may demand signing, and a shadow
122
122
  // commit must never block on a passphrase prompt. Automatic GC stays off:
123
- // explicit GC runs inside the same serialization as snapshot/restore, after
124
- // their source objects are no longer needed.
123
+ // explicit GC runs in the same per-folder queue as snapshots and diffs, on
124
+ // the throttle below, after the objects it drops are no longer needed.
125
125
  const GITCONFIG = "[commit]\n\tgpgsign = false\n[core]\n\tautocrlf = false\n[gc]\n\tauto = 0\n";
126
+ /** Hard ceiling on one git call. A hung git (index lock, dead network
127
+ * filesystem, cloud placeholders being downloaded) would otherwise jam the
128
+ * per-repo queue for the whole session. */
129
+ const GIT_TIMEOUT_MS = 120_000;
130
+ // The snapshot is awaited before every turn's dispatch, so GC does not run on
131
+ // every snapshot: collect after this many HEAD-advancing snapshots of one
132
+ // folder, or sooner when loose objects (git's own gc.auto default) or new
133
+ // object data pile up. Counters are per process and reset on restart.
134
+ export const GC_EVERY_COMMITS = 25;
135
+ const GC_LOOSE_OBJECTS = 6_700;
136
+ const GC_NEW_DATA_KIB = 262_144;
126
137
  /** One failed git call disables checkpoints for that bot until restart —
127
138
  * a broken shadow repo must cost the user one log line, not a failed turn. */
128
139
  const disabledBots = new Set();
@@ -130,6 +141,30 @@ function disable(botId, message) {
130
141
  disabledBots.add(botId);
131
142
  console.warn(`workspace checkpoints disabled for bot ${botId} this session: ${message}`);
132
143
  }
144
+ /** A git call that hit GIT_TIMEOUT_MS (not a cancellation). The folder is
145
+ * too slow to snapshot — waiting that long again on every turn is worse than
146
+ * having no checkpoint, so only that bot+folder is switched off. */
147
+ class GitTimeoutError extends Error {
148
+ }
149
+ /** Bot+folder pairs (keyed by shadow dir) switched off after a timeout. */
150
+ const disabledFolders = new Set();
151
+ function disableFolder(shadow, botId, worktree, message) {
152
+ if (disabledFolders.has(shadow))
153
+ return;
154
+ disabledFolders.add(shadow);
155
+ console.warn(`workspace checkpoints disabled for bot ${botId} in ${worktree} this session: ${message}`);
156
+ }
157
+ /** The shadow dir doubles as the bot+folder key. Null when the folder cannot
158
+ * be resolved (the refusal check reports that case). */
159
+ function folderShadow(botId, cwd) {
160
+ try {
161
+ const worktree = realpathSync(resolve(cwd));
162
+ return { worktree, shadow: shadowDir(botId, worktree) };
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
133
168
  // probed once: either the system has a usable git or checkpoints stay off
134
169
  let gitProbe = null;
135
170
  function gitAvailable() {
@@ -140,8 +175,7 @@ function gitAvailable() {
140
175
  }
141
176
  /** Folders a checkpoint must never be taken in: missing paths, the sprawling
142
177
  * personal folders (home, Desktop, Documents, Downloads), and the filesystem
143
- * root — snapshotting those would trawl unbounded personal data into a repo,
144
- * and a restore's `git clean -fd` there would be an act of vandalism. */
178
+ * root — snapshotting those would trawl unbounded personal data into a repo. */
145
179
  export function refusalReason(cwd) {
146
180
  if (!isAbsolute(cwd))
147
181
  return "the working folder must be an absolute path";
@@ -161,7 +195,7 @@ export function refusalReason(cwd) {
161
195
  // the refusal while git still follows the symlink into the protected tree.
162
196
  // The native realpath also settles Windows' spellings of one folder —
163
197
  // c:\users\me, C:\Users\me\DOCUME~1 — and names compare case-insensitively
164
- // there, as turn-resources.ts does for workspace claims.
198
+ // there.
165
199
  const same = (a, b) => process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
166
200
  if (dir === parse(dir).root)
167
201
  return "checkpoints are not taken at the filesystem root";
@@ -202,16 +236,23 @@ function gitEnv(shadow, cwd) {
202
236
  env.GIT_COMMITTER_EMAIL = "checkpoint@openmausbot.local";
203
237
  return env;
204
238
  }
239
+ /** execFile reports its own timeout as a killed child; a cancellation via
240
+ * `signal` arrives as an AbortError instead and is not a timeout. */
241
+ function gitFailure(command, err, stderr, signal) {
242
+ if (err.killed && err.name !== "AbortError" && !signal?.aborted) {
243
+ return new GitTimeoutError(`git ${command} timed out after ${GIT_TIMEOUT_MS / 1000} s`);
244
+ }
245
+ return new Error(`git ${command}: ${(stderr || err.message).trim().slice(0, 400)}`);
246
+ }
205
247
  /** Run one git command against the shadow repo. cwd is the WORK TREE — the
206
- * "." pathspec in add/restore resolves relative to it. A hung git (index
207
- * lock, dead network filesystem) would otherwise jam the per-repo queue for
208
- * the whole session, so every call carries a hard timeout. */
248
+ * "." pathspec in add resolves relative to it. Every call carries the
249
+ * GIT_TIMEOUT_MS hard timeout. */
209
250
  function runGit(args, cwd, env, signal) {
210
251
  return new Promise((resolvePromise, rejectPromise) => {
211
252
  signal?.throwIfAborted();
212
- execFile("git", args, { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
253
+ execFile("git", args, { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
213
254
  if (err)
214
- rejectPromise(new Error(`git ${args[0]}: ${(stderr || err.message).trim().slice(0, 400)}`));
255
+ rejectPromise(gitFailure(args[0], err, stderr, signal));
215
256
  else
216
257
  resolvePromise(stdout);
217
258
  });
@@ -224,20 +265,20 @@ function runGit(args, cwd, env, signal) {
224
265
  function hasStagedChanges(cwd, env, signal) {
225
266
  return new Promise((resolvePromise, rejectPromise) => {
226
267
  signal?.throwIfAborted();
227
- execFile("git", ["diff", "--cached", "--quiet", "--ignore-submodules=dirty"], { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
268
+ execFile("git", ["diff", "--cached", "--quiet", "--ignore-submodules=dirty"], { cwd, env, signal, windowsHide: true, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (err, _stdout, stderr) => {
228
269
  if (err === null)
229
270
  resolvePromise(false);
230
271
  else if (err.code === 1)
231
272
  resolvePromise(true);
232
273
  else
233
- rejectPromise(new Error(`git diff: ${(stderr || err.message).trim().slice(0, 400)}`));
274
+ rejectPromise(gitFailure("diff", err, stderr, signal));
234
275
  });
235
276
  });
236
277
  }
237
- // One operation at a time per shadow repo: snapshots and restores against the
238
- // same folder queue behind each other (git's index lock would fail the loser
239
- // anyway — this turns a crash into a wait). The stored tail never rejects, so
240
- // one failed operation can't poison the queue.
278
+ // One operation at a time per shadow repo: snapshots, diffs and pin releases
279
+ // against the same folder queue behind each other (git's index lock would
280
+ // fail the loser anyway — this turns a crash into a wait). The stored tail
281
+ // never rejects, so one failed operation can't poison the queue.
241
282
  const chains = new Map();
242
283
  function serialize(key, fn) {
243
284
  const tail = chains.get(key) ?? Promise.resolve();
@@ -245,12 +286,36 @@ function serialize(key, fn) {
245
286
  chains.set(key, run.then(() => undefined, () => undefined));
246
287
  return run;
247
288
  }
289
+ // A ref per running turn. Every snapshot parents only the empty base and a
290
+ // collection prunes whatever HEAD no longer reaches — so when a bot's threads
291
+ // work side by side in one folder, a collection after a sibling's snapshot
292
+ // would discard the pre-turn commit a still-running turn diffs against at its
293
+ // end. A pin keeps that commit reachable until release(). The pin's name is
294
+ // hashed so any token is a legal ref name.
295
+ const LIVE_REFS = "refs/omb-live/";
296
+ function liveRef(pin) {
297
+ return LIVE_REFS + createHash("sha256").update(pin).digest("hex").slice(0, 16);
298
+ }
299
+ // Pins outlive the process that wrote them (a crash mid-turn), so the first
300
+ // use of each shadow in this process drops every pin left behind: no turn of
301
+ // ours can be live in a shadow this process has not touched yet. Dropping one
302
+ // makes the next snapshot's collection due (see collectionDue).
303
+ const sweptShadows = new Set();
304
+ const droppedPins = new Set();
305
+ async function sweepLiveRefs(cwd, env, shadow, signal) {
306
+ if (sweptShadows.has(shadow))
307
+ return;
308
+ const stale = (await runGit(["for-each-ref", "--format=%(refname)", LIVE_REFS], cwd, env, signal)).split("\n").filter(Boolean);
309
+ for (const ref of stale)
310
+ await runGit(["update-ref", "-d", ref], cwd, env, signal);
311
+ if (stale.length > 0)
312
+ droppedPins.add(shadow);
313
+ sweptShadows.add(shadow);
314
+ }
248
315
  /** Create the shadow repo on first use; self-heal its config files on every
249
316
  * use (they are tiny, and rewriting them lets exclude-list updates reach
250
317
  * shadows that already exist). The base commit is an EMPTY marker so HEAD
251
- * always resolves — it is filtered from listings and refused as a restore
252
- * target, because "restore to empty" on a user's own project folder would
253
- * delete their files. */
318
+ * always resolves; every snapshot parents only it. */
254
319
  async function ensureShadow(cwd, env, shadow, signal) {
255
320
  signal?.throwIfAborted();
256
321
  mkdirSync(shadow, { recursive: true, mode: 0o700 });
@@ -291,49 +356,212 @@ async function ensureShadow(cwd, env, shadow, signal) {
291
356
  // brand-new repo (or a crash between init and first commit)
292
357
  await runGit(["commit", "--no-verify", "--allow-empty", "-m", "checkpoint base"], cwd, env, signal);
293
358
  }
359
+ await sweepLiveRefs(cwd, env, shadow, signal);
360
+ }
361
+ /** Folder listings in flight at once (libuv's pool has four threads). */
362
+ const LISTING_CONCURRENCY = 4;
363
+ /** Case-insensitive file systems only (APFS/HFS+ as macOS formats them,
364
+ * NTFS): does a staged path, or one of its folders, now exist on disk under a
365
+ * different case? git init sets core.ignorecase there, so an incremental
366
+ * `add -A` matches `R0` to the staged `r0` and keeps the old spelling
367
+ * forever. Reads folder listings only — each folder holding a staged path
368
+ * once, asynchronously, so a big tree never stalls the event loop — never
369
+ * file contents. Names compare in NFC: git stores precomposed paths on macOS,
370
+ * readdir returns names as they were created. */
371
+ async function caseRenamed(cwd, paths) {
372
+ if (paths.length === 0)
373
+ return false;
374
+ const pending = new Set([""]);
375
+ for (const path of paths) {
376
+ for (let slash = path.indexOf("/"); slash > 0; slash = path.indexOf("/", slash + 1))
377
+ pending.add(path.slice(0, slash));
378
+ }
379
+ const listings = new Map();
380
+ const queue = [...pending];
381
+ const read = async () => {
382
+ for (let dir = queue.pop(); dir !== undefined; dir = queue.pop()) {
383
+ try {
384
+ const exact = new Set((await readdir(join(cwd, dir))).map((name) => name.normalize("NFC")));
385
+ listings.set(dir, { exact, folded: new Set([...exact].map((name) => name.toLowerCase())) });
386
+ }
387
+ catch {
388
+ listings.set(dir, null); // gone or unreadable: add -A handles deletions itself
389
+ }
390
+ }
391
+ };
392
+ await Promise.all(Array.from({ length: Math.min(LISTING_CONCURRENCY, queue.length) }, read));
393
+ const folders = new Map();
394
+ const renamed = (path, isFolder) => {
395
+ const known = isFolder ? folders.get(path) : undefined;
396
+ if (known !== undefined)
397
+ return known;
398
+ const slash = path.lastIndexOf("/");
399
+ const parent = slash < 0 ? "" : path.slice(0, slash);
400
+ const name = path.slice(slash + 1).normalize("NFC");
401
+ const names = listings.get(parent) ?? null;
402
+ let verdict;
403
+ if (names === null)
404
+ verdict = false;
405
+ else if (!names.exact.has(name))
406
+ verdict = names.folded.has(name.toLowerCase()); // else simply deleted
407
+ else
408
+ verdict = parent !== "" && renamed(parent, true);
409
+ if (isFolder)
410
+ folders.set(path, verdict);
411
+ return verdict;
412
+ };
413
+ return paths.some((path) => renamed(path, false));
294
414
  }
295
- /** Capture fully before advancing HEAD. A partial add must never replace
296
- * the last usable checkpoint or authorize removal of its objects. */
415
+ /** Would an incremental `git add -A` leave the shadow index different from
416
+ * one rebuilt from empty? Only in four cases, all detected here: a tracked
417
+ * path is now ignored or inside a cache-tagged directory (add -A never drops
418
+ * tracked files), a folder holding tracked files became a nested repository
419
+ * (add -A keeps the files instead of a gitlink), a recorded nested
420
+ * repository lost its .git (add -A keeps the stale gitlink), or — on a
421
+ * case-insensitive file system — a tracked path was renamed by case only
422
+ * (add -A keeps the old spelling). */
423
+ async function indexNeedsRebuild(cwd, env, signal) {
424
+ // If the index cannot be inspected (say, a listing past maxBuffer on a huge
425
+ // folder), rebuild: that is always correct, just slower.
426
+ const list = (args) => runGit(args, cwd, env, signal).catch((e) => {
427
+ if (e instanceof GitTimeoutError || signal?.aborted)
428
+ throw e;
429
+ return null;
430
+ });
431
+ const ignored = await list(["ls-files", "--cached", "--ignored", "--exclude-standard", "-z"]);
432
+ if (ignored === null || ignored.length > 0)
433
+ return true;
434
+ const staged = await list(["ls-files", "--stage", "-z"]);
435
+ if (staged === null)
436
+ return true;
437
+ const paths = [];
438
+ const parents = new Set();
439
+ for (const entry of staged.split("\0")) {
440
+ const tab = entry.indexOf("\t");
441
+ if (tab < 0)
442
+ continue;
443
+ const path = entry.slice(tab + 1);
444
+ paths.push(path);
445
+ if (entry.startsWith("160000 ")) {
446
+ if (!existsSync(join(cwd, path, ".git")))
447
+ return true;
448
+ continue;
449
+ }
450
+ for (let slash = path.indexOf("/"); slash > 0; slash = path.indexOf("/", slash + 1))
451
+ parents.add(path.slice(0, slash));
452
+ }
453
+ for (const dir of parents)
454
+ if (existsSync(join(cwd, dir, ".git")))
455
+ return true;
456
+ return (process.platform === "darwin" || process.platform === "win32") && (await caseRenamed(cwd, paths));
457
+ }
458
+ /** Stage the work tree into the shadow index. The index persists between
459
+ * snapshots so `git add -A` only re-reads files whose stat data changed;
460
+ * rebuilding it from empty re-reads and re-hashes every file (and downloads
461
+ * every cloud placeholder). It is rebuilt only when indexNeedsRebuild says an
462
+ * incremental add would differ, so the staged content always matches a
463
+ * from-empty rebuild. Only the shadow index changes; no user index or
464
+ * work-tree file does. */
465
+ async function stageWorkTree(cwd, env, signal) {
466
+ if (await indexNeedsRebuild(cwd, env, signal))
467
+ await runGit(["read-tree", "--empty"], cwd, env, signal);
468
+ await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal);
469
+ }
470
+ /** Capture fully before advancing HEAD: null when the add was partial, so a
471
+ * partial capture never replaces the last usable checkpoint or authorizes
472
+ * removal of its objects. */
297
473
  async function commitAll(cwd, env, label, signal) {
298
474
  const previous = (await runGit(["rev-parse", "HEAD"], cwd, env, signal)).trim();
299
475
  try {
300
- // Rebuild only the shadow index so newly ignored/cache-tagged files stop
301
- // pinning old blobs; no user index or work-tree file is changed.
302
- await runGit(["read-tree", "--empty"], cwd, env, signal);
303
- await runGit(["add", "-A", "--ignore-errors", "."], cwd, env, signal);
476
+ await stageWorkTree(cwd, env, signal);
304
477
  }
305
- catch {
306
- return { hash: previous, complete: false };
478
+ catch (e) {
479
+ // A timeout is not an unreadable file: the caller switches the folder off.
480
+ if (e instanceof GitTimeoutError)
481
+ throw e;
482
+ return null;
307
483
  }
308
484
  const changed = await hasStagedChanges(cwd, env, signal);
309
485
  const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], cwd, env, signal)).trim();
310
486
  const count = (await runGit(["rev-list", "--count", "HEAD"], cwd, env, signal)).trim();
311
487
  if (!changed && Number(count) <= 2)
312
- return { hash: previous, complete: true };
488
+ return { hash: previous, advanced: false, compacted: false };
313
489
  const tree = (await runGit(["write-tree"], cwd, env, signal)).trim();
314
490
  const message = changed ? label : (await runGit(["show", "-s", "--format=%B", "HEAD"], cwd, env, signal)).trim();
315
491
  // Parent only the empty marker, never the previous snapshot: keeping the
316
492
  // previous commit as an ancestor would retain every old tree indefinitely.
317
493
  const hash = (await runGit(["commit-tree", tree, "-p", base, "-m", message], cwd, env, signal)).trim();
318
494
  await runGit(["update-ref", "HEAD", hash, previous], cwd, env, signal);
319
- return { hash, complete: true };
495
+ return { hash, advanced: true, compacted: Number(count) > 2 };
496
+ }
497
+ /** HEAD-advancing snapshots per shadow since this process last collected. */
498
+ const commitsSinceGc = new Map();
499
+ /** Pack size (KiB) first counted after the last collection — the live
500
+ * snapshot. Packs also grow between collections: `git add` streams files over
501
+ * core.bigFileThreshold straight into new packs. */
502
+ const packBaselineKib = new Map();
503
+ /** A collection the throttle asked for, held until that turn's settled diff
504
+ * has been queued, or — when the turn settled without one — until the next
505
+ * snapshot, which runs it first (see snapshot and diffWorkingTree). */
506
+ const pendingCollections = new Map();
507
+ /** Queue the folder's pending collection behind whatever was just queued for
508
+ * it. Nothing awaits it. */
509
+ function releaseCollection(shadow) {
510
+ const collect = pendingCollections.get(shadow);
511
+ if (collect === undefined)
512
+ return;
513
+ pendingCollections.delete(shadow);
514
+ void serialize(shadow, collect);
515
+ }
516
+ /** Throttle for collectObsolete (see GC_EVERY_COMMITS). */
517
+ async function collectionDue(shadow, result, cwd, env, signal) {
518
+ // A compaction or a swept leftover pin frees a whole tree at once: reclaim it now.
519
+ const freed = droppedPins.delete(shadow) || result.compacted;
520
+ if (!result.advanced && !freed)
521
+ return false;
522
+ const commits = (commitsSinceGc.get(shadow) ?? 0) + (result.advanced ? 1 : 0);
523
+ commitsSinceGc.set(shadow, commits);
524
+ let due = freed || commits >= GC_EVERY_COMMITS;
525
+ if (!due) {
526
+ // Advisory only: a failed count must not cost the snapshot just taken.
527
+ const counted = await runGit(["count-objects", "-v"], cwd, env, signal).catch(() => "");
528
+ if (counted !== "") {
529
+ const field = (name) => Number(new RegExp(`^${name}: (\\d+)$`, "m").exec(counted)?.[1] ?? 0);
530
+ const pack = field("size-pack");
531
+ if (!packBaselineKib.has(shadow))
532
+ packBaselineKib.set(shadow, pack);
533
+ // Every loose object is new since the last collection (gc packs or
534
+ // prunes them all). Only pack growth counts, so a folder whose live
535
+ // snapshot alone passes the ceiling does not collect every turn.
536
+ const newData = field("size") + Math.max(0, pack - packBaselineKib.get(shadow));
537
+ due = field("count") >= GC_LOOSE_OBJECTS || newData >= GC_NEW_DATA_KIB;
538
+ }
539
+ }
540
+ if (due) {
541
+ commitsSinceGc.set(shadow, 0);
542
+ packBaselineKib.delete(shadow);
543
+ }
544
+ return due;
320
545
  }
321
546
  async function collectObsolete(cwd, env) {
322
- // No caller may still need the previous tree. In particular, restore runs
323
- // this only AFTER using its source; the safety checkpoint stays reachable.
547
+ // No caller may still need an unpinned previous tree: HEAD stays reachable,
548
+ // and so does every commit a live turn still pins.
324
549
  try {
325
550
  await runGit(["reflog", "expire", "--expire=now", "--all"], cwd, env);
326
551
  await runGit(["gc", "--prune=now", "--quiet"], cwd, env);
327
552
  }
328
553
  catch {
329
- // A busy/full disk must not hide an otherwise usable restore point.
330
- console.warn("checkpoint cleanup deferred; the latest restore point is preserved");
554
+ // A busy/full disk must not hide an otherwise usable snapshot.
555
+ console.warn("checkpoint cleanup deferred; the latest snapshot is preserved");
331
556
  }
332
557
  }
333
558
  /** Snapshot the folder. Returns the checkpoint hash, or null when the
334
- * feature is off for this bot, git is missing, or the folder is refused.
335
- * Never throws — this is called fire-and-forget on the turn path. */
336
- export async function snapshot(botId, cwd, label, signal) {
559
+ * feature is off for this bot or folder, git is missing, the folder is
560
+ * refused, or a file could not be read.
561
+ * With `pin`, the returned commit stays reachable through later snapshots of
562
+ * the same folder until release(pin) — for a turn that will diff against it
563
+ * when it ends. Never throws — this is called fire-and-forget on the turn path. */
564
+ export async function snapshot(botId, cwd, label, signal, opts) {
337
565
  if (signal?.aborted)
338
566
  return null;
339
567
  if (disabledBots.has(botId))
@@ -342,40 +570,80 @@ export async function snapshot(botId, cwd, label, signal) {
342
570
  return null;
343
571
  if (refusalReason(cwd) !== null)
344
572
  return null;
573
+ const folder = folderShadow(botId, cwd);
574
+ if (folder === null || disabledFolders.has(folder.shadow))
575
+ return null;
576
+ const { worktree, shadow } = folder;
345
577
  try {
346
- const worktree = realpathSync(resolve(cwd));
347
- const shadow = shadowDir(botId, worktree);
578
+ // Still pending from the previous snapshot: that turn settled without a
579
+ // diff. Collect ahead of this snapshot, not behind it, so this turn's own
580
+ // diff never queues behind GC and the object counts below see the
581
+ // collected repo (a stale count would make GC due again at once).
582
+ releaseCollection(shadow);
348
583
  return await serialize(shadow, async () => {
349
584
  const env = gitEnv(shadow, worktree);
350
585
  await ensureShadow(worktree, env, shadow, signal);
351
586
  const result = await commitAll(worktree, env, label, signal);
352
- if (!result.complete)
587
+ if (!result)
353
588
  return null;
354
- await collectObsolete(worktree, env);
589
+ if (opts?.pin)
590
+ await runGit(["update-ref", liveRef(opts.pin), result.hash], worktree, env, signal);
591
+ // Never awaited by the turn waiting on this snapshot, and held for that
592
+ // turn's settled diff: diffWorkingTree queues it behind itself, so GC
593
+ // never eats into the digest's capture window.
594
+ if (await collectionDue(shadow, result, worktree, env, signal)) {
595
+ pendingCollections.set(shadow, () => collectObsolete(worktree, env));
596
+ }
355
597
  return result.hash;
356
598
  });
357
599
  }
358
600
  catch (e) {
359
- if (!signal?.aborted)
360
- disable(botId, e instanceof Error ? e.message : String(e));
601
+ if (signal?.aborted)
602
+ return null;
603
+ const message = e instanceof Error ? e.message : String(e);
604
+ if (e instanceof GitTimeoutError)
605
+ disableFolder(shadow, botId, worktree, message);
606
+ else
607
+ disable(botId, message);
361
608
  return null;
362
609
  }
363
610
  }
364
- /** Diff the settled workspace without replacing its pre-turn restore point.
611
+ /** Let go of a pinned pre-turn commit: the next collection may reclaim it.
612
+ * Queued behind the shadow's pending operations, so a digest's diff already
613
+ * in that queue still finds its commit. Best-effort like the
614
+ * rest of this module — a pin that cannot be dropped costs one log line and
615
+ * is swept on the next start. */
616
+ export async function release(botId, cwd, pin) {
617
+ if (!(await gitAvailable()))
618
+ return;
619
+ try {
620
+ const worktree = realpathSync(resolve(cwd));
621
+ const shadow = shadowDir(botId, worktree);
622
+ if (!existsSync(join(shadow, ".git", "HEAD")))
623
+ return;
624
+ const env = gitEnv(shadow, worktree);
625
+ await serialize(shadow, () => runGit(["update-ref", "-d", liveRef(pin)], worktree, env));
626
+ }
627
+ catch (e) {
628
+ console.warn(`checkpoint pin not released for bot ${botId}: ${e instanceof Error ? e.message : String(e)}`);
629
+ }
630
+ }
631
+ /** Diff the settled workspace without replacing its pre-turn snapshot.
365
632
  * Staging is confined to the shadow index; the user's files and Git are untouched. */
366
633
  export async function diffWorkingTree(botId, cwd, fromHash, signal) {
367
634
  if (signal?.aborted || !COMMIT_HASH.test(fromHash))
368
635
  return null;
369
636
  if (disabledBots.has(botId) || !(await gitAvailable()) || refusalReason(cwd) !== null)
370
637
  return null;
638
+ const folder = folderShadow(botId, cwd);
639
+ if (folder === null || disabledFolders.has(folder.shadow))
640
+ return null;
641
+ const { worktree, shadow } = folder;
371
642
  try {
372
- const worktree = realpathSync(resolve(cwd));
373
- const shadow = shadowDir(botId, worktree);
374
643
  const env = gitEnv(shadow, worktree);
375
- return await serialize(shadow, async () => {
644
+ const run = serialize(shadow, async () => {
376
645
  await ensureShadow(worktree, env, shadow, signal);
377
- await runGit(["read-tree", "--empty"], worktree, env, signal);
378
- await runGit(["add", "-A", "--ignore-errors", "."], worktree, env, signal);
646
+ await stageWorkTree(worktree, env, signal);
379
647
  const out = await runGit(["diff", "--cached", "--name-status", "--no-renames", "-z", fromHash], worktree, env, signal);
380
648
  const diff = { changed: [], added: [], deleted: [] };
381
649
  const fields = out.split("\0").filter((field) => field.length > 0);
@@ -391,109 +659,14 @@ export async function diffWorkingTree(botId, cwd, fromHash, signal) {
391
659
  }
392
660
  return diff;
393
661
  });
394
- }
395
- catch {
396
- return null;
397
- }
398
- }
399
- /** The latest usable checkpoint (plus legacy history until the next snapshot). Empty when nothing
400
- * was ever snapshotted (listing never creates the shadow repo). The empty
401
- * base marker is omitted — it is not a state anyone should return to. */
402
- export async function listCheckpoints(botId, cwd) {
403
- if (disabledBots.has(botId))
404
- return [];
405
- if (!(await gitAvailable()))
406
- return [];
407
- if (refusalReason(cwd) !== null)
408
- return [];
409
- try {
410
- const worktree = realpathSync(resolve(cwd));
411
- const shadow = shadowDir(botId, worktree);
412
- if (!existsSync(join(shadow, ".git", "HEAD")))
413
- return [];
414
- return await serialize(shadow, async () => {
415
- const env = gitEnv(shadow, worktree);
416
- const out = await runGit(["log", "--format=%H%x09%ct%x09%s"], worktree, env);
417
- const lines = out.split("\n").filter((line) => line.trim() !== "");
418
- lines.pop(); // the root of the log is always the empty base marker
419
- return lines.flatMap((line) => {
420
- const [hash, seconds, ...subject] = line.split("\t");
421
- if (!hash || !COMMIT_HASH.test(hash))
422
- return [];
423
- return [{ hash, at: Number(seconds) * 1000, label: subject.join("\t") }];
424
- });
425
- });
662
+ // The collection this turn's snapshot left pending runs behind this
663
+ // diff, never ahead of it.
664
+ releaseCollection(shadow);
665
+ return await run;
426
666
  }
427
667
  catch (e) {
428
- disable(botId, e instanceof Error ? e.message : String(e));
429
- return [];
430
- }
431
- }
432
- /** Can this bot take/restore checkpoints in this folder right now? */
433
- export async function checkpointsEnabled(botId, cwd) {
434
- return !disabledBots.has(botId) && (await gitAvailable()) && refusalReason(cwd) === null;
435
- }
436
- /** Move the folder's files back to a checkpoint. The current state is
437
- * committed first ("before restore"), so the restore itself shows up as a
438
- * checkpoint and can be undone; HEAD never moves backwards, only forward
439
- * over the "restored" commit. Excluded and gitignored files are untouched. */
440
- export async function restore(botId, cwd, hash) {
441
- if (disabledBots.has(botId)) {
442
- return { ok: false, error: "checkpoints are disabled for this bot until the app restarts (an earlier snapshot failed — see the server log)" };
443
- }
444
- if (!(await gitAvailable()))
445
- return { ok: false, error: "git is not installed on this machine" };
446
- const reason = refusalReason(cwd);
447
- if (reason !== null)
448
- return { ok: false, error: reason };
449
- if (!COMMIT_HASH.test(hash))
450
- return { ok: false, error: "hash must be a full 40-character checkpoint hash" };
451
- try {
452
- const worktree = realpathSync(resolve(cwd));
453
- const shadow = shadowDir(botId, worktree);
454
- if (!existsSync(join(shadow, ".git", "HEAD"))) {
455
- return { ok: false, error: "no checkpoints exist for this folder" };
456
- }
457
- return await serialize(shadow, async () => {
458
- const env = gitEnv(shadow, worktree);
459
- try {
460
- await runGit(["cat-file", "-e", `${hash}^{commit}`], worktree, env);
461
- }
462
- catch {
463
- return { ok: false, error: "no such checkpoint" };
464
- }
465
- const base = (await runGit(["rev-list", "--max-parents=0", "HEAD"], worktree, env)).trim();
466
- if (base === hash)
467
- return { ok: false, error: "that is the empty base marker, not a checkpoint" };
468
- // safety point: whatever is about to be overwritten becomes restorable
469
- await ensureShadow(worktree, env, shadow);
470
- const safety = await commitAll(worktree, env, "before restore");
471
- if (!safety.complete) {
472
- return {
473
- ok: false,
474
- error: "restore stopped because some current files could not be added to the safety checkpoint",
475
- };
476
- }
477
- // Index AND work tree move to the source; HEAD stays put. --staged
478
- // matters: with a work-tree-only restore, a file that exists in the
479
- // source but not in the index (deleted in a later checkpoint, now
480
- // resurrected) would be untracked the moment restore recreates it —
481
- // and the clean below would delete it right back. Restoring the index
482
- // too makes clean blind to everything the checkpoint owns; what clean
483
- // then sweeps is exactly the strays the safety commit could not stage
484
- // (unreadable files, add races) — never ignored/excluded files (no -x).
485
- await runGit(["restore", "--source", hash, "--staged", "--worktree", "--", "."], worktree, env);
486
- await runGit(["clean", "-fd"], worktree, env);
487
- // Retain the pre-restore safety point, not another copy of the state
488
- // now on disk. Reset only the shadow index so it cannot pin old blobs.
489
- await runGit(["read-tree", "HEAD"], worktree, env);
490
- await collectObsolete(worktree, env);
491
- return { ok: true };
492
- });
493
- }
494
- catch (e) {
495
- const message = e instanceof Error ? e.message : String(e);
496
- disable(botId, message);
497
- return { ok: false, error: `restore failed: ${message}` };
668
+ if (e instanceof GitTimeoutError && !signal?.aborted)
669
+ disableFolder(shadow, botId, worktree, e.message);
670
+ return null;
498
671
  }
499
672
  }