Merge branch 'main' into OrcaWin/fix-crlf-dead-config-script-tests

This commit is contained in:
Neil
2026-09-01 21:00:48 -07:00
committed by GitHub
87 changed files with 5975 additions and 1778 deletions
+11 -4
View File
@@ -18,7 +18,7 @@
"fumadocs-mdx": "^14.3.1",
"fumadocs-ui": "^16.8.4",
"lucide-react": "^1.6.0",
"next": "16.2.1",
"next": "16.3.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0",
@@ -32,10 +32,10 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.1",
"eslint-config-next": "16.3.4",
"tailwindcss": "^4",
"typescript": "^5",
"vercel": "50.37.0"
"vercel": "59.11.1"
},
"engines": {
"node": "22.x"
@@ -46,6 +46,13 @@
"esbuild",
"sharp",
"unrs-resolver"
]
],
"overrides": {
"@vercel/fun>tar": "7.5.22",
"@vercel/fun>@tootallnate/once": "2.0.1",
"@vercel/node>undici": "5.29.0",
"@vercel/python-analysis>js-yaml": "4.3.2",
"@vercel/python-analysis>minimatch": "10.2.6"
}
}
}
+1255 -737
View File
File diff suppressed because it is too large Load Diff
+179 -152
View File
@@ -93,7 +93,7 @@ importers:
version: 55.0.27(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@6.0.3)
expo-router:
specifier: ^55.0.18
version: 55.0.18(2f99795f9796def5cdb1516a493dc117)
version: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
expo-secure-store:
specifier: ^55.0.18
version: 55.0.18(expo@55.0.30)
@@ -178,7 +178,7 @@ importers:
version: 0.25.4
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
happy-dom:
specifier: ^20.11.8
version: 20.11.8
@@ -199,10 +199,10 @@ importers:
version: 6.0.3
vite:
specifier: ^8.0.16
version: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
version: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.11
version: 4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -2897,6 +2897,9 @@ packages:
'@types/node@26.1.2':
resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==}
'@types/node@26.4.0':
resolution: {integrity: sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==}
'@types/react-native@0.73.0':
resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==}
deprecated: This is a stub types definition. react-native provides its own type definitions, so you do not need this installed.
@@ -3356,8 +3359,8 @@ packages:
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.10.27:
resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==}
baseline-browser-mapping@2.11.20:
resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -3398,8 +3401,8 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
browserslist@4.28.8:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
@@ -3448,8 +3451,8 @@ packages:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
@@ -3941,8 +3944,8 @@ packages:
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
electron-to-chromium@1.5.352:
resolution: {integrity: sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg==}
electron-to-chromium@1.5.416:
resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==}
emittery@0.13.1:
resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==}
@@ -5228,6 +5231,10 @@ packages:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
jsc-safe-url@0.2.4:
resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==}
@@ -5492,8 +5499,8 @@ packages:
resolution: {integrity: sha512-tnn0J5wzgTgTx2OJy3Cwr1y79bJz4eNgFQd+2HENOs5Vz6QOMnt05z7J+BedIo9wIbpEa0iN9U1nerxyvMRE9g==}
engines: {node: '>=20.19.4'}
metro-babel-transformer@0.84.4:
resolution: {integrity: sha512-rvCfz8snl9h20VcvpOHxZuHP1SlAkv4HXbzw7nyyVwu6Eqo5PRerbakQ9XmUCOsRy70spJ37O+G1TK8oMzo48g==}
metro-babel-transformer@0.84.5:
resolution: {integrity: sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-cache-key@0.83.7:
@@ -5504,8 +5511,8 @@ packages:
resolution: {integrity: sha512-I38PtcjT4crS5HY9UQ8i6z8S7tJ2WewtPGr/OwS6FcLKfy5T/1hlTaFw+wozUZkEtNpR6Gc0oJuvuKCbSoSN5A==}
engines: {node: '>=20.19.4'}
metro-cache-key@0.84.4:
resolution: {integrity: sha512-wVO79aGrkYImpnaVS4+d5RrRBRPX31QtvKB3wKGBuiNSznduZTQHzsrJZRroFJSwnygrzdsGUtDQPuqqFjFdvw==}
metro-cache-key@0.84.5:
resolution: {integrity: sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-cache@0.83.7:
@@ -5516,8 +5523,8 @@ packages:
resolution: {integrity: sha512-aogMG5WbKzW5000otNjYrS9hIoORzkCI1faPJK+vxQLaf2BorJKBBFe/jl2Tfsi1mZUglS2EBuBt8B3JB7MYDQ==}
engines: {node: '>=20.19.4'}
metro-cache@0.84.4:
resolution: {integrity: sha512-gpcFQdSLUwUCk71saKoE64jLFbx2nwTfVCcPSULMNT8QYq0p1eZZE29Jvd0HtT/UlhC3ZOutLxJME5xqD2JUZg==}
metro-cache@0.84.5:
resolution: {integrity: sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-config@0.83.7:
@@ -5528,8 +5535,8 @@ packages:
resolution: {integrity: sha512-crNbNy+/B4tCne2+HjUshwvC57gNBQj+V9fFSy3lHH2RlKcLHib3Mil/SfTX8Pfh2fCY5pPuSu2OKa7YiadB+Q==}
engines: {node: '>=20.19.4'}
metro-config@0.84.4:
resolution: {integrity: sha512-PMotGDjXcXLWo2TMRH+VR99phFNgYTwqh4OoieIKK3yTJa1Jmkl+fZJxDO0jfBvNF+WESHciHvpNuBtXaF3B0Q==}
metro-config@0.84.5:
resolution: {integrity: sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-core@0.83.7:
@@ -5540,8 +5547,8 @@ packages:
resolution: {integrity: sha512-NTyOUOQaQKvQgJG9VI2ymN6KTM7gHEqkVFhkPc9bK4BsHSTz/EaXuFBXtC+wtwINLeH9tbusL/jfsSmdEmuU7A==}
engines: {node: '>=20.19.4'}
metro-core@0.84.4:
resolution: {integrity: sha512-HONpWC5LGXZn3ffkd4Hu6AIrfE7j4Z0g0wMo/goV24WOB3lhuFZ40KgvaDiSw8iyQHloMYay5N/wPX+z8oN/PQ==}
metro-core@0.84.5:
resolution: {integrity: sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-file-map@0.83.7:
@@ -5552,8 +5559,8 @@ packages:
resolution: {integrity: sha512-+W++EUuzEXIfWQEFTWQMVThzhWbnJL4gRNJ9WSHzIAM5pT7gsprUxo9+2hrfirURm/TLvrKwhq37oCECJcDSyQ==}
engines: {node: '>=20.19.4'}
metro-file-map@0.84.4:
resolution: {integrity: sha512-KSVDi/u60hKPx++NLu3MTIvyjzNoJnFAF8PQFxaj1jiSka/wjw+Ua6sNuJ0TDHQv+7AAoFQxeMgaRAe8Yic5wQ==}
metro-file-map@0.84.5:
resolution: {integrity: sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-minify-terser@0.83.7:
@@ -5564,8 +5571,8 @@ packages:
resolution: {integrity: sha512-7tU0J5/c7LZaZJwTlOb1xq0NepTFvGzRxigDhZOq4jSE6g0BRHmBqe7XvLH3WcRiJNGy+eshcZr34RpMjb6mmg==}
engines: {node: '>=20.19.4'}
metro-minify-terser@0.84.4:
resolution: {integrity: sha512-5qpbaVOMC7CPitIpuewzVeGw7E+C3ykbv2mqTjQLl85Z3annSVGlSCTcsZjqXZzjupfK4Ztj3dDc4kc44NZwtQ==}
metro-minify-terser@0.84.5:
resolution: {integrity: sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-resolver@0.83.7:
@@ -5576,8 +5583,8 @@ packages:
resolution: {integrity: sha512-piU0NVTI9i37YztDVF5rtn9uxP3NebVT0xZM9NKJ9z0jbigrctUQksi3NoYIeJMvL6Wn2dgAehpcmmnfn+gUwA==}
engines: {node: '>=20.19.4'}
metro-resolver@0.84.4:
resolution: {integrity: sha512-1qLgbxQ5ZGhhutuPot1Yp348ofDsATL2WkrHF65TobqTT9K3P9qJXw38bomk7ncp5B7OYMfWwtyBZo1lCV792A==}
metro-resolver@0.84.5:
resolution: {integrity: sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-runtime@0.83.7:
@@ -5588,8 +5595,8 @@ packages:
resolution: {integrity: sha512-f7FfeM0pamq8vrvs8aO9KvIUabbUKe0WkHFpLt6Q9yIIIsORqNFwlgJeHGraOFPU7Cxqj5yLXkJu5bT1uwDXvw==}
engines: {node: '>=20.19.4'}
metro-runtime@0.84.4:
resolution: {integrity: sha512-Jibypds4g7AhzdRKY+kDoj51s5EXMwgyp5ddtlreDAsWefMdOx+agWqgm0H2XSZ/ueanHHVM89fnf5OJnlxa8Q==}
metro-runtime@0.84.5:
resolution: {integrity: sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-source-map@0.83.7:
@@ -5600,8 +5607,8 @@ packages:
resolution: {integrity: sha512-60Uor7bM+KsVewLkLCcZfkPFCbqjPDdoSmeBuT3+ye+ac80BuLWbbR8DpyxWpUqQeZPdaAT5ZqFgDIs8BNEcVA==}
engines: {node: '>=20.19.4'}
metro-source-map@0.84.4:
resolution: {integrity: sha512-jbWkPxIesVuo1IWkvezmMJld6iu8nD62GsrZiV6jP37AOdbo4OBq1FJ+qkOg8sV05wAHB//jAbziuW0SlJfW4g==}
metro-source-map@0.84.5:
resolution: {integrity: sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-symbolicate@0.83.7:
@@ -5614,8 +5621,8 @@ packages:
engines: {node: '>=20.19.4'}
hasBin: true
metro-symbolicate@0.84.4:
resolution: {integrity: sha512-OnfpacxUqGPZQ27t8qK9mFa7uqHIlVWeqRqkCbvMvreEBiamEeOn8krKtcwgP5M4cYDPwuSmCTopHMVthqG4zA==}
metro-symbolicate@0.84.5:
resolution: {integrity: sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
hasBin: true
@@ -5627,8 +5634,8 @@ packages:
resolution: {integrity: sha512-9JRPkvi+m0QH2Y/w5RjCF9mHqOUNFpMFLDDLhKUjhcKESh8Wm3HKDdHXHVldTN1lTToCIabv81nF0zyJzKEJ5g==}
engines: {node: '>=20.19.4'}
metro-transform-plugins@0.84.4:
resolution: {integrity: sha512-kehr6HbAecqD0/a3xLXobELdPaAmRAl8bel0qagPF4vhZtux93nS8S4eq2kgKt6J2GnQpVjSoW1PXdst04mwow==}
metro-transform-plugins@0.84.5:
resolution: {integrity: sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro-transform-worker@0.83.7:
@@ -5639,8 +5646,8 @@ packages:
resolution: {integrity: sha512-Pa2hOfhUmWpI/dmkhsLq8uGyFHK2opoEK7j/YCiRtqNSe0YzzxYguXTNgg8AMiuZfc9LPcB1AhGENS10O5wcIw==}
engines: {node: '>=20.19.4'}
metro-transform-worker@0.84.4:
resolution: {integrity: sha512-W1IYMvvXTu4MxYr7d9h7CeG2vpIr3bmLLIavkPY4O1ilzDrvS8z/NEe6y+pC44Ff7raMXQgYSfdqDUwN/i39gg==}
metro-transform-worker@0.84.5:
resolution: {integrity: sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
metro@0.83.7:
@@ -5653,8 +5660,8 @@ packages:
engines: {node: '>=20.19.4'}
hasBin: true
metro@0.84.4:
resolution: {integrity: sha512-8ETTubqfD6ornDy2zYDvRcKnVDOXdFJsjetYDBsY4oAsb6NJkiwFR+FaMESyGppFmQUyBQA4H4sFGxzcQSGtFA==}
metro@0.84.5:
resolution: {integrity: sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
hasBin: true
@@ -5763,8 +5770,9 @@ packages:
node-int64@0.4.0:
resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==}
node-releases@2.0.38:
resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==}
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
@@ -5795,8 +5803,8 @@ packages:
resolution: {integrity: sha512-pk7el+eTOzfSKMAY4QBiiwKzegXn633JQj13y+pW5E5IdS+yV2CfDJ9Hf20K/LKy8nCrP9dAmd7XOk52OJxifA==}
engines: {node: '>=20.19.4'}
ob1@0.84.4:
resolution: {integrity: sha512-eJXMpz4aQHXF/YBB9ddqZDIS+ooO91hObo9FoW/xBkr54/zCwYYCDqT/O54vNo8kOkWs5Ou/y28NgdrV0edQNA==}
ob1@0.84.5:
resolution: {integrity: sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==}
engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0}
object-assign@4.1.1:
@@ -6677,6 +6685,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
terser@5.51.2:
resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==}
engines: {node: '>=10'}
hasBin: true
test-exclude@6.0.0:
resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==}
engines: {node: '>=8'}
@@ -6862,8 +6875,8 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
update-browserslist-db@1.3.2:
resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
@@ -7301,7 +7314,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.3
'@babel/helper-validator-option': 7.27.1
browserslist: 4.28.2
browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -7309,7 +7322,7 @@ snapshots:
dependencies:
'@babel/compat-data': 7.29.7
'@babel/helper-validator-option': 7.29.7
browserslist: 4.28.2
browserslist: 4.28.8
lru-cache: 5.1.1
semver: 6.3.1
@@ -8694,7 +8707,7 @@ snapshots:
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.1
js-yaml: 4.3.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -8773,7 +8786,7 @@ snapshots:
ws: 8.21.3
zod: 3.25.76
optionalDependencies:
expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117)
expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
transitivePeerDependencies:
- '@expo/dom-webview'
@@ -8985,7 +8998,7 @@ snapshots:
'@expo/json-file': 10.0.16
'@expo/metro': 55.1.2
'@expo/spawn-async': 1.8.0
browserslist: 4.28.2
browserslist: 4.28.8
chalk: 4.1.2
debug: 4.4.3
getenv: 2.0.0
@@ -9116,7 +9129,7 @@ snapshots:
react: 19.2.8
optionalDependencies:
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
expo-router: 55.0.18(2f99795f9796def5cdb1516a493dc117)
expo-router: 55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e)
react-dom: 19.2.8(react@19.2.8)
transitivePeerDependencies:
- supports-color
@@ -9201,14 +9214,14 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
ansi-escapes: 4.3.2
chalk: 4.1.2
ci-info: 3.9.0
exit: 0.1.2
graceful-fs: 4.2.11
jest-changed-files: 29.7.0
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-haste-map: 29.7.0
jest-message-util: 29.7.0
jest-regex-util: 29.6.3
@@ -9281,7 +9294,7 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@jridgewell/trace-mapping': 0.3.31
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
collect-v8-coverage: 1.0.3
exit: 0.1.2
@@ -9975,8 +9988,8 @@ snapshots:
dependencies:
'@react-native/js-polyfills': 0.85.2
'@react-native/metro-babel-transformer': 0.85.2(@babel/core@7.29.7)
metro-config: 0.84.4
metro-runtime: 0.84.4
metro-config: 0.84.5
metro-runtime: 0.84.5
transitivePeerDependencies:
- '@babel/core'
- bufferutil
@@ -10126,7 +10139,7 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)':
'@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
jest-matcher-utils: 30.3.0
picocolors: 1.1.1
@@ -10136,7 +10149,7 @@ snapshots:
react-test-renderer: 19.2.8(react@19.2.8)
redent: 3.0.0
optionalDependencies:
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
'@tootallnate/once@2.0.1': {}
@@ -10345,6 +10358,10 @@ snapshots:
dependencies:
undici-types: 8.3.0
'@types/node@26.4.0':
dependencies:
undici-types: 8.3.0
'@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)':
dependencies:
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -10525,13 +10542,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))':
'@vitest/mocker@4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.11
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
'@vitest/pretty-format@4.1.11':
dependencies:
@@ -10934,7 +10951,7 @@ snapshots:
base64-js@1.5.1: {}
baseline-browser-mapping@2.10.27: {}
baseline-browser-mapping@2.11.20: {}
better-opn@3.0.2:
dependencies:
@@ -10972,13 +10989,13 @@ snapshots:
dependencies:
fill-range: 7.1.1
browserslist@4.28.2:
browserslist@4.28.8:
dependencies:
baseline-browser-mapping: 2.10.27
caniuse-lite: 1.0.30001792
electron-to-chromium: 1.5.352
node-releases: 2.0.38
update-browserslist-db: 1.2.3(browserslist@4.28.2)
baseline-browser-mapping: 2.11.20
caniuse-lite: 1.0.30001810
electron-to-chromium: 1.5.416
node-releases: 2.0.54
update-browserslist-db: 1.3.2(browserslist@4.28.8)
bs-logger@0.2.6:
dependencies:
@@ -11024,7 +11041,7 @@ snapshots:
camelcase@6.3.0: {}
caniuse-lite@1.0.30001792: {}
caniuse-lite@1.0.30001810: {}
chai@6.2.2: {}
@@ -11174,7 +11191,7 @@ snapshots:
core-js-compat@3.49.0:
dependencies:
browserslist: 4.28.2
browserslist: 4.28.8
cose-base@1.0.3:
dependencies:
@@ -11184,13 +11201,13 @@ snapshots:
dependencies:
layout-base: 2.0.1
create-jest@29.7.0(@types/node@26.1.2):
create-jest@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
@@ -11554,7 +11571,7 @@ snapshots:
ee-first@1.1.1: {}
electron-to-chromium@1.5.352: {}
electron-to-chromium@1.5.416: {}
emittery@0.13.1: {}
@@ -12169,7 +12186,7 @@ snapshots:
expo: 55.0.30(10e8e71dd92768dd7f344108f3edbbe3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(prettier@2.8.8)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-refresh@0.14.2)(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -12177,7 +12194,7 @@ snapshots:
'@babel/preset-typescript': 7.28.5(@babel/core@7.29.7)
'@expo/npm-proofread': 1.0.1
'@expo/spawn-async': 1.7.2
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@tsconfig/node18': 18.2.6
'@types/jest': 29.5.14
babel-plugin-dynamic-import-node: 2.3.3
@@ -12185,11 +12202,11 @@ snapshots:
commander: 12.1.0
eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3)
glob: 13.0.6
jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2))
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0))
resolve-workspace-root: 2.0.1
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3)
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -12252,7 +12269,7 @@ snapshots:
- supports-color
- typescript
expo-router@55.0.18(2f99795f9796def5cdb1516a493dc117):
expo-router@55.0.18(4a60a26fd685ffdcc7f556016ae4bc5e):
dependencies:
'@expo/log-box': 55.0.13(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
'@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.30)(react-dom@19.2.8(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
@@ -12289,7 +12306,7 @@ snapshots:
use-latest-callback: 0.2.6(react@19.2.8)
vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
optionalDependencies:
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
'@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react-test-renderer@19.2.8(react@19.2.8))(react@19.2.8)
react-dom: 19.2.8(react@19.2.8)
react-native-gesture-handler: 2.31.2(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
react-native-reanimated: 4.3.4(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)
@@ -12950,7 +12967,7 @@ snapshots:
'@jest/expect': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
co: 4.6.0
dedent: 1.7.2
@@ -12970,16 +12987,16 @@ snapshots:
- babel-plugin-macros
- supports-color
jest-cli@29.7.0(@types/node@26.1.2):
jest-cli@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/core': 29.7.0
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
create-jest: 29.7.0(@types/node@26.1.2)
create-jest: 29.7.0(@types/node@26.4.0)
exit: 0.1.2
import-local: 3.2.0
jest-config: 29.7.0(@types/node@26.1.2)
jest-config: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.3
@@ -12989,7 +13006,7 @@ snapshots:
- supports-color
- ts-node
jest-config@29.7.0(@types/node@26.1.2):
jest-config@29.7.0(@types/node@26.4.0):
dependencies:
'@babel/core': 7.29.7
'@jest/test-sequencer': 29.7.0
@@ -13014,7 +13031,7 @@ snapshots:
slash: 3.0.0
strip-json-comments: 3.1.1
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
transitivePeerDependencies:
- babel-plugin-macros
- supports-color
@@ -13069,7 +13086,7 @@ snapshots:
jest-mock: 29.7.0
jest-util: 29.7.0
jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.1.2))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.30)(jest@29.7.0(@types/node@26.4.0))(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8))(react@19.2.8)(typescript@5.9.3):
dependencies:
'@expo/config': 55.0.16(typescript@5.9.3)
'@expo/json-file': 10.0.14
@@ -13080,7 +13097,7 @@ snapshots:
jest-environment-jsdom: 29.7.0
jest-snapshot: 29.7.0
jest-watch-select-projects: 2.0.0
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.2))
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.4.0))
json5: 2.2.3
lodash: 4.18.1
react-native: 0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.8)
@@ -13184,7 +13201,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
emittery: 0.13.1
graceful-fs: 4.2.11
@@ -13212,7 +13229,7 @@ snapshots:
'@jest/test-result': 29.7.0
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
'@types/node': 26.1.2
'@types/node': 26.4.0
chalk: 4.1.2
cjs-module-lexer: 1.4.3
collect-v8-coverage: 1.0.3
@@ -13279,11 +13296,11 @@ snapshots:
chalk: 3.0.0
prompts: 2.4.2
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.2)):
jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.4.0)):
dependencies:
ansi-escapes: 6.2.1
chalk: 4.1.2
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
jest-regex-util: 29.6.3
jest-watcher: 29.7.0
slash: 5.1.0
@@ -13308,12 +13325,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
jest@29.7.0(@types/node@26.1.2):
jest@29.7.0(@types/node@26.4.0):
dependencies:
'@jest/core': 29.7.0
'@jest/types': 29.6.3
import-local: 3.2.0
jest-cli: 29.7.0(@types/node@26.1.2)
jest-cli: 29.7.0(@types/node@26.4.0)
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -13333,6 +13350,10 @@ snapshots:
dependencies:
argparse: 2.0.1
js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
jsc-safe-url@0.2.4: {}
jsdom@20.0.3:
@@ -13604,12 +13625,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-babel-transformer@0.84.4:
metro-babel-transformer@0.84.5:
dependencies:
'@babel/core': 7.29.7
flow-enums-runtime: 0.0.6
hermes-parser: 0.35.0
metro-cache-key: 0.84.4
metro-cache-key: 0.84.5
nullthrows: 1.1.1
transitivePeerDependencies:
- supports-color
@@ -13622,7 +13643,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
metro-cache-key@0.84.4:
metro-cache-key@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -13644,12 +13665,12 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-cache@0.84.4:
metro-cache@0.84.5:
dependencies:
exponential-backoff: 3.1.3
flow-enums-runtime: 0.0.6
https-proxy-agent: 7.0.6
metro-core: 0.84.4
metro-core: 0.84.5
transitivePeerDependencies:
- supports-color
@@ -13683,15 +13704,15 @@ snapshots:
- supports-color
- utf-8-validate
metro-config@0.84.4:
metro-config@0.84.5:
dependencies:
connect: 3.7.0
flow-enums-runtime: 0.0.6
jest-validate: 29.7.0
metro: 0.84.4
metro-cache: 0.84.4
metro-core: 0.84.4
metro-runtime: 0.84.4
metro: 0.84.5
metro-cache: 0.84.5
metro-core: 0.84.5
metro-runtime: 0.84.5
yaml: 2.9.0
transitivePeerDependencies:
- bufferutil
@@ -13710,11 +13731,11 @@ snapshots:
lodash.throttle: 4.1.1
metro-resolver: 0.83.8
metro-core@0.84.4:
metro-core@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
lodash.throttle: 4.1.1
metro-resolver: 0.84.4
metro-resolver: 0.84.5
metro-file-map@0.83.7:
dependencies:
@@ -13744,7 +13765,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-file-map@0.84.4:
metro-file-map@0.84.5:
dependencies:
debug: 4.4.3
fb-watchman: 2.0.2
@@ -13768,10 +13789,10 @@ snapshots:
flow-enums-runtime: 0.0.6
terser: 5.49.1
metro-minify-terser@0.84.4:
metro-minify-terser@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
terser: 5.49.1
terser: 5.51.2
metro-resolver@0.83.7:
dependencies:
@@ -13781,7 +13802,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
metro-resolver@0.84.4:
metro-resolver@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -13795,7 +13816,7 @@ snapshots:
'@babel/runtime': 7.29.7
flow-enums-runtime: 0.0.6
metro-runtime@0.84.4:
metro-runtime@0.84.5:
dependencies:
'@babel/runtime': 7.29.7
flow-enums-runtime: 0.0.6
@@ -13828,15 +13849,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-source-map@0.84.4:
metro-source-map@0.84.5:
dependencies:
'@babel/traverse': 7.29.8
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-symbolicate: 0.84.4
metro-symbolicate: 0.84.5
nullthrows: 1.1.1
ob1: 0.84.4
ob1: 0.84.5
source-map: 0.5.7
vlq: 1.0.1
transitivePeerDependencies:
@@ -13864,11 +13885,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-symbolicate@0.84.4:
metro-symbolicate@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
invariant: 2.2.4
metro-source-map: 0.84.4
metro-source-map: 0.84.5
nullthrows: 1.1.1
source-map: 0.5.7
vlq: 1.0.1
@@ -13897,7 +13918,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
metro-transform-plugins@0.84.4:
metro-transform-plugins@0.84.5:
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
@@ -13948,20 +13969,20 @@ snapshots:
- supports-color
- utf-8-validate
metro-transform-worker@0.84.4:
metro-transform-worker@0.84.5:
dependencies:
'@babel/core': 7.29.7
'@babel/generator': 7.29.8
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
flow-enums-runtime: 0.0.6
metro: 0.84.4
metro-babel-transformer: 0.84.4
metro-cache: 0.84.4
metro-cache-key: 0.84.4
metro-minify-terser: 0.84.4
metro-source-map: 0.84.4
metro-transform-plugins: 0.84.4
metro: 0.84.5
metro-babel-transformer: 0.84.5
metro-cache: 0.84.5
metro-cache-key: 0.84.5
metro-minify-terser: 0.84.5
metro-source-map: 0.84.5
metro-transform-plugins: 0.84.5
nullthrows: 1.1.1
transitivePeerDependencies:
- bufferutil
@@ -14059,7 +14080,7 @@ snapshots:
- supports-color
- utf-8-validate
metro@0.84.4:
metro@0.84.5:
dependencies:
'@babel/code-frame': 7.29.7
'@babel/core': 7.29.7
@@ -14076,23 +14097,22 @@ snapshots:
flow-enums-runtime: 0.0.6
graceful-fs: 4.2.11
hermes-parser: 0.35.0
image-size: 1.2.1
invariant: 2.2.4
jest-worker: 29.7.0
jsc-safe-url: 0.2.4
lodash.throttle: 4.1.1
metro-babel-transformer: 0.84.4
metro-cache: 0.84.4
metro-cache-key: 0.84.4
metro-config: 0.84.4
metro-core: 0.84.4
metro-file-map: 0.84.4
metro-resolver: 0.84.4
metro-runtime: 0.84.4
metro-source-map: 0.84.4
metro-symbolicate: 0.84.4
metro-transform-plugins: 0.84.4
metro-transform-worker: 0.84.4
metro-babel-transformer: 0.84.5
metro-cache: 0.84.5
metro-cache-key: 0.84.5
metro-config: 0.84.5
metro-core: 0.84.5
metro-file-map: 0.84.5
metro-resolver: 0.84.5
metro-runtime: 0.84.5
metro-source-map: 0.84.5
metro-symbolicate: 0.84.5
metro-transform-plugins: 0.84.5
metro-transform-worker: 0.84.5
mime-types: 3.0.2
nullthrows: 1.1.1
serialize-error: 2.1.0
@@ -14175,7 +14195,7 @@ snapshots:
node-int64@0.4.0: {}
node-releases@2.0.38: {}
node-releases@2.0.54: {}
normalize-path@3.0.0: {}
@@ -14206,7 +14226,7 @@ snapshots:
dependencies:
flow-enums-runtime: 0.0.6
ob1@0.84.4:
ob1@0.84.5:
dependencies:
flow-enums-runtime: 0.0.6
@@ -15212,6 +15232,13 @@ snapshots:
commander: 2.20.3
source-map-support: 0.5.21
terser@5.51.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
test-exclude@6.0.0:
dependencies:
'@istanbuljs/schema': 0.1.6
@@ -15267,11 +15294,11 @@ snapshots:
ts-dedent@2.3.0: {}
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3):
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.4.0))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
jest: 29.7.0(@types/node@26.1.2)
jest: 29.7.0(@types/node@26.4.0)
jest-util: 29.7.0
json5: 2.2.3
lodash.memoize: 4.1.2
@@ -15381,9 +15408,9 @@ snapshots:
unpipe@1.0.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.2):
update-browserslist-db@1.3.2(browserslist@4.28.8):
dependencies:
browserslist: 4.28.2
browserslist: 4.28.8
escalade: 3.2.0
picocolors: 1.1.1
@@ -15442,7 +15469,7 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0):
vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -15450,17 +15477,17 @@ snapshots:
rolldown: 1.1.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
esbuild: 0.25.4
fsevents: 2.3.3
terser: 5.49.1
terser: 5.51.2
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.11(@types/node@26.1.2)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.11(@types/node@26.4.0)(happy-dom@20.11.8)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.11
'@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/mocker': 4.1.11(vite@8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.11
'@vitest/runner': 4.1.11
'@vitest/snapshot': 4.1.11
@@ -15477,10 +15504,10 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.0(@types/node@26.1.2)(esbuild@0.25.4)(terser@5.49.1)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@26.4.0)(esbuild@0.25.4)(terser@5.51.2)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.1.2
'@types/node': 26.4.0
happy-dom: 20.11.8
jsdom: 20.0.3
transitivePeerDependencies:
@@ -0,0 +1,168 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import { validate } from '../telemetry/validator'
const { trackMock, accessSyncMock, existsSyncMock, readFileSyncMock, getVersionMock } = vi.hoisted(
() => ({
trackMock: vi.fn(),
accessSyncMock: vi.fn(),
existsSyncMock: vi.fn(() => true),
readFileSyncMock: vi.fn(),
getVersionMock: vi.fn(() => '1.4.191')
})
)
vi.mock('../telemetry/client', () => ({ track: trackMock }))
vi.mock('node:fs', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
accessSync: accessSyncMock,
existsSync: existsSyncMock,
readFileSync: readFileSyncMock
}))
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
homedir: () => '/Users/alice'
}))
vi.mock('../../shared/app-environment', () => ({
getAppEnvironment: () => ({ getVersion: getVersionMock })
}))
import {
classifyDaemonAdoptionOrigin,
trackDaemonAdopted,
trackDaemonPtyCwdDeniedIfDiverged
} from './daemon-adoption-telemetry-event'
const stalePidRecord: ParsedDaemonPid = {
pid: 1530,
startedAtMs: 1,
entryPath: '/x/daemon-entry.js',
appVersion: '1.4.187',
launchNonce: 'n',
linuxStartTicks: null,
bootId: null,
spawnerExecPath:
'/Users/alice/Library/Caches/com.stablyai.orca.ShipIt/u/Orca.app/Contents/MacOS/Orca'
}
const origin = { app_version_match: 'different', spawner_path_class: 'updater-cache' } as const
const PID_PATH = '/fake/daemon.pid'
beforeEach(() => {
trackMock.mockReset()
accessSyncMock.mockReset()
existsSyncMock.mockReset().mockReturnValue(true)
readFileSyncMock.mockReset().mockReturnValue(JSON.stringify(stalePidRecord))
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('classifyDaemonAdoptionOrigin', () => {
it('compares the recorded app version and classifies the spawner path', () => {
expect(classifyDaemonAdoptionOrigin(stalePidRecord)).toEqual(origin)
expect(classifyDaemonAdoptionOrigin({ ...stalePidRecord, appVersion: '1.4.191' })).toEqual({
app_version_match: 'same',
spawner_path_class: 'updater-cache'
})
expect(classifyDaemonAdoptionOrigin(null)).toEqual({
app_version_match: 'unknown',
spawner_path_class: 'unknown'
})
})
})
describe('trackDaemonAdopted', () => {
it('emits a validator-accepted payload', () => {
trackDaemonAdopted(stalePidRecord, 'intact', 7)
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_adopted')
expect(props).toEqual({
...origin,
tcc_attribution: 'intact',
live_session_count_bucket: '6+'
})
expect(validate('daemon_adopted', props).ok).toBe(true)
})
it('swallows a throwing telemetry client', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() => trackDaemonAdopted(null, 'unknown', null)).not.toThrow()
})
})
describe('trackDaemonPtyCwdDeniedIfDiverged', () => {
it('emits only when the daemon was denied and the app can read the same cwd', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).toHaveBeenCalledWith('/Users/alice/Documents/repo', expect.any(Number))
expect(trackMock).toHaveBeenCalledTimes(1)
const [name, props] = trackMock.mock.calls[0]
expect(name).toBe('daemon_pty_cwd_denied')
expect(props).toEqual({ cwd_class: 'documents', ...origin })
expect(validate('daemon_pty_cwd_denied', props).ok).toBe(true)
})
// False positives would drown the signal this event exists to measure, so every
// non-divergent shape must stay silent.
it('stays silent when the daemon could read the cwd or did not report', () => {
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', true, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', undefined, PID_PATH)
trackDaemonPtyCwdDeniedIfDiverged(undefined, false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent when the app cannot read the cwd either (no divergence)', () => {
accessSyncMock.mockImplementation(() => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' })
})
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(trackMock).not.toHaveBeenCalled()
})
it('attributes the denial to the daemon recorded right now, not a startup snapshot', () => {
readFileSyncMock.mockReturnValue(
JSON.stringify({
...stalePidRecord,
appVersion: '1.4.191',
spawnerExecPath: '/Applications/Orca.app/Contents/MacOS/Orca'
})
)
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
expect(readFileSyncMock).toHaveBeenCalledWith(PID_PATH, 'utf8')
expect(trackMock.mock.calls[0][1]).toEqual({
cwd_class: 'documents',
app_version_match: 'same',
spawner_path_class: 'applications'
})
})
it('swallows a throwing app environment or pid-record read instead of failing the spawn', () => {
getVersionMock.mockImplementationOnce(() => {
throw new Error('AppEnvironment not initialized')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
expect(trackMock).not.toHaveBeenCalled()
})
it('stays silent off macOS', () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
trackDaemonPtyCwdDeniedIfDiverged('/home/alice/Documents/repo', false, PID_PATH)
expect(accessSyncMock).not.toHaveBeenCalled()
expect(trackMock).not.toHaveBeenCalled()
})
it('swallows a throwing telemetry client', () => {
trackMock.mockImplementationOnce(() => {
throw new Error('posthog exploded')
})
expect(() =>
trackDaemonPtyCwdDeniedIfDiverged('/Users/alice/Documents/repo', false, PID_PATH)
).not.toThrow()
})
})
@@ -0,0 +1,81 @@
// App-side emitters for `daemon_adopted` and `daemon_pty_cwd_denied` (#17696). Both sit on the
// daemon launch / PTY spawn path, so every failure dies here — telemetry can never cost a terminal.
import { accessSync, constants as fsConstants, existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { getAppEnvironment } from '../../shared/app-environment'
import {
classifyDaemonPtyCwd,
classifyDaemonSpawnerPath,
type DaemonAdoptedAppVersionMatch,
type DaemonSpawnerPathClass
} from '../../shared/daemon-adoption-telemetry'
import { bucketDaemonLiveSessionCount } from '../../shared/daemon-lifecycle-telemetry'
import type { EventProps } from '../../shared/telemetry-events'
import { track } from '../telemetry/client'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import type { ParsedDaemonPid } from './daemon-pid-file-parse'
import type { MacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
export type DaemonAdoptionOrigin = Pick<
EventProps<'daemon_pty_cwd_denied'>,
'app_version_match' | 'spawner_path_class'
>
/** Classifies the adopted daemon's pid record against the running app; enum-only by construction. */
export function classifyDaemonAdoptionOrigin(
pidRecord: ParsedDaemonPid | null
): DaemonAdoptionOrigin {
const appVersionMatch: DaemonAdoptedAppVersionMatch = !pidRecord?.appVersion
? 'unknown'
: pidRecord.appVersion === getAppEnvironment().getVersion()
? 'same'
: 'different'
const spawnerPathClass: DaemonSpawnerPathClass = classifyDaemonSpawnerPath(
pidRecord?.spawnerExecPath ?? null,
existsSync
)
return { app_version_match: appVersionMatch, spawner_path_class: spawnerPathClass }
}
// Adopted a daemon that a previous app launch forked (macOS only; that is where attribution matters).
export function trackDaemonAdopted(
pidRecord: ParsedDaemonPid | null,
tccAttribution: MacDaemonTccAttributionHealth,
liveSessionCount: number | null
): void {
try {
track('daemon_adopted', {
...classifyDaemonAdoptionOrigin(pidRecord),
tcc_attribution: tccAttribution,
live_session_count_bucket: bucketDaemonLiveSessionCount(liveSessionCount)
})
} catch {
// Telemetry is best-effort; a dropped event must not fail daemon adoption.
}
}
/**
* Emits only on proven divergence: the daemon reported the cwd unreadable AND this process can
* read it. A cwd neither can read (chmod, ENOENT, unmounted volume) is not the #17696 shape.
*/
export function trackDaemonPtyCwdDeniedIfDiverged(
cwd: string | undefined,
cwdReadableByDaemon: boolean | undefined,
pidPath: string | null
): void {
try {
if (process.platform !== 'darwin' || !cwd || cwdReadableByDaemon !== false) {
return
}
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
// Why read now, not the adapter's startup snapshot: a respawn swaps the daemon under a
// long-lived adapter, and the denial must be attributed to the daemon that just spawned.
track('daemon_pty_cwd_denied', {
cwd_class: classifyDaemonPtyCwd(cwd, homedir()),
...classifyDaemonAdoptionOrigin(readDaemonPidRecord(pidPath))
})
} catch {
// Either the app cannot read it (no divergence) or telemetry failed; neither may reach the caller.
}
}
@@ -14,6 +14,12 @@ export type DaemonCreateOrAttachResult = {
wslDistro?: string | null
agentSessionEnsure?: AgentSessionClaimedSpawnResult
incarnationId?: PtyIncarnationId
/**
* Whether the daemon process itself could read the requested cwd at spawn. Only the daemon's own
* verdict counts: macOS TCC scopes folder access per process tree, so the app's view of the same
* path proves nothing about the daemon's (#17696). Omitted by daemons predating this field.
*/
cwdReadableByDaemon?: boolean
}
export function getDaemonSessionResultMetadata(session: {
@@ -50,7 +50,8 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
} = state
// Why: both fakes are annotated with constructor types so the exported factories widen to
@@ -82,6 +83,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
if (result.mode) {
this.handle.mode = result.mode
}
if (result.adopted) {
this.handle.adopted = true
}
return {
socketPath: result.socketPath,
tokenPath: result.tokenPath
@@ -199,6 +203,9 @@ export function createDaemonInitModuleFactories(state: DaemonInitMockState) {
trackDaemonReplaced: trackDaemonReplacedMock,
trackDaemonRetired: trackDaemonRetiredMock
}),
daemonAdoptionTelemetryEvent: () => ({
trackDaemonAdopted: trackDaemonAdoptedMock
}),
daemonSpawner: () => ({
DaemonSpawner: MockDaemonSpawner,
getDaemonSocketPath: (_dir: string, version?: number) =>
+3 -1
View File
@@ -41,7 +41,8 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
} = state
vi.resetModules()
@@ -64,6 +65,7 @@ export async function importFreshDaemonInit(state: DaemonInitMockState) {
rebindLocalProviderListenersMock.mockClear()
trackDaemonReplacedMock.mockClear()
trackDaemonRetiredMock.mockClear()
trackDaemonAdoptedMock.mockClear()
checkDaemonHealthMock.mockClear()
checkDaemonHealthMock.mockResolvedValue('healthy')
healthCheckDaemonMock.mockClear()
@@ -47,6 +47,7 @@ export type MockAdapterConstructor = new (opts: MockAdapter['options']) => MockA
/** Handle the fake spawner hands back from ensureRunning/getHandle. */
export type MockSpawnerHandle = {
mode?: 'degraded-new-pty-fallback'
adopted?: true
releaseAdoptionLease?: () => void
shutdown: () => Promise<void>
}
@@ -95,6 +96,7 @@ export type EnsureRunningOverride = () => Promise<{
socketPath: string
tokenPath: string
mode?: 'degraded-new-pty-fallback'
adopted?: true
}>
/** Every stub daemon-init's suites share, plus the control knobs they mutate per test. */
@@ -143,6 +145,7 @@ export type DaemonInitMockState = {
rebindLocalProviderListenersMock: Mock<(...args: unknown[]) => void>
trackDaemonReplacedMock: Mock<(...args: unknown[]) => void>
trackDaemonRetiredMock: Mock<(...args: unknown[]) => void>
trackDaemonAdoptedMock: Mock<(...args: unknown[]) => void>
}
/** net.connect stubs the suites install in beforeEach. */
@@ -2,6 +2,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
isPackagedMock,
getMacDaemonTccAttributionHealthMock,
trackDaemonAdoptedMock,
probeSocketExistsMock,
readFileSyncMock,
unlinkSyncMock,
@@ -42,6 +44,7 @@ vi.mock('./daemon-process-start-time', () => moduleFactories.daemonProcessStartT
vi.mock('./daemon-pid-file-parse', () => moduleFactories.daemonPidFileParse())
vi.mock('./client', () => moduleFactories.client())
vi.mock('./daemon-lifecycle-event', () => moduleFactories.daemonLifecycleEvent())
vi.mock('./daemon-adoption-telemetry-event', () => moduleFactories.daemonAdoptionTelemetryEvent())
vi.mock('./daemon-spawner', () => moduleFactories.daemonSpawner())
vi.mock('./daemon-pty-adapter', () => moduleFactories.daemonPtyAdapter())
vi.mock('../ipc/pty', () => moduleFactories.ipcPty())
@@ -228,6 +231,48 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => {
expect(adapterInstances[1].disconnectOnly).toHaveBeenCalledOnce()
})
// #17696: adopting a daemon from an earlier app launch is invisible to daemon_lifecycle, so
// it gets its own event — macOS only, and only for adopted (not freshly forked) daemons.
it('reports a macOS daemon adoption with its TCC attribution and live session bucket', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/adopted-socket',
tokenPath: '/fake/adopted-token',
adopted: true
}))
getMacDaemonTccAttributionHealthMock.mockResolvedValueOnce('severed')
defaultListSessionsSessions.push({ sessionId: 'wt-1@@a' }, { sessionId: 'wt-1@@b' })
await mod.initDaemonPtyProvider()
await vi.waitFor(() => expect(trackDaemonAdoptedMock).toHaveBeenCalledOnce())
// null pid record: the harness has no pid file, which the emitter classifies as 'unknown'.
expect(trackDaemonAdoptedMock).toHaveBeenCalledWith(null, 'severed', 2)
vi.restoreAllMocks()
})
it('does not report adoption for a freshly forked daemon or off macOS', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
const mod = await importFresh()
await mod.initDaemonPtyProvider()
await new Promise((resolve) => setImmediate(resolve))
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
vi.restoreAllMocks()
vi.spyOn(process, 'platform', 'get').mockReturnValue('linux')
const linuxMod = await importFresh()
ensureRunningOverrides.push(async () => ({
socketPath: '/fake/adopted-socket',
tokenPath: '/fake/adopted-token',
adopted: true
}))
await linuxMod.initDaemonPtyProvider()
await new Promise((resolve) => setImmediate(resolve))
expect(trackDaemonAdoptedMock).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('routes fresh PTYs to the local fallback when a preserved daemon cannot spawn new PTYs', async () => {
const mod = await importFresh()
ensureRunningOverrides.push(async () => ({
+3 -1
View File
@@ -156,6 +156,7 @@ function createDaemonInitMockState(): DaemonInitMockState {
const rebindLocalProviderListenersMock = vi.fn()
const trackDaemonReplacedMock = vi.fn()
const trackDaemonRetiredMock = vi.fn()
const trackDaemonAdoptedMock = vi.fn()
return {
getPathMock,
@@ -197,7 +198,8 @@ function createDaemonInitMockState(): DaemonInitMockState {
unbindLocalProviderListenersMock,
rebindLocalProviderListenersMock,
trackDaemonReplacedMock,
trackDaemonRetiredMock
trackDaemonRetiredMock,
trackDaemonAdoptedMock
}
}
@@ -41,6 +41,7 @@ function createPreservedDaemonHandle(
mode?: 'degraded-new-pty-fallback'
): DaemonProcessHandle {
const handle: DaemonProcessHandle = {
adopted: true,
shutdown: async () => {
await cleanupDaemonForProtocol(runtimeDir, protocolVersion)
}
+31
View File
@@ -24,7 +24,10 @@ import {
import type { DaemonProvider } from './daemon-provider-routing'
import { installDaemonProvider } from './daemon-provider-state'
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
import { trackDaemonAdopted } from './daemon-adoption-telemetry-event'
import { readDaemonPidRecord } from './daemon-endpoint-incarnation'
import { trackDaemonRetired } from './daemon-lifecycle-event'
import { getMacDaemonTccAttributionHealth } from './daemon-tcc-attribution'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import type { DaemonRespawnReason } from './daemon-pty-runtime-state'
import { DaemonPtyRouter } from './daemon-pty-router'
@@ -156,9 +159,37 @@ export async function initDaemonPtyProvider(
logDaemonMilestone('daemon-init-done', {
legacyAdapters: legacyAdapters.length
})
if (process.platform === 'darwin' && newSpawner.getHandle()?.adopted) {
void reportDaemonAdoption(runtimeDir, info.socketPath, info.tokenPath, newAdapter)
}
await reconcileSeededClaudeLivePtys(routedAdapter)
}
// Why off the init path: this is measurement of an adopted daemon (#17696), and neither its probes nor their failure may delay or fail startup.
async function reportDaemonAdoption(
runtimeDir: string,
socketPath: string,
tokenPath: string,
adapter: DaemonPtyAdapter
): Promise<void> {
try {
const [tccAttribution, liveSessionCount] = await Promise.all([
getMacDaemonTccAttributionHealth(runtimeDir, socketPath, tokenPath),
adapter.listSessions().then(
(sessions) => sessions.length,
() => null
)
])
trackDaemonAdopted(
readDaemonPidRecord(getDaemonPidPath(runtimeDir)),
tccAttribution,
liveSessionCount
)
} catch {
// Best-effort measurement only.
}
}
// Why: release gate ids only for daemon-confirmed-dead sessions; keep seeds on listing failure since releasing early can rotate a live CLI's refresh token.
async function reconcileSeededClaudeLivePtys(provider: DaemonProvider): Promise<void> {
if (!hasSeededUnconfirmedClaudePtys()) {
@@ -4,6 +4,7 @@ import type {
HistoryRecoveryContext,
PendingDaemonSpawnOperation
} from './daemon-pty-runtime-state'
import { trackDaemonPtyCwdDeniedIfDiverged } from './daemon-adoption-telemetry-event'
import { STABLE_PANE_ATTACH_ONLY_DAEMON_PROTOCOL_VERSION } from './daemon-protocol-version'
import { TerminalKilledError } from './daemon-pty-lifecycle-errors'
import { DaemonPtySpawnResult } from './daemon-pty-spawn-result'
@@ -246,6 +247,9 @@ export abstract class DaemonPtySessionSpawn extends DaemonPtySpawnResult {
}
activeSpawnContext = context
const result = await this.createOrAttachSpawn(context, context.historySeedSegments)
if (result.isNew && !attachOnly) {
trackDaemonPtyCwdDeniedIfDiverged(effectiveCwd, result.cwdReadableByDaemon, this.pidPath)
}
return this.finishSpawn(context, result)
}
+2
View File
@@ -31,6 +31,8 @@ export type DaemonPidFile = {
export type DaemonProcessHandle = {
mode?: 'degraded-new-pty-fallback'
/** Set when the launcher kept a daemon some earlier app launch forked, rather than forking one. */
adopted?: true
releaseAdoptionLease?(): void
shutdown(): Promise<void>
}
+4 -1
View File
@@ -161,7 +161,10 @@ export class DaemonTerminalAdmission {
...(result.launchAgent ? { launchAgent: result.launchAgent } : {}),
wslDistro: result.wslDistro,
...(result.historySeeded !== undefined ? { historySeeded: result.historySeeded } : {}),
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {})
...(result.agentSessionEnsure ? { agentSessionEnsure: result.agentSessionEnsure } : {}),
...(result.cwdReadableByDaemon !== undefined
? { cwdReadableByDaemon: result.cwdReadableByDaemon }
: {})
}
}
@@ -54,4 +54,6 @@ export type CreateOrAttachResult = {
attachToken: symbol
incarnationId: PtyIncarnationId
agentSessionEnsure?: AgentSessionClaimedSpawnResult
/** Daemon-process verdict on the spawn cwd; only set on a fresh spawn that was given a cwd. */
cwdReadableByDaemon?: boolean
}
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session-subprocess-handle'
import { TerminalHost, type TerminalHostOptions } from './terminal-host'
vi.mock('../pty-descendant-termination', () => ({ killWithDescendantSweep: vi.fn() }))
function createMockSubprocess(): SubprocessHandle {
let onExitCb: ((code: number) => void) | null = null
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => onExitCb?.(0), 5)
}),
terminateOwnedTree: () => 'unavailable' as const,
forceKill: vi.fn(() => onExitCb?.(137)),
signal: vi.fn(),
onData() {},
onExit(cb) {
onExitCb = cb
},
dispose: vi.fn()
}
}
// #17696: only the daemon process can say whether TCC lets it read the cwd, so its verdict
// rides on the create result. A non-permission failure must never read as denial.
describe('TerminalHost cwd readability verdict', () => {
let host: TerminalHost
let platformDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
const spawnSubprocess: TerminalHostOptions['spawnSubprocess'] = () => createMockSubprocess()
host = new TerminalHost({ spawnSubprocess })
})
afterEach(async () => {
await host.dispose()
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor)
}
})
const create = (sessionId: string, cwd?: string) =>
host.createOrAttach({
sessionId,
cols: 80,
rows: 24,
...(cwd ? { cwd } : {}),
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
it('reports a readable cwd as readable', async () => {
expect((await create('readable', process.cwd())).cwdReadableByDaemon).toBe(true)
})
it('reports a missing cwd as readable — absence is not a permission denial', async () => {
expect((await create('missing', '/definitely/not/a/real/dir')).cwdReadableByDaemon).toBe(true)
})
it('omits the verdict when no cwd was requested', async () => {
expect((await create('no-cwd')).cwdReadableByDaemon).toBeUndefined()
})
it('omits the verdict on attach to an existing session', async () => {
await create('attach', process.cwd())
const attached = await create('attach', process.cwd())
expect(attached.isNew).toBe(false)
expect(attached.cwdReadableByDaemon).toBeUndefined()
})
})
@@ -1,3 +1,4 @@
import { accessSync, constants as fsConstants } from 'node:fs'
import { buildStartupCommandSubmission } from '../../shared/startup-command-submission'
import { resolvePtyOwnerBackend } from '../../shared/pty-owner-backend'
import { getDaemonSessionResultMetadata } from './daemon-create-or-attach-result'
@@ -88,6 +89,8 @@ async function spawnAndPublishSession(
ctx: { size: { cols: number; rows: number }; wslDistro: string | undefined }
): Promise<CreateOrAttachResult> {
const { size, wslDistro } = ctx
// Why before the fork: the shell's own cwd may already have fallen back, so probe the requested path.
const cwdReadableByDaemon = opts.cwd && !wslDistro ? isCwdReadableByThisProcess(opts.cwd) : null
const subprocess = await deps.spawnSubprocess({
sessionId: opts.sessionId,
cols: size.cols,
@@ -184,6 +187,20 @@ async function spawnAndPublishSession(
shellState: session.shellState,
incarnationId: session.incarnationId,
...getDaemonSessionResultMetadata(session),
...(cwdReadableByDaemon !== null ? { cwdReadableByDaemon } : {}),
attachToken: token
}
}
// Why R_OK|X_OK: listing a directory needs read, and entering it needs search — both are what
// TCC withholds. A non-permission failure (ENOENT, ENOTDIR) reads as readable so it can never
// masquerade as a permission denial.
function isCwdReadableByThisProcess(cwd: string): boolean {
try {
accessSync(cwd, fsConstants.R_OK | fsConstants.X_OK)
return true
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
return code !== 'EACCES' && code !== 'EPERM'
}
}
@@ -0,0 +1,99 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
measureRetargetDivergence,
RETARGET_MAX_COMMIT_DIVERGENCE
} from './worktree-base-divergence'
const tempRoots: string[] = []
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim()
}
async function createRepo(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-base-divergence-'))
tempRoots.push(root)
const repoPath = join(root, 'repo')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
await writeFile(join(repoPath, 'version.txt'), 'one\n')
git(repoPath, ['add', 'version.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
return repoPath
}
function commitEmpty(repoPath: string, count: number): void {
for (let index = 0; index < count; index += 1) {
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', `commit ${index}`])
}
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('measureRetargetDivergence with real Git', () => {
it('allows the drift between a local branch and its remote-tracking copy', async () => {
const repoPath = await createRepo()
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
commitEmpty(repoPath, 5)
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repoPath, ['reset', '--hard', '--quiet', 'HEAD~3'])
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('within')
})
it('counts drift in both directions', async () => {
const repoPath = await createRepo()
const forkPoint = git(repoPath, ['rev-parse', 'HEAD'])
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE)
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repoPath, ['reset', '--hard', '--quiet', forkPoint])
commitEmpty(repoPath, 1)
// 100 ahead + 1 behind is over the cap even though neither side alone exceeds it.
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
})
it('refuses a base that has drifted past the cap', async () => {
const repoPath = await createRepo()
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
commitEmpty(repoPath, RETARGET_MAX_COMMIT_DIVERGENCE + 1)
await expect(
measureRetargetDivergence(repoPath, 'refs/remotes/origin/main', 'refs/heads/main')
).resolves.toBe('exceeded')
})
it('refuses unrelated histories, which share no commits at all', async () => {
const repoPath = await createRepo()
git(repoPath, ['checkout', '--quiet', '--orphan', 'unrelated'])
git(repoPath, ['commit', '--quiet', '--allow-empty', '-m', 'unrelated root'])
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/unrelated')
).resolves.toBe('exceeded')
})
it('reports an unreadable ref as unverifiable, not as excess drift', async () => {
const repoPath = await createRepo()
await expect(
measureRetargetDivergence(repoPath, 'refs/heads/main', 'refs/heads/missing')
).resolves.toBe('unknown')
})
})
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ gitExecFileAsync: vi.fn() }))
vi.mock('./runner', () => ({ gitExecFileAsync: mocks.gitExecFileAsync }))
import { GIT_READ_TIMEOUT_MS } from './command-runner/git-command-timeout'
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
import {
measureRetargetDivergence,
RETARGET_DIVERGENCE_BUDGET_MS
} from './worktree-base-divergence'
type ExecOptions = { cwd: string; timeout?: number; wslDistro?: string; signal?: AbortSignal }
function callOptions(): ExecOptions[] {
return mocks.gitExecFileAsync.mock.calls.map((call) => call[1] as ExecOptions)
}
function subcommands(): string[] {
return mocks.gitExecFileAsync.mock.calls.map((call) => (call[0] as string[])[0]!)
}
function answerProbes(count: string, mergeBase = 'abc123\n') {
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) =>
args[0] === 'merge-base' ? { stdout: mergeBase } : { stdout: count }
)
}
function exitCodeError(code: number): Error & { code: number } {
return Object.assign(new Error('git exited'), { code })
}
beforeEach(() => {
mocks.gitExecFileAsync.mockReset()
})
describe('measureRetargetDivergence deadlines', () => {
it('puts every probe under one shared budget, not a budget each', async () => {
answerProbes('3\n')
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('within')
expect(subcommands()).toEqual(['rev-list', 'rev-list', 'merge-base'])
const signals = callOptions().map((options) => options.signal)
// One signal object across all three: the counts and merge-base are staged, so per-probe
// budgets would let the check cost the sum of them.
expect(new Set(signals).size).toBe(1)
expect(signals[0]).toBeInstanceOf(AbortSignal)
})
it('also gives each probe a command timeout well below git default read deadline', async () => {
answerProbes('3\n')
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
// The signal covers admission queueing and the WSL environment wait, which start before a
// command timeout exists; the timeout still covers a hung spawn.
for (const options of callOptions()) {
expect(options.timeout).toBe(RETARGET_DIVERGENCE_BUDGET_MS)
}
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
})
it('really aborts the in-flight probes when the shared budget expires', async () => {
// A probe that behaves like a slow walk: it produces nothing on its own and only settles when
// its signal fires. If the budget never fired, or never reached the probe, this hangs and the
// test fails on its own timeout rather than passing on a signal that does nothing.
mocks.gitExecFileAsync.mockImplementation(
(_args: string[], options: ExecOptions) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener(
'abort',
() =>
reject(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
),
{ once: true }
)
})
)
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
budgetMsForTest: 25
})
).resolves.toBe('unknown')
})
it('clears the WSL read-environment wait, which starts before any command timeout', () => {
// Equal to it would make the first WSL-routed create of a session `unknown` by construction,
// and the WSL numbers meaningless.
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeGreaterThan(WSL_GIT_READ_ENVIRONMENT_WAIT_MS)
expect(RETARGET_DIVERGENCE_BUDGET_MS).toBeLessThan(GIT_READ_TIMEOUT_MS)
})
it('stops the probes when the create itself is cancelled, without waiting for the budget', async () => {
mocks.gitExecFileAsync.mockImplementation(
(_args: string[], options: ExecOptions) =>
new Promise((_resolve, reject) => {
options.signal?.addEventListener(
'abort',
() =>
reject(
Object.assign(new Error('The operation was aborted.'), { name: 'AbortError' })
),
{ once: true }
)
})
)
const controller = new AbortController()
const pending = measureRetargetDivergence(
'/repo',
'refs/heads/main',
'refs/remotes/origin/main',
// A budget long enough that only the caller's cancellation can end this in time.
{ signal: controller.signal, budgetMsForTest: 60_000 }
)
controller.abort()
await expect(pending).resolves.toBe('unknown')
})
it('reports a blown deadline as unverifiable rather than as excess drift', async () => {
mocks.gitExecFileAsync.mockRejectedValue(new Error('git timed out.'))
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('unknown')
// A count that never answered must not go on to spend a merge-base walk.
expect(subcommands()).not.toContain('merge-base')
})
it('separates merge-base saying no from merge-base failing', async () => {
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
if (args[0] === 'merge-base') {
// Exit 1 is Git's answer for unrelated histories.
throw exitCodeError(1)
}
return { stdout: '2\n' }
})
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
mocks.gitExecFileAsync.mockReset()
mocks.gitExecFileAsync.mockImplementation(async (args: string[]) => {
if (args[0] === 'merge-base') {
// A timeout carries no exit code and must not be read as "no common ancestor".
throw new Error('git timed out.')
}
return { stdout: '2\n' }
})
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('unknown')
})
it('reports drift past the cap without spending a merge-base walk', async () => {
answerProbes('101\n')
await expect(
measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main')
).resolves.toBe('exceeded')
expect(subcommands()).not.toContain('merge-base')
})
it('routes every probe to the caller-named WSL distro', async () => {
answerProbes('1\n')
await measureRetargetDivergence('/repo', 'refs/heads/main', 'refs/remotes/origin/main', {
wslDistro: 'Ubuntu'
})
for (const options of callOptions()) {
expect(options).toMatchObject({ cwd: '/repo', wslDistro: 'Ubuntu' })
}
})
})
+165
View File
@@ -0,0 +1,165 @@
import { WSL_GIT_READ_ENVIRONMENT_WAIT_MS } from './wsl-git-read-environment'
import { gitExecFileAsync } from './runner'
export type RetargetDivergenceOptions = {
wslDistro?: string
/** The create's own cancellation signal. Without it a cancelled create leaves these probes
* running until the budget expires. */
signal?: AbortSignal
/** Shortens only the end-to-end budget so a test can observe a real abort; production always
* uses the constant. Mirrors `timeoutMsForTest` on the git exec options. */
budgetMsForTest?: number
}
/** `unknown` is deliberately not folded into `exceeded`: "the bound says no" and "the bound could
* not be evaluated" have different causes and different fixes, and only the second one means a
* retarget that would have been cheap was skipped. `unknown` covers a blown deadline, a
* cancelled create, and an ordinary Git failure alike — it is "no answer", not "slow". */
export type RetargetDivergence = 'within' | 'exceeded' | 'unknown'
/**
* How far two bases may drift and still be worth retargeting a prepared checkout between.
*
* Measured on a 21,715-file repo: a local `main` and its `origin/main` were 5 commits and 74
* files apart, while an abandoned fork's `main` — same branch name, so the same base family —
* was 8,173 commits and 21,708 files from `origin/main`, i.e. a whole-tree checkout. A commit
* count separates those by three orders of magnitude, so it is the cheap proxy for the tree diff
* the retarget reset would have to write.
*/
export const RETARGET_MAX_COMMIT_DIVERGENCE = 100
/**
* Headroom for the walk itself, on top of the worst pre-spawn wait.
*
* ~3x the slowest walk measured on the 12GB/80k-ref repo (180ms to reject, 57ms to allow), so a
* cold WSL environment probe cannot eat the whole budget and make the answer `unknown` by
* construction.
*/
const RETARGET_DIVERGENCE_WALK_HEADROOM_MS = 500
/**
* End-to-end deadline for the whole check, not per probe.
*
* Derived from the WSL read-environment wait rather than picked: `git-exec-file` awaits that probe
* before a command timeout even exists, so a budget merely equal to it would guarantee `unknown`
* on the first WSL-routed create of a session and make the WSL numbers meaningless. Deriving it
* keeps that relationship explicit instead of coincidental.
*
* Sized against what it competes with: this exists only to decide whether to skip a ~4.1s p50 cold
* `worktree add`, so when it expires the create pays the budget and then does that add anyway. The
* total stays under that add even on Windows, where a killed probe also awaits `taskkill /t`.
*
* It must be a signal, not just a per-command timeout, because a per-command timeout starts only
* after `git-exec-file` has awaited admission and the WSL read-environment probe, and because the
* counts and `merge-base` are staged — two per-probe budgets in sequence would be twice the number
* written here.
*/
export const RETARGET_DIVERGENCE_BUDGET_MS =
WSL_GIT_READ_ENVIRONMENT_WAIT_MS + RETARGET_DIVERGENCE_WALK_HEADROOM_MS
function probeOptions(
repoPath: string,
options: RetargetDivergenceOptions,
signal: AbortSignal
): { cwd: string; wslDistro?: string; signal: AbortSignal; timeout: number } {
// Built field by field rather than spread: the caller's bag carries a test-only key that must
// never reach git's exec options.
// Both bounds: the signal covers the pre-spawn waits (admission queue, WSL environment) that a
// command timeout cannot see, and the timeout keeps the bounded tree-kill path for a hung spawn.
return {
cwd: repoPath,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
signal,
timeout: RETARGET_DIVERGENCE_BUDGET_MS
}
}
/** Commits reachable from `toRef` but not `fromRef`, capped; null when the probe was unusable. */
async function countCommitsAhead(
repoPath: string,
fromRef: string,
toRef: string,
options: RetargetDivergenceOptions,
signal: AbortSignal
): Promise<number | null> {
try {
// `--max-count` stops the walk, so an unrelated history costs a bounded number of commits
// rather than a full traversal. Both flags predate the Git 2.25 baseline.
// `--end-of-options` (Git 2.24) because a range whose left side began with `-` would
// otherwise parse as an option; callers only pass `refs/`-qualified names today, and this
// keeps that from being load-bearing.
const { stdout } = await gitExecFileAsync(
[
'rev-list',
'--count',
`--max-count=${RETARGET_MAX_COMMIT_DIVERGENCE + 1}`,
'--end-of-options',
`${fromRef}..${toRef}`
],
probeOptions(repoPath, options, signal)
)
const count = Number.parseInt(stdout.trim(), 10)
return Number.isNaN(count) ? null : count
} catch {
return null
}
}
/** True/false when Git decided, null when the probe was unusable. */
async function hasCommonHistory(
repoPath: string,
leftRef: string,
rightRef: string,
options: RetargetDivergenceOptions,
signal: AbortSignal
): Promise<boolean | null> {
try {
const { stdout } = await gitExecFileAsync(
['merge-base', '--end-of-options', leftRef, rightRef],
probeOptions(repoPath, options, signal)
)
return stdout.trim().length > 0
} catch (error) {
// Exit 1 is `merge-base` reporting no common ancestor, which is an answer. A timeout or abort
// carries no exit code and must not be read as one.
return (error as { code?: unknown }).code === 1 ? false : null
}
}
/**
* Whether retargeting a checkout prepared at `preparedBase` onto `targetBase` stays cheap.
*
* Fails closed on error, slowness, and cancellation alike: only a positive `within` authorizes
* reusing the checkout, so every other outcome lands on the cold create path.
*/
export async function measureRetargetDivergence(
repoPath: string,
preparedBase: string,
targetBase: string,
options: RetargetDivergenceOptions = {}
): Promise<RetargetDivergence> {
const budget = AbortSignal.timeout(options.budgetMsForTest ?? RETARGET_DIVERGENCE_BUDGET_MS)
// Combined so cancelling the create stops the probes immediately rather than at the deadline.
const signal = options.signal ? AbortSignal.any([options.signal, budget]) : budget
// Both directions: commits the target adds decide what the reset writes, commits only the
// preparation has decide what it must delete.
const [ahead, behind] = await Promise.all([
countCommitsAhead(repoPath, preparedBase, targetBase, options, signal),
countCommitsAhead(repoPath, targetBase, preparedBase, options, signal)
])
if (ahead === null || behind === null) {
return 'unknown'
}
if (ahead + behind > RETARGET_MAX_COMMIT_DIVERGENCE) {
return 'exceeded'
}
// Only now: `merge-base` has no `--max-count`, so on unrelated histories it would walk both of
// them in full. Reaching here already proved neither side is more than the cap ahead of the
// other, which bounds that walk — and unrelated histories of any size fail the counts first.
// Required because unrelated histories replace the whole tree however few commits they carry.
const shareHistory = await hasCommonHistory(repoPath, preparedBase, targetBase, options, signal)
if (shareHistory === null) {
return 'unknown'
}
return shareHistory ? 'within' : 'exceeded'
}
+15
View File
@@ -42,6 +42,21 @@ export async function hasWorktreeBaseCommitRef(
return (await resolveWorktreeBaseCommitOid(repoPath, qualifiedRef, options)) !== null
}
/**
* The qualified ref a worktree base names in this repo, or the base unchanged when nothing
* matches. Callers that key on a base must compare this, not the raw string, or `main` and
* `refs/heads/main` look like different bases.
*/
export function resolveLocalWorktreeBaseRef(
repoPath: string,
baseRef: string,
options: GitExecOptions = {}
): Promise<string> {
return resolveWorktreeAddBaseRef(baseRef, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
}
/**
* Whether a worktree base — a qualified ref, a short branch or remote name, or a
* full commit id — already resolves in this repo's own object/ref store.
@@ -68,6 +68,55 @@ describe('prepared worktree creation with real Git', () => {
expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1)
})
it('lands a cross-base retarget on exactly the requested commit', async () => {
const { repoPath, root } = await createRepo()
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
const preparedPath = join(preparationRoot, `${process.pid}-retarget`)
const finalPath = join(root, 'retargeted-worktree')
await mkdir(preparationRoot, { recursive: true })
await writeFile(join(repoPath, 'shared.txt'), 'kept\n')
git(repoPath, ['add', 'shared.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'local main'])
const localMainHead = git(repoPath, ['rev-parse', 'HEAD'])
// A remote-tracking `main` that diverged: different content, an extra file, and one deletion.
git(repoPath, ['checkout', '--quiet', '-b', 'upstream-main'])
await writeFile(join(repoPath, 'version.txt'), 'two\n')
await writeFile(join(repoPath, 'only-upstream.txt'), 'upstream\n')
git(repoPath, ['rm', '--quiet', 'shared.txt'])
git(repoPath, ['add', 'version.txt', 'only-upstream.txt'])
git(repoPath, ['commit', '--quiet', '-m', 'upstream main'])
git(repoPath, ['update-ref', 'refs/remotes/origin/main', 'HEAD'])
git(repoPath, ['checkout', '--quiet', 'main'])
git(repoPath, ['branch', '--quiet', '-D', 'upstream-main'])
await prepareWorktreeCreateCheckout(
repoPath,
preparedPath,
'refs/remotes/origin/main',
createWorktreePreparationLockReason('retarget-test')
)
expect(git(preparedPath, ['rev-parse', 'HEAD'])).not.toBe(localMainHead)
await finalizePreparedWorktree(repoPath, preparedPath, finalPath, 'feature/retargeted', 'main')
expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(localMainHead)
// A retarget that left stale files behind would be a wrong checkout, not just a slow one.
expect(git(finalPath, ['status', '--porcelain'])).toBe('')
expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
'one\n'
)
expect((await readFile(join(finalPath, 'shared.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe(
'kept\n'
)
await expect(readFile(join(finalPath, 'only-upstream.txt'), 'utf8')).rejects.toThrow()
expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/retargeted')
expect(git(finalPath, ['config', '--get', 'branch.feature/retargeted.base'])).toBe(
'refs/heads/main'
)
})
it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => {
const { repoPath, root } = await createRepo()
const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY)
+1 -1
View File
@@ -97,7 +97,7 @@ export async function listWorktreesStrict(
return annotateSparseCheckoutStatus(repoPath, visibleWorktrees, options)
}
async function annotateSparseCheckoutStatus(
export async function annotateSparseCheckoutStatus(
repoPath: string,
worktrees: GitWorktreeInfo[],
options: GitWorktreeExecOptions = {}
@@ -0,0 +1,93 @@
// The annotated listing is the graph listing plus a sparse probe: callers that read only
// `worktree.path` must skip the probe, without costing a second `git worktree list`.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
const { detectSparseCheckoutMock, readWorktreeListMock, readTranslatedWorktreeGraphMock } =
vi.hoisted(() => ({
detectSparseCheckoutMock: vi.fn(),
readWorktreeListMock: vi.fn(),
readTranslatedWorktreeGraphMock: vi.fn()
}))
vi.mock('./worktree-sparse-state', () => ({
detectSparseCheckout: detectSparseCheckoutMock,
resolveGitCommonDir: vi.fn()
}))
vi.mock('./worktree-list-reader', () => ({
readCheckedOutBranchRef: vi.fn(),
readRepoCommonDirFromGit: vi.fn(),
readRepoLocation: vi.fn(),
readTranslatedWorktreeGraph: readTranslatedWorktreeGraphMock,
readWorktreeHeadOid: vi.fn(),
readWorktreeList: readWorktreeListMock
}))
import { _resetWorktreeScanCacheForTests, listWorktreeGraph, listWorktrees } from './worktree'
import { __resetSparseCheckoutStateCacheForTests } from './worktree-sparse-checkout-cache'
const REPO = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo'
const ROW: GitWorktreeInfo = {
path: 'C:\\wt\\x',
head: 'a'.repeat(40),
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
describe('graph and annotated worktree scans', () => {
beforeEach(() => {
detectSparseCheckoutMock.mockReset()
detectSparseCheckoutMock.mockResolvedValue(true)
readWorktreeListMock.mockReset()
readWorktreeListMock.mockResolvedValue([ROW])
readTranslatedWorktreeGraphMock.mockReset()
readTranslatedWorktreeGraphMock.mockResolvedValue([ROW])
_resetWorktreeScanCacheForTests()
__resetSparseCheckoutStateCacheForTests()
})
it('does not probe sparse state for a graph scan', async () => {
const rows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' })
expect(rows[0]?.path).toBe('C:\\wt\\x')
expect(rows[0]?.isSparse).toBeUndefined()
expect(detectSparseCheckoutMock).not.toHaveBeenCalled()
})
it('still probes sparse state for the annotated scan', async () => {
const rows = await listWorktrees(REPO, { wslDistro: 'Ubuntu' })
expect(rows[0]?.isSparse).toBe(true)
expect(detectSparseCheckoutMock).toHaveBeenCalledTimes(1)
})
it('reads the git listing once for an overlapping graph and annotated scan', async () => {
const [graphRows, annotatedRows] = await Promise.all([
listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' }),
listWorktrees(REPO, { wslDistro: 'Ubuntu' })
])
expect(readTranslatedWorktreeGraphMock).toHaveBeenCalledTimes(1)
expect(graphRows[0]?.isSparse).toBeUndefined()
expect(annotatedRows[0]?.isSparse).toBe(true)
})
// Sharing the listing must not make the probe-free caller wait on the probe it opted out of.
it('resolves a graph scan while the annotated scan is still probing', async () => {
let releaseProbe!: () => void
detectSparseCheckoutMock.mockImplementation(
() =>
new Promise((resolve) => {
releaseProbe = () => resolve(true)
})
)
const annotatedScan = listWorktrees(REPO, { wslDistro: 'Ubuntu' })
const graphRows = await listWorktreeGraph(REPO, { wslDistro: 'Ubuntu' })
expect(graphRows[0]?.path).toBe('C:\\wt\\x')
releaseProbe()
expect((await annotatedScan)[0]?.isSparse).toBe(true)
})
})
@@ -98,7 +98,9 @@ describe('listWorktrees in-flight sharing', () => {
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('keeps graph and annotated scans separate despite sharing the same Git listing', async () => {
// The annotated scan is the graph scan plus a sparse probe, so the two share one `git worktree
// list` and only the annotated caller pays the probe. They ran Git twice before.
it('runs one git listing for concurrent graph and annotated scans', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
@@ -109,13 +111,34 @@ describe('listWorktrees in-flight sharing', () => {
const graphScan = listWorktreeGraph('/repo')
const annotatedScan = listWorktrees('/repo')
expect(resolvers).toHaveLength(2)
expect(resolvers).toHaveLength(1)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([graphScan, annotatedScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
// Order must not matter: whichever runs first owns the listing and the other joins it.
it('runs one git listing when the annotated scan starts first', async () => {
const resolvers: ((value: { stdout: string }) => void)[] = []
gitExecFileAsyncMock.mockImplementation(
() =>
new Promise((resolve) => {
resolvers.push(resolve)
})
)
const annotatedScan = listWorktrees('/repo')
const graphScan = listWorktreeGraph('/repo')
expect(resolvers).toHaveLength(1)
for (const resolve of resolvers) {
resolve({ stdout: 'worktree /repo\nHEAD abc123\nbranch refs/heads/main\n' })
}
await Promise.all([annotatedScan, graphScan])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('keeps graph scans with an AbortSignal isolated from shared callers', async () => {
@@ -352,14 +375,15 @@ describe('listWorktrees in-flight sharing', () => {
expect(scanResolvers).toHaveLength(1)
await moveWorktree('/repo', '/repo-old', '/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
// Two entries per annotated scan: its own, plus the graph listing it shares with probe-free callers.
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
const freshScan = listWorktrees('/repo')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 4, generations: 1 })
scanResolvers[1]?.('worktree /repo-new\nHEAD fresh\nbranch refs/heads/main\n')
expect((await freshScan)[0]?.path).toBe('/repo-new')
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 1, generations: 1 })
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 2, generations: 1 })
scanResolvers[0]?.('worktree /repo\nHEAD stale\nbranch refs/heads/main\n')
expect((await staleScan)[0]?.path).toBe('/repo')
@@ -396,7 +420,7 @@ describe('listWorktrees in-flight sharing', () => {
const newestScan = listWorktrees('/repo')
expect(listCalls).toBe(3)
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 3, generations: 1 })
expect(_getWorktreeScanCacheSizesForTests()).toEqual({ inFlight: 6, generations: 1 })
scanResolvers[0]?.()
scanResolvers[2]?.()
+19 -3
View File
@@ -1,8 +1,8 @@
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import {
annotateSparseCheckoutStatus,
listWorktreeGraph as listWorktreeGraphUnshared,
listWorktreesStrict as listWorktreesStrictUnshared,
listWorktreesUnshared
listWorktreesStrict as listWorktreesStrictUnshared
} from './worktree-listing'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
@@ -90,6 +90,22 @@ function shareWorktreeScan(
return scan
}
/**
* Sparse annotation layered over the shared graph scan rather than its own `git worktree list`.
*
* Both paths soften a Git failure to `[]`, so they can share one listing; only this one pays the
* per-worktree sparse probe. That lets a caller which reads just `worktree.path` skip the probes
* without costing a second subprocess when it overlaps a badge reader — the two ran Git twice
* before. Strict stays on its own scan because it must be able to reject.
*/
async function runAnnotatedWorktreeScan(
repoPath: string,
options: GitWorktreeExecOptions
): Promise<GitWorktreeInfo[]> {
const worktrees = await listWorktreeGraph(repoPath, options)
return annotateSparseCheckoutStatus(repoPath, worktrees, options)
}
/**
* List all worktrees for a git repo at the given path. Concurrent calls for
* the same repo share one scan (unless the caller passes an AbortSignal,
@@ -99,7 +115,7 @@ export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
return shareWorktreeScan(repoPath, options, 'lenient', listWorktreesUnshared)
return shareWorktreeScan(repoPath, options, 'lenient', runAnnotatedWorktreeScan)
}
/**
@@ -2,7 +2,7 @@ import type * as NodeFsPromises from 'node:fs/promises'
import { resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/repo-types'
import {
@@ -22,7 +22,7 @@ vi.mock('node:fs/promises', async () => {
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktrees: vi.fn() }
return { ...actual, listRepoWorktreeGraph: vi.fn() }
})
const repo: Repo = {
@@ -56,10 +56,10 @@ describe('recovered worktree root pruning', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktrees).mockReset()
vi.mocked(listRepoWorktreeGraph).mockReset()
// The #16520 outage itself: `listWorktrees` softens every Git failure to `[]`, so the rebuild
// reports success with the recovered row missing and the probe is the only remaining evidence.
vi.mocked(listRepoWorktrees).mockResolvedValue([])
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
statMock.mockReset()
statMock.mockResolvedValue({})
})
+13 -13
View File
@@ -5,7 +5,7 @@ import { join, resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
@@ -29,7 +29,7 @@ vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return {
...actual,
listRepoWorktrees: vi.fn()
listRepoWorktreeGraph: vi.fn()
}
})
@@ -98,7 +98,7 @@ describe('filesystem auth worktree roots', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktrees).mockReset()
vi.mocked(listRepoWorktreeGraph).mockReset()
})
it('rebuilds the authorized roots cache for large worktree lists', async () => {
@@ -112,7 +112,7 @@ describe('filesystem auth worktree roots', () => {
isMainWorktree: false
})
)
vi.mocked(listRepoWorktrees).mockResolvedValue(worktrees)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees)
const store = makeStore()
await rebuildAuthorizedRootsCache(store)
@@ -121,7 +121,7 @@ describe('filesystem auth worktree roots', () => {
await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe(
resolve(lastWorktreePath)
)
expect(listRepoWorktrees).toHaveBeenCalledTimes(1)
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1)
})
it("keeps a repo's roots when its listing fails mid-rebuild", async () => {
@@ -129,7 +129,7 @@ describe('filesystem auth worktree roots', () => {
// a worktree a create just recovered without a listing (#16520).
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered')
vi.mocked(listRepoWorktrees).mockRejectedValue(new Error('git worktree list failed.'))
vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.'))
await rebuildAuthorizedRootsCache(store)
@@ -146,7 +146,7 @@ describe('filesystem auth worktree roots', () => {
await mkdir(recovered)
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, recovered)
vi.mocked(listRepoWorktrees).mockResolvedValue([])
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
@@ -160,7 +160,7 @@ describe('filesystem auth worktree roots', () => {
await mkdir(recovered)
const store = makeStore()
// Register mid-listing: the rebuild's own result was computed before this worktree existed.
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
registerCreatedWorktreeRoot(store, repo.id, recovered)
return []
})
@@ -174,7 +174,7 @@ describe('filesystem auth worktree roots', () => {
it('retires a recovered root once the listing can see it again', async () => {
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/feature')
vi.mocked(listRepoWorktrees).mockResolvedValue([
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([
{
path: '/linked/feature',
head: '',
@@ -189,7 +189,7 @@ describe('filesystem auth worktree roots', () => {
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe(
resolve('/linked/feature')
)
vi.mocked(listRepoWorktrees).mockResolvedValue([])
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow(
@@ -205,7 +205,7 @@ describe('filesystem auth worktree roots', () => {
}))
let active = 0
let maxActive = 0
vi.mocked(listRepoWorktrees).mockImplementation(async () => {
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
active += 1
maxActive = Math.max(maxActive, active)
await new Promise((resolve) => setTimeout(resolve, 1))
@@ -215,7 +215,7 @@ describe('filesystem auth worktree roots', () => {
await rebuildAuthorizedRootsCache(makeStore(repos))
expect(listRepoWorktrees).toHaveBeenCalledTimes(repos.length)
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length)
expect(maxActive).toBeLessThanOrEqual(8)
})
})
@@ -392,7 +392,7 @@ describe('filesystem-auth path containment', () => {
vi.resetModules()
vi.doMock('../repo-worktrees', () => ({
isRepoRoot: vi.fn(),
listRepoWorktrees: vi.fn()
listRepoWorktreeGraph: vi.fn()
}))
vi.doMock('path', async () => {
const path = await vi.importActual<typeof NodePath>('node:path')
+1
View File
@@ -107,6 +107,7 @@ export const gitStatusModuleMock = {
export const gitIgnoredPathsMock = { checkIgnoredPaths: checkIgnoredPathsMock }
export const gitWorktreeMock = {
listWorktreeGraph: listWorktreesMock,
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesMock
}
@@ -5,7 +5,7 @@ import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/comm
import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation'
import { resolve } from 'node:path'
import { getSshGitProvider } from '../../providers/ssh-git-dispatch'
import { listRepoWorktrees } from '../../repo-worktrees'
import { listRepoWorktreeGraph } from '../../repo-worktrees'
import { resolveAuthorizedPath } from '../filesystem-auth'
import { resolveRegisteredWorktreePath } from '../registered-worktree-roots-cache'
import { splitWorktreeId } from '../../../shared/worktree/id'
@@ -77,7 +77,7 @@ async function localRepoOwnsWorktree(
return true
}
try {
const worktrees = await listRepoWorktrees(repo)
const worktrees = await listRepoWorktreeGraph(repo)
return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path)))
} catch {
return false
+8 -8
View File
@@ -17,7 +17,7 @@ const {
getHostedReviewCreationEligibilityMock,
getHostedReviewForBranchMock,
resolveRegisteredWorktreePathMock,
listRepoWorktreesMock
listRepoWorktreeGraphMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
createHostedReviewMock: vi.fn(),
@@ -25,7 +25,7 @@ const {
getHostedReviewCreationEligibilityMock: vi.fn(),
getHostedReviewForBranchMock: vi.fn(),
resolveRegisteredWorktreePathMock: vi.fn(),
listRepoWorktreesMock: vi.fn()
listRepoWorktreeGraphMock: vi.fn()
}))
vi.mock('electron', () => ({
@@ -52,7 +52,7 @@ vi.mock('./registered-worktree-roots-cache', () => ({
}))
vi.mock('../repo-worktrees', () => ({
listRepoWorktrees: listRepoWorktreesMock
listRepoWorktreeGraph: listRepoWorktreeGraphMock
}))
import { registerHostedReviewHandlers } from './hosted-review'
@@ -97,7 +97,7 @@ describe('registerHostedReviewHandlers', () => {
getHostedReviewCreationEligibilityMock.mockReset()
getHostedReviewForBranchMock.mockReset()
resolveRegisteredWorktreePathMock.mockReset()
listRepoWorktreesMock.mockReset()
listRepoWorktreeGraphMock.mockReset()
store.getRepo.mockReset()
store.getRepos.mockReset()
store.getProjects.mockReset()
@@ -114,7 +114,7 @@ describe('registerHostedReviewHandlers', () => {
store.getRepos.mockReturnValue([repo])
store.getProjects.mockReturnValue([])
store.getSettings.mockReturnValue({ localWindowsRuntimeDefault: { kind: 'windows-host' } })
listRepoWorktreesMock.mockResolvedValue([{ path: worktreePath }])
listRepoWorktreeGraphMock.mockResolvedValue([{ path: worktreePath }])
})
it('routes local WSL project review creation through main-process runtime options', async () => {
@@ -143,7 +143,7 @@ describe('registerHostedReviewHandlers', () => {
])
const resolvedWorktreePath = resolve('/workspace/feature')
resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath)
listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }])
listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }])
createHostedReviewMock.mockResolvedValueOnce({
ok: true,
number: 42,
@@ -162,7 +162,7 @@ describe('registerHostedReviewHandlers', () => {
title: 'Feature PR'
})
expect(listRepoWorktreesMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' })
expect(listRepoWorktreeGraphMock).toHaveBeenCalledWith(localRepo, { wslDistro: 'Ubuntu' })
expect(createHostedReviewMock).toHaveBeenCalledWith(
resolvedWorktreePath,
expect.objectContaining({
@@ -193,7 +193,7 @@ describe('registerHostedReviewHandlers', () => {
store.getRepos.mockReturnValue([localRepo])
const resolvedWorktreePath = resolve('/workspace/feature')
resolveRegisteredWorktreePathMock.mockResolvedValue(resolvedWorktreePath)
listRepoWorktreesMock.mockResolvedValue([{ path: resolvedWorktreePath }])
listRepoWorktreeGraphMock.mockResolvedValue([{ path: resolvedWorktreePath }])
createHostedReviewMock.mockResolvedValueOnce({ ok: true, number: 42, url: 'https://x/1' })
registerHostedReviewHandlers(store as never, stats as never)
+4 -4
View File
@@ -16,7 +16,7 @@ import {
import { createStackedHostedReview } from '../source-control/stacked-hosted-review-creation'
import { getHostedReviewForBranch } from '../source-control/hosted-review'
import { resolveRegisteredWorktreePath } from './registered-worktree-roots-cache'
import { listRepoWorktrees } from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
import { getWorktreeSharedLinkPaths } from '../git/worktree-shared-directories'
import { getRepoExecutionHostId } from '../../shared/execution-host'
@@ -68,7 +68,7 @@ async function resolveHostedReviewWorktreePath(
}
if (repo.connectionId) {
const remoteWorktreePath = normalizeRemoteHostedReviewPath(worktreePath)
const repoWorktrees = await listRepoWorktrees(repo)
const repoWorktrees = await listRepoWorktreeGraph(repo)
if (
!repoWorktrees.some(
(worktree) => normalizeRemoteHostedReviewPath(worktree.path) === remoteWorktreePath
@@ -82,8 +82,8 @@ async function resolveHostedReviewWorktreePath(
const localGitOptions = getLocalProjectWorktreeGitOptions(store, repo)
const repoWorktrees =
Object.keys(localGitOptions).length > 0
? await listRepoWorktrees(repo, localGitOptions)
: await listRepoWorktrees(repo)
? await listRepoWorktreeGraph(repo, localGitOptions)
: await listRepoWorktreeGraph(repo)
if (!repoWorktrees.some((worktree) => resolve(worktree.path) === resolvedWorktreePath)) {
throw new Error('Access denied: worktree does not belong to repository')
}
@@ -3,7 +3,7 @@ import { resolve } from 'node:path'
import { withTimeout } from '../../shared/promise-timeout-fallback'
import { getErrorCode } from '../git/worktree-operation-options'
import type { Store } from '../persistence'
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
import { isRepoRoot, listRepoWorktreeGraph } from '../repo-worktrees'
import { getLocalRepos } from './filesystem-allowed-roots'
import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment'
@@ -54,7 +54,7 @@ export async function rebuildAuthorizedRootsCache(store: Store): Promise<void> {
try {
roots.push(resolve(repo.path))
for (const worktree of await listRepoWorktrees(repo)) {
for (const worktree of await listRepoWorktreeGraph(repo)) {
roots.push(resolve(worktree.path))
}
} catch (error) {
+2
View File
@@ -24,7 +24,9 @@ let storeRef: Store | null = null
const MAIN_OWNED_TELEMETRY_EVENTS = new Set<EventName>([
'app_starred_orca',
'daemon_adopted',
'daemon_audit_eligibility',
'daemon_pty_cwd_denied',
'star_nag_outcome',
'feature_interaction_usage_bucket_reached'
])
@@ -0,0 +1,289 @@
import { readdir, stat } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import type {
WorktreeBasePollerOptions,
WorktreeBasePollEvent,
WorktreeBaseSubscription,
WorktreePollerWindowVisibility
} from './worktree-base-directory-poller'
// Why: the mtime gate is an optimization, not a correctness boundary — some
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
// A periodic ungated scan guarantees eventual convergence.
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// Why: a `.git` completion marker lands within moments of its worktree dir
// (git writes it before populating the checkout). Dirs that never get one are
// not worktrees; stop re-statting them after this many ticks and let the
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
// concurrency turns hundreds of serial round trips into a handful of batches
// without dumping every candidate onto libuv's 4-thread pool at once.
const MARKER_PROBE_CONCURRENCY = 8
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
async function dirSignature(path: string): Promise<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
async function readdirSafe(path: string): Promise<Dirent[]> {
try {
return await readdir(path, { withFileTypes: true })
} catch {
return []
}
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
configs
.filter((config) => config.nestWorkspaces)
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
// Root vanished or unreadable: readdirSafe yields [], producing the same
// empty markers/candidates result as the old watcher's error path.
const rootEntries = await readdirSafe(rootPath)
const candidates: string[] = []
for (const entry of rootEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue
}
const entryPath = join(rootPath, entry.name)
if (includeFlat) {
candidates.push(entryPath)
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
const subEntries = await readdirSafe(entryPath)
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
markers.set(dir, await hasGitMarker(dir))
})
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [dir, marker] of next.markers) {
if (marker && prev.markers.get(dir) !== true) {
events.push({ type: 'create', path: join(dir, '.git') })
}
}
for (const dir of prev.markers.keys()) {
if (!next.markers.has(dir)) {
events.push({ type: 'delete', path: dir })
}
}
return events
}
export async function startBasePoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
// Idle fast path: when the dirs whose listings define the candidate set
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
+7 -280
View File
@@ -1,15 +1,13 @@
import { readdir, stat } from 'node:fs/promises'
import type { Dirent } from 'node:fs'
import { join } from 'node:path'
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import { forEachWithConcurrency } from '../../shared/map-with-concurrency'
import { isMainWindowVisible, onMainWindowBecameVisible } from '../window/main-window-visibility'
import type {
WorktreeBaseRepoWatchConfig,
WorktreeBaseWatchTarget
} from './worktree-base-directory-event-filter'
import { startBasePoller } from './worktree-base-directory-marker-poller'
import { startGitCommonWatch } from './worktree-git-common-watch'
export { WORKTREE_BASE_BACKSTOP_TICKS } from './worktree-base-directory-marker-poller'
export type WorktreeBasePollEvent = { type: 'create' | 'update' | 'delete'; path: string }
export type WorktreeBaseSubscription = { unsubscribe: () => Promise<void> }
@@ -63,6 +61,8 @@ export type WorktreeBasePollerOptions = {
visibility?: WorktreePollerWindowVisibility
getGitStatusRefPaths?: () => readonly string[]
onWatchError?: (error: Error) => void
/** Called when the watcher child dropped an event batch (git-common narrow watch only). */
onOverflow?: () => void
/** Test hook: called whenever a full snapshot scan runs (vs. a gated skip). */
onFullScan?: () => void
/** Test hook: called before a pending `.git` marker stat. */
@@ -83,280 +83,6 @@ export type WorktreeBasePollerOptions = {
// Orca's own worktree operations notify the renderer directly.
export const WORKTREE_BASE_POLL_INTERVAL_MS = 2_000
// Why: the mtime gate is an optimization, not a correctness boundary — some
// filesystems have coarse dir timestamps, and pending `.git` markers expire.
// A periodic ungated scan guarantees eventual convergence.
export const WORKTREE_BASE_BACKSTOP_TICKS = 15
// Why: a `.git` completion marker lands within moments of its worktree dir
// (git writes it before populating the checkout). Dirs that never get one are
// not worktrees; stop re-statting them after this many ticks and let the
// backstop scan cover the pathological case.
const PENDING_MARKER_MAX_TICKS = 300
// Why: matches the git-common poller's fan-out bound (#17828) — bounded
// concurrency turns hundreds of serial round trips into a handful of batches
// without dumping every candidate onto libuv's 4-thread pool at once.
const MARKER_PROBE_CONCURRENCY = 8
function statSignature(s: { mtimeMs: number; ctimeMs: number; ino: number }): string {
return `${s.mtimeMs}:${s.ctimeMs}:${s.ino}`
}
async function dirSignature(path: string): Promise<string> {
try {
return statSignature(await stat(path))
} catch {
return 'missing'
}
}
async function hasGitMarker(dir: string): Promise<boolean> {
try {
await stat(join(dir, '.git'))
return true
} catch {
return false
}
}
type BaseSnapshot = {
// worktree-candidate dir → whether its `.git` completion marker exists
markers: Map<string, boolean>
// dirs whose listing determines the candidate set: the root plus any
// nested repo containers. Their stat signatures gate the next full scan.
gateDirs: string[]
// index-aligned with gateDirs, each sampled *before* that dir's listing
gateSignatures: string[]
}
async function readdirSafe(path: string): Promise<Dirent[]> {
try {
return await readdir(path, { withFileTypes: true })
} catch {
return []
}
}
// Depth-1 worktree dirs (flat layout), plus depth-2 dirs under each nested
// repo's container, mirroring what worktree-base-directory-event-filter
// matches: `<wt>/.git` completion markers and `<wt>` deletions.
async function snapshotBase(
rootPath: string,
repos: ReadonlyMap<string, WorktreeBaseRepoWatchConfig>
): Promise<BaseSnapshot> {
const markers = new Map<string, boolean>()
const gateDirs = [rootPath]
// Why: sampling the signature before the listing makes a write that races the
// scan look stale next tick (one redundant rescan) instead of invisible until
// the backstop, which is up to 15 ticks of missed creates/deletes.
const gateSignatures = [await dirSignature(rootPath)]
const configs = [...repos.values()]
const includeFlat = configs.some((config) => !config.nestWorkspaces)
const nestedRepoNames = new Set(
configs
.filter((config) => config.nestWorkspaces)
.map((config) => normalizeRuntimePathForComparison(config.repoName))
)
// Root vanished or unreadable: readdirSafe yields [], producing the same
// empty markers/candidates result as the old watcher's error path.
const rootEntries = await readdirSafe(rootPath)
const candidates: string[] = []
for (const entry of rootEntries) {
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
continue
}
const entryPath = join(rootPath, entry.name)
if (includeFlat) {
candidates.push(entryPath)
}
if (nestedRepoNames.has(normalizeRuntimePathForComparison(entry.name))) {
gateDirs.push(entryPath)
gateSignatures.push(await dirSignature(entryPath))
const subEntries = await readdirSafe(entryPath)
for (const sub of subEntries) {
if (sub.isDirectory() || sub.isSymbolicLink()) {
candidates.push(join(entryPath, sub.name))
}
}
}
}
await forEachWithConcurrency(candidates, MARKER_PROBE_CONCURRENCY, async (dir) => {
markers.set(dir, await hasGitMarker(dir))
})
return { markers, gateDirs, gateSignatures }
}
function diffBase(prev: BaseSnapshot, next: BaseSnapshot): WorktreeBasePollEvent[] {
const events: WorktreeBasePollEvent[] = []
for (const [dir, marker] of next.markers) {
if (marker && prev.markers.get(dir) !== true) {
events.push({ type: 'create', path: join(dir, '.git') })
}
}
for (const dir of prev.markers.keys()) {
if (!next.markers.has(dir)) {
events.push({ type: 'delete', path: dir })
}
}
return events
}
async function startBasePoller(
target: WorktreeBaseWatchTarget,
getRepos: () => ReadonlyMap<string, WorktreeBaseRepoWatchConfig>,
onEvents: (events: WorktreeBasePollEvent[]) => void,
pollIntervalMs: number,
visibility: WorktreePollerWindowVisibility,
options: WorktreeBasePollerOptions
): Promise<WorktreeBaseSubscription> {
let disposed = false
let ticking = false
let tickCount = 0
let snapshot = await snapshotBase(target.path, getRepos())
let timer: ReturnType<typeof setTimeout> | null = null
let parkedWhileHidden = false
const pendingMarkerMaxTicks = options.pendingMarkerMaxTicks ?? PENDING_MARKER_MAX_TICKS
// dir → first probe tick; null means backstop scans only
const markerProbeStartedAt = new Map<string, number | null>()
for (const [dir, marker] of snapshot.markers) {
if (!marker) {
markerProbeStartedAt.set(dir, 0)
}
}
const fullScan = async (): Promise<void> => {
options.onFullScan?.()
const next = await snapshotBase(target.path, getRepos())
await options.onSnapshotTaken?.(tickCount)
if (disposed) {
return
}
const events = diffBase(snapshot, next)
for (const [dir, marker] of next.markers) {
if (marker) {
markerProbeStartedAt.delete(dir)
} else if (!markerProbeStartedAt.has(dir)) {
markerProbeStartedAt.set(dir, tickCount)
}
}
for (const dir of markerProbeStartedAt.keys()) {
if (!next.markers.has(dir)) {
markerProbeStartedAt.delete(dir)
}
}
snapshot = next
if (events.length > 0) {
onEvents(events)
}
}
const checkPendingMarkers = async (): Promise<void> => {
const events: WorktreeBasePollEvent[] = []
for (const [dir, firstSeenTick] of markerProbeStartedAt) {
if (firstSeenTick === null) {
continue
}
if (tickCount - firstSeenTick > pendingMarkerMaxTicks) {
markerProbeStartedAt.set(dir, null)
continue
}
options.onPendingMarkerProbe?.(join(dir, '.git'))
if (await hasGitMarker(dir)) {
markerProbeStartedAt.delete(dir)
snapshot.markers.set(dir, true)
events.push({ type: 'create', path: join(dir, '.git') })
}
}
if (!disposed && events.length > 0) {
onEvents(events)
}
}
const poll = async (forceFullScan = false): Promise<void> => {
tickCount++
if (forceFullScan || tickCount % WORKTREE_BASE_BACKSTOP_TICKS === 0) {
await fullScan()
return
}
// Idle fast path: when the dirs whose listings define the candidate set
// are untouched, skip the readdir + per-candidate stat fan-out entirely.
const signatures = await Promise.all(snapshot.gateDirs.map(dirSignature))
const gateChanged =
signatures.length !== snapshot.gateSignatures.length ||
signatures.some((sig, index) => sig !== snapshot.gateSignatures[index])
if (gateChanged) {
await fullScan()
return
}
if (markerProbeStartedAt.size > 0) {
await checkPendingMarkers()
}
}
const tick = async (forceFullScan = false): Promise<void> => {
timer = null
if (disposed) {
return
}
if (!visibility.isWindowVisible()) {
parkedWhileHidden = true
return
}
if (ticking) {
return
}
ticking = true
// Why: measure from tick start so the cadence is start-to-start (like the old setInterval), not
// gap-after-completion — otherwise each visible refresh lands a full scan-duration late every tick.
const startedAt = Date.now()
try {
await poll(forceFullScan)
} catch {
// Transient fs error: keep the previous snapshot and retry next tick.
} finally {
ticking = false
}
if (!disposed) {
// Why: clamp to [0, pollIntervalMs]. Date.now() is not monotonic — a backward wall-clock jump (NTP) would
// otherwise make elapsed negative and push the next tick out by the adjustment (suppressing refreshes for
// minutes); the upper clamp caps the wait at one interval, the lower clamp keeps a long scan from going negative.
const nextDelay = Math.max(
0,
Math.min(pollIntervalMs, pollIntervalMs - (Date.now() - startedAt))
)
timer = setTimeout(() => void tick(), nextDelay)
timer.unref?.()
}
}
const unsubscribeVisibility = visibility.onWindowBecameVisible(() => {
if (disposed || !parkedWhileHidden) {
return
}
parkedWhileHidden = false
// Why: the ordinary dir-signature gate can miss same-granule changes made
// while hidden; resume must diff a fresh full snapshot against the baseline.
void tick(true)
})
timer = setTimeout(() => void tick(), pollIntervalMs)
timer.unref?.()
return {
unsubscribe: async () => {
disposed = true
if (timer) {
clearTimeout(timer)
}
unsubscribeVisibility()
}
}
}
/** Watches the shallow paths a worktree base target cares about and emits
* watcher-shaped events. Resolves once the baseline (snapshot or narrow
* native subscription) is established. */
@@ -378,7 +104,8 @@ export async function startWorktreeBaseDirectoryPoller(
visibility,
options.onFullScan,
options.getGitStatusRefPaths,
options.onWatchError
options.onWatchError,
options.onOverflow
)
}
return startBasePoller(target, getRepos, onEvents, pollIntervalMs, visibility, options)
@@ -0,0 +1,90 @@
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
scheduleWorktreeBaseNotification,
type WorktreeBaseNotificationWatch
} from './worktree-base-directory-notifications'
import {
invalidateActiveGitStatusRefResolution,
invalidateGitStatusRefResolutionForPaths
} from './worktree-git-status-ref-watch'
import type { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
export type ActiveWatch = WorktreeBaseNotificationWatch & {
subscription: { unsubscribe: () => Promise<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
export function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
if (watch.watcherFailureRefresh.consume()) {
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
return
}
watch.watcherFailureRefresh.reset()
invalidateGitStatusRefResolutionForPaths(
watch,
events.map((event) => event.path),
getActiveWatches
)
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
// Why: after a dropped event batch nothing about the prior state can be
// trusted — widen unconditionally (structural + status + head-identity),
// same shape as the remote overflow branch below, bypassing the watcher-error
// cooldown so a burst of overflows during one bulk op cannot suppress the
// refresh the fleet actually needs.
export function handleWatchOverflow(
watch: ActiveWatch,
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateActiveGitStatusRefResolution(watch, getActiveWatches)
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
export function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1],
getActiveWatches: () => Iterable<ActiveWatch>
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
getActiveWatches
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
handleWatchOverflow(watch, getActiveWatches)
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
@@ -404,6 +404,46 @@ describe('worktree base directory watcher', () => {
expect(notifyWorktreesChanged).toHaveBeenCalledOnce()
})
it('widens an overflowed local git-common watch to a structural refresh', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
const request = {
worktreeId: `repo-1::${PROJECT_ROOT}`,
worktreePath: PROJECT_ROOT,
executionHostId: 'local',
branch: 'refs/heads/feature',
upstreamName: 'origin/feature'
}
const resolve = vi.fn(async () => 'refs/remotes/origin/feature')
await setWorktreeGitStatusRefWatch(request, resolve)
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
expect(notifyWorktreesChanged).toHaveBeenCalledWith(expect.anything(), 'repo-1')
// Overflow is definite proof of loss, not a possibly-transient error — it
// invalidates the cached ref resolution unconditionally.
await setWorktreeGitStatusRefWatch(request, resolve)
expect(resolve).toHaveBeenCalledTimes(2)
})
it('does not throttle repeated overflow refreshes the way watcher-error refreshes are throttled', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
const onOverflow = pollerOptions.get(PROJECT_GIT_COMMON_DIR)?.onOverflow
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
onOverflow?.()
await vi.advanceTimersByTimeAsync(300)
// A watcher-error burst within the 60s cooldown window collapses to one
// refresh (see "throttles repeated structural refreshes from watcher
// failures" above); overflow must not inherit that gate, since a bulk op
// can legitimately overflow more than once before it settles.
expect(notifyWorktreesChanged).toHaveBeenCalledTimes(2)
})
it('keeps linked HEAD and lock metadata structural', async () => {
await syncWorktreeBaseDirectoryWatchers(makeStore([makeRepo()]) as never, makeWindow() as never)
+16 -72
View File
@@ -6,16 +6,9 @@ import {
disposeWorktreeHeadIdentityRefreshState,
refreshWorktreeHeadIdentities
} from './worktree-head-identity-refresh'
import {
collectLocalWorktreeBaseChanges,
collectRemoteWorktreeBaseChanges,
hasCollectedWorktreeBaseChanges
} from './worktree-base-directory-change-collector'
import {
clearPendingWorktreeBaseNotifications,
scheduleWorktreeBaseNotification,
supportsWorktreeHeadIdentityRefresh,
type WorktreeBaseNotificationWatch
supportsWorktreeHeadIdentityRefresh
} from './worktree-base-directory-notifications'
import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter'
import { EMPTY_HEAD_IDENTITY_SCOPE } from './worktree-head-identity-scope'
@@ -30,18 +23,16 @@ import {
import {
applyActiveGitStatusRefBinding,
clearActiveGitStatusRefBinding,
invalidateActiveGitStatusRefResolution,
invalidateGitStatusRefResolutionForPaths,
updateActiveGitStatusRefBinding,
type GitStatusRefBindingRequest
} from './worktree-git-status-ref-watch'
import { WorktreeWatcherFailureRefreshCooldown } from './worktree-watcher-failure-refresh-cooldown'
type ActiveWatch = WorktreeBaseNotificationWatch & {
subscription: { unsubscribe: () => Promise<void> }
gitStatusRefPaths: Set<string>
watcherFailureRefresh: WorktreeWatcherFailureRefreshCooldown
}
import {
handleLocalWatchEvents,
handleRemoteWatchEvents,
handleWatchOverflow,
type ActiveWatch
} from './worktree-base-directory-watch-events'
const activeWatches = new Map<string, ActiveWatch>()
let syncGeneration = 0
@@ -54,59 +45,6 @@ export function setWorktreeGitStatusRefWatch(
return updateActiveGitStatusRefBinding(args, () => activeWatches.values(), resolveUpstreamRef)
}
function handleLocalWatchEvents(
watch: ActiveWatch,
error: Error | null,
events: { type: 'create' | 'update' | 'delete'; path: string }[]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
if (error) {
console.warn(`[worktree-base-watcher] watcher failed for ${watch.path}:`, error)
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
if (watch.watcherFailureRefresh.consume()) {
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
}
return
}
watch.watcherFailureRefresh.reset()
invalidateGitStatusRefResolutionForPaths(
watch,
events.map((event) => event.path),
() => activeWatches.values()
)
const changes = collectLocalWorktreeBaseChanges(watch, events)
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
function handleRemoteWatchEvents(
watch: ActiveWatch,
events: Parameters<typeof collectRemoteWorktreeBaseChanges>[1]
): void {
if (watch.disposed || watch.mainWindow.isDestroyed()) {
return
}
invalidateGitStatusRefResolutionForPaths(
watch,
events.flatMap((event) =>
event.kind === 'overflow' ? [] : [event.absolutePath, event.oldAbsolutePath]
),
() => activeWatches.values()
)
const changes = collectRemoteWorktreeBaseChanges(watch, events)
if (changes.overflow) {
invalidateActiveGitStatusRefResolution(watch, () => activeWatches.values())
scheduleWorktreeBaseNotification(watch, { structureRepoIds: [...watch.repos.keys()] })
return
}
if (hasCollectedWorktreeBaseChanges(changes)) {
scheduleWorktreeBaseNotification(watch, changes)
}
}
function createActiveWatch(
target: WorktreeBaseWatchTarget,
mainWindow: BrowserWindow,
@@ -146,7 +84,7 @@ async function subscribeTarget(
if (!currentWatch || currentWatch.disposed) {
return
}
handleRemoteWatchEvents(currentWatch, events)
handleRemoteWatchEvents(currentWatch, events, () => activeWatches.values())
})
activeWatch = createActiveWatch(
target,
@@ -167,7 +105,7 @@ async function subscribeTarget(
(events) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, null, events)
handleLocalWatchEvents(currentWatch, null, events, () => activeWatches.values())
}
},
{
@@ -178,7 +116,13 @@ async function subscribeTarget(
onWatchError: (error) => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch && !currentWatch.disposed) {
handleLocalWatchEvents(currentWatch, error, [])
handleLocalWatchEvents(currentWatch, error, [], () => activeWatches.values())
}
},
onOverflow: () => {
const currentWatch = activeWatches.get(target.key) ?? activeWatch
if (currentWatch) {
handleWatchOverflow(currentWatch, () => activeWatches.values())
}
}
}
@@ -24,7 +24,12 @@ export async function startGitCommonNarrowWatch(
platform: NodeJS.Platform,
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
onWatchError?: (error: Error) => void
onWatchError?: (error: Error) => void,
// Why: a dropped event batch (>5,000 events, e.g. a fleet-wide bulk op) is a
// harder loss signal than a transient error — nothing about the prior state
// can be trusted, so this bypasses onWatchError's failure cooldown instead
// of reusing it.
onOverflow?: () => void
): Promise<WorktreeBaseSubscription> {
const worktreesDir = join(target.path, 'worktrees')
const watcherOptions = platform === 'win32' ? { backend: 'windows' as const } : {}
@@ -227,6 +232,23 @@ export async function startGitCommonNarrowWatch(
onEvents([{ type: 'update', path: worktreesDir }])
}
}
},
// Why: the watcher child drops the whole batch past 5,000 events
// (native FSEvents overflow maps to the same op) instead of reporting
// which paths changed. Unlike a transient error, this is definite
// proof of loss, so it always widens rather than falling back to the
// failure-cooldown-gated onWatchError path.
onOverflow: () => {
if (disposed || !active || generation !== nativeSubscriptionGeneration) {
return
}
if (onOverflow) {
onOverflow()
} else if (onWatchError) {
onWatchError(new Error('Git common watcher overflowed'))
} else {
onEvents([{ type: 'update', path: worktreesDir }])
}
}
}
)
@@ -526,6 +526,45 @@ describe('worktree git-common narrow watch (local native platforms)', () => {
expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled()
})
it('routes a dropped event batch through the dedicated overflow callback', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(true)
const received: WorktreeBasePollEvent[][] = []
const onOverflow = vi.fn()
const watch = await startGitCommonWatch(
makeTarget(commonDir),
(events) => received.push(events),
POLL_MS,
'darwin',
alwaysVisible,
undefined,
() => [],
undefined,
onOverflow
)
cleanups.push(() => watch.unsubscribe())
narrowSubscription().hooks.onOverflow?.()
expect(onOverflow).toHaveBeenCalledOnce()
// The dedicated callback owns the refresh; the generic event/error paths
// must not also fire so the caller cannot double-count the same loss.
expect(received).toEqual([])
expect(narrowSubscription().unsubscribe).not.toHaveBeenCalled()
})
it('falls back to a structural change when no overflow callback is wired', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(true)
const worktreesDir = join(commonDir, 'worktrees')
const received: WorktreeBasePollEvent[][] = []
await startWatch(commonDir, received)
narrowSubscription().hooks.onOverflow?.()
expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir })
})
it('arms via existence polling when the worktrees dir appears later', async () => {
installSubscribeMock()
const commonDir = await makeCommonDir(false)
+4 -2
View File
@@ -31,7 +31,8 @@ export async function startGitCommonWatch(
visibility: WorktreePollerWindowVisibility,
onFullScan?: () => void,
getStatusRefPaths: () => readonly string[] = () => [],
onWatchError?: (error: Error) => void
onWatchError?: (error: Error) => void,
onOverflow?: () => void
): Promise<WorktreeBaseSubscription> {
if (supportsNarrowWatch(platform)) {
const [narrowWatch, primaryWatch] = await Promise.all([
@@ -42,7 +43,8 @@ export async function startGitCommonWatch(
platform,
visibility,
onFullScan,
onWatchError
onWatchError,
onOverflow
),
startGitCommonPrimaryWatch(
target.path,
+13 -3
View File
@@ -2402,7 +2402,7 @@ export async function createLocalWorktree(
addResult =
(await timing.time('git_worktree_add', async () => {
if (sparseDirectories.length === 0 && !checkoutExistingBranch) {
const preparedResult = await consumePreparedWorktreeCreate({
const prepared = await consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot,
worktreePath,
@@ -2411,9 +2411,19 @@ export async function createLocalWorktree(
refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate,
...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {})
})
if (preparedResult) {
return preparedResult
timing.recordPreparedCheckout(
prepared.status === 'hit'
? { status: 'hit', retargeted: prepared.retargeted }
: { status: 'miss', reason: prepared.reason }
)
if (prepared.status === 'hit') {
return prepared.result
}
} else {
timing.recordPreparedCheckout({
status: 'miss',
reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch'
})
}
if (sparseDirectories.length > 0) {
if (checkoutExistingBranch) {
@@ -633,7 +633,10 @@ describe('registerWorktreeHandlers', () => {
})) as {
setup?: unknown
startupTerminal?: { spawned: boolean; surface?: string }
timing?: { phases: { phase: string }[] }
timing?: {
phases: { phase: string }[]
preparedCheckout?: { status: string; reason?: string }
}
}
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
@@ -695,6 +698,8 @@ describe('registerWorktreeHandlers', () => {
'spawn_startup_terminal'
])
)
// Nothing warmed this repo, so the create must report the cold path rather than stay silent.
expect(result.timing?.preparedCheckout).toEqual({ status: 'miss', reason: 'none_armed' })
})
it('returns the wrapped setup command when startup spawned but setup creation failed', async () => {
@@ -205,14 +205,14 @@ describe('registerWorktreeHandlers', () => {
}
])
const result = await handlers['worktrees:create'](null, {
const result = (await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
sparseCheckout: {
directories: [' packages/web ', 'apps\\api\\', 'packages/web/'],
presetId: 'preset-1'
}
})
})) as { timing?: { preparedCheckout?: { status: string; reason?: string } } }
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(addSparseWorktreeMock).toHaveBeenCalledWith(
@@ -240,6 +240,11 @@ describe('registerWorktreeHandlers', () => {
sparsePresetId: 'preset-1'
})
})
// A sparse create can never claim a prepared checkout; say so rather than looking like a miss.
expect(result.timing?.preparedCheckout).toEqual({
status: 'miss',
reason: 'sparse_checkout'
})
})
it('retires a generated sparse name when creation rollback also fails', async () => {
@@ -214,4 +214,38 @@ describe('addWorktreeCreatePhaseAttributes', () => {
expect(attributes['worktree.create.total_ms']).toBe(500)
expect(attributes['worktree.create.unattributed_ms']).toBe(200)
})
it('records a prepared-checkout hit and whether it had to be retargeted', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 900,
phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }],
preparedCheckout: { status: 'hit', retargeted: true }
})
expect(attributes['worktree.create.prepared_checkout']).toBe('hit')
expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true)
expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined()
expect(attributes['worktree.create.unattributed_ms']).toBe(500)
})
it('records why a create missed the prepared checkout', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, {
totalDurationMs: 8_000,
phases: [],
preparedCheckout: { status: 'miss', reason: 'base_mismatch' }
})
expect(attributes['worktree.create.prepared_checkout']).toBe('miss')
expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch')
expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined()
})
it('stays silent on paths that never consult the prepared checkout', () => {
const { attributes, span } = capture()
addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 10, phases: [] })
expect(attributes['worktree.create.prepared_checkout']).toBeUndefined()
})
})
+18 -1
View File
@@ -21,6 +21,7 @@
// itself becomes a `noopSpan` that swallows all calls — call sites do not
// need to branch on whether tracing is on.
import type { PreparedCheckoutOutcome } from '../../shared/worktree/create-types'
import { startSpan, withSpan, type ActiveSpan } from './tracer'
const GIT_FAST_SUCCESS_THRESHOLD_MS = 250
@@ -259,9 +260,25 @@ type WorktreePhaseInterval = Pick<WorktreeCreatePhaseTiming, 'startedAtMs' | 'du
* the recorder, so they are safe to key on; nothing here carries a branch name or a path. */
export function addWorktreeCreatePhaseAttributes(
span: ActiveSpan,
timing: { totalDurationMs: number; phases: readonly WorktreeCreatePhaseTiming[] }
timing: {
totalDurationMs: number
phases: readonly WorktreeCreatePhaseTiming[]
preparedCheckout?: PreparedCheckoutOutcome
}
): void {
span.setAttribute('worktree.create.total_ms', Math.round(timing.totalDurationMs))
if (timing.preparedCheckout) {
span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status)
if (timing.preparedCheckout.status === 'hit') {
// A retargeted hit still pays a reset, so it must not be read as a free hit.
span.setAttribute(
'worktree.create.prepared_checkout_retargeted',
timing.preparedCheckout.retargeted
)
} else {
span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason)
}
}
for (const phase of timing.phases) {
span.setAttribute(`worktree.create.phase.${phase.phase}_ms`, Math.round(phase.durationMs))
}
+48 -1
View File
@@ -1,11 +1,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
const { listWorktreeGraphMock, listWorktreesMock, listWorktreesStrictMock } = vi.hoisted(() => ({
listWorktreeGraphMock: vi.fn(),
listWorktreesMock: vi.fn(),
listWorktreesStrictMock: vi.fn()
}))
vi.mock('./git/worktree', () => ({
listWorktreeGraph: listWorktreeGraphMock,
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesStrictMock
}))
@@ -14,11 +16,13 @@ import {
createFolderWorktree,
isRepoRoot,
listLocalRepoWorktreesStrict,
listRepoWorktreeGraph,
listRepoWorktrees
} from './repo-worktrees'
describe('repo-worktrees', () => {
beforeEach(() => {
listWorktreeGraphMock.mockReset()
listWorktreesMock.mockReset()
listWorktreesStrictMock.mockReset()
})
@@ -109,6 +113,49 @@ describe('repo-worktrees', () => {
expect(result).toHaveLength(1)
})
// Path-only callers must reach the probe-free listing, never the annotated one.
it('delegates to the graph listing without sparse annotation', async () => {
listWorktreeGraphMock.mockResolvedValue([
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
])
const result = await listRepoWorktreeGraph({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
kind: 'git'
})
expect(listWorktreeGraphMock).toHaveBeenCalledWith('/workspace/repo')
expect(listWorktreesMock).not.toHaveBeenCalled()
expect(result).toHaveLength(1)
})
it('returns the synthetic folder worktree from the graph listing', async () => {
const result = await listRepoWorktreeGraph({
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
expect(listWorktreeGraphMock).not.toHaveBeenCalled()
expect(result).toEqual([
createFolderWorktree({
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
])
})
it('delegates strict local listing with the signal and WSL options', async () => {
listWorktreesStrictMock.mockResolvedValue([
{ path: '/workspace/repo', head: 'abc', branch: '', isBare: false, isMainWorktree: true }
+24 -1
View File
@@ -1,6 +1,6 @@
import type { Repo } from '../shared/repo-types'
import type { GitWorktreeInfo } from '../shared/worktree/types'
import { listWorktrees, listWorktreesStrict } from './git/worktree'
import { listWorktreeGraph, listWorktrees, listWorktreesStrict } from './git/worktree'
import { isFolderRepo } from '../shared/repo-kind'
import { getSshGitProvider } from './providers/ssh-git-dispatch'
import { areWorktreePathsEqual } from './ipc/worktree-logic'
@@ -52,6 +52,29 @@ export async function listRepoWorktrees(
: await listWorktrees(repo.path)
}
/**
* Worktree rows for callers that read only `worktree.path`.
*
* Skips the sparse-checkout probe behind the badge, which those callers discard. On a WSL repo the
* probe is a 9p stat plus a config read per worktree, re-paid cold after every worktree
* create/remove because that invalidates both the authorized-roots cache and the sparse cache.
*/
export async function listRepoWorktreeGraph(
repo: Repo,
options?: LocalRepoWorktreeListOptions
): Promise<GitWorktreeInfo[]> {
if (isFolderRepo(repo)) {
return [createFolderWorktree(repo)]
}
if (repo.connectionId) {
const provider = getSshGitProvider(repo.connectionId)
return provider ? await provider.listWorktrees(repo.path) : []
}
return hasLocalRepoWorktreeListOptions(options)
? await listWorktreeGraph(repo.path, options)
: await listWorktreeGraph(repo.path)
}
export async function listLocalRepoWorktreesStrict(
repo: Repo,
options?: LocalRepoWorktreeListOptions
@@ -185,7 +185,7 @@ export async function createRuntimeLocalGitWorktree(args: {
)) ?? {})
let addResult: AddWorktreeResult
try {
const preparedResult =
const preparedAttempt =
sparseDirectories.length === 0 && !args.checkoutExistingBranch
? await consumePreparedWorktreeCreate({
repoPath: args.repo.path,
@@ -197,8 +197,9 @@ export async function createRuntimeLocalGitWorktree(args: {
...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {})
})
: null
if (preparedResult) {
addResult = preparedResult
// This path has no create-span recorder, so the miss reason is only observable on the IPC path.
if (preparedAttempt?.status === 'hit') {
addResult = preparedAttempt.result
} else if (sparseDirectories.length > 0) {
addResult =
(await (addOptions
@@ -0,0 +1,152 @@
import { describe, expect, it } from 'vitest'
import {
preparationPathKey,
selectPreparationForCreate,
type PreparationCandidate,
type PreparationRequest
} from './worktree-create-preparation-claim'
function candidate(overrides: Partial<PreparationCandidate> = {}): PreparationCandidate {
return {
repoPathKey: '/repo',
workspaceRootKey: '/workspace',
wslDistro: '',
baseBranch: 'origin/main',
canonicalBase: 'refs/remotes/origin/main',
createdAt: 1_000,
...overrides
}
}
function request(overrides: Partial<PreparationRequest> = {}): PreparationRequest {
return {
repoPathKey: '/repo',
workspaceRootKey: '/workspace',
wslDistro: '',
baseBranch: 'origin/main',
canonicalBase: 'refs/remotes/origin/main',
...overrides
}
}
describe('selectPreparationForCreate', () => {
it('matches the identical base before any ref probe has run', () => {
const selection = selectPreparationForCreate([candidate()], request({ canonicalBase: null }))
expect(selection).toEqual({
kind: 'exact',
candidate: candidate(),
canonicalBase: 'refs/remotes/origin/main'
})
})
it('asks for a canonical base only when something is armed under another spelling', () => {
expect(
selectPreparationForCreate(
[candidate()],
request({ baseBranch: 'main', canonicalBase: null })
)
).toEqual({ kind: 'needs-canonical-base' })
// Nothing armed for this repo, so the create must not pay a probe to learn that.
expect(
selectPreparationForCreate([], request({ baseBranch: 'main', canonicalBase: null }))
).toEqual({ kind: 'miss', reason: 'none_armed' })
})
it('matches when the two sides spell the same ref differently', () => {
const selection = selectPreparationForCreate(
[candidate()],
request({ baseBranch: 'refs/remotes/origin/main' })
)
expect(selection).toEqual({
kind: 'exact',
candidate: candidate(),
canonicalBase: 'refs/remotes/origin/main'
})
})
it('retargets a local base onto the armed remote-tracking base of the same branch', () => {
const selection = selectPreparationForCreate(
[candidate()],
request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' })
)
expect(selection).toEqual({
kind: 'retarget',
candidate: candidate(),
canonicalBase: 'refs/heads/main'
})
})
it('prefers the freshest armed entry when several share the family', () => {
const older = candidate({ canonicalBase: 'refs/remotes/origin/main', createdAt: 1 })
const newer = candidate({ canonicalBase: 'refs/remotes/upstream/main', createdAt: 2 })
const selection = selectPreparationForCreate(
[older, newer],
request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' })
)
expect(selection).toMatchObject({ kind: 'retarget', candidate: newer })
})
it('refuses to retarget onto a different branch', () => {
const selection = selectPreparationForCreate(
[candidate()],
request({ baseBranch: 'origin/release', canonicalBase: 'refs/remotes/origin/release' })
)
expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' })
})
it('refuses to retarget onto a bare commit id, whose divergence is unbounded', () => {
const selection = selectPreparationForCreate(
[candidate()],
request({ baseBranch: '1f2e3d4c5b6a7988', canonicalBase: '1f2e3d4c5b6a7988' })
)
expect(selection).toEqual({ kind: 'miss', reason: 'base_mismatch' })
})
it('names the key field that disagreed', () => {
expect(selectPreparationForCreate([], request())).toEqual({
kind: 'miss',
reason: 'none_armed'
})
// Something is warm, just not for this repo — the shape of a size-cap eviction.
expect(
selectPreparationForCreate([candidate()], request({ repoPathKey: '/other-repo' }))
).toEqual({ kind: 'miss', reason: 'repo_mismatch' })
expect(selectPreparationForCreate([candidate()], request({ wslDistro: 'Ubuntu' }))).toEqual({
kind: 'miss',
reason: 'wsl_distro_mismatch'
})
expect(
selectPreparationForCreate([candidate()], request({ workspaceRootKey: '/other' }))
).toEqual({ kind: 'miss', reason: 'workspace_root_mismatch' })
})
it('never crosses hosts to satisfy a family retarget', () => {
const selection = selectPreparationForCreate(
[candidate({ wslDistro: 'Ubuntu' })],
request({ baseBranch: 'main', canonicalBase: 'refs/heads/main' })
)
expect(selection).toEqual({ kind: 'miss', reason: 'wsl_distro_mismatch' })
})
})
describe('preparationPathKey', () => {
it('normalizes a posix path without folding case', () => {
expect(preparationPathKey('/workspace/./repo/')).toBe('/workspace/repo/')
expect(preparationPathKey('/Workspace/Repo')).toBe('/Workspace/Repo')
})
it('folds case for Windows drive and UNC paths, which compare case-insensitively', () => {
expect(preparationPathKey('C:\\Workspace\\Repo')).toBe('c:\\workspace\\repo')
expect(preparationPathKey('\\\\wsl.localhost\\Ubuntu\\home\\jin')).toBe(
'\\\\wsl.localhost\\ubuntu\\home\\jin'
)
})
})
@@ -0,0 +1,130 @@
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import { worktreeBaseRefFamily } from '../shared/worktree/base-ref'
import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types'
/** The subset of miss reasons this selection can produce; the rest are decided by the caller
* (sparse/existing-branch skips) or by the finalize step. */
export type PreparationSelectionMissReason = Extract<
PreparedCheckoutMissReason,
| 'none_armed'
| 'repo_mismatch'
| 'base_mismatch'
| 'workspace_root_mismatch'
| 'wsl_distro_mismatch'
>
export type PreparationCandidate = {
repoPathKey: string
workspaceRootKey: string
wslDistro: string
/** The base exactly as the prefetch handler armed it. */
baseBranch: string
/** That base after `resolveWorktreeAddBaseRef`, so `main` and `refs/heads/main` compare equal. */
canonicalBase: string
createdAt: number
}
export type PreparationRequest = {
repoPathKey: string
workspaceRootKey: string
wslDistro: string
baseBranch: string
/** `null` until the caller has paid the ref probe. A raw-base match resolves without it, so the
* common hit spawns no git at all. */
canonicalBase: string | null
}
/** Case-folded on Windows, so the arming and claiming sides key on the same path. */
export function preparationPathKey(path: string): string {
if (isWindowsAbsolutePathLike(path)) {
return win32.normalize(path).toLowerCase()
}
return posix.normalize(path)
}
/** Keyed on the canonical base so the prefetch and the create agree when they spell the same ref
* differently; a genuinely different ref still gets its own entry. */
export function preparationEntryKey(
repoPathKey: string,
workspaceRootKey: string,
canonicalBase: string,
wslDistro: string
): string {
return `${repoPathKey}\0${workspaceRootKey}\0${canonicalBase}\0${wslDistro}`
}
export type PreparationSelection<T> =
/** `canonicalBase` is echoed back so the caller can re-arm on the create's own base without
* paying the ref probe a second time. */
| { kind: 'exact'; candidate: T; canonicalBase: string }
| { kind: 'retarget'; candidate: T; canonicalBase: string }
/** Something is armed for this repo but not under this raw base; only a resolved canonical base
* can decide between a hit and a miss. */
| { kind: 'needs-canonical-base' }
| { kind: 'miss'; reason: PreparationSelectionMissReason }
/**
* Picks the armed preparation a create may claim.
*
* The two sides of the pool disagree in practice — the prefetch arms `origin/main` while the
* create resolves `main`, or vice versa — and an exact-string key turns every such disagreement
* into a silent cold create. Canonicalizing catches the spelling differences; the base-family
* retarget catches the local-vs-remote-tracking ones, where finalize's existing drift reset lands
* the checkout on the requested commit for far less than a cold add plus a full materialize.
*
* The bound matters: refs outside the same branch family are rejected, because a retarget across
* unrelated history degenerates into a full checkout and wins nothing.
*
* Synchronous on purpose: the caller claims the returned entry in the same run, so two concurrent
* creates cannot both walk away with the same prepared checkout.
*/
export function selectPreparationForCreate<T extends PreparationCandidate>(
candidates: readonly T[],
request: PreparationRequest
): PreparationSelection<T> {
if (candidates.length === 0) {
return { kind: 'miss', reason: 'none_armed' }
}
const sameRepo = candidates.filter((candidate) => candidate.repoPathKey === request.repoPathKey)
if (sameRepo.length === 0) {
// Separate from `none_armed`: this is what a size-cap eviction looks like from the create side.
return { kind: 'miss', reason: 'repo_mismatch' }
}
// Distro before root: the distro decides which filesystem the root is even on.
const sameHost = sameRepo.filter((candidate) => candidate.wslDistro === request.wslDistro)
if (sameHost.length === 0) {
return { kind: 'miss', reason: 'wsl_distro_mismatch' }
}
const sameRoot = sameHost.filter(
(candidate) => candidate.workspaceRootKey === request.workspaceRootKey
)
if (sameRoot.length === 0) {
return { kind: 'miss', reason: 'workspace_root_mismatch' }
}
const { canonicalBase } = request
if (canonicalBase === null) {
const rawMatch = sameRoot.find((candidate) => candidate.baseBranch === request.baseBranch)
// Same spelling, so the armed entry already holds this request's canonical form.
return rawMatch
? { kind: 'exact', candidate: rawMatch, canonicalBase: rawMatch.canonicalBase }
: { kind: 'needs-canonical-base' }
}
const canonicalMatch = sameRoot.find((candidate) => candidate.canonicalBase === canonicalBase)
if (canonicalMatch) {
return { kind: 'exact', candidate: canonicalMatch, canonicalBase }
}
const family = worktreeBaseRefFamily(canonicalBase)
if (family) {
const retarget = sameRoot
.filter((candidate) => worktreeBaseRefFamily(candidate.canonicalBase) === family)
.sort((left, right) => right.createdAt - left.createdAt)[0]
if (retarget) {
return { kind: 'retarget', candidate: retarget, canonicalBase }
}
}
return { kind: 'miss', reason: 'base_mismatch' }
}
@@ -0,0 +1,210 @@
import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import {
WORKTREE_CREATE_PREPARATION_DIRECTORY,
createWorktreePreparationLockReason
} from '../shared/worktree/create-preparation'
import type { AddWorktreeOptions } from './git/worktree'
import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation'
import { toHostFilesystemPath } from './host-tree-removal'
import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim'
import {
cleanupStalePreparations,
hasPendingStalePreparationCleanup,
resetStalePreparationCleanupForTests
} from './worktree-create-preparation-stale-cleanup'
import {
discardPreparationWithRetry,
resetPendingPreparationDiscardsForTests,
trackPreparationDiscard
} from './worktree-preparation-discard-retry'
export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000
export const WORKTREE_CREATE_PREPARATION_LIMIT = 3
export type PreparationEntry = {
key: string
repoPath: string
repoPathKey: string
workspaceRoot: string
workspaceRootKey: string
wslDistro: string
baseBranch: string
canonicalBase: string
preparedPath: string
options: AddWorktreeOptions
createdAt: number
ready: Promise<void>
expiration: NodeJS.Timeout
}
export type StartPreparationArgs = {
repoPath: string
workspaceRoot: string
baseBranch: string
canonicalBase: string
options: AddWorktreeOptions
}
const preparations = new Map<string, PreparationEntry>()
/** One repo on one Git host: the scope a stranded discard is retried under. */
function preparationHostKey(repoPathKey: string, wslDistro: string): string {
return `${repoPathKey}\0${wslDistro}`
}
/** A prepared checkout is a create that is either in flight or imminent. */
export function hasPendingPreparations(): boolean {
return preparations.size > 0 || hasPendingStalePreparationCleanup()
}
function pathOps(path: string): Pick<typeof posix, 'dirname' | 'join'> {
return isWindowsAbsolutePathLike(path) ? win32 : posix
}
async function discardEntry(entry: PreparationEntry): Promise<void> {
// A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the
// registration for the same reason the discard here can. Enrol either way.
await entry.ready.catch(() => {})
await discardPreparationWithRetry({
hostKey: preparationHostKey(entry.repoPathKey, entry.wslDistro),
repoPath: entry.repoPath,
preparedPath: entry.preparedPath,
options: entry.options
})
}
function discardEntryInBackground(entry: PreparationEntry): void {
// Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry.
trackPreparationDiscard(discardEntry(entry))
}
function expireEntry(entry: PreparationEntry): void {
if (preparations.get(entry.key) !== entry) {
return
}
preparations.delete(entry.key)
discardEntryInBackground(entry)
}
/**
* Frees a slot for an incoming preparation, preferring one the same workspace already owns.
*
* The cap is a disk bound — a prepared checkout is a full tree, ~200 MB of tracked content in the
* repo this was measured against — so it stays small. But flipping through the composer's base
* picker arms several preparations for one repo, and a plain oldest-first eviction let that churn
* throw away another project's warm checkout, which is a structural miss for anyone working across
* several repos. Evict the incoming workspace's own oldest entry first; only reach across
* workspaces when this one holds none.
*/
function enforcePreparationLimit(
repoPathKey: string,
workspaceRootKey: string,
wslDistro: string
): void {
while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) {
const byAge = [...preparations.values()].sort((left, right) => left.createdAt - right.createdAt)
const victim =
byAge.find(
(entry) =>
entry.repoPathKey === repoPathKey &&
entry.workspaceRootKey === workspaceRootKey &&
entry.wslDistro === wslDistro
) ?? byAge[0]
if (!victim) {
return
}
preparations.delete(victim.key)
clearTimeout(victim.expiration)
discardEntryInBackground(victim)
}
}
export function listPreparations(): PreparationEntry[] {
return [...preparations.values()]
}
export function findPreparation(
repoPathKey: string,
workspaceRootKey: string,
canonicalBase: string,
wslDistro: string
): PreparationEntry | undefined {
return preparations.get(
preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro)
)
}
/** Removes an entry from the pool so no other create can claim it. Callers must run this in the
* same synchronous turn as the selection that produced `entry`. */
export function takePreparation(entry: PreparationEntry): void {
preparations.delete(entry.key)
clearTimeout(entry.expiration)
}
export function startPreparation({
repoPath,
workspaceRoot,
baseBranch,
canonicalBase,
options
}: StartPreparationArgs): Promise<void> {
const repoPathKey = preparationPathKey(repoPath)
const workspaceRootKey = preparationPathKey(workspaceRoot)
const wslDistro = options.wslDistro ?? ''
const key = preparationEntryKey(repoPathKey, workspaceRootKey, canonicalBase, wslDistro)
enforcePreparationLimit(repoPathKey, workspaceRootKey, wslDistro)
const preparationId = `${process.pid}-${randomUUID()}`
const lockReason = createWorktreePreparationLockReason(preparationId)
const preparationRoot = pathOps(workspaceRoot).join(
workspaceRoot,
WORKTREE_CREATE_PREPARATION_DIRECTORY
)
const preparedPath = pathOps(workspaceRoot).join(preparationRoot, preparationId)
const entry = {} as PreparationEntry
const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS)
expiration.unref()
Object.assign(entry, {
key,
repoPath,
repoPathKey,
workspaceRoot,
workspaceRootKey,
wslDistro,
baseBranch,
canonicalBase,
preparedPath,
options,
createdAt: Date.now(),
expiration,
ready: (async () => {
await cleanupStalePreparations(preparationHostKey(repoPathKey, wslDistro), repoPath, options)
await mkdir(toHostFilesystemPath(preparationRoot), { recursive: true })
// Already canonical, so the add re-resolves nothing.
await prepareWorktreeCreateCheckout(repoPath, preparedPath, canonicalBase, lockReason, options)
})()
} satisfies PreparationEntry)
preparations.set(key, entry)
void entry.ready.catch(() => {
if (preparations.get(key) === entry) {
preparations.delete(key)
clearTimeout(entry.expiration)
}
})
return entry.ready
}
export async function _resetPreparationPoolForTests(): Promise<void> {
const entries = [...preparations.values()]
preparations.clear()
resetStalePreparationCleanupForTests()
await Promise.all(
entries.map(async (entry) => {
clearTimeout(entry.expiration)
await discardEntry(entry)
})
)
await resetPendingPreparationDiscardsForTests()
}
@@ -74,6 +74,11 @@ export async function cleanupStalePreparations(
}
}
/** True while a crash-recovery scan is running, which means a create is in flight or imminent. */
export function hasPendingStalePreparationCleanup(): boolean {
return staleCleanupInFlight.size > 0
}
export function resetStalePreparationCleanupForTests(): void {
staleCleanupInFlight.clear()
}
@@ -16,7 +16,8 @@ const mocks = vi.hoisted(() => ({
getWorktreeOptions: vi.fn(),
getMirrorDistro: vi.fn(),
getWslHome: vi.fn(),
getWslHomeAsync: vi.fn()
getWslHomeAsync: vi.fn(),
resolveBaseRef: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir }))
@@ -27,6 +28,9 @@ vi.mock('./git/worktree-create-preparation', () => ({
discardPreparedWorktree: mocks.discard,
unlockPreparedWorktree: mocks.unlock
}))
vi.mock('./git/worktree-base-ref-probe', () => ({
resolveLocalWorktreeBaseRef: mocks.resolveBaseRef
}))
vi.mock('./project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions,
getWorktreeMirrorDistro: mocks.getMirrorDistro
@@ -86,6 +90,9 @@ beforeEach(() => {
throw new Error('the blocking wsl.exe home probe must not run while preparing')
})
mocks.getWslHomeAsync.mockReset().mockResolvedValue(WSL_HOME)
mocks.resolveBaseRef
.mockReset()
.mockImplementation(async (_repoPath: string, baseRef: string) => `refs/remotes/${baseRef}`)
})
afterEach(async () => {
+298 -67
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation'
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
const mocks = vi.hoisted(() => ({
mkdir: vi.fn(),
@@ -12,7 +13,9 @@ const mocks = vi.hoisted(() => ({
unlock: vi.fn(),
getWorktreeOptions: vi.fn(),
computeWorkspaceRoot: vi.fn(),
computeWorkspaceRootAsync: vi.fn()
computeWorkspaceRootAsync: vi.fn(),
resolveBaseRef: vi.fn(),
measureDivergence: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir }))
@@ -23,6 +26,12 @@ vi.mock('./git/worktree-create-preparation', () => ({
discardPreparedWorktree: mocks.discard,
unlockPreparedWorktree: mocks.unlock
}))
vi.mock('./git/worktree-base-ref-probe', () => ({
resolveLocalWorktreeBaseRef: mocks.resolveBaseRef
}))
vi.mock('./git/worktree-base-divergence', () => ({
measureRetargetDivergence: mocks.measureDivergence
}))
vi.mock('./project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions,
getWorktreeMirrorDistro: () => undefined
@@ -39,6 +48,7 @@ vi.mock('./ipc/worktree-logic', () => ({
import {
_resetWorktreeCreatePreparationsForTests,
consumePreparedWorktreeCreate,
hasPendingWorktreeCreatePreparations,
prepareWorktreeCreateForRepo
} from './worktree-create-preparation'
@@ -47,6 +57,11 @@ function flushBackgroundWork(ms = 0): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
const EXISTING_REFS = new Set([
'refs/heads/main',
'refs/remotes/origin/main',
'refs/remotes/origin/release'
])
const repo = { id: 'repo-1', path: '/repo' } as Repo
const store = { getSettings: () => ({}) } as unknown as Store
@@ -58,6 +73,12 @@ beforeEach(() => {
mocks.discard.mockReset().mockResolvedValue(undefined)
mocks.unlock.mockReset().mockResolvedValue(undefined)
mocks.getWorktreeOptions.mockReset().mockReturnValue({})
mocks.measureDivergence.mockReset().mockResolvedValue('within')
mocks.resolveBaseRef
.mockReset()
.mockImplementation((_repoPath: string, baseRef: string) =>
resolveWorktreeAddBaseRef(baseRef, async (candidate) => EXISTING_REFS.has(candidate))
)
mocks.computeWorkspaceRoot.mockReset().mockImplementation(() => {
throw new Error('synchronous workspace-root lookup must not run on the main thread')
})
@@ -134,7 +155,7 @@ describe('worktree create preparation registry', () => {
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
})
it('does not claim a preparation after the selected base changes', async () => {
it('does not claim a preparation after the selected base changes to another branch', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(
@@ -145,10 +166,185 @@ describe('worktree create preparation registry', () => {
branch: 'feature/test',
baseBranch: 'origin/release'
})
).resolves.toBeNull()
).resolves.toEqual({ status: 'miss', reason: 'base_mismatch' })
expect(mocks.finalize).not.toHaveBeenCalled()
})
it('claims across the local/remote spelling of the same base and reports the retarget', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
// `main` has no local ref here, so the canonical forms differ and only the base family matches.
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'main'
})
).resolves.toEqual({ status: 'hit', retargeted: true, result: {} })
// Finalize still receives the requested base, so it resets onto the requested commit.
expect(mocks.finalize).toHaveBeenCalledWith(
repo.path,
expect.any(String),
'/workspace/final',
'feature/test',
'main',
undefined,
{}
)
})
it('refuses a same-family retarget whose bases have drifted too far apart', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
// An abandoned fork's `main` is the same base family but a whole-tree checkout away.
mocks.measureDivergence.mockResolvedValue('exceeded')
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'main'
})
).resolves.toEqual({ status: 'miss', reason: 'retarget_too_divergent' })
expect(mocks.finalize).not.toHaveBeenCalled()
// The preparation is left armed for the base it actually holds.
expect(mocks.discard).not.toHaveBeenCalled()
})
it('separates a drift check that said no from one that could not answer', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
// A timed-out or aborted walk skipped a retarget that may well have been cheap; that is a
// tuning signal, not the bound working as intended, so it must not report as excess drift.
mocks.measureDivergence.mockResolvedValue('unknown')
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'main'
})
).resolves.toEqual({ status: 'miss', reason: 'retarget_unverifiable' })
expect(mocks.finalize).not.toHaveBeenCalled()
expect(mocks.discard).not.toHaveBeenCalled()
})
it('does not spend a divergence walk when the base matches exactly', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'origin/main'
})
expect(mocks.measureDivergence).not.toHaveBeenCalled()
})
it('claims when the two sides spell the same ref differently', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'refs/remotes/origin/main'
})
).resolves.toEqual({ status: 'hit', retargeted: false, result: {} })
})
it('never hands the same prepared checkout to two concurrent creates', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
// `main` needs the ref probe, so the claim has to await mid-flight — the window where a
// second create could otherwise walk away with the same preparation.
const [first, second] = await Promise.all([
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/first',
branch: 'feature/first',
baseBranch: 'main'
}),
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/second',
branch: 'feature/second',
baseBranch: 'main'
})
])
expect([first.status, second.status]).toContain('hit')
const preparedPaths = mocks.finalize.mock.calls.map((call) => call[1])
expect(new Set(preparedPaths).size).toBe(preparedPaths.length)
})
it('reports which part of the claim key disagreed', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/other-workspace',
worktreePath: '/other-workspace/final',
branch: 'feature/test',
baseBranch: 'origin/main'
})
).resolves.toEqual({ status: 'miss', reason: 'workspace_root_mismatch' })
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'origin/main',
options: { wslDistro: 'Ubuntu' }
})
).resolves.toEqual({ status: 'miss', reason: 'wsl_distro_mismatch' })
await expect(
consumePreparedWorktreeCreate({
repoPath: '/other-repo',
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'origin/main'
})
).resolves.toEqual({ status: 'miss', reason: 'repo_mismatch' })
expect(mocks.finalize).not.toHaveBeenCalled()
})
it("evicts a repo's own stale preparation before another repo's", async () => {
const otherRepo = { id: 'repo-2', path: '/other-repo' } as Repo
await prepareWorktreeCreateForRepo(store, otherRepo, 'origin/main')
// Fill the pool from one repo, as flipping the composer's base picker does, until the next
// arm has to evict something.
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await prepareWorktreeCreateForRepo(store, repo, 'origin/release')
await prepareWorktreeCreateForRepo(store, repo, 'main')
// The eviction must cost `repo` a slot, not `otherRepo` its warm checkout.
await expect(
consumePreparedWorktreeCreate({
repoPath: otherRepo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/other',
branch: 'feature/other',
baseBranch: 'origin/main'
})
).resolves.toMatchObject({ status: 'hit' })
})
it('routes preparation and finalization through the selected WSL runtime', async () => {
const options = { wslDistro: 'Ubuntu' }
mocks.getWorktreeOptions.mockReturnValue(options)
@@ -166,7 +362,7 @@ describe('worktree create preparation registry', () => {
expect(mocks.prepareCheckout).toHaveBeenCalledWith(
repo.path,
expect.any(String),
'origin/main',
'refs/remotes/origin/main',
expect.any(String),
options
)
@@ -246,6 +442,76 @@ describe('worktree create preparation registry', () => {
)
})
it('cleans up and reports a finalize miss so normal add can run', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move'))
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'origin/main'
})
).resolves.toEqual({ status: 'miss', reason: 'finalize_failed' })
expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true })
expect(mocks.discard).toHaveBeenCalledTimes(1)
})
async function consumeOnce(name: string): Promise<void> {
await consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: `/workspace/${name}`,
branch: `feature/${name}`,
baseBranch: 'origin/main'
})
}
it('does not re-arm after an isolated create', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await consumeOnce('only')
// Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL.
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
})
it('re-arms a preparation once creates arrive in a burst', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await consumeOnce('first')
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await consumeOnce('second')
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3)
// The replacement is claimable, so a third create still skips the cold add.
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/third',
branch: 'feature/third',
baseBranch: 'origin/main'
})
).resolves.toEqual({ status: 'hit', retargeted: false, result: {} })
expect(mocks.finalize).toHaveBeenCalledTimes(3)
})
it('does not re-arm when finalization failed', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await consumeOnce('first')
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
mocks.prepareCheckout.mockClear()
mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move'))
await consumeOnce('second')
expect(mocks.prepareCheckout).not.toHaveBeenCalled()
})
it('retries a discard that failed while this process is still alive', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
const leakedPath = mocks.prepareCheckout.mock.calls[0][1] as string
@@ -285,10 +551,14 @@ describe('worktree create preparation registry', () => {
unremovable.add(leakedHere)
unremovable.add(leakedElsewhere)
// Evict both, oldest first, so each host has one recorded discard failure.
for (const base of ['origin/one', 'origin/two', 'origin/three']) {
// Evict through each host's own arming: eviction prefers the incoming workspace's oldest
// entry, so preparing for `repo` no longer reaches across and takes `otherRepo`'s.
for (const base of ['origin/one', 'origin/two']) {
await prepareWorktreeCreateForRepo(store, repo, base)
}
for (const base of ['origin/one', 'origin/two']) {
await prepareWorktreeCreateForRepo(store, otherRepo, base)
}
await flushBackgroundWork()
expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedHere, {})
expect(mocks.discard).toHaveBeenCalledWith(otherRepo.path, leakedElsewhere, {})
@@ -378,11 +648,15 @@ describe('worktree create preparation registry', () => {
unremovable.add(leakedOnUbuntu)
unremovable.add(leakedOnDebian)
// Evict both, oldest first, so each distro has one recorded discard failure.
// Evict through each distro's own arming: the eviction scope includes the distro, so arming
// under Ubuntu no longer reaches across and takes the Debian entry.
mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' })
for (const base of ['origin/one', 'origin/two', 'origin/three']) {
for (const base of ['origin/one', 'origin/two']) {
await prepareWorktreeCreateForRepo(store, repo, base)
}
mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Debian' })
await prepareWorktreeCreateForRepo(store, repo, 'origin/one')
mocks.getWorktreeOptions.mockReturnValue({ wslDistro: 'Ubuntu' })
await flushBackgroundWork()
expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnUbuntu, { wslDistro: 'Ubuntu' })
expect(mocks.discard).toHaveBeenCalledWith(repo.path, leakedOnDebian, { wslDistro: 'Debian' })
@@ -450,67 +724,24 @@ describe('worktree create preparation registry', () => {
expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, leakedPath, {})
})
it('cleans up and returns null so normal add can run when finalization fails', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move'))
await expect(
consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: '/workspace/final',
branch: 'feature/test',
baseBranch: 'origin/main'
it('reports a pending create while a stale-cleanup scan is running', async () => {
let releaseListing!: () => void
mocks.listWorktreeGraph.mockReturnValueOnce(
new Promise((resolve) => {
releaseListing = () => resolve([])
})
).resolves.toBeNull()
expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true })
expect(mocks.discard).toHaveBeenCalledTimes(1)
})
)
const arming = prepareWorktreeCreateForRepo(store, repo, 'origin/main')
// Anchor on the scan actually starting, not on a fixed number of microtasks: an await added
// ahead of it would otherwise make this pass vacuously rather than fail.
while (mocks.listWorktreeGraph.mock.calls.length === 0) {
await Promise.resolve()
}
function consumeOnce(name: string): ReturnType<typeof consumePreparedWorktreeCreate> {
return consumePreparedWorktreeCreate({
repoPath: repo.path,
workspaceRoot: '/workspace',
worktreePath: `/workspace/${name}`,
branch: `feature/${name}`,
baseBranch: 'origin/main'
})
}
// Why: the idle gate must not start repo maintenance while crash recovery is mid-scan.
expect(hasPendingWorktreeCreatePreparations()).toBe(true)
it('does not re-arm after an isolated create', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('only')).resolves.toEqual({})
// Why: a lone create would otherwise leave a full spare checkout on disk for the whole TTL.
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
})
it('re-arms a preparation once creates arrive in a burst', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('first')).resolves.toEqual({})
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1)
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(2)
// No arming call follows this consume: the third checkout can only come from the re-arm.
await expect(consumeOnce('second')).resolves.toEqual({})
expect(mocks.prepareCheckout).toHaveBeenCalledTimes(3)
// The replacement is claimable, so a third create still skips the cold add.
await expect(consumeOnce('third')).resolves.toEqual({})
expect(mocks.finalize).toHaveBeenCalledTimes(3)
})
it('does not re-arm when finalization failed', async () => {
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
await expect(consumeOnce('first')).resolves.toEqual({})
await prepareWorktreeCreateForRepo(store, repo, 'origin/main')
mocks.prepareCheckout.mockClear()
mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move'))
await expect(consumeOnce('second')).resolves.toBeNull()
expect(mocks.prepareCheckout).not.toHaveBeenCalled()
releaseListing()
await arming
})
})
+145 -188
View File
@@ -1,19 +1,26 @@
import { randomUUID } from 'node:crypto'
import { mkdir } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { isFolderRepo } from '../shared/repo-kind'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
import {
WORKTREE_CREATE_PREPARATION_DIRECTORY,
createWorktreePreparationLockReason
} from '../shared/worktree/create-preparation'
import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types'
import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree'
import { measureRetargetDivergence } from './git/worktree-base-divergence'
import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe'
import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim'
import {
_resetPreparationPoolForTests,
findPreparation,
hasPendingPreparations,
listPreparations,
startPreparation,
takePreparation,
type PreparationEntry
} from './worktree-create-preparation-pool'
import {
discardPreparedWorktree,
finalizePreparedWorktree,
prepareWorktreeCreateCheckout
finalizePreparedWorktree
} from './git/worktree-create-preparation'
import {
getLocalProjectWorktreeGitOptions,
@@ -24,31 +31,22 @@ import {
recordPreparationConsume,
resetPreparationConsumeHistoryForTests
} from './worktree-create-preparation-burst'
import {
cleanupStalePreparations,
resetStalePreparationCleanupForTests
} from './worktree-create-preparation-stale-cleanup'
import { toHostFilesystemPath } from './host-tree-removal'
import {
discardPreparationWithRetry,
resetPendingPreparationDiscardsForTests,
trackPreparationDiscard
} from './worktree-preparation-discard-retry'
export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000
export const WORKTREE_CREATE_PREPARATION_LIMIT = 3
export {
WORKTREE_CREATE_PREPARATION_LIMIT,
WORKTREE_CREATE_PREPARATION_TTL_MS
} from './worktree-create-preparation-pool'
type PreparationEntry = {
key: string
repoPath: string
workspaceRoot: string
preparedPath: string
options: AddWorktreeOptions
createdAt: number
ready: Promise<void>
expiration: NodeJS.Timeout
/** A prepared checkout is a create that is either in flight or imminent. */
export function hasPendingWorktreeCreatePreparations(): boolean {
return hasPendingPreparations()
}
export type PreparedWorktreeCreateAttempt =
| { status: 'hit'; retargeted: boolean; result: AddWorktreeResult }
| { status: 'miss'; reason: PreparedCheckoutMissReason }
type ConsumePreparedWorktreeArgs = {
repoPath: string
workspaceRoot: string
@@ -59,72 +57,16 @@ type ConsumePreparedWorktreeArgs = {
options?: AddWorktreeOptions
}
const preparations = new Map<string, PreparationEntry>()
/** A prepared checkout is a create that is either in flight or imminent. */
export function hasPendingWorktreeCreatePreparations(): boolean {
return preparations.size > 0 || staleCleanupInFlight.size > 0
}
function pathOps(path: string): Pick<typeof posix, 'dirname' | 'join' | 'normalize'> {
return isWindowsAbsolutePathLike(path) ? win32 : posix
}
function pathKey(path: string): string {
const normalized = pathOps(path).normalize(path)
return isWindowsAbsolutePathLike(path) ? normalized.toLowerCase() : normalized
}
function preparationKey(
function canonicalBaseRef(
repoPath: string,
workspaceRoot: string,
baseBranch: string,
options: AddWorktreeOptions
): string {
return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}`
}
function preparationHostKey(repoPath: string, options: AddWorktreeOptions): string {
return `${pathKey(repoPath)}\0${options.wslDistro ?? ''}`
}
async function discardEntry(entry: PreparationEntry): Promise<void> {
// A failed checkout self-discards, but that self-discard is best-effort too, so it can strand the
// registration for the same reason the discard here can. Enrol either way.
await entry.ready.catch(() => {})
await discardPreparationWithRetry({
hostKey: preparationHostKey(entry.repoPath, entry.options),
repoPath: entry.repoPath,
preparedPath: entry.preparedPath,
options: entry.options
})
}
function discardEntryInBackground(entry: PreparationEntry): void {
// Tracked, not bare `void`: the test reset must be able to settle it before dropping the registry.
trackPreparationDiscard(discardEntry(entry))
}
function expireEntry(entry: PreparationEntry): void {
if (preparations.get(entry.key) !== entry) {
return
}
preparations.delete(entry.key)
discardEntryInBackground(entry)
}
function enforcePreparationLimit(): void {
while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) {
const oldest = [...preparations.values()].sort(
(left, right) => left.createdAt - right.createdAt
)[0]
if (!oldest) {
return
}
preparations.delete(oldest.key)
clearTimeout(oldest.expiration)
discardEntryInBackground(oldest)
}
): Promise<string> {
return resolveLocalWorktreeBaseRef(
repoPath,
baseBranch,
options.wslDistro ? { wslDistro: options.wslDistro } : {}
)
}
export async function prepareWorktreeCreateForRepo(
@@ -144,122 +86,146 @@ export async function prepareWorktreeCreateForRepo(
repo.path,
getWorktreePathSettings(repo, store.getSettings(), getWorktreeMirrorDistro(store, repo))
)
const key = preparationKey(repo.path, workspaceRoot, baseBranch, options)
const existing = preparations.get(key)
const canonicalBase = await canonicalBaseRef(repo.path, baseBranch, options)
const existing = findPreparation(
preparationPathKey(repo.path),
preparationPathKey(workspaceRoot),
canonicalBase,
options.wslDistro ?? ''
)
if (existing) {
return existing.ready
}
return startPreparation(key, repo.path, workspaceRoot, baseBranch, options)
return startPreparation({
repoPath: repo.path,
workspaceRoot,
baseBranch,
canonicalBase,
options
})
}
function startPreparation(
key: string,
repoPath: string,
workspaceRoot: string,
baseBranch: string,
options: AddWorktreeOptions
): Promise<void> {
enforcePreparationLimit()
const preparationId = `${process.pid}-${randomUUID()}`
const lockReason = createWorktreePreparationLockReason(preparationId)
const preparedPath = pathOps(workspaceRoot).join(
workspaceRoot,
WORKTREE_CREATE_PREPARATION_DIRECTORY,
preparationId
)
const entry = {} as PreparationEntry
const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS)
expiration.unref()
Object.assign(entry, {
key,
repoPath,
workspaceRoot,
preparedPath,
options,
createdAt: Date.now(),
expiration,
ready: (async () => {
await cleanupStalePreparations(preparationHostKey(repoPath, options), repoPath, options)
await mkdir(
toHostFilesystemPath(
pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY)
),
{ recursive: true }
)
await prepareWorktreeCreateCheckout(repoPath, preparedPath, baseBranch, lockReason, options)
})()
} satisfies PreparationEntry)
preparations.set(key, entry)
void entry.ready.catch(() => {
if (preparations.get(key) === entry) {
preparations.delete(key)
clearTimeout(entry.expiration)
}
})
return entry.ready
}
type ClaimedPreparation =
| { status: 'claimed'; entry: PreparationEntry; retargeted: boolean; canonicalBase: string }
| { status: 'miss'; reason: PreparedCheckoutMissReason }
async function claimPreparedWorktree(
repoPath: string,
workspaceRoot: string,
baseBranch: string,
args: ConsumePreparedWorktreeArgs,
options: AddWorktreeOptions
): Promise<PreparationEntry | null> {
const key = preparationKey(repoPath, workspaceRoot, baseBranch, options)
const entry = preparations.get(key)
if (!entry) {
return null
): Promise<ClaimedPreparation> {
const request = {
repoPathKey: preparationPathKey(args.repoPath),
workspaceRootKey: preparationPathKey(args.workspaceRoot),
wslDistro: options.wslDistro ?? '',
baseBranch: args.baseBranch
}
preparations.delete(key)
clearTimeout(entry.expiration)
let selection = selectPreparationForCreate(listPreparations(), {
...request,
canonicalBase: null
})
if (selection.kind === 'needs-canonical-base') {
// The probe is the only await here, and the pool is re-read after it, so the select-and-take
// below stays one synchronous run and no other create can hold the same entry.
const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options)
selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase })
}
if (selection.kind !== 'exact' && selection.kind !== 'retarget') {
return {
status: 'miss',
reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch'
}
}
if (selection.kind === 'retarget') {
const candidate = selection.candidate
const { canonicalBase } = selection
const divergence = await measureRetargetDivergence(
args.repoPath,
candidate.canonicalBase,
canonicalBase,
{
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
// Why forward it: a cancelled create must stop these probes now, not at the deadline.
...(options.signal ? { signal: options.signal } : {})
}
)
if (divergence !== 'within') {
return {
status: 'miss',
reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable'
}
}
// Re-select after the walk: the pool may have gained an exact match or lost this entry. A
// different retarget candidate is left for the next create rather than claimed unverified.
selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase })
if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') {
return { status: 'miss', reason: 'base_mismatch' }
}
if (selection.kind === 'retarget' && selection.candidate !== candidate) {
return { status: 'miss', reason: 'base_mismatch' }
}
}
const entry = selection.candidate
takePreparation(entry)
try {
await entry.ready
return entry
return {
status: 'claimed',
entry,
retargeted: selection.kind === 'retarget',
canonicalBase: selection.canonicalBase
}
} catch {
return null
return { status: 'miss', reason: 'prepare_failed' }
}
}
/** Replaces a just-consumed preparation, but only once the user has shown they are creating in a
* burst. A replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one
* after an isolated create spends that on nobody. Never awaited: create has already returned by
* the time the replacement checkout finishes. */
function rearmPreparation(entry: PreparationEntry, baseBranch: string): void {
/** Replaces a just-consumed preparation, re-armed on the base the create actually used so the
* next one hits exactly — but only once the user has shown they are creating in a burst. A
* replacement costs a full checkout and ~5 minutes of disk until its TTL, so arming one after an
* isolated create spends that on nobody. Never awaited: create has already returned by the time
* the replacement checkout finishes. */
function rearmPreparation(
entry: PreparationEntry,
baseBranch: string,
canonicalBase: string
): void {
// Record first: a prefetch that re-armed this key while we finalized would otherwise swallow the
// consume, and the next create would look isolated when it is really the middle of a burst.
const continuesBurst = recordPreparationConsume(entry.key)
if (preparations.has(entry.key) || !continuesBurst) {
if (
!continuesBurst ||
findPreparation(entry.repoPathKey, entry.workspaceRootKey, canonicalBase, entry.wslDistro)
) {
return
}
void startPreparation(
entry.key,
entry.repoPath,
entry.workspaceRoot,
void startPreparation({
repoPath: entry.repoPath,
workspaceRoot: entry.workspaceRoot,
baseBranch,
entry.options
).catch(() => {
canonicalBase,
options: entry.options
}).catch(() => {
// Why: a warm-up failure is recovered by the normal add on the next create.
})
}
export async function consumePreparedWorktreeCreate(
args: ConsumePreparedWorktreeArgs
): Promise<AddWorktreeResult | null> {
): Promise<PreparedWorktreeCreateAttempt> {
const options = args.options ?? {}
const entry = await claimPreparedWorktree(
args.repoPath,
args.workspaceRoot,
args.baseBranch,
options
)
if (!entry) {
return null
const claim = await claimPreparedWorktree(args, options)
if (claim.status === 'miss') {
return { status: 'miss', reason: claim.reason }
}
const { entry } = claim
try {
await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), {
recursive: true
})
const parentDir = isWindowsAbsolutePathLike(args.worktreePath)
? win32.dirname(args.worktreePath)
: posix.dirname(args.worktreePath)
await mkdir(toHostFilesystemPath(parentDir), { recursive: true })
// Finalize resolves the requested base itself and resets the prepared checkout onto that
// commit, so a retargeted claim is handed over at the requested commit or not at all.
const result = await finalizePreparedWorktree(
args.repoPath,
entry.preparedPath,
@@ -271,28 +237,19 @@ export async function consumePreparedWorktreeCreate(
)
// Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is
// creating in a burst; the TTL and the preparation limit still bound an unused replacement.
rearmPreparation(entry, args.baseBranch)
return result
rearmPreparation(entry, args.baseBranch, claim.canonicalBase)
return { status: 'hit', retargeted: claim.retargeted, result }
} catch (error) {
await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {})
console.warn(
'[worktree-create] prepared checkout could not be finalized; using normal add',
error
)
return null
return { status: 'miss', reason: 'finalize_failed' }
}
}
export async function _resetWorktreeCreatePreparationsForTests(): Promise<void> {
const entries = [...preparations.values()]
preparations.clear()
resetPreparationConsumeHistoryForTests()
resetStalePreparationCleanupForTests()
await Promise.all(
entries.map(async (entry) => {
clearTimeout(entry.expiration)
await discardEntry(entry)
})
)
await resetPendingPreparationDiscardsForTests()
await _resetPreparationPoolForTests()
}
+8 -1
View File
@@ -1,4 +1,5 @@
import type {
PreparedCheckoutOutcome,
WorktreeCreateTiming,
WorktreeCreateTimingPhase
} from '../shared/worktree/create-types'
@@ -8,6 +9,7 @@ type TimingClock = () => number
export type WorktreeCreateTimingRecorder = {
time<T>(phase: string, operation: () => Promise<T>): Promise<T>
timeSync<T>(phase: string, operation: () => T): T
recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void
finish(): WorktreeCreateTiming
}
@@ -37,6 +39,7 @@ export function createWorktreeCreateTimingRecorder(
): WorktreeCreateTimingRecorder {
const startedAt = clock()
const phases: WorktreeCreateTimingPhase[] = []
let preparedCheckout: PreparedCheckoutOutcome | undefined
const recordPhase = (phase: string, operationStartedAt: number): void => {
phases.push(createPhase(phase, operationStartedAt, clock(), startedAt))
@@ -59,10 +62,14 @@ export function createWorktreeCreateTimingRecorder(
recordPhase(phase, operationStartedAt)
}
},
recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void {
preparedCheckout = outcome
},
finish() {
return {
totalDurationMs: clampDuration(clock() - startedAt),
phases: [...phases]
phases: [...phases],
...(preparedCheckout ? { preparedCheckout } : {})
}
}
}
@@ -6,7 +6,7 @@ import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-worksp
import { useStartupActions } from './use-app-startup-actions'
import { WORKTREE_REFRESH_CONCURRENCY } from '../store/slices/worktrees'
import { sweepRestoredCodexPanesForStaleAccounts } from '../lib/codex-stale-pane-sweep'
import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-persistence'
import { fetchWorkspaceSessionWithRuntimeHostOwners } from '../lib/workspace-session-host-hydration'
import {
collectFolderWorkspaceKeysFromSession,
collectWorktreeHydrationRepoIdsFromSession
@@ -189,7 +189,9 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta
timeRendererStartupSyncStep('hydrate-session-stores', () => {
actions.hydrateWorkspaceSession(sessionRead.session, {
...sessionHydrationOptions,
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey
runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey,
contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions,
contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey
})
actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
@@ -1,7 +1,7 @@
// @vitest-environment happy-dom
import React from 'react'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const environmentMocks = vi.hoisted(() => ({
@@ -15,6 +15,7 @@ vi.mock('@/lib/client-environment-info', () => ({
import {
TerminalErrorToast,
humanizeTerminalError,
isPaneOwnerUnverifiedError,
isExplainedTerminalError,
isSshReconnectOwnedTerminalError,
shouldOfferDaemonRestart,
@@ -69,7 +70,15 @@ describe('humanizeTerminalError', () => {
it('replaces the pane-owner-unverified code with actionable copy', () => {
const humanized = humanizeTerminalError('terminal_pane_owner_unverified')
expect(humanized).not.toContain('terminal_pane_owner_unverified')
expect(humanized).toContain('Reopen this pane to retry')
expect(humanized).toContain('Click Retry to try reconnecting now')
expect(humanized).toContain('Orca left the saved session unchanged')
expect(humanized).not.toContain('was not closed or deleted')
})
it('identifies the owner-unverified safety state', () => {
expect(isPaneOwnerUnverifiedError('terminal_pane_owner_unverified')).toBe(true)
expect(isPaneOwnerUnverifiedError('Paste failed.')).toBe(false)
expect(isPaneOwnerUnverifiedError('Paste failed.\nterminal_pane_owner_unverified')).toBe(false)
})
it('humanizes an IPC-wrapped pane-owner-unverified error', () => {
@@ -78,6 +87,22 @@ describe('humanizeTerminalError', () => {
expect(humanizeTerminalError(wrapped)).not.toContain('terminal_pane_owner_unverified')
})
it('humanizes an owner marker without classifying mixed errors as safe warnings', () => {
const mixed = humanizeTerminalError('Paste failed.\nterminal_pane_owner_unverified')
expect(mixed).toContain('Paste failed.')
expect(mixed).toContain("Orca couldn't verify this terminal's owner.")
expect(mixed).not.toContain('terminal_pane_owner_unverified')
expect(isPaneOwnerUnverifiedError('Paste failed.\nterminal_pane_owner_unverified')).toBe(false)
})
it('humanizes every owner marker in an aggregated warning', () => {
const repeated = humanizeTerminalError(
"terminal_pane_owner_unverified\nError invoking remote method 'pty:spawn': Error: terminal_pane_owner_unverified"
)
expect(repeated).not.toContain('terminal_pane_owner_unverified')
})
it('leaves other errors untouched', () => {
expect(humanizeTerminalError('Paste failed.')).toBe('Paste failed.')
})
@@ -302,4 +327,59 @@ describe('TerminalErrorToast environment footer', () => {
await waitFor(() => expect(environmentMocks.resolveFooter).not.toHaveBeenCalled())
})
it('renders owner-unverified as a warning without an issue link', () => {
const onRetry = vi.fn().mockResolvedValue(true)
const view = render(
React.createElement(TerminalErrorToast, {
error: 'terminal_pane_owner_unverified',
onDismiss: vi.fn(),
onRetry
})
)
const toast = view.container.querySelector('[data-terminal-error-toast]')
expect(toast?.getAttribute('data-terminal-error-kind')).toBe('owner-unverified')
expect(toast?.querySelector('a')).toBeNull()
expect(toast?.textContent).toContain('Orca left the saved session unchanged')
expect(view.getByRole('button', { name: 'Retry' }).getAttribute('data-slot')).toBe('button')
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('keeps Retry available when the recovery attempt rejects', async () => {
const onRetry = vi.fn().mockRejectedValue(new Error('recovery unavailable'))
const view = render(
React.createElement(TerminalErrorToast, {
error: 'terminal_pane_owner_unverified',
onDismiss: vi.fn(),
onRetry
})
)
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
await waitFor(() =>
expect((view.getByRole('button', { name: 'Retry' }) as HTMLButtonElement).disabled).toBe(
false
)
)
expect(onRetry).toHaveBeenCalledTimes(1)
})
it('explains when Retry is temporarily unavailable', async () => {
const onRetry = vi.fn().mockResolvedValue(false)
const view = render(
React.createElement(TerminalErrorToast, {
error: 'terminal_pane_owner_unverified',
onDismiss: vi.fn(),
onRetry
})
)
fireEvent.click(view.getByRole('button', { name: 'Retry' }))
await waitFor(() =>
expect(view.container.textContent).toContain('Retry could not reconnect yet')
)
})
})
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import { translate } from '@/i18n/i18n'
import { resolveClientEnvironmentFooter } from '@/lib/client-environment-info'
import { Button } from '@/components/ui/button'
import { hasClientEnvironmentFooter } from '../../../../shared/client-environment-info'
const SSH_PREFIX = 'SSH connection is not active'
@@ -78,6 +79,11 @@ export function isExplainedTerminalError(error: string): boolean {
)
}
export function isPaneOwnerUnverifiedError(error: string): boolean {
const lines = error.split('\n').filter((line) => line.length > 0)
return lines.length > 0 && lines.every((line) => line.includes(PANE_OWNER_UNVERIFIED_MARKER))
}
function humanizeUnreattachableSession(error: string): string {
const explanation = translate(
'auto.components.terminal.pane.TerminalErrorToast.sessionUnavailable',
@@ -94,13 +100,16 @@ function humanizeUnreattachableSession(error: string): string {
export function humanizeTerminalError(error: string): string {
let humanized = error
if (humanized.includes(PANE_OWNER_UNVERIFIED_MARKER)) {
humanized = humanized.replace(
PANE_OWNER_UNVERIFIED_MARKER,
translate(
'auto.components.terminal.pane.TerminalErrorToast.7ee11bc0db',
"Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry."
)
)
const explanation = isPaneOwnerUnverifiedError(humanized)
? translate(
'auto.components.terminal.pane.TerminalErrorToast.42b283ecfc',
"Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal."
)
: translate(
'auto.components.terminal.pane.TerminalErrorToast.ownerUnknown',
"Orca couldn't verify this terminal's owner."
)
humanized = humanized.replaceAll(PANE_OWNER_UNVERIFIED_MARKER, () => explanation)
}
humanized = humanizeUnreattachableSession(humanized)
if (!isExplainedTerminalError(humanized)) {
@@ -127,17 +136,23 @@ export function humanizeTerminalError(error: string): string {
export function TerminalErrorToast({
error,
onDismiss,
onRestartDaemon
onRestartDaemon,
onRetry
}: {
error: string
onDismiss: () => void
onRestartDaemon?: () => void
onRetry?: () => Promise<boolean>
}): React.JSX.Element {
const ssh = isSshError(error)
const paneOwnerUnverified = isPaneOwnerUnverifiedError(error)
const showDaemonRestart = !ssh && onRestartDaemon && shouldOfferDaemonRestart(error)
// Restart cannot recover a session after its owning daemon exits.
const showIssueLink = !ssh && !showDaemonRestart && !isExplainedTerminalError(error)
const showIssueLink =
!ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error)
const displayError = humanizeTerminalError(error)
const [retrying, setRetrying] = useState(false)
const [retryFailed, setRetryFailed] = useState(false)
const [environmentFooter, setEnvironmentFooter] = useState<{
error: string
footer: string
@@ -160,10 +175,26 @@ export function TerminalErrorToast({
}, [displayError, ssh])
const footer = environmentFooter?.error === displayError ? environmentFooter.footer : ''
const handleRetry = async (): Promise<void> => {
if (!onRetry || retrying) {
return
}
setRetrying(true)
setRetryFailed(false)
try {
setRetryFailed(!(await onRetry()))
} catch {
// Keep the safety warning available when a best-effort remount cannot start.
setRetryFailed(true)
} finally {
setRetrying(false)
}
}
return (
<div
data-terminal-error-toast
data-terminal-error-kind={paneOwnerUnverified ? 'owner-unverified' : ssh ? 'ssh' : 'error'}
style={{
position: 'absolute',
bottom: 12,
@@ -172,9 +203,17 @@ export function TerminalErrorToast({
zIndex: 50,
padding: '10px 14px',
borderRadius: 6,
background: ssh ? 'rgba(234, 179, 8, 0.12)' : 'rgba(220, 38, 38, 0.15)',
border: ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)',
color: ssh ? '#fde68a' : '#fca5a5',
background: paneOwnerUnverified
? 'var(--popover)'
: ssh
? 'rgba(234, 179, 8, 0.12)'
: 'rgba(220, 38, 38, 0.15)',
border: paneOwnerUnverified
? '1px solid var(--color-amber-500)'
: ssh
? '1px solid rgba(234, 179, 8, 0.35)'
: '1px solid rgba(220, 38, 38, 0.4)',
color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5',
fontSize: 12,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
@@ -212,6 +251,12 @@ export function TerminalErrorToast({
</>
) : null}
{!ssh && footer ? `\n\n${footer}` : null}
{paneOwnerUnverified && retryFailed
? `\n${translate(
'auto.components.terminal.pane.TerminalErrorToast.retryUnavailable',
'Retry could not reconnect yet. Try again shortly.'
)}`
: null}
</span>
{showDaemonRestart ? (
<button
@@ -235,12 +280,25 @@ export function TerminalErrorToast({
)}
</button>
) : null}
{paneOwnerUnverified && onRetry ? (
<Button
variant="outline"
size="xs"
onClick={() => void handleRetry()}
disabled={retrying}
className="ml-3 border-amber-500/50 bg-popover text-popover-foreground hover:bg-amber-500/20"
>
{retrying
? translate('auto.components.terminal.pane.TerminalErrorToast.retrying', 'Retrying…')
: translate('auto.components.terminal.pane.TerminalErrorToast.retry', 'Retry')}
</Button>
) : null}
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: ssh ? '#fde68a' : '#fca5a5',
color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5',
cursor: 'pointer',
fontSize: 14,
padding: '0 0 0 8px',
@@ -6,7 +6,8 @@ import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/works
import CloseTerminalDialog from './CloseTerminalDialog'
import TerminalContextMenu from './TerminalContextMenu'
import TerminalPaneHeaderOverlay from './TerminalPaneHeaderOverlay'
import { TerminalErrorToast } from './TerminalErrorToast'
import { isPaneOwnerUnverifiedError, TerminalErrorToast } from './TerminalErrorToast'
import { requestTerminalPaneRecovery } from './terminal-pane-recovery'
import { TerminalSessionStateSaveFailureDialog } from './TerminalSessionStateSaveFailureDialog'
import { TerminalLinkActionPopover } from './TerminalLinkActionPopover'
import { TerminalAgentSessionForkDialog } from './TerminalAgentSessionForkDialog'
@@ -157,6 +158,25 @@ export function TerminalPaneSurface({
error={visibleTerminalError}
onDismiss={dismissTerminalError}
onRestartDaemon={() => daemonActions.setPending('restart')}
onRetry={
isPaneOwnerUnverifiedError(visibleTerminalError)
? () => {
const ptyId = activePane
? (paneTransportsRef.current.get(activePane.id)?.getPtyId() ?? null)
: null
return requestTerminalPaneRecovery({
tabId,
ptyId,
reason: 'reattach-unverifiable'
}).then((recovered) => {
if (recovered) {
dismissTerminalError()
}
return recovered
})
}
: undefined
}
/>,
activePane.container,
`terminal-error-${activePane.id}`
@@ -2,7 +2,14 @@ import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toAppSshPtyId } from '../../../../shared/ssh-pty-id'
import { flushAsyncTicks, createDeferred } from './pty-connection-test-async'
import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures'
import {
createMockTransport,
createPane,
createManager,
LEAF_2,
type ConnectCallbacks,
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps, buildDirectSshSplitRetryCommit } from './pty-connection-test-deps'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
@@ -10,7 +17,6 @@ import {
pendingSpawnByPaneKey,
pendingSpawnGenerationByPaneKey
} from './pty-connection/pty-connect-limits'
import type { MockTransport } from './pty-connection-test-pane-fixtures'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
@@ -665,6 +671,88 @@ describe('connectPanePty', () => {
await flushAsyncTicks(12)
})
it('rejects an owner-unverified reattach after direct SSH authority rotates', async () => {
const { connectPanePty } = await import('./pty-connection')
const restoredPtyId = toAppSshPtyId('target-a', 'pty-live')
const delayedReattach = createDeferred<void>()
const capturedCallbacks: { current: ConnectCallbacks | null } = { current: null }
const transport = createMockTransport(restoredPtyId)
transport.detach = vi.fn()
transport.connect.mockImplementation(({ callbacks }) => {
capturedCallbacks.current = callbacks ?? null
return delayedReattach.promise
})
transportFactoryQueue.push(transport)
const liveRetry = {
attemptId: 'attempt-live-owner-unverified',
authority: {
targetId: 'target-a',
providerEpoch: 'epoch-old',
connectionGeneration: 3
},
tabGeneration: 7,
ptyId: restoredPtyId
}
const settleDirectSshPaneRetry = vi.fn()
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: restoredPtyId, generation: 7 }] },
ptyIdsByTabId: { 'tab-1': [restoredPtyId] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: restoredPtyId }
}
},
repos: [{ id: 'repo1', connectionId: 'target-a', displayName: 'orca' }],
sshConnectionStates: new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
providerEpoch: 'epoch-old',
connectionGeneration: 3
}
]
]),
directSshPaneRetryByTabId: {},
directSshLivePtyBindingByTabId: { 'tab-1': liveRetry },
settleDirectSshPaneRetry
}
const deps = createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: restoredPtyId }
})
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
mockStoreState.sshConnectionStates = new Map([
[
'target-a',
{
targetId: 'target-a',
status: 'connected',
providerEpoch: 'epoch-new',
connectionGeneration: 4
}
]
])
capturedCallbacks.current?.onError?.('terminal_pane_owner_unverified')
delayedReattach.resolve()
await flushAsyncTicks(12)
expect(deps.onPtyErrorRef.current).not.toHaveBeenCalled()
expect(transport.detach).toHaveBeenCalledExactlyOnceWith({ preserveExitObserver: false })
expect(settleDirectSshPaneRetry).not.toHaveBeenCalled()
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF_2]: restoredPtyId
})
})
it('starts a new spawn and rejects a late callback after direct SSH authority rotates', async () => {
const { connectPanePty } = await import('./pty-connection')
const oldPendingSpawn = createDeferred<string>()
@@ -132,6 +132,26 @@ function createDeps(overrides: Record<string, unknown> = {}) {
return buildPaneConnectionDeps(() => mockStoreState, overrides)
}
function seedUnverifiableRestoredPane() {
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'unverifiable-pty' }] },
ptyIdsByTabId: { 'tab-1': ['unverifiable-pty'] },
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'unverifiable-pty' }
}
}
} as StoreState
return createDeps({
restoredLeafId: LEAF_2,
restoredPtyIdByLeafId: { [LEAF_2]: 'unverifiable-pty' }
})
}
// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status.
function enableActiveRuntimeEnvironment(environmentId = 'env-1'): void {
mockStoreState = buildActiveRuntimeEnvironmentState(mockStoreState, environmentId)
@@ -548,6 +568,51 @@ describe('connectPanePty', () => {
})
})
it('preserves an owner-unverified restored pane after an empty reattach result', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockImplementation(async (opts: { callbacks?: ConnectCallbacks }) => {
opts.callbacks?.onError?.('terminal_pane_owner_unverified')
return undefined
})
transportFactoryQueue.push(transport)
const deps = seedUnverifiableRestoredPane()
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified')
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(mockStoreState.tabsByWorktree['wt-1'][0]?.ptyId).toBe('unverifiable-pty')
expect(mockStoreState.ptyIdsByTabId?.['tab-1']).toEqual(['unverifiable-pty'])
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF_2]: 'unverifiable-pty'
})
expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1)
})
it('preserves an owner-unverified restored pane after a rejected reattach', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transport.connect.mockRejectedValueOnce(new Error('terminal_pane_owner_unverified'))
transportFactoryQueue.push(transport)
const deps = seedUnverifiableRestoredPane()
connectPanePty(createPane(2) as never, createManager(2) as never, deps as never)
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(2, 'terminal_pane_owner_unverified')
expect(deps.clearExitedPanePtyLayoutBinding).not.toHaveBeenCalled()
expect(deps.clearTabPtyId).not.toHaveBeenCalled()
expect(mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId).toEqual({
[LEAF_2]: 'unverifiable-pty'
})
expect(window.api.pty.clearPendingPaneSerializer).toHaveBeenCalledWith(expect.any(String), 1)
})
describe('terminal input liveness IPC gating (perf)', () => {
// Why (perf regression guard): listSessions() is a renderer→main→daemon round-trip; terminal input must never trigger it.
async function connectActivePaneWithInput(): Promise<{
@@ -6,6 +6,8 @@ import { toProcessExitStartup } from './process-exit-startup'
import { recoverUnverifiableDirectSshReattach } from './direct-ssh-reattach-recovery'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
const PANE_OWNER_UNVERIFIED_ERROR = 'terminal_pane_owner_unverified'
export function startDeferredSessionReattach(
session: ConnectPanePtySession,
deferredReattachSessionId: string
@@ -22,6 +24,7 @@ export function startDeferredSessionReattach(
: window.api.pty.declarePendingPaneSerializer(session.cacheKey).catch(() => null)
let expiredReattachError = false
let paneOwnerUnverified = false
const coldRestoreStartup = session.buildColdRestoreAgentResumeStartup()
const outputCallbacks = session.captureTransportOutputCallbacks(
(message) => {
@@ -29,6 +32,9 @@ export function startDeferredSessionReattach(
expiredReattachError = true
return
}
if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) {
paneOwnerUnverified = true
}
if (!session.isCapturedDirectSshReattachCurrent(deferredReattachSessionId)) {
return
}
@@ -79,6 +85,15 @@ export function startDeferredSessionReattach(
}
return
}
if (!result && paneOwnerUnverified) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
if (typeof gen === 'number') {
void window.api.pty.clearPendingPaneSerializer(session.cacheKey, gen).catch(() => {})
}
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
return
}
if (!result && expiredReattachError) {
session.finishReattachLiveDataDeferral(false, outputCallbacks.generation)
const gen = await preSignalPromise
@@ -137,6 +152,11 @@ export function startDeferredSessionReattach(
if (session.rejectObsoleteDirectSshReattach(deferredReattachSessionId)) {
return
}
if (message.includes(PANE_OWNER_UNVERIFIED_ERROR)) {
session.reportError(message)
session.settleDirectSshPaneRetryAttempt(session.directSshRetryAttempt, 'failed')
return
}
warnTerminalLifecycleAnomaly('restored PTY reattach threw', {
tabId: session.deps.tabId,
worktreeId: session.deps.worktreeId,
+5 -1
View File
@@ -3078,10 +3078,14 @@
},
"TerminalErrorToast": {
"e4aa243f8c": "Restart daemon",
"retry": "Retry",
"retrying": "Retrying…",
"retryUnavailable": "Retry could not reconnect yet. Try again shortly.",
"a7e2fd2699": "file an issue",
"5c8ce20be6": "If this persists, please",
"cc6d997c65": "Restart the terminal daemon from here to clear stale daemon state.",
"7ee11bc0db": "Orca couldn't confirm whether this terminal's previous session is still running, so it left the session untouched. Reopen this pane to retry.",
"ownerUnknown": "Orca couldn't verify this terminal's owner.",
"42b283ecfc": "Orca couldn't safely reconnect this terminal because the host couldn't verify its saved session. Orca left the saved session unchanged. Click Retry to try reconnecting now. If it still cannot reconnect, open a new terminal.",
"e16012e31e": "The terminal daemon that owned this session exited, so the session and its scrollback could not be recovered. Open a new terminal to continue.",
"sessionUnavailable": "Orca couldn't reattach to this pane's terminal session on the host. Open a new terminal to continue."
},
@@ -0,0 +1,432 @@
/**
* A worktree id is `repoId::path` with no host component, so one repo registered on two hosts
* publishes the same id for two different workspaces (STA-4343). Persistence used to fold every
* such id into the 'local' partition, which gave both workspaces ONE `tabsByWorktree` bucket:
* whichever host wrote last erased the other's tabs permanently.
*/
import { describe, expect, it, vi, type Mock } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { folderWorkspaceKey } from '../../../shared/workspace-scope'
import {
indexWorktreeHostClaims,
mergeWorkspaceSessionsWithHostShadow,
pickPrimaryHostForClaims
} from './workspace-session-host-contention'
import { fetchWorkspaceSessionWithRuntimeHostOwners } from './workspace-session-host-hydration'
import {
buildHostIdByWorktreeId,
patchWorkspaceSessionByHost,
persistWorkspaceSessionByHost,
type HostPersistenceState
} from './workspace-session-host-persistence'
const SHARED_ID = 'repo-shared::/work/orca'
const SSH_HOST: ExecutionHostId = 'ssh:build-box'
function tab(id: string, worktreeId = SHARED_ID): TerminalTab {
return {
id,
ptyId: null,
worktreeId,
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
}
function sessionWithTabs(entries: Record<string, TerminalTab[]>): WorkspaceSessionState {
return { ...getDefaultWorkspaceSession(), tabsByWorktree: entries }
}
function contestedState(overrides: Partial<HostPersistenceState> = {}): HostPersistenceState {
return {
repos: [
{ id: 'repo-shared', connectionId: null, executionHostId: 'local' },
{
id: 'repo-shared',
connectionId: 'build-box',
executionHostId: SSH_HOST
}
],
worktreesByRepo: {
'repo-shared': [
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' },
{ id: SHARED_ID, repoId: 'repo-shared', hostId: SSH_HOST }
]
},
...overrides
}
}
describe('indexWorktreeHostClaims', () => {
it('records every host publishing the same workspace id', () => {
const claims = indexWorktreeHostClaims(contestedState().worktreesByRepo, new Map())
expect([...(claims.get(SHARED_ID) ?? [])].sort()).toEqual(['local', SSH_HOST])
})
it('attributes an unqualified row through its repo when the repo names one host', () => {
const claims = indexWorktreeHostClaims(
{ 'repo-a': [{ id: 'repo-a::/work/a', repoId: 'repo-a' }] },
new Map([['repo-a', SSH_HOST]])
)
expect([...(claims.get('repo-a::/work/a') ?? [])]).toEqual([SSH_HOST])
})
it('leaves an unqualified row unattributed when its repo id is itself ambiguous', () => {
const claims = indexWorktreeHostClaims(
{ 'repo-a': [{ id: 'repo-a::/work/a', repoId: 'repo-a' }] },
new Map([['repo-a', null]])
)
expect(claims.has('repo-a::/work/a')).toBe(false)
})
it('prefers local as primary, else the lowest host id', () => {
expect(pickPrimaryHostForClaims([SSH_HOST, 'local'])).toBe('local')
expect(pickPrimaryHostForClaims(['runtime:b', 'runtime:a'])).toBe('runtime:a')
})
})
describe('buildHostIdByWorktreeId for a contested workspace id', () => {
it('routes a local/SSH collision to one deterministic primary', () => {
expect(buildHostIdByWorktreeId(contestedState())(SHARED_ID)).toBe('local')
})
it('gives two runtime claimants a runtime primary instead of folding them into local', () => {
const owner = buildHostIdByWorktreeId({
repos: [],
worktreesByRepo: {
'repo-shared': [
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'runtime:env-b' },
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'runtime:env-a' }
]
}
})
expect(owner(SHARED_ID)).toBe('runtime:env-a')
})
})
describe('mergeWorkspaceSessionsWithHostShadow', () => {
it('parks a co-claimant partition entry instead of letting it win the shared key', () => {
const merged = mergeWorkspaceSessionsWithHostShadow({
local: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }),
[SSH_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('ssh-tab')] })
})
expect(merged.session.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([
'local-tab'
])
expect(
(merged.shadow[SSH_HOST]?.tabsByWorktree?.[SHARED_ID] ?? []).map((entry) => entry.id)
).toEqual(['ssh-tab'])
expect(merged.shadow.local).toBeUndefined()
})
it('leaves uncontested partitions untouched', () => {
const merged = mergeWorkspaceSessionsWithHostShadow({
local: sessionWithTabs({ 'repo-a::/a': [tab('a', 'repo-a::/a')] }),
'runtime:env-1': sessionWithTabs({
'repo-b::/b': [tab('b', 'repo-b::/b')]
})
})
expect(Object.keys(merged.session.tabsByWorktree).sort()).toEqual(['repo-a::/a', 'repo-b::/b'])
expect(merged.shadow).toEqual({})
})
})
type SessionWriteMock = Mock<
(session: WorkspaceSessionState, hostId?: ExecutionHostId) => Promise<void>
>
type SessionPatchMock = Mock<
(patch: Partial<WorkspaceSessionState>, hostId?: ExecutionHostId) => Promise<void>
>
describe('writing a contested workspace id back', () => {
const RUNTIME_HOST: ExecutionHostId = 'runtime:env-1'
const RUNTIME_ONLY_ID = 'repo-runtime::/srv/app'
const shadow = {
[RUNTIME_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] })
}
/** The runtime host owns a second workspace, so its partition is rewritten by every persist —
* the write that used to take the contested row down with it. */
function runtimeCoClaimantState(
overrides: Partial<HostPersistenceState> = {}
): HostPersistenceState {
return {
repos: [],
worktreesByRepo: {
'repo-shared': [
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' },
{ id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST }
],
'repo-runtime': [{ id: RUNTIME_ONLY_ID, repoId: 'repo-runtime', hostId: RUNTIME_HOST }]
},
contestedHostWorkspaceSessions: shadow,
...overrides
}
}
function livePayload(): WorkspaceSessionState {
return sessionWithTabs({
[SHARED_ID]: [tab('local-tab')],
[RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)]
})
}
async function persist(state: HostPersistenceState): Promise<SessionWriteMock> {
const set: SessionWriteMock = vi.fn(async () => {})
await persistWorkspaceSessionByHost(
{
set,
get: vi.fn(),
patch: vi.fn(),
setSync: vi.fn(),
flush: vi.fn(async () => {})
},
livePayload(),
state
)
return set
}
function tabIds(session: Partial<WorkspaceSessionState> | undefined, key: string): string[] {
return (session?.tabsByWorktree?.[key] ?? []).map((entry) => entry.id)
}
it('keeps the co-claimant rows in its own partition when that partition is rewritten', async () => {
const set = await persist(runtimeCoClaimantState())
const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(tabIds(runtimeWrite, SHARED_ID)).toEqual(['runtime-tab'])
expect(tabIds(runtimeWrite, RUNTIME_ONLY_ID)).toEqual(['runtime-only-tab'])
const localWrite = set.mock.calls.find(([, hostId]) => hostId === undefined)?.[0]
expect(tabIds(localWrite, SHARED_ID)).toEqual(['local-tab'])
})
it('carries a parked field the slice never seeded through a full partition replace', async () => {
// Why: api.set swaps the whole partition, so a field with no live entry routing to the
// co-claimant would otherwise be written without its parked rows and erased on disk.
const set: SessionWriteMock = vi.fn(async () => {})
await persistWorkspaceSessionByHost(
{
set,
get: vi.fn(),
patch: vi.fn(),
setSync: vi.fn(),
flush: vi.fn(async () => {})
},
{
...sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }),
// Seeds the runtime slice (so its partition IS rewritten) without seeding tabsByWorktree.
lastVisitedAtByWorktreeId: { [RUNTIME_ONLY_ID]: 1 }
},
runtimeCoClaimantState()
)
const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(runtimeWrite).toBeDefined()
expect(tabIds(runtimeWrite, SHARED_ID)).toEqual(['runtime-tab'])
})
it('restores the parked rows on the debounced patch path too', () => {
const patch: SessionPatchMock = vi.fn(async () => {})
patchWorkspaceSessionByHost(
{ patch, get: vi.fn(), setSync: vi.fn() },
{ tabsByWorktree: livePayload().tabsByWorktree },
runtimeCoClaimantState()
)
const runtimePatch = patch.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(tabIds(runtimePatch, SHARED_ID)).toEqual(['runtime-tab'])
})
it('omits a field the patch never touched so the partition keeps its own copy', () => {
const patch: SessionPatchMock = vi.fn(async () => {})
patchWorkspaceSessionByHost(
{ patch, get: vi.fn(), setSync: vi.fn() },
{ activeTabId: 'tab-1' },
runtimeCoClaimantState()
)
const runtimePatch = patch.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(runtimePatch?.tabsByWorktree).toBeUndefined()
})
it('drops a parked row once the catalog says that host no longer publishes the id', async () => {
const set = await persist(
runtimeCoClaimantState({
worktreesByRepo: {
'repo-shared': [{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' }],
'repo-runtime': [
{
id: RUNTIME_ONLY_ID,
repoId: 'repo-runtime',
hostId: RUNTIME_HOST
}
]
}
})
)
const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(runtimeWrite?.tabsByWorktree[SHARED_ID]).toBeUndefined()
})
it('keeps a parked row whose workspace the catalog cannot speak for yet', async () => {
const folderKey = folderWorkspaceKey('folder-1')
const set = await persist(
runtimeCoClaimantState({
contestedHostWorkspaceSessions: {
[RUNTIME_HOST]: sessionWithTabs({
[folderKey]: [tab('folder-tab', folderKey)]
})
}
})
)
const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(tabIds(runtimeWrite, folderKey)).toEqual(['folder-tab'])
})
it('leaves an untouched partition alone rather than rewriting it from the shadow', async () => {
const set = await persist(contestedState({ contestedHostWorkspaceSessions: shadow }))
expect(set.mock.calls.some(([, hostId]) => hostId === RUNTIME_HOST)).toBe(false)
})
})
/**
* The read decides which partition a row came from; the write must not re-decide it. When the two
* disagreed, a write copied one host's workspace into another host's partition — worse than the
* shared bucket this PR set out to fix.
*/
describe('read-time primary is the one the write path honours', () => {
const RUNTIME_HOST: ExecutionHostId = 'runtime:env-1'
const RUNTIME_ONLY_ID = 'repo-runtime::/srv/app'
function sshVersusRuntimeState(
overrides: Partial<HostPersistenceState> = {}
): HostPersistenceState {
return {
repos: [],
worktreesByRepo: {
'repo-shared': [
{ id: SHARED_ID, repoId: 'repo-shared', hostId: SSH_HOST },
{ id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST }
],
'repo-runtime': [{ id: RUNTIME_ONLY_ID, repoId: 'repo-runtime', hostId: RUNTIME_HOST }]
},
...overrides
}
}
it('keeps an SSH claimant in the local partition it actually persists in', () => {
// Why this shape: the claims catalog sorts `runtime:` before `ssh:`, so picking a primary from
// claimants sent the SSH workspace's rows into the runtime partition.
expect(buildHostIdByWorktreeId(sshVersusRuntimeState())(SHARED_ID)).toBe('local')
})
it('does not strand the runtime co-claimant when the SSH row is written', async () => {
const set: SessionWriteMock = vi.fn(async () => {})
await persistWorkspaceSessionByHost(
{ set, get: vi.fn(), patch: vi.fn(), setSync: vi.fn(), flush: vi.fn(async () => {}) },
sessionWithTabs({
[SHARED_ID]: [tab('ssh-tab')],
[RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)]
}),
sshVersusRuntimeState({
contestedHostWorkspaceSessions: {
[RUNTIME_HOST]: sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] })
}
})
)
const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(runtimeWrite?.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([
'runtime-tab'
])
const localWrite = set.mock.calls.find(([, hostId]) => hostId === undefined)?.[0]
expect(localWrite?.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual(['ssh-tab'])
})
it('writes a row back to the only partition that had it instead of copying it', async () => {
const set: SessionWriteMock = vi.fn(async () => {})
await persistWorkspaceSessionByHost(
{ set, get: vi.fn(), patch: vi.fn(), setSync: vi.fn(), flush: vi.fn(async () => {}) },
sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] }),
{
repos: [],
worktreesByRepo: {
'repo-shared': [
{ id: SHARED_ID, repoId: 'repo-shared', hostId: 'local' },
{ id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST }
]
},
contestedPrimaryHostBySessionKey: { [SHARED_ID]: RUNTIME_HOST }
}
)
const runtimeWrite = set.mock.calls.find(([, hostId]) => hostId === RUNTIME_HOST)?.[0]
expect(runtimeWrite?.tabsByWorktree[SHARED_ID]?.map((entry) => entry.id)).toEqual([
'runtime-tab'
])
const localWrite = set.mock.calls.find(([, hostId]) => hostId === undefined)?.[0]
expect(localWrite?.tabsByWorktree[SHARED_ID]).toBeUndefined()
})
it('still migrates a workspace the catalog has re-attributed to another partition', () => {
const owner = buildHostIdByWorktreeId({
repos: [],
worktreesByRepo: {
'repo-shared': [{ id: SHARED_ID, repoId: 'repo-shared', hostId: RUNTIME_HOST }]
},
contestedPrimaryHostBySessionKey: { [SHARED_ID]: 'local' }
})
expect(owner(SHARED_ID)).toBe(RUNTIME_HOST)
})
it('records the partition every restored key came from, contested or not', () => {
const merged = mergeWorkspaceSessionsWithHostShadow({
local: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] }),
[RUNTIME_HOST]: sessionWithTabs({
[SHARED_ID]: [tab('runtime-tab')],
[RUNTIME_ONLY_ID]: [tab('runtime-only-tab', RUNTIME_ONLY_ID)]
})
})
expect(merged.primaryHostBySessionKey).toEqual({
[SHARED_ID]: 'local',
[RUNTIME_ONLY_ID]: RUNTIME_HOST
})
})
it('does not name a runtime owner for a key the local partition kept', async () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners(
{
get: vi.fn(async (hostId?: ExecutionHostId) =>
hostId === RUNTIME_HOST
? sessionWithTabs({ [SHARED_ID]: [tab('runtime-tab')] })
: sessionWithTabs({ [SHARED_ID]: [tab('local-tab')] })
)
},
[],
[RUNTIME_HOST]
)
// Why it matters: a runtime owner here makes startup build runtime placeholders for the local
// workspace whose row the merge actually kept.
expect(read.runtimeHostIdByWorkspaceSessionKey[SHARED_ID]).toBeUndefined()
expect(read.contestedPrimaryHostBySessionKey[SHARED_ID]).toBe('local')
})
})
@@ -0,0 +1,302 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership'
import {
isWorkspaceSessionRecord,
type WorkspaceSessionRecord
} from './workspace-session-host-records'
import type { WorkspaceRuntimeOwnerProjection } from './workspace-runtime-host-ownership'
import {
mergeWorkspaceSessionsFromHosts,
type HostSessionSlices
} from './workspace-session-host-split'
/**
* Which execution hosts publish each workspace id, and what persistence does when two of them
* publish the same one.
*
* A worktree id is `repoId::path` with no host component, so one repo registered on two hosts
* publishes the SAME id for two different workspaces (STA-4343). Session state is keyed by that
* bare id, so without this the two workspaces share one `tabsByWorktree` bucket and whichever host
* writes last erases the other's tabs for good.
*
* The contested id gets one primary host, whose entries keep the normal bare key in the unified
* renderer session. Every other claimant's entries are parked in a shadow that never reaches
* renderer state and is written straight back to that host's own partition, so no host's session is
* destroyed by another's write.
*
* The primary is decided ONCE, at read time, from the partition each row actually came from, and
* that decision is carried back to the write path. Re-deriving it from the catalog at write time
* would let the two disagree — the catalog names `ssh:*` hosts that own no partition — and the
* write would then copy one host's workspace into another host's partition.
*
* Known gaps: hosts that share a partition cannot be separated at all ('local' and every `ssh:*`
* host persist into the 'local' blob), and the unified renderer session still holds one bucket per
* bare id, so both workspaces display the primary's tabs. Closing either needs host-qualified keys
* through the whole tab store.
*/
export type WorktreeHostClaims = ReadonlyMap<string, ReadonlySet<ExecutionHostId>>
const WORKTREE_KEYED_FIELDS = (
Object.keys(WORKSPACE_SESSION_FIELD_OWNERSHIP) as (keyof WorkspaceSessionState)[]
).filter((field) => WORKSPACE_SESSION_FIELD_OWNERSHIP[field] === 'worktreeKeyed')
/** Bare worktree id behind a session key, which may be a WorkspaceKey or a host-qualified identity. */
export function normalizeWorkspaceSessionKeyToWorktreeId(value: string): string {
if (isWorktreeHostIdentity(value)) {
return getWorktreeIdFromHostIdentity(value)
}
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
function resolveClaimedHostId(
worktree: WorkspaceRuntimeOwnerProjection,
repoHostById: ReadonlyMap<string, ExecutionHostId | null>
): ExecutionHostId | null {
const runtimeOwner = worktree.runtimeOwnerEnvironmentId?.trim()
if (runtimeOwner) {
return toRuntimeExecutionHostId(runtimeOwner)
}
const parsed = parseExecutionHostId(worktree.hostId)
if (parsed) {
return parsed.id
}
// Why: an unqualified row is attributable only when its repo id names exactly one host —
// guessing would invent a contest that is not there, or hide one that is.
return repoHostById.get(worktree.repoId) ?? null
}
export function indexWorktreeHostClaims(
worktreesByRepo: Record<string, readonly WorkspaceRuntimeOwnerProjection[]>,
repoHostById: ReadonlyMap<string, ExecutionHostId | null>
): WorktreeHostClaims {
const claims = new Map<string, Set<ExecutionHostId>>()
for (const worktrees of Object.values(worktreesByRepo)) {
for (const worktree of worktrees) {
const hostId = resolveClaimedHostId(worktree, repoHostById)
if (!hostId) {
continue
}
const existing = claims.get(worktree.id)
if (existing) {
existing.add(hostId)
} else {
claims.set(worktree.id, new Set([hostId]))
}
}
}
return claims
}
/** The partition a host's session rows live in: a runtime host owns one, while 'local' and every
* `ssh:*` host share the 'local' blob. */
export function sessionPartitionHostFor(hostId: ExecutionHostId): ExecutionHostId {
return parseExecutionHostId(hostId)?.kind === 'runtime' ? hostId : LOCAL_EXECUTION_HOST_ID
}
/** Distinct partitions a set of claimants spans. Fewer than two means persistence cannot tell the
* claimants apart, so the id keeps its uncontested routing. */
export function contestedPartitionHosts(claimed: Iterable<ExecutionHostId>): ExecutionHostId[] {
return [...new Set([...claimed].map(sessionPartitionHostFor))]
}
/** Stable owner of a contested id: 'local' when it is a claimant, else the lowest host id.
* Deliberately not the active host — a primary that followed navigation would migrate the same
* rows between partitions on every workspace switch. */
export function pickPrimaryHostForClaims(hostIds: Iterable<ExecutionHostId>): ExecutionHostId {
const sorted = [...hostIds].sort()
return sorted.includes(LOCAL_EXECUTION_HOST_ID)
? LOCAL_EXECUTION_HOST_ID
: (sorted[0] ?? LOCAL_EXECUTION_HOST_ID)
}
function definedHostIds(slices: HostSessionSlices): ExecutionHostId[] {
return (Object.keys(slices) as ExecutionHostId[]).filter((hostId) => slices[hostId])
}
function indexHostIdsBySessionKey(
slices: HostSessionSlices,
hostIds: readonly ExecutionHostId[]
): Map<string, ExecutionHostId[]> {
const hostIdsByKey = new Map<string, ExecutionHostId[]>()
for (const hostId of hostIds) {
for (const field of WORKTREE_KEYED_FIELDS) {
const record = slices[hostId]?.[field]
if (!isWorkspaceSessionRecord(record)) {
continue
}
for (const key of Object.keys(record)) {
const owners = hostIdsByKey.get(key)
if (!owners) {
hostIdsByKey.set(key, [hostId])
} else if (!owners.includes(hostId)) {
owners.push(hostId)
}
}
}
}
return hostIdsByKey
}
function shadowHostEntries(
slice: WorkspaceSessionState,
hostId: ExecutionHostId,
primaryByKey: ReadonlyMap<string, ExecutionHostId>
): { slice: WorkspaceSessionState; shadow: WorkspaceSessionState | null } {
let nextSlice: WorkspaceSessionState | null = null
let shadow: WorkspaceSessionState | null = null
for (const field of WORKTREE_KEYED_FIELDS) {
const record = slice[field]
if (!isWorkspaceSessionRecord(record)) {
continue
}
const kept: WorkspaceSessionRecord = {}
const parked: WorkspaceSessionRecord = {}
for (const [key, entry] of Object.entries(record)) {
const primary = primaryByKey.get(key)
if (primary && primary !== hostId) {
parked[key] = entry
} else {
kept[key] = entry
}
}
if (Object.keys(parked).length === 0) {
continue
}
nextSlice ??= { ...slice }
shadow ??= {} as WorkspaceSessionState
;(nextSlice as WorkspaceSessionRecord)[field] = kept
;(shadow as WorkspaceSessionRecord)[field] = parked
}
return { slice: nextSlice ?? slice, shadow }
}
/** Split contested worktree-keyed entries out of the read partitions: the primary host's rows stay
* in the slices the renderer merges, every other claimant's rows move to the shadow.
*
* `primaryHostBySessionKey` records where each key's live row came from — including the
* uncontested single-partition case, so the write path can put every row back in its own
* partition instead of re-deriving an owner that may not match. */
export function extractContestedHostSessionEntries(slices: HostSessionSlices): {
slices: HostSessionSlices
shadow: HostSessionSlices
primaryHostBySessionKey: Record<string, ExecutionHostId>
} {
const shadow: HostSessionSlices = {}
const hostIds = definedHostIds(slices)
const hostIdsByKey = indexHostIdsBySessionKey(slices, hostIds)
const primaryHostBySessionKey: Record<string, ExecutionHostId> = {}
for (const [key, owners] of hostIdsByKey) {
primaryHostBySessionKey[key] = pickPrimaryHostForClaims(owners)
}
if (hostIds.length < 2) {
return { slices, shadow, primaryHostBySessionKey }
}
const primaryByKey = new Map<string, ExecutionHostId>()
for (const [key, owners] of hostIdsByKey) {
if (owners.length > 1) {
primaryByKey.set(key, pickPrimaryHostForClaims(owners))
}
}
if (primaryByKey.size === 0) {
return { slices, shadow, primaryHostBySessionKey }
}
const next: HostSessionSlices = { ...slices }
for (const hostId of hostIds) {
const slice = slices[hostId]
if (!slice) {
continue
}
const result = shadowHostEntries(slice, hostId, primaryByKey)
next[hostId] = result.slice
if (result.shadow) {
shadow[hostId] = result.shadow
}
}
return { slices: next, shadow, primaryHostBySessionKey }
}
export function mergeWorkspaceSessionsWithHostShadow(slices: HostSessionSlices): {
session: WorkspaceSessionState
slices: HostSessionSlices
shadow: HostSessionSlices
primaryHostBySessionKey: Record<string, ExecutionHostId>
} {
const extracted = extractContestedHostSessionEntries(slices)
return {
session: mergeWorkspaceSessionsFromHosts(extracted.slices),
slices: extracted.slices,
shadow: extracted.shadow,
primaryHostBySessionKey: extracted.primaryHostBySessionKey
}
}
function hostStillClaimsKey(
claims: WorktreeHostClaims,
key: string,
hostId: ExecutionHostId
): boolean {
const claimed = claims.get(normalizeWorkspaceSessionKeyToWorktreeId(key))
// Why: a missing catalog row is not evidence the host lost the workspace — the catalog may not
// have hydrated, or the key may be a folder workspace. Only a positive re-attribution drops a row.
return !claimed || claimed.has(hostId)
}
/** Whether the slices will be applied as a merge-by-field patch or a full partition replace. */
export type HostSessionWriteMode = 'patch' | 'replace'
/** Write parked entries back into their own host's slice so a write for the primary host cannot
* erase a co-claimant's persisted session. Mutates the slices produced by the split. */
export function attachHostSessionShadow(
slices: HostSessionSlices,
shadow: HostSessionSlices | undefined,
claims: WorktreeHostClaims,
mode: HostSessionWriteMode
): void {
if (!shadow) {
return
}
for (const [hostId, shadowSlice] of Object.entries(shadow) as [
ExecutionHostId,
WorkspaceSessionState | undefined
][]) {
const slice = slices[hostId]
if (!slice || !shadowSlice) {
continue
}
for (const field of WORKTREE_KEYED_FIELDS) {
const parked = shadowSlice[field]
if (!isWorkspaceSessionRecord(parked)) {
continue
}
let target = slice[field]
if (!isWorkspaceSessionRecord(target)) {
// Why the mode split: a patch that omits the field leaves the partition's own copy
// untouched, but a full set erases omitted fields, so the parked rows must ride along.
if (mode === 'patch') {
continue
}
target = {}
;(slice as WorkspaceSessionRecord)[field] = target
}
for (const [key, entry] of Object.entries(parked)) {
if (Object.hasOwn(target, key) || !hostStillClaimsKey(claims, key, hostId)) {
continue
}
target[key] = entry
}
}
}
}
@@ -0,0 +1,161 @@
import type { Repo } from '../../../shared/repo-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import {
getRepoExecutionHostId,
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import {
mergeWorkspaceSessionsWithHostShadow,
normalizeWorkspaceSessionKeyToWorktreeId
} from './workspace-session-host-contention'
import { nonLocalHostSessionEntries, type HostSessionSlices } from './workspace-session-host-split'
type SessionReadApi = {
get: (hostId?: ExecutionHostId) => Promise<WorkspaceSessionState>
}
export type WorkspaceSessionHostRead = {
session: WorkspaceSessionState
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
contestedHostWorkspaceSessions: HostSessionSlices
contestedPrimaryHostBySessionKey: Record<string, ExecutionHostId>
}
const WORKSPACE_SESSION_KEYED_FIELDS = [
'tabsByWorktree',
'openFilesByWorktree',
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'browserTabsByWorktree',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function addWorkspaceSessionKeyForOwnerMap(ids: Set<string>, value: unknown): void {
if (typeof value === 'string') {
ids.add(normalizeWorkspaceSessionKeyToWorktreeId(value))
}
}
function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] {
const ids = new Set<string>()
for (const field of WORKSPACE_SESSION_KEYED_FIELDS) {
const value = session[field]
if (isPlainRecord(value)) {
for (const id of Object.keys(value)) {
addWorkspaceSessionKeyForOwnerMap(ids, id)
}
}
}
for (const id of session.activeWorktreeIdsOnShutdown ?? []) {
addWorkspaceSessionKeyForOwnerMap(ids, id)
}
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
if (!Array.isArray(pages)) {
continue
}
for (const page of pages) {
addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId)
}
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
// Why: a hibernated agent can be the only restored session evidence for a
// runtime worktree before its remote catalog answers.
addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId)
}
return [...ids]
}
function buildRuntimeHostIdByWorkspaceSessionKey(
slices: HostSessionSlices
): Record<string, ExecutionHostId> {
const owners: Record<string, ExecutionHostId> = {}
const ambiguous = new Set<string>()
for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) {
for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) {
if (owners[worktreeId] && owners[worktreeId] !== hostId) {
ambiguous.add(worktreeId)
delete owners[worktreeId]
} else if (!ambiguous.has(worktreeId)) {
owners[worktreeId] = hostId
}
}
}
return owners
}
/** Collect the distinct runtime hosts owning any persisted repo. */
export function listKnownRuntimeHostIds(
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[]
): ExecutionHostId[] {
const hostIds = new Set<ExecutionHostId>()
for (const repo of repos) {
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
if (parsed?.kind === 'runtime') {
hostIds.add(parsed.id)
}
}
return [...hostIds]
}
/** Boot-time hydration: fetch the local partition plus one partition per known
* runtime host (from loaded repos and saved runtime ids), then merge them into
* the unified session the hydrators expect.
*
* Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with
* the rest. Corrupt partitions never reach here; persistence zod-validates
* each one and falls back to defaults on the main side. */
export async function fetchWorkspaceSessionFromHosts(
api: SessionReadApi,
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
): Promise<WorkspaceSessionState> {
return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds))
.session
}
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
api: SessionReadApi,
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
): Promise<WorkspaceSessionHostRead> {
const slices: HostSessionSlices = {
[LOCAL_EXECUTION_HOST_ID]: await api.get()
}
// Why: startup can know saved runtime session hosts before their repo
// catalogs hydrate, so include those partitions in the first read.
const runtimeHostIds = new Set<ExecutionHostId>([
...listKnownRuntimeHostIds(repos),
...additionalRuntimeHostIds
])
await Promise.all(
[...runtimeHostIds].map(async (hostId) => {
try {
slices[hostId] = await api.get(hostId)
} catch (err) {
console.warn(`[session] skipping unreadable host partition ${hostId}:`, err)
}
})
)
const merged = mergeWorkspaceSessionsWithHostShadow(slices)
return {
session: merged.session,
// Why the merged slices and not the raw ones: a row parked out of the renderer session must not
// still name its host as the owner, or startup builds runtime placeholders for a local row.
runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(merged.slices),
contestedHostWorkspaceSessions: merged.shadow,
contestedPrimaryHostBySessionKey: merged.primaryHostBySessionKey
}
}
@@ -5,13 +5,15 @@ import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/worksp
import {
buildHostIdByWorktreeId,
buildWorkspaceSessionHostSnapshots,
fetchWorkspaceSessionFromHosts,
fetchWorkspaceSessionWithRuntimeHostOwners,
patchWorkspaceSessionByHost,
persistWorkspaceSessionByHost,
persistWorkspaceSessionByHostSync,
type HostPersistenceState
} from './workspace-session-host-persistence'
import {
fetchWorkspaceSessionFromHosts,
fetchWorkspaceSessionWithRuntimeHostOwners
} from './workspace-session-host-hydration'
describe('fetchWorkspaceSessionFromHosts', () => {
it('reads saved runtime host partitions before runtime repos are loaded', async () => {
@@ -77,7 +79,9 @@ describe('fetchWorkspaceSessionFromHosts', () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.session.tabsByWorktree[worktreeId]).toHaveLength(1)
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
[worktreeId]: 'runtime:env-1'
})
})
it('normalizes canonical worktree session keys in runtime owner maps', async () => {
@@ -108,7 +112,9 @@ describe('fetchWorkspaceSessionFromHosts', () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
[worktreeId]: 'runtime:env-1'
})
})
it('returns runtime owners for folder workspace session keys', async () => {
@@ -140,7 +146,9 @@ describe('fetchWorkspaceSessionFromHosts', () => {
const read = await fetchWorkspaceSessionWithRuntimeHostOwners({ get }, [], ['runtime:env-1'])
expect(read.session.tabsByWorktree[folderKey]).toHaveLength(1)
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [folderKey]: 'runtime:env-1' })
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
[folderKey]: 'runtime:env-1'
})
})
it('returns runtime owners for sleeping-agent-only runtime worktrees', async () => {
@@ -172,7 +180,9 @@ describe('fetchWorkspaceSessionFromHosts', () => {
expect(read.session.sleepingAgentSessionsByPaneKey?.['remote-tab:leaf-1']?.worktreeId).toBe(
worktreeId
)
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({ [worktreeId]: 'runtime:env-1' })
expect(read.runtimeHostIdByWorkspaceSessionKey).toEqual({
[worktreeId]: 'runtime:env-1'
})
})
it('routes restored runtime folder workspace patches back to the runtime host', async () => {
@@ -200,7 +210,9 @@ describe('fetchWorkspaceSessionFromHosts', () => {
{
repos: [],
worktreesByRepo: {},
restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:env-1' }
restoredRuntimeHostIdByWorkspaceSessionKey: {
[folderKey]: 'runtime:env-1'
}
}
)
@@ -242,7 +254,9 @@ describe('fetchWorkspaceSessionFromHosts', () => {
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group-1' }],
projectGroups: [{ id: 'group-1', executionHostId: 'local' }],
worktreesByRepo: {},
restoredRuntimeHostIdByWorkspaceSessionKey: { [folderKey]: 'runtime:stale-env' }
restoredRuntimeHostIdByWorkspaceSessionKey: {
[folderKey]: 'runtime:stale-env'
}
}
)
@@ -280,8 +294,16 @@ describe('fetchWorkspaceSessionFromHosts', () => {
}
},
{
repos: [{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }],
worktreesByRepo: { 'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }] }
repos: [
{
id: 'remote-repo',
connectionId: null,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'remote-repo': [{ id: worktreeId, repoId: 'remote-repo' }]
}
}
)
@@ -334,12 +356,20 @@ describe('fetchWorkspaceSessionFromHosts', () => {
{
repos: [
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
{ id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' }
{
id: 'same-repo',
connectionId: null,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'same-repo': [
{ id: localWorktreeId, repoId: 'same-repo' },
{ id: remoteWorktreeId, repoId: 'same-repo', hostId: 'runtime:env-1' }
{
id: remoteWorktreeId,
repoId: 'same-repo',
hostId: 'runtime:env-1'
}
]
}
}
@@ -366,7 +396,11 @@ describe('fetchWorkspaceSessionFromHosts', () => {
const owner = buildHostIdByWorktreeId({
repos: [
{ id: 'same-repo', connectionId: null, executionHostId: 'local' },
{ id: 'same-repo', connectionId: null, executionHostId: 'runtime:env-1' }
{
id: 'same-repo',
connectionId: null,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'same-repo': [{ id: 'same-repo::/local-only', repoId: 'same-repo' }]
@@ -411,11 +445,21 @@ describe('fetchWorkspaceSessionFromHosts', () => {
const state = {
repos: [
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }
{
id: 'remote-repo',
connectionId: null,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
'remote-repo': [
{
id: remoteWorktreeId,
repoId: 'remote-repo',
hostId: 'runtime:env-1'
}
]
}
} satisfies HostPersistenceState
@@ -507,18 +551,30 @@ describe('persistWorkspaceSessionByHost', () => {
{
repos: [
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }
{
id: 'remote-repo',
connectionId: null,
executionHostId: 'runtime:env-1'
}
],
worktreesByRepo: {
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
'remote-repo': [
{
id: remoteWorktreeId,
repoId: 'remote-repo',
hostId: 'runtime:env-1'
}
]
}
}
)
expect(set).toHaveBeenCalledTimes(2)
expect(set).toHaveBeenCalledWith(
expect.objectContaining({ tabsByWorktree: { [localWorktreeId]: expect.any(Array) } })
expect.objectContaining({
tabsByWorktree: { [localWorktreeId]: expect.any(Array) }
})
)
expect(set).toHaveBeenCalledWith(
expect.objectContaining({
@@ -12,11 +12,16 @@ import {
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
attachHostSessionShadow,
contestedPartitionHosts,
indexWorktreeHostClaims,
normalizeWorkspaceSessionKeyToWorktreeId,
pickPrimaryHostForClaims,
type HostSessionWriteMode,
type WorktreeHostClaims
} from './workspace-session-host-contention'
import {
mergeWorkspaceSessionsFromHosts,
nonLocalHostSessionEntries,
splitWorkspaceSessionByHost,
type HostSessionSlices,
type HostIdByWorktreeId
@@ -36,6 +41,12 @@ export type HostPersistenceState = {
}[]
worktreesByRepo: Record<string, readonly WorkspaceRuntimeOwnerProjection[]>
restoredRuntimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
/** Entries a co-claimant host lost to the primary of a contested workspace id; written straight
* back to their own partition so the primary's write cannot erase them. */
contestedHostWorkspaceSessions?: HostSessionSlices
/** Partition each restored session key was read from. Routing honours it so a write returns rows
* to their own partition instead of re-deriving an owner the read never agreed to. */
contestedPrimaryHostBySessionKey?: Record<string, ExecutionHostId>
}
type SessionApi = {
@@ -49,97 +60,11 @@ type DurableSessionApi = SessionApi & {
flush: () => Promise<void>
}
export type WorkspaceSessionHostRead = {
session: WorkspaceSessionState
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
}
export type WorkspaceSessionHostSnapshot = {
state: WorkspaceSessionState
hostId?: ExecutionHostId
}
const WORKSPACE_SESSION_KEYED_FIELDS = [
'tabsByWorktree',
'openFilesByWorktree',
'activeFileIdByWorktree',
'activeBrowserTabIdByWorktree',
'activeTabTypeByWorktree',
'activeTabIdByWorktree',
'browserTabsByWorktree',
'unifiedTabs',
'tabGroups',
'tabGroupLayouts',
'activeGroupIdByWorktree',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId'
] as const satisfies readonly (keyof WorkspaceSessionState)[]
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string {
if (isWorktreeHostIdentity(value)) {
return getWorktreeIdFromHostIdentity(value)
}
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
function addWorkspaceSessionKeyForOwnerMap(ids: Set<string>, value: unknown): void {
if (typeof value === 'string') {
ids.add(normalizeWorkspaceSessionKeyForOwnerMap(value))
}
}
function collectWorkspaceSessionKeysFromHostSession(session: WorkspaceSessionState): string[] {
const ids = new Set<string>()
for (const field of WORKSPACE_SESSION_KEYED_FIELDS) {
const value = session[field]
if (isPlainRecord(value)) {
for (const id of Object.keys(value)) {
addWorkspaceSessionKeyForOwnerMap(ids, id)
}
}
}
for (const id of session.activeWorktreeIdsOnShutdown ?? []) {
addWorkspaceSessionKeyForOwnerMap(ids, id)
}
for (const pages of Object.values(session.browserPagesByWorkspace ?? {})) {
if (!Array.isArray(pages)) {
continue
}
for (const page of pages) {
addWorkspaceSessionKeyForOwnerMap(ids, page.worktreeId)
}
}
for (const record of Object.values(session.sleepingAgentSessionsByPaneKey ?? {})) {
// Why: a hibernated agent can be the only restored session evidence for a
// runtime worktree before its remote catalog answers.
addWorkspaceSessionKeyForOwnerMap(ids, record.worktreeId)
}
return [...ids]
}
function buildRuntimeHostIdByWorkspaceSessionKey(
slices: HostSessionSlices
): Record<string, ExecutionHostId> {
const owners: Record<string, ExecutionHostId> = {}
const ambiguous = new Set<string>()
for (const [hostId, slice] of nonLocalEntries(slices)) {
for (const worktreeId of collectWorkspaceSessionKeysFromHostSession(slice)) {
if (owners[worktreeId] && owners[worktreeId] !== hostId) {
ambiguous.add(worktreeId)
delete owners[worktreeId]
} else if (!ambiguous.has(worktreeId)) {
owners[worktreeId] = hostId
}
}
}
return owners
}
function getRestoredRuntimeHostId(
owners: Record<string, ExecutionHostId> | undefined,
key: string
@@ -176,35 +101,82 @@ function getFolderWorkspaceRuntimeHostId(
return restoredHostId ?? LOCAL_EXECUTION_HOST_ID
}
/** Map a worktree to the host partition it persists under.
*
* Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay
* in the 'local' partition because the SSH flow already persists them there (in
* the unified blob) and separately mirrors them to each target's remote
* snapshot — partitioning them too would double-own that data. */
export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId {
export type HostSessionRouting = {
hostIdByWorktreeId: HostIdByWorktreeId
claims: WorktreeHostClaims
}
function buildRepoHostById(
repos: HostPersistenceState['repos']
): Map<string, ExecutionHostId | null> {
const repoHostById = new Map<string, ExecutionHostId | null>()
for (const repo of state.repos) {
for (const repo of repos) {
const hostId = getRepoExecutionHostId(repo)
const existing = repoHostById.get(repo.id)
// Why: repo ids can repeat across hosts; ambiguous repo-only ownership
// must not let a runtime placeholder steal local session state.
repoHostById.set(repo.id, existing === undefined ? hostId : existing === hostId ? hostId : null)
}
return repoHostById
}
/** Map a worktree to the host partition it persists under, plus the host claims behind it.
*
* Why: only `runtime:*` worktrees are partitioned out. SSH-owned worktrees stay
* in the 'local' partition because the SSH flow already persists them there (in
* the unified blob) and separately mirrors them to each target's remote
* snapshot — partitioning them too would double-own that data. The one exception is an id two
* hosts both publish: it gets a deterministic primary so the co-claimant's rows can be parked in
* the shadow instead of sharing one bucket with it. */
/** True only when the catalog positively says `hostId` no longer holds the workspace. An id the
* catalog cannot speak for yet keeps its restored partition — the same rule the shadow uses. */
function catalogReattributedAwayFrom(
claims: WorktreeHostClaims,
worktreeId: string,
hostId: ExecutionHostId
): boolean {
const claimed = claims.get(worktreeId)
return Boolean(claimed) && !contestedPartitionHosts(claimed ?? []).includes(hostId)
}
export function buildHostSessionRouting(state: HostPersistenceState): HostSessionRouting {
const repoHostById = buildRepoHostById(state.repos)
const claims = indexWorktreeHostClaims(state.worktreesByRepo, repoHostById)
const restoredPrimaryByWorktreeId = new Map<string, ExecutionHostId>()
for (const [key, hostId] of Object.entries(state.contestedPrimaryHostBySessionKey ?? {})) {
restoredPrimaryByWorktreeId.set(normalizeWorkspaceSessionKeyToWorktreeId(key), hostId)
}
const { repoIdByWorktreeId, runtimeHostIdByWorktreeId } = indexWorkspaceRuntimeHostOwnership(
state.worktreesByRepo
)
return (worktreeId: string): ExecutionHostId => {
const hostIdByWorktreeId = (worktreeId: string): ExecutionHostId => {
const workspaceScope = parseWorkspaceKey(worktreeId)
if (workspaceScope?.type === 'folder') {
return getFolderWorkspaceRuntimeHostId(state, worktreeId)
}
const rawWorktreeId =
workspaceScope?.type === 'worktree' ? workspaceScope.worktreeId : worktreeId
const restoredPrimary =
state.contestedPrimaryHostBySessionKey?.[worktreeId] ??
restoredPrimaryByWorktreeId.get(rawWorktreeId)
if (restoredPrimary && !catalogReattributedAwayFrom(claims, rawWorktreeId, restoredPrimary)) {
// Why first: the read already decided which partition each row came from. Re-deriving an
// owner here is what let a write copy one host's workspace into another host's partition.
return restoredPrimary
}
const claimed = claims.get(rawWorktreeId)
if (claimed && claimed.size > 1) {
// Why partitions, not claimants: 'local' and every ssh host share one blob, so a claimant set
// that collapses to a single partition is not separable and keeps its normal routing.
const partitions = contestedPartitionHosts(claimed)
if (partitions.length > 1) {
return pickPrimaryHostForClaims(partitions)
}
}
const worktreeHostId = runtimeHostIdByWorktreeId.get(rawWorktreeId)
if (runtimeHostIdByWorktreeId.has(rawWorktreeId) && !worktreeHostId) {
// Why: a bare worktree id cannot safely select between two HUB partitions.
// Why: a bare worktree id whose claimants the catalog cannot name apart stays local.
return LOCAL_EXECUTION_HOST_ID
}
if (worktreeHostId) {
@@ -218,12 +190,24 @@ export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWo
const parsed = parseExecutionHostId(repoHostId)
return parsed?.kind === 'runtime' ? parsed.id : LOCAL_EXECUTION_HOST_ID
}
return { hostIdByWorktreeId, claims }
}
function nonLocalEntries(slices: HostSessionSlices): [ExecutionHostId, WorkspaceSessionState][] {
return (Object.entries(slices) as [ExecutionHostId, WorkspaceSessionState][]).filter(
([hostId, slice]) => hostId !== LOCAL_EXECUTION_HOST_ID && slice !== undefined
)
export function buildHostIdByWorktreeId(state: HostPersistenceState): HostIdByWorktreeId {
return buildHostSessionRouting(state).hostIdByWorktreeId
}
/** Partition a session for writing: route each entry to its owner host, then restore the parked
* rows of every host that lost a contested id so this write cannot erase them. */
function splitWorkspaceSessionForWrite(
payload: WorkspaceSessionState,
state: HostPersistenceState,
mode: HostSessionWriteMode
): HostSessionSlices {
const routing = buildHostSessionRouting(state)
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId)
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode)
return slices
}
/** Patch path of the debounced session writer: split the partial patch by owner
@@ -234,13 +218,10 @@ export function patchWorkspaceSessionByHost(
patch: WorkspaceSessionPatch,
state: HostPersistenceState
): Promise<void> {
const slices = splitWorkspaceSessionByHost(
patch as WorkspaceSessionState,
buildHostIdByWorktreeId(state)
)
const slices = splitWorkspaceSessionForWrite(patch as WorkspaceSessionState, state, 'patch')
const local = (slices[LOCAL_EXECUTION_HOST_ID] ?? patch) as WorkspaceSessionPatch
const localWrite = api.patch(local)
for (const [hostId, slice] of nonLocalEntries(slices)) {
for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) {
// Why: a failed runtime-partition write must not reject the local chain.
void api.patch(slice as WorkspaceSessionPatch, hostId).catch((err) => {
console.warn(`[session] host partition patch failed for ${hostId}:`, err)
@@ -257,9 +238,11 @@ export async function persistWorkspaceSessionByHost(
payload: WorkspaceSessionState,
state: HostPersistenceState
): Promise<void> {
const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state))
// Why 'replace': api.set swaps the whole partition, so parked rows must ride along even for
// fields nothing else routed to this host.
const slices = splitWorkspaceSessionForWrite(payload, state, 'replace')
const writes: Promise<void>[] = [api.set(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)]
for (const [hostId, slice] of nonLocalEntries(slices)) {
for (const [hostId, slice] of nonLocalHostSessionEntries(slices)) {
writes.push(api.set(slice, hostId))
}
await Promise.all(writes)
@@ -271,10 +254,14 @@ export function buildWorkspaceSessionHostSnapshots(
payload: WorkspaceSessionState,
state: HostPersistenceState
): WorkspaceSessionHostSnapshot[] {
const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state))
// Why 'replace': quit snapshots are applied as full partition sets.
const slices = splitWorkspaceSessionForWrite(payload, state, 'replace')
return [
{ state: slices[LOCAL_EXECUTION_HOST_ID] ?? payload },
...nonLocalEntries(slices).map(([hostId, hostState]) => ({ state: hostState, hostId }))
...nonLocalHostSessionEntries(slices).map(([hostId, hostState]) => ({
state: hostState,
hostId
}))
]
}
@@ -288,62 +275,3 @@ export function persistWorkspaceSessionByHostSync(
api.setSync(snapshot.state, snapshot.hostId)
}
}
/** Collect the distinct runtime hosts owning any persisted repo. */
export function listKnownRuntimeHostIds(
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[]
): ExecutionHostId[] {
const hostIds = new Set<ExecutionHostId>()
for (const repo of repos) {
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
if (parsed?.kind === 'runtime') {
hostIds.add(parsed.id)
}
}
return [...hostIds]
}
/** Boot-time hydration: fetch the local partition plus one partition per known
* runtime host (from loaded repos and saved runtime ids), then merge them into
* the unified session the hydrators expect.
*
* Fail-soft: a partition whose fetch rejects is skipped — boot proceeds with
* the rest. Corrupt partitions never reach here; persistence zod-validates
* each one and falls back to defaults on the main side. */
export async function fetchWorkspaceSessionFromHosts(
api: Pick<SessionApi, 'get'>,
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
): Promise<WorkspaceSessionState> {
return (await fetchWorkspaceSessionWithRuntimeHostOwners(api, repos, additionalRuntimeHostIds))
.session
}
export async function fetchWorkspaceSessionWithRuntimeHostOwners(
api: Pick<SessionApi, 'get'>,
repos: readonly Pick<Repo, 'connectionId' | 'executionHostId'>[],
additionalRuntimeHostIds: readonly ExecutionHostId[] = []
): Promise<WorkspaceSessionHostRead> {
const slices: HostSessionSlices = {
[LOCAL_EXECUTION_HOST_ID]: await api.get()
}
// Why: startup can know saved runtime session hosts before their repo
// catalogs hydrate, so include those partitions in the first read.
const runtimeHostIds = new Set<ExecutionHostId>([
...listKnownRuntimeHostIds(repos),
...additionalRuntimeHostIds
])
await Promise.all(
[...runtimeHostIds].map(async (hostId) => {
try {
slices[hostId] = await api.get(hostId)
} catch (err) {
console.warn(`[session] skipping unreadable host partition ${hostId}:`, err)
}
})
)
return {
session: mergeWorkspaceSessionsFromHosts(slices),
runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(slices)
}
}
@@ -291,6 +291,15 @@ export function splitWorkspaceSessionByHost(
return slices
}
/** Every defined non-'local' partition; 'local' is handled by its own dedicated write. */
export function nonLocalHostSessionEntries(
slices: HostSessionSlices
): [ExecutionHostId, WorkspaceSessionState][] {
return (Object.entries(slices) as [ExecutionHostId, WorkspaceSessionState][]).filter(
([hostId, slice]) => hostId !== LOCAL_EXECUTION_HOST_ID && slice !== undefined
)
}
/** Inverse of split: combine per-host slices into one unified session. Global
* fields are taken from the 'local' slice (it owns them); worktree/tab-scoped
* maps are unioned across all hosts. Tolerates missing or partial slices. */
@@ -55,6 +55,8 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
set({ terminalStartupRestorationReady: value })
},
restoredRuntimeHostIdByWorkspaceSessionKey: {},
contestedHostWorkspaceSessions: {},
contestedPrimaryHostBySessionKey: {},
defaultTerminalTabsAppliedByWorktreeId: {},
closedTerminalTabTombstonesByTabId: {},
hydrationSucceeded: false,
@@ -3,6 +3,7 @@ import type { AgentProviderSessionMetadata } from '../../../../shared/agent-sess
import type { DirectSshAuthority } from '../../../../shared/ssh-types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { WorkspaceSessionHydrationOptions } from '@/lib/workspace-session-hydration-keys'
import type { HostSessionSlices } from '@/lib/workspace-session-host-split'
/** In-memory recovery claim consumed only after the resumed terminal hook becomes live. */
export type AutomaticAgentResumeClaim = {
@@ -29,6 +30,10 @@ export type CodexRestartNotice = {
export type HydrateWorkspaceSessionOptions = {
directSshAuthority?: DirectSshAuthority
runtimeHostIdByWorkspaceSessionKey?: Record<string, ExecutionHostId>
/** Rows parked for hosts that lost a contested workspace id; omitted leaves the store's copy. */
contestedHostWorkspaceSessions?: HostSessionSlices
/** Partition each restored session key was read from; omitted leaves the store's copy. */
contestedPrimaryHostBySessionKey?: Record<string, ExecutionHostId>
} & WorkspaceSessionHydrationOptions
/** Scoped reconnect must still match this exact provider epoch and connection generation. */
@@ -16,6 +16,7 @@ import type {
DirectSshPaneRetryHistory
} from '../slices/direct-ssh-terminal-recovery'
import type { NativeChatLaunchDraft, NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'
import type { HostSessionSlices } from '@/lib/workspace-session-host-split'
import type { AutomaticAgentResumeClaim, CodexRestartNotice } from './terminal-contracts'
import type { StateCreator } from 'zustand'
import type { AppState } from '../types'
@@ -92,6 +93,14 @@ export type TerminalState = {
/** True after main ownership restoration, renderer PTY adoption, and structured-tab projection settle. */
terminalStartupRestorationReady: boolean
restoredRuntimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
/**
* Worktree-keyed session rows belonging to hosts that co-publish a workspace id with the host
* that owns it here. Never read by the UI: it is the carrier that lets a write for the owning
* host round-trip the other hosts' partitions instead of erasing them.
*/
contestedHostWorkspaceSessions: HostSessionSlices
/** Partition each restored session key was read from, so a write returns its rows there. */
contestedPrimaryHostBySessionKey: Record<string, ExecutionHostId>
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId
hydrationSucceeded: boolean
@@ -32,7 +32,10 @@ export type WorkspaceHydrationPatch = Pick<
| 'worktreeNavHistoryIndex'
| 'ptyIdsByTabId'
| 'terminalLayoutsByTabId'
>
> &
// Why partial: only a cold read carries the contested-host shadow; a scoped re-hydration must
// leave the store's copy alone rather than replace it with an empty one.
Partial<Pick<AppState, 'contestedHostWorkspaceSessions' | 'contestedPrimaryHostBySessionKey'>>
export function replaceHydratedRecordKeys<T>(
current: Record<string, T>,
@@ -172,6 +172,14 @@ export function createWorkspaceTerminalHydrationActions(
activeTabIdByWorktree,
restoredRuntimeHostIdByWorkspaceSessionKey:
options?.runtimeHostIdByWorkspaceSessionKey ?? {},
// Why conditional: a mid-session re-hydration (the SSH pull merge) carries no shadow, and
// clearing it there would drop the co-claimant rows the next write has to put back.
...(options?.contestedHostWorkspaceSessions
? { contestedHostWorkspaceSessions: options.contestedHostWorkspaceSessions }
: {}),
...(options?.contestedPrimaryHostBySessionKey
? { contestedPrimaryHostBySessionKey: options.contestedPrimaryHostBySessionKey }
: {}),
repos: runtimeSessionPlaceholders.repos,
tabsByWorktree,
worktreesByRepo,
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import { classifyDaemonPtyCwd, classifyDaemonSpawnerPath } from './daemon-adoption-telemetry'
import { eventSchemas } from './telemetry-event-registry'
describe('classifyDaemonSpawnerPath', () => {
const alwaysExists = () => true
it('classifies the installed app, the ShipIt staging area, and everything else', () => {
expect(
classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists)
).toBe('applications')
expect(
classifyDaemonSpawnerPath('/private/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists)
).toBe('applications')
expect(
classifyDaemonSpawnerPath(
'/Users/a/Library/Caches/com.stablyai.orca.ShipIt/update.abc/Orca.app/Contents/MacOS/Orca',
alwaysExists
)
).toBe('updater-cache')
expect(
classifyDaemonSpawnerPath('/Users/a/Applications/Orca.app/Contents/MacOS/Orca', alwaysExists)
).toBe('other')
expect(classifyDaemonSpawnerPath('/tmp/OrcaA.app/Contents/MacOS/Orca', alwaysExists)).toBe(
'other'
)
})
it('reports a deleted spawner as missing and an unrecorded one as unknown', () => {
expect(
classifyDaemonSpawnerPath('/Applications/Orca.app/Contents/MacOS/Orca', () => false)
).toBe('missing')
expect(classifyDaemonSpawnerPath(null, alwaysExists)).toBe('unknown')
})
})
describe('classifyDaemonPtyCwd', () => {
it('maps the TCC-protected home folders and separates the rest of home from outside it', () => {
expect(classifyDaemonPtyCwd('/Users/a/Documents/repo', '/Users/a')).toBe('documents')
expect(classifyDaemonPtyCwd('/Users/a/Desktop', '/Users/a/')).toBe('desktop')
expect(classifyDaemonPtyCwd('/Users/a/Downloads/x/y', '/Users/a')).toBe('downloads')
expect(classifyDaemonPtyCwd('/Users/a/projects/repo', '/Users/a')).toBe('other-home')
expect(classifyDaemonPtyCwd('/Users/a', '/Users/a')).toBe('other-home')
expect(classifyDaemonPtyCwd('/Volumes/ext/repo', '/Users/a')).toBe('outside-home')
// A sibling home that merely shares the prefix is not inside this home.
expect(classifyDaemonPtyCwd('/Users/ab/Documents', '/Users/a')).toBe('outside-home')
})
})
// Privacy invariant: enum-only. A raw path, version, or exact count must be rejected by .strict().
describe('daemon_adopted / daemon_pty_cwd_denied schemas', () => {
const adopted = {
app_version_match: 'different',
spawner_path_class: 'updater-cache',
tcc_attribution: 'intact',
live_session_count_bucket: '2-5'
}
const denied = {
cwd_class: 'documents',
app_version_match: 'different',
spawner_path_class: 'updater-cache'
}
it('accepts the enum payloads', () => {
expect(eventSchemas.daemon_adopted.safeParse(adopted).success).toBe(true)
expect(eventSchemas.daemon_pty_cwd_denied.safeParse(denied).success).toBe(true)
})
it('rejects leaked paths, versions, counts, and unknown enum values', () => {
for (const leak of [
{ spawner_exec_path: '/Users/alice/Library/Caches/ShipIt/Orca.app' },
{ app_version: '1.4.187' },
{ live_session_count: 3 },
{ cwd: '/Users/alice/Documents' }
]) {
expect(eventSchemas.daemon_adopted.safeParse({ ...adopted, ...leak }).success).toBe(false)
expect(eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, ...leak }).success).toBe(
false
)
}
expect(
eventSchemas.daemon_adopted.safeParse({ ...adopted, spawner_path_class: '/Applications' })
.success
).toBe(false)
expect(
eventSchemas.daemon_pty_cwd_denied.safeParse({ ...denied, cwd_class: 'Documents' }).success
).toBe(false)
})
})
+67
View File
@@ -0,0 +1,67 @@
// Enums for the `daemon_adopted` and `daemon_pty_cwd_denied` telemetry events (#17696).
// Both exist to measure how often a macOS app runs on a daemon left behind by an earlier app
// bundle, and how often such a daemon actually spawns a terminal whose cwd it cannot read.
// Enum-only: no paths, versions, or exact counts ever reach the wire.
/** How the adopted daemon's recorded app version compares to the running app. */
export const DAEMON_ADOPTED_APP_VERSION_MATCH = ['same', 'different', 'unknown'] as const
export type DaemonAdoptedAppVersionMatch = (typeof DAEMON_ADOPTED_APP_VERSION_MATCH)[number]
/**
* Where the binary that forked the adopted daemon lives now. `updater-cache` is the Squirrel
* ShipIt staging area — a daemon attributed there is the reported #17696 shape.
*/
export const DAEMON_SPAWNER_PATH_CLASSES = [
'applications',
'updater-cache',
'other',
'missing',
'unknown'
] as const
export type DaemonSpawnerPathClass = (typeof DAEMON_SPAWNER_PATH_CLASSES)[number]
export const DAEMON_TCC_ATTRIBUTION_VALUES = ['intact', 'severed', 'unknown'] as const
/** Which macOS-protected folder class the denied cwd falls under. */
export const DAEMON_PTY_CWD_CLASSES = [
'documents',
'desktop',
'downloads',
'other-home',
'outside-home'
] as const
export type DaemonPtyCwdClass = (typeof DAEMON_PTY_CWD_CLASSES)[number]
export function classifyDaemonSpawnerPath(
spawnerExecPath: string | null,
exists: (path: string) => boolean
): DaemonSpawnerPathClass {
if (!spawnerExecPath) {
return 'unknown'
}
if (!exists(spawnerExecPath)) {
return 'missing'
}
if (/\/Library\/Caches\/[^/]*ShipIt\//.test(spawnerExecPath)) {
return 'updater-cache'
}
return /^(?:\/private)?\/Applications\//.test(spawnerExecPath) ? 'applications' : 'other'
}
export function classifyDaemonPtyCwd(cwd: string, homeDir: string): DaemonPtyCwdClass {
const home = homeDir.replace(/\/+$/, '')
if (!home || !(cwd === home || cwd.startsWith(`${home}/`))) {
return 'outside-home'
}
const topLevel = cwd.slice(home.length + 1).split('/')[0]
switch (topLevel) {
case 'Documents':
return 'documents'
case 'Desktop':
return 'desktop'
case 'Downloads':
return 'downloads'
default:
return 'other-home'
}
}
@@ -182,6 +182,38 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
await runGit(['branch', '-D', 'compat-prepared-final'])
})
// Why pin this: the prepared-checkout retarget bound reads these as data, and it fails closed,
// so a version that printed a different shape would silently stop every retarget rather than
// error. Built with `commit-tree` so the check leaves no ref, branch, or worktree behind.
it('measures retarget drift identically on every supported Git', async () => {
const tree = (await runGit(['rev-parse', 'HEAD^{tree}'])).stdout.trim()
const head = (await runGit(['rev-parse', 'HEAD'])).stdout.trim()
const ahead1 = (await runGit(['commit-tree', tree, '-p', head, '-m', 'drift 1'])).stdout.trim()
const ahead2 = (
await runGit(['commit-tree', tree, '-p', ahead1, '-m', 'drift 2'])
).stdout.trim()
await expect(
runGit(['rev-list', '--count', '--max-count=101', '--end-of-options', `${head}..${ahead2}`])
).resolves.toMatchObject({ stdout: '2\n' })
// `--max-count` must report the capped number, not the full one: the bound reads it as a
// ceiling, so a Git that returned the true count would reject every retarget instead.
await expect(
runGit(['rev-list', '--count', '--max-count=1', '--end-of-options', `${head}..${ahead2}`])
).resolves.toMatchObject({ stdout: '1\n' })
await expect(
runGit(['rev-list', '--count', '--max-count=101', '--end-of-options', `${ahead2}..${head}`])
).resolves.toMatchObject({ stdout: '0\n' })
await expect(runGit(['merge-base', '--end-of-options', head, ahead2])).resolves.toMatchObject({
stdout: `${head}\n`
})
// A parentless commit shares no history, which is the case the bound must reject however few
// commits each side carries.
const unrelated = (await runGit(['commit-tree', tree, '-m', 'unrelated root'])).stdout.trim()
await expect(runGit(['merge-base', '--end-of-options', head, unrelated])).rejects.toBeDefined()
})
it('recognizes ref and merge-tree compatibility boundaries', async () => {
const fetchHeadPath = join(repoPath, '.git', 'FETCH_HEAD')
await writeFile(fetchHeadPath, 'sentinel\n')
@@ -14,6 +14,12 @@ import {
DAEMON_AUDIT_TRIGGER_VALUES,
DAEMON_EVIDENCE_SOURCE_VALUES
} from './daemon-audit-eligibility'
import {
DAEMON_ADOPTED_APP_VERSION_MATCH,
DAEMON_PTY_CWD_CLASSES,
DAEMON_SPAWNER_PATH_CLASSES,
DAEMON_TCC_ATTRIBUTION_VALUES
} from './daemon-adoption-telemetry'
import { errorClassSchema, settingsChangedKeySchema } from './telemetry-property-schemas'
// Why: daemon start-failure signal (fleet-wide outage like v1.4.129-rc.1); enum-only so raw stderr never reaches the wire.
@@ -50,6 +56,27 @@ export const mainThreadHangDetectedSchema = z
})
.strict()
// Why: #17696 — a macOS app adopting a daemon from an earlier bundle is invisible to
// `daemon_lifecycle` (nothing is replaced). Once per macOS launch that adopts; enum-only.
export const daemonAdoptedSchema = z
.object({
app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH),
spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES),
tcc_attribution: z.enum(DAEMON_TCC_ATTRIBUTION_VALUES),
live_session_count_bucket: z.enum(DAEMON_LIFECYCLE_SESSION_BUCKETS)
})
.strict()
// Why: the #17696 symptom itself — the daemon spawned a terminal into a cwd it cannot read while
// the app can. Emitted only on that proven divergence, so a missing or app-unreadable cwd never counts.
export const daemonPtyCwdDeniedSchema = z
.object({
cwd_class: z.enum(DAEMON_PTY_CWD_CLASSES),
app_version_match: z.enum(DAEMON_ADOPTED_APP_VERSION_MATCH),
spawner_path_class: z.enum(DAEMON_SPAWNER_PATH_CLASSES)
})
.strict()
// Why: daemon replace/retire lifecycle signal — issue #7936 was undiagnosable without asking a user for daemon.log.
// Enum-only + bucketed session count so no paths, raw versions, or exact counts reach the wire.
// The union keeps each reason pinned to its transition, so a death can't be reported as a replace.
+4
View File
@@ -14,8 +14,10 @@ import {
agentHookTransportBlockedSchema,
agentHookUnattributedSchema,
codexTrustGrantSchema,
daemonAdoptedSchema,
daemonAuditEligibilitySchema,
daemonLifecycleSchema,
daemonPtyCwdDeniedSchema,
daemonStartFailedSchema,
mainThreadHangDetectedSchema,
remoteOutboundBudgetCloseSchema,
@@ -122,6 +124,8 @@ export const eventSchemas = {
daemon_start_failed: daemonStartFailedSchema,
main_thread_hang_detected: mainThreadHangDetectedSchema,
daemon_lifecycle: daemonLifecycleSchema,
daemon_adopted: daemonAdoptedSchema,
daemon_pty_cwd_denied: daemonPtyCwdDeniedSchema,
daemon_audit_eligibility: daemonAuditEligibilitySchema,
runtime_rpc_start_failed: runtimeRpcStartFailedSchema,
remote_outbound_budget_close: remoteOutboundBudgetCloseSchema,
+29 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { resolveWorktreeAddBaseRef } from './base-ref'
import { resolveWorktreeAddBaseRef, worktreeBaseRefFamily } from './base-ref'
describe('resolveWorktreeAddBaseRef', () => {
it('leaves fully qualified refs unchanged', async () => {
@@ -62,3 +62,31 @@ describe('resolveWorktreeAddBaseRef', () => {
await expect(resolveWorktreeAddBaseRef('abc1234', refExists)).resolves.toBe('abc1234')
})
})
describe('worktreeBaseRefFamily', () => {
it('gives a local branch and its remote-tracking copies the same family', () => {
expect(worktreeBaseRefFamily('refs/heads/main')).toBe('main')
expect(worktreeBaseRefFamily('refs/remotes/origin/main')).toBe('main')
expect(worktreeBaseRefFamily('refs/remotes/upstream/main')).toBe('main')
})
it('keeps the full branch path for slash-containing branches', () => {
expect(worktreeBaseRefFamily('refs/heads/release/24.1')).toBe('release/24.1')
expect(worktreeBaseRefFamily('refs/remotes/origin/release/24.1')).toBe('release/24.1')
})
it('separates different branches', () => {
expect(worktreeBaseRefFamily('refs/heads/main')).not.toBe(
worktreeBaseRefFamily('refs/heads/release')
)
})
it('has no family for anything that is not a branch ref', () => {
expect(worktreeBaseRefFamily('abc1234')).toBeNull()
expect(worktreeBaseRefFamily('main')).toBeNull()
expect(worktreeBaseRefFamily('refs/tags/v1.0.0')).toBeNull()
expect(worktreeBaseRefFamily('refs/pull/123/head')).toBeNull()
expect(worktreeBaseRefFamily('refs/remotes/origin/HEAD')).toBeNull()
expect(worktreeBaseRefFamily('refs/remotes/origin')).toBeNull()
})
})
+27
View File
@@ -23,3 +23,30 @@ export async function resolveWorktreeAddBaseRef(
return baseRef
}
/**
* The branch identity two base refs share when one is the local branch and the
* other is a remote-tracking copy of it: `refs/heads/main` and
* `refs/remotes/origin/main` both return `main`.
*
* Bounds the prepared-checkout retarget. A prepared checkout may only be reused
* for a different base when both name the same branch, so the retarget reset is
* bounded by that branch's drift across remotes rather than by an arbitrary
* divergence. Anything unqualified — a bare name, a commit id — has no family.
*/
export function worktreeBaseRefFamily(qualifiedRef: string): string | null {
if (qualifiedRef.startsWith('refs/heads/')) {
return qualifiedRef.slice('refs/heads/'.length) || null
}
if (qualifiedRef.startsWith('refs/remotes/')) {
const withoutRemote = qualifiedRef.slice('refs/remotes/'.length)
const separator = withoutRemote.indexOf('/')
if (separator <= 0) {
return null
}
const branch = withoutRemote.slice(separator + 1)
// `refs/remotes/<remote>/HEAD` is a symbolic pointer, not a branch identity.
return branch && branch !== 'HEAD' ? branch : null
}
return null
}
+31
View File
@@ -31,9 +31,40 @@ export type WorktreeCreateTimingPhase = {
durationMs: number
}
/** Closed vocabulary: these values reach span attributes, so none of them may ever
* be derived from a branch name, a ref, or a path. */
export type PreparedCheckoutMissReason =
| 'none_armed'
/** Preparations exist, but none for this repo — it was never warmed, or the pool's size cap
* evicted it for another repo. Distinguished from `none_armed` because it is the signal that
* the cap is thrashing for a multi-project user. */
| 'repo_mismatch'
| 'base_mismatch'
| 'retarget_too_divergent'
/** The drift check returned no answer. Distinct from `retarget_too_divergent` because that one
* is the bound working as intended, while this one means a possibly cheap retarget was skipped
* anyway. Deliberately a mixed bucket — a blown deadline, a cancelled create, and an ordinary
* Git failure such as a missing ref all land here — so treat a rise as "look at why", not as a
* direct readout of the budget being too small. */
| 'retarget_unverifiable'
| 'workspace_root_mismatch'
| 'wsl_distro_mismatch'
| 'prepare_failed'
| 'finalize_failed'
| 'checkout_existing_branch'
| 'sparse_checkout'
/** Whether a create reused a prewarmed checkout, and when it did not, which part of
* the claim key disagreed. `retargeted` marks a hit that had to reset the prepared
* checkout onto a different ref in the same base family. */
export type PreparedCheckoutOutcome =
| { status: 'hit'; retargeted: boolean }
| { status: 'miss'; reason: PreparedCheckoutMissReason }
export type WorktreeCreateTiming = {
totalDurationMs: number
phases: WorktreeCreateTimingPhase[]
preparedCheckout?: PreparedCheckoutOutcome
}
export type CreateSparseCheckoutRequest = {