@akanjs/devkit 3.0.0-beta.9 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -3,9 +3,7 @@ import { DevGeneratedIndexSync } from "../frontendBuild";
3
3
  import { DevStabilityHarness, type DevStabilityHmrProbe } from "./devStabilityHarness";
4
4
 
5
5
  const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
6
- // Every test here boots a real dev server, and contention pushes a cold boot from ~3s to 21-55s
7
- // (`05-phase1-results.md`). At 120s a loaded machine ran out of budget mid-restart and reported it as a
8
- // product failure, which is the exact ambiguity this file exists to remove.
6
+ // Contention pushes a cold dev-server boot from ~3s to nearly a minute; 120s ran out mid-restart.
9
7
  const INTEGRATION_TIMEOUT_MS = 180_000;
10
8
  const MB = 1024 * 1024;
11
9
  const harnesses: DevStabilityHarness[] = [];
@@ -26,7 +24,7 @@ const isRefreshMessage = (msg: unknown): boolean =>
26
24
  typeof msg === "object" &&
27
25
  msg !== null &&
28
26
  "type" in msg &&
29
- (msg.type === "client-refresh" || msg.type === "rsc-refresh" || msg.type === "reload");
27
+ (msg.type === "ssr-update" || msg.type === "rsc-refresh" || msg.type === "reload");
30
28
 
31
29
  const isBuildStatus =
32
30
  (status: "error" | "ok") =>
@@ -38,15 +36,7 @@ const isBuildStatus =
38
36
  "status" in msg &&
39
37
  msg.status === status;
40
38
 
41
- /**
42
- * Assert an HMR message arrives — unless the socket dropped while waiting.
43
- *
44
- * The hub does not replay (`akanjs/server/hmr/wsHub.ts`): anything published while the probe was
45
- * reconnecting is simply gone, so a miss across a reconnect says nothing about the product. A real browser
46
- * covers the same gap by reloading when the `hello` buildId moved, not by expecting the message. Under
47
- * parallel load the socket flaps repeatedly, and this is what that looks like from the probe:
48
- * `socket=closed reconnects=5 since-mark=[build-status,hello,hello,rsc-refresh,…]`.
49
- */
39
+ // The hub does not replay (`akanjs/server/hmr/wsHub.ts`), so a miss across a probe reconnect proves nothing.
50
40
  const expectHmrMessage = async (
51
41
  probe: DevStabilityHmrProbe,
52
42
  mark: number,
@@ -117,21 +107,12 @@ const waitForProcessesGone = async (pids: number[], timeoutMs = 15_000): Promise
117
107
  return false;
118
108
  };
119
109
 
120
- /**
121
- * `cleanup()` waits up to 3s for a SIGTERM'd dev host to exit and shells out to `ps` twice, and on a
122
- * loaded machine that overruns Bun's default 5s hook budget. A hook timeout fails the test that had
123
- * already passed, which reads exactly like a product failure — two of the five failures in a 3-way
124
- * parallel run were this and nothing else.
125
- */
110
+ // `cleanup()` can overrun Bun's default 5s hook budget, and a hook timeout fails the already-passed test.
126
111
  const HOOK_TIMEOUT_MS = 60_000;
127
112
 
128
113
  beforeAll(async () => {
129
114
  if (!integrationEnabled) return;
130
- // A run killed mid-test (a `-t` filter interrupted, an editor stopping the runner) leaves its fixture
131
- // in `apps/` and its dev host running. Both interfere with every later run: the process holds ports
132
- // and rebuilds a deleted app, and the directory shifts the app index every port prediction derives
133
- // from. Only fixtures whose owning test pid is gone are swept, so this is safe with a suite running
134
- // alongside.
115
+ // A killed run leaves its fixture and dev host behind, holding ports and shifting every app index.
135
116
  const swept = await DevStabilityHarness.sweepAbandonedFixtures(DevStabilityHarness.defaultWorkspaceRoot);
136
117
  if (swept.length) console.info(`[harness] swept ${swept.length} abandoned fixture(s): ${swept.join(", ")}`);
137
118
  }, HOOK_TIMEOUT_MS);
@@ -143,14 +124,8 @@ afterEach(async () => {
143
124
  afterAll(() => {
144
125
  if (!integrationEnabled) return;
145
126
  const { edits, retried } = DevStabilityHarness.editStats();
146
- // Reported on purpose, every run. A retry means a real save produced no rebuild whatsoever — Bun's
147
- // dropped `fs.watch` event (`local/optimize-resource/06-watcher-dropped-event.md`), which users hit too
148
- // through format-on-save and save-all. A non-zero number here means this suite is passing *around* a
149
- // product bug rather than because it is absent, and it is the signal for fixing that bug properly.
127
+ // A retry is a save Bun's `fs.watch` dropped — a product bug users hit too, which this suite passes around.
150
128
  console.info(`[harness] ${edits} observed edit(s), ${retried} needed a retry after a dropped watch event`);
151
- // Every wait is individually bounded but their budgets sum past the per-test timeout, so a loaded round
152
- // can kill a test without any single wait failing — and Bun then reports only "this test timed out". These
153
- // are the numbers that say how close a green run came, and which step to look at when one dies.
154
129
  const slowest = DevStabilityHarness.waitStats();
155
130
  if (slowest.length)
156
131
  console.info(`[harness] slowest waits: ${slowest.map((wait) => `${wait.ms}ms ${wait.label}`).join(" | ")}`);
@@ -173,11 +148,46 @@ describe("dev stability integration harness", () => {
173
148
  await host.waitForLogSince(mark, /\[backend-reload\]|Shutting down gracefully|stopping backend/);
174
149
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
175
150
  expect(host.proc.killed).toBe(false);
176
- expect(host.logs.join("").slice(mark)).not.toMatch(/\[hmr\].*(client-refresh|rsc-refresh)/);
151
+ expect(host.logs.join("").slice(mark)).not.toMatch(/\[hmr\].*rsc-refresh|\[ssr\] registry generation/);
177
152
  await hmr?.waitForNoMessageSince(hmrMark, isRefreshMessage);
178
153
  hmr?.close();
179
154
  });
180
155
 
156
+ integrationTest(
157
+ "server edits landing while a restart stops the backend leave the port to the backend it tracks",
158
+ async () => {
159
+ const harness = await createHarness();
160
+ const host = await harness.startHost();
161
+ const port = await harness.resolvePort();
162
+ const writeMarker = (label: string) =>
163
+ harness.writeFile("srvkit/backendMarker.ts", `export const backendMarker = "${label}";\n`);
164
+
165
+ const { mark } = await harness.editUntilSeen(host, (attempt) => writeMarker(`burst-first-${attempt}`), {
166
+ evidence: /stopping backend pid=\d+/,
167
+ });
168
+ // Each lands past the 120ms debounce, so a restart timer fires while the one before still waits for its exit.
169
+ for (let idx = 0; idx < 8; idx++) {
170
+ await writeMarker(`burst-${idx}`);
171
+ await new Promise((resolve) => setTimeout(resolve, 200));
172
+ }
173
+
174
+ const trackedPid = () =>
175
+ [
176
+ ...host.logs
177
+ .join("")
178
+ .slice(mark)
179
+ .matchAll(/backend spawned pid=(\d+)/g),
180
+ ].at(-1)?.[1];
181
+ const settled = await waitForGatewayHealth(
182
+ port,
183
+ (health) => String(health.pid) === trackedPid() && health.children.some((child) => child.ready),
184
+ );
185
+ expect(String(settled.pid)).toBe(trackedPid() ?? "");
186
+ const since = host.logs.join("").slice(mark);
187
+ expect(since).not.toMatch(/already in use|not starting a second one/);
188
+ },
189
+ );
190
+
181
191
  integrationTest("client-only valid edits refresh browser state without backend restart", async () => {
182
192
  const harness = await createHarness();
183
193
  const host = await harness.startHost();
@@ -204,7 +214,10 @@ describe("dev stability integration harness", () => {
204
214
  if (hmr) {
205
215
  await expectHmrMessage(hmr, hmrMark, isRefreshMessage, "a client refresh");
206
216
  } else {
207
- await host.waitForLogSince(mark, /\[hmr\].*(client-refresh|rsc-refresh|reload)|\[SSR\] pages-updated/);
217
+ await host.waitForLogSince(
218
+ mark,
219
+ /\[hmr\].*(rsc-refresh|reload)|\[ssr\] registry generation|\[SSR\] pages-updated/,
220
+ );
208
221
  }
209
222
  expect(host.logs.join("").slice(mark)).not.toMatch(/\[backend-reload\]/);
210
223
  hmr?.close();
@@ -227,13 +240,7 @@ describe("dev stability integration harness", () => {
227
240
  );
228
241
  const generation = evidence[1];
229
242
  await host.waitForLogSince(mark, new RegExp(`\\[backend-reload\\].*generation=${generation}`));
230
- // Asserted backend-side, not through the probe. A shared edit restarts the backend, which closes the
231
- // socket the probe opened; the probe reconnects, but a reconnect does not replay what was published
232
- // while it was down, and a real browser handles that by reloading when the `hello` buildId moved
233
- // (`akanjs/server/hmr/clientScript.ts`) rather than by expecting the message. Requiring a probe
234
- // message here only held while the client rebuild happened to finish before the restart killed the
235
- // connection — a race this test lost the moment builds moved into a worker process and took ~240ms
236
- // longer to start.
243
+ // Backend-side: the restart closes the probe's socket, and a reconnect does not replay what it missed.
237
244
  await host.waitForLogSince(mark, new RegExp(`\\[SSR\\] pages-updated.*generation=${generation}`));
238
245
  await harness.waitForHttpText("updated-shared-marker");
239
246
  hmr?.close();
@@ -265,8 +272,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
265
272
  });
266
273
  `,
267
274
  ),
268
- // As with the config edit: each attempt recycles the builder and the backend, so patience is cheaper
269
- // than a retry.
275
+ // Each attempt recycles the builder and the backend, so patience is cheaper than a retry.
270
276
  { evidence: /\[dev-plan\].*actions=.*restart-builder/, attempts: 2, evidenceTimeoutMs: 30_000 },
271
277
  );
272
278
 
@@ -297,8 +303,7 @@ const config: AppConfig = { externalLibs: [] };
297
303
  export default config;
298
304
  `,
299
305
  ),
300
- // Re-applying this edit is expensive — every attempt restarts the whole dev host — so wait longer
301
- // before concluding the event was dropped rather than merely slow.
306
+ // Every attempt restarts the whole dev host, so wait longer before calling the event dropped.
302
307
  { evidence: /\[dev-plan\].*actions=.*restart-dev-host/, attempts: 2, evidenceTimeoutMs: 30_000 },
303
308
  );
304
309
 
@@ -338,8 +343,7 @@ export default config;
338
343
  await harness.waitForHttpText("initial-client-marker");
339
344
  const recoveryHmrMark = hmr?.mark() ?? 0;
340
345
 
341
- // The edit that used to be dropped: it lands right after the failed build's write burst, and in a
342
- // 3-way parallel run all three shards timed out here on 60s of completely empty log output.
346
+ // Lands right after the failed build's write burst, where Bun's watcher drops events.
343
347
  const { mark: recoveryMark } = await harness.editUntilSeen(host, (attempt) =>
344
348
  harness.writeFile(
345
349
  "ui/ClientMarker.tsx",
@@ -359,8 +363,7 @@ export default config;
359
363
  integrationTest("barrel add/delete includes generated indexes in watch generation", async () => {
360
364
  const harness = await createHarness();
361
365
  const sync = new DevGeneratedIndexSync({ workspaceRoot: harness.workspaceRoot });
362
- // `common` barrels export camelCase names; `ui` barrels export PascalCase component names. Each facet's
363
- // fixture file must follow its own casing convention or the barrel deliberately skips it.
366
+ // Each facet's barrel skips a file that breaks its casing convention (`common` camelCase, `ui` PascalCase).
364
367
  const facets = [
365
368
  { facet: "common", moduleName: "tmpExample", fileName: "tmpExample.ts", exportName: "commonTmpExample" },
366
369
  { facet: "ui", moduleName: "TmpExample", fileName: "TmpExample.tsx", exportName: "TmpExample" },
@@ -398,8 +401,7 @@ export default config;
398
401
  integrationTest("backend boot failure stops the crash loop, surfaces build-status, and recovers on fix", async () => {
399
402
  const harness = await createHarness();
400
403
  const host = await harness.startHost();
401
- // The service file is part of the generated server graph (`akan start` regenerates server.ts
402
- // from lib/), so a module-level throw here breaks every replica boot.
404
+ // `akan start` regenerates server.ts from lib/, so a module-level throw here breaks every replica boot.
403
405
  const { mark: failureMark } = await harness.editUntilSeen(host, (attempt) =>
404
406
  harness.writeFile(
405
407
  "lib/_fixture/fixture.service.ts",
@@ -412,9 +414,7 @@ throw new Error("intentional-backend-boot-crash-${attempt}");
412
414
  ),
413
415
  );
414
416
 
415
- // The gateway abandons the replica after three failed boots instead of retrying forever...
416
417
  await host.waitForLogSince(failureMark, /\[child-crash-loop\].*failed 3 consecutive boots/);
417
- // ...and the failure reaches the dev host's build-status pipeline (HMR overlay path).
418
418
  await host.waitForLogSince(failureMark, /\[build-status\].*phase=backend.*ok=false/);
419
419
  expect(host.proc.killed).toBe(false);
420
420
 
@@ -451,8 +451,7 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
451
451
 
452
452
  process.kill(gatewayPid, "SIGKILL");
453
453
 
454
- // Children must notice the closed IPC channel and exit instead of orphaning (they would
455
- // otherwise keep holding their ws ports and break every subsequent boot).
454
+ // An orphaned child would keep holding its ws port and break every later boot.
456
455
  expect(await waitForProcessesGone(childPids)).toBe(true);
457
456
 
458
457
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/);
@@ -467,8 +466,7 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
467
466
  integrationTest("occupied preferred ws port falls back to an ephemeral port and stays bootable", async () => {
468
467
  const harness = await createHarness();
469
468
  const port = await harness.resolvePort();
470
- // The gateway assigns child 0 the deterministic ws port `port + 10_000`; occupy it up front
471
- // the way an orphaned replica from a killed run would.
469
+ // Child 0's deterministic ws port, held the way an orphaned replica from a killed run would hold it.
472
470
  const blocker = Bun.serve({ port: port + 10_000, fetch: () => new Response("occupied") });
473
471
  try {
474
472
  const host = await harness.startHost();
@@ -491,34 +489,12 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
491
489
  });
492
490
  });
493
491
 
494
- /**
495
- * Resource budgets for `akan start`. A dev sandbox holds this process tree for a whole session, so
496
- * every regression here multiplies by the number of tenants. Budgets are deliberately loose (they
497
- * carry headroom over the measured values) — they exist to catch a *reintroduced* eager import or an
498
- * unbounded per-save ratchet, not to pin exact numbers. Raising one should be a visible diff.
499
- *
500
- * Deliberately two tests, not one per property: each boots a full dev server, and four boots after
501
- * the eleven tests above pushed cold boot from 3s to 21-55s through sheer machine contention, which
502
- * silently exhausted the waits and looked like product failures. Both tests therefore assert several
503
- * related properties against a single boot, with explicit generous waits. Run the block on its own
504
- * (`-t "dev resource budgets"`) when timing matters.
505
- *
506
- * **This block is not parallel-safe, and cannot be made so.** It asserts *absolute* resident memory, so
507
- * several dev servers competing for the machine changes what the number means rather than adding noise
508
- * around it — a 3-way parallel run reported 178MB against the 120MB budget. That is a property of the
509
- * measurement, not flakiness: run this block on an otherwise idle machine and parallelise the behavioural
510
- * block above. Loosening a budget to survive a parallel run would throw away the regression signal the
511
- * budget exists for.
512
- */
492
+ // Loose budgets that catch a reintroduced eager import or a per-save ratchet, not exact numbers. Absolute RSS is
493
+ // not parallel-safe: run this block alone on an idle machine (`-t "dev resource budgets"`), never loosen it.
513
494
  describe("dev resource budgets", () => {
514
495
  const BOOT_MS = 150_000;
515
496
  const WAIT_MS = 90_000;
516
- /**
517
- * Measured on this fixture at **260-516ms** per save. Set ~6× above that, like every budget here: it is
518
- * looking for a path that got slower by a factor — a boot build that stopped being avoidable, a worker
519
- * spawn that stopped being warm — not for jitter on a busy laptop. `apps/akan`, ~25× this fixture's
520
- * pages bundle, took ~1.5s per save when the plan started.
521
- */
497
+ // ~6x the measured 260-516ms per save: catches a path that got slower by a factor, not jitter.
522
498
  const SAVE_LATENCY_BUDGET_MS = 3_000;
523
499
  const budgetTest = (name: string, fn: () => Promise<void>): void => {
524
500
  if (integrationEnabled) test(name, fn, 300_000);
@@ -533,9 +509,7 @@ describe("dev resource budgets", () => {
533
509
  // A full minified browser-target build of every page, only reachable via `/__csr` and `?csr=true`.
534
510
  expect(host.logs.join("")).not.toMatch(/\[csr-build\] output ->/);
535
511
 
536
- // Mobile local dev points a device WebView at this URL, so it must serve HTML, not a 404. Wait
537
- // for the app to actually serve first: `backend ready` fires before the gateway routes to the
538
- // replica, and a too-early request 503s without reaching the router that arms CSR.
512
+ // `backend ready` fires before the gateway routes to the replica; an earlier request 503s without arming CSR.
539
513
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
540
514
  const armMark = host.markLog();
541
515
  const res = await fetch(`http://127.0.0.1:${port}/?csr=true`);
@@ -543,11 +517,7 @@ describe("dev resource budgets", () => {
543
517
  expect(await res.text()).toContain("<html");
544
518
  expect(host.logs.join("").slice(armMark)).toMatch(/csr-build ok on demand/);
545
519
 
546
- // Armed: from here on every save rebuilds CSR, which is what keeps a live mobile session working.
547
- //
548
- // `editUntilSeen` rather than a bare save, because the CSR build above emits a whole minified tree
549
- // into `.akan/artifact/csr` and a save landing in that window is dropped by Bun 100% of the time
550
- // (`local/optimize-resource/06-watcher-dropped-event.md`).
520
+ // `editUntilSeen`: Bun always drops a save landing in the CSR build's write burst.
551
521
  const { mark: resyncMark } = await harness.editUntilSeen(host, (attempt) =>
552
522
  harness.replaceText(
553
523
  "ui/ClientMarker.tsx",
@@ -555,38 +525,32 @@ describe("dev resource budgets", () => {
555
525
  `csr-armed-marker-${attempt}`,
556
526
  ),
557
527
  );
558
- await host.waitForLogSince(resyncMark, /csr-rebundle ok/, WAIT_MS);
528
+ // A worker logs `csr-dev generation=N patch`; the resident builder's patcher logs `csr-patch generation=N patch`.
529
+ await host.waitForLogSince(resyncMark, /csr-rebundle ok|csr-(?:dev|patch) generation=\d+ patch/, WAIT_MS);
559
530
  });
560
531
 
561
532
  budgetTest("bounds the rsc worker and the tree across repeated saves", async () => {
562
533
  const harness = await createHarness();
563
534
  const host = await harness.startHost({
564
- // Recycle on the second reload so this needs a couple of saves rather than the ten a
565
- // default-threshold run would take, and take the burst-coalescing floor out of the equation.
535
+ // Recycle on the second reload instead of the default tenth, with no burst-coalescing floor.
566
536
  timeoutMs: BOOT_MS,
567
537
  env: { AKAN_RSC_WORKER_MAX_RELOADS: "1", AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS: "1" },
568
538
  });
569
539
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
540
+ // The first page no longer waits for the SSR registry's boot build; the host logs when its worker is done, whether or
541
+ // not a backend was up to take the build's announcement.
542
+ await host.waitForLogSince(0, /\[builder\] boot builds settled/, WAIT_MS);
570
543
 
571
544
  const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
572
545
  const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
573
546
  const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
574
- // Nothing should be building at idle, so the disposable worker must not be resident.
575
547
  expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
576
- // Printed so a run that came close to its budget says so, instead of being indistinguishable from a
577
- // comfortable one — the same reason the recycle and idle-suspend guards report their numbers.
578
548
  console.info(
579
549
  `[budget-guard] idle tree ${Math.round(idleTotal / MB)}MB (builder ${Math.round((idleBuilder?.rssBytes ?? 0) / MB)}MB, rest ${Math.round(idleWithoutBuilder / MB)}MB)`,
580
550
  );
581
- // Split in two because the two halves have very different variance. The builder legitimately swings
582
- // from a ~130MB fresh floor to ~520MB once it has built a route (`Bun.build` arenas the RSS-ceiling
583
- // recycle is what bounds), so a tight total would flake. Measured 929-959MB total / ~414MB without
584
- // the builder; the old 1000MB total came from a phase-1 topology where the fixture measured ~670MB
585
- // and is only ~4% above what the current topology legitimately uses.
551
+ // Split: the builder alone swings ~130-520MB with `Bun.build` arenas, so a tight total would flake.
586
552
  expect(idleTotal).toBeLessThan(1_200 * MB);
587
- // This is the half that catches what the budget exists for: an eager import lands in the dev host or
588
- // the backend, not in the builder's arenas. The cheapest reintroduction is ~30MB and the devkit
589
- // barrel cycle was 236MB, so this bound still fails on the latter.
553
+ // An eager import lands here, in the dev host or backend, not in the builder's arenas.
590
554
  expect(idleWithoutBuilder).toBeLessThan(600 * MB);
591
555
 
592
556
  const start = host.markLog();
@@ -599,53 +563,39 @@ describe("dev resource budgets", () => {
599
563
  const afterSave = host.logs.join("").slice(mark);
600
564
  expect(afterSave).toMatch(/csr-rebundle skipped/);
601
565
  expect(afterSave).not.toMatch(/csr-rebundle ok/);
602
- // Waiting for the builder alone is not enough before the next iteration: the backend is still
603
- // applying the reload after that, and it writes into the watched tree too. `editUntilSeen` handles
604
- // the drop window itself, but these two waits are still what makes each iteration a whole
605
- // generation, which is what the RSS deltas below are measured across.
566
+ // The backend applies the reload after the builder finishes; wait for both so each save is a whole generation.
606
567
  await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
607
568
  await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
608
569
  }
609
570
 
610
- // Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
611
- // never evicts — so without a recycle the worker grows for the life of the process.
571
+ // Each reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry never evicts.
612
572
  await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
613
573
 
614
- // The dev host, gateway, replica and rsc worker must all stay flat across saves.
615
574
  const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
616
575
  excludeBuilder: true,
617
576
  });
618
577
  expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
619
578
 
620
- // And so must the builder. It used to be excluded from this budget because `Bun.build` retains
621
- // native arenas no GC reclaims, which made it grow ~120MB per save on this fixture; every build
622
- // that scales per save now runs in a process that exits, so its memory goes back to the OS.
579
+ // Every per-save build runs in a worker that exits, so the builder must stay flat too.
623
580
  const afterBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
624
581
  expect(afterBuilder?.pid).toBe(idleBuilder?.pid);
625
582
  expect((afterBuilder?.rssBytes ?? 0) - (idleBuilder?.rssBytes ?? 0)).toBeLessThan(30 * MB);
626
- // The worker is transient: three generations built, and none of them is still around.
627
583
  expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
628
584
  });
629
585
 
630
586
  budgetTest("recycles the builder at an unmeetable ceiling and keeps developing through it", async () => {
631
587
  const harness = await createHarness();
632
- // Deliberately *below* this fixture's post-boot builder. Moving every per-save build into a
633
- // disposable worker means the builder no longer grows into a ceiling, so a ceiling it is already
634
- // over is the only way left to drive the recycle path end to end — and it is also the case the
635
- // escape hatch exists for: an app whose boot floor simply does not fit under the limit.
588
+ // Below the builder once it has served the first route build (about 380MiB), which arms the recycle.
636
589
  const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
637
590
  const start = host.markLog();
638
591
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
639
592
 
640
- // One save is enough: the builder reports its rss as soon as the batch drains, and the host arms
641
- // the recycle from that report. The old pid comes from the log rather than from `ps`, so this does
642
- // not race the swap it is about to observe.
593
+ // One save arms the recycle; the old pid comes from the log, not `ps`, so it cannot race the swap.
643
594
  const { mark: firstSave } = await harness.editUntilSeen(host, (attempt) =>
644
595
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-1-${attempt}`),
645
596
  );
646
597
  await host.waitForLogSince(firstSave, /pages-rebundle ok/, WAIT_MS);
647
598
 
648
- // The host decides, the builder drains rather than being killed, and the replacement comes up.
649
599
  const recycleLog = await host.waitForLogSince(
650
600
  start,
651
601
  /recycling builder pid=(\d+) \((rss=\d+MiB>=200MiB after \d+ build\(s\))\)/,
@@ -654,19 +604,14 @@ describe("dev resource budgets", () => {
654
604
  await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
655
605
  await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
656
606
  await host.waitForLogSince(start, /builder ready after restart/, WAIT_MS);
657
- // The backend read `base-artifact.json` once at boot, so the replacement has to re-announce what
658
- // it booted with or the backend keeps serving the artifact of the builder that just exited.
607
+ // The backend read `base-artifact.json` once at boot, so the replacement must re-announce its boot state.
659
608
  await host.waitForLogSince(start, /announced boot state after recycle/, WAIT_MS);
660
609
 
661
610
  const recycled = await DevStabilityHarness.builderProcess(host.proc.pid);
662
611
  expect(recycled).not.toBeNull();
663
612
  expect(String(recycled?.pid)).not.toBe(recycleLog[1]);
664
613
 
665
- // And the dev server is still a dev server: the replacement watches, rebuilds and serves.
666
- //
667
- // Waiting for readiness above is load-bearing, not padding. The watcher is installed at the end of
668
- // the boot build, so a save during the recycle is seen by neither builder — this test lost one
669
- // exactly that way.
614
+ // The readiness waits above are load-bearing: a save during the recycle is seen by neither builder.
670
615
  const postRecycle = await harness.editUntilSeen(host, (attempt) =>
671
616
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-recycle-${attempt}`),
672
617
  );
@@ -676,90 +621,72 @@ describe("dev resource budgets", () => {
676
621
  );
677
622
  await harness.waitForHttpText("marker-after-recycle", WAIT_MS);
678
623
 
679
- // This fixture's builder boots well under the ceiling and blows past it on its first build, which
680
- // is the shape of every app the ceiling is derived for — a 1.2GB sandbox gives the builder ~420MB
681
- // and a single route build costs ~247MB. So the host says the ceiling is tight...
624
+ // A ceiling the replacement is back over within the interval is reported, and still enforced (recycles are
625
+ // throttled), never disabled for the session. Whether it is back over depends on the machine's allocator, so the
626
+ // warning is asserted only when it is; the decision itself is unit-tested (devHostPolicy).
627
+ const settledFrom = host.markLog();
682
628
  for (let i = 1; i <= 3; i++) {
683
629
  const { mark } = await harness.editUntilSeen(host, (attempt) =>
684
630
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-settled-${i}-${attempt}`),
685
631
  );
686
632
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS).catch(() => undefined);
687
633
  }
688
- await host.waitForLogSince(start, /ceiling costs about one boot build per interval/, WAIT_MS);
689
- // ...and goes on enforcing it. This used to disable the ceiling for the rest of the session on the
690
- // same evidence, which on a small sandbox means nothing bounds the builder from the first page load
691
- // onwards. Recycling is throttled to one per interval; that throttle is the answer to the cost, not
692
- // dropping the bound.
634
+ const settled = await DevStabilityHarness.builderProcess(host.proc.pid);
635
+ const warned = /ceiling costs about one boot build per interval/.test(host.logs.join("").slice(start));
636
+ //? Past the interval (a slow machine) the next report recycles instead of warning: either answer settles it.
637
+ if ((settled?.rssBytes ?? 0) >= 200 * MB && !warned)
638
+ await host.waitForLogSince(
639
+ settledFrom,
640
+ /ceiling costs about one boot build per interval|recycling builder pid=\d+|skipped: the builder fell to/,
641
+ WAIT_MS,
642
+ );
693
643
  expect(host.logs.join("").slice(start)).not.toMatch(/no longer enforcing it this session/);
694
644
  });
695
645
 
696
- // The guard the memory work did not have. Every budget above asserts RSS; none of them asserts that a
697
- // page still renders while the machinery those budgets require is mid-swap — and that gap is where two
698
- // shipped defects lived (`local/optimize-resource/19-shutdown-and-restart-races.md`).
699
646
  budgetTest("serves a page requested while the builder is being replaced", async () => {
700
647
  const harness = await createHarness();
701
- // Same unmeetable ceiling as the guard above, for the same reason: a builder that no longer grows
702
- // into a ceiling has to start under one for the recycle path to run end to end.
703
648
  const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_BUILDER_MAX_RSS_MB: "200" } });
704
649
  const port = await harness.resolvePort();
705
650
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
706
651
 
707
652
  const start = host.markLog();
708
- // One save does both halves of the setup: it drops the route's client entries, so the next request
709
- // has to ask the builder again rather than being served from cache, and it makes the builder report
710
- // an rss over the ceiling, which is what arms the recycle.
653
+ // One save both evicts the route's cached client entries and arms the recycle.
711
654
  const { mark } = await harness.editUntilSeen(host, (attempt) =>
712
655
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-through-recycle-${attempt}`),
713
656
  );
714
657
  await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
715
658
 
716
- // A builder that has been asked to drain is still alive and refuses everything new, so this request
717
- // has to be held for the replacement exactly like one that arrives after the process is gone. Timing
718
- // decides which of the two windows it actually lands in — the drain of an idle builder is short, and
719
- // the log is polled — so what is asserted is the outcome both windows must produce. The drain itself
720
- // is pinned deterministically one layer down, in `incrementalBuilder.host.test.ts`.
659
+ // Timing decides whether this lands in the drain or after the exit, so assert what both must produce.
721
660
  await host.waitForLogSince(start, /recycling builder pid=\d+/, WAIT_MS);
722
- // Not awaited here: a held request only returns once the replacement is up, and waiting for it would
723
- // put every assertion below on the far side of the window they are about.
661
+ // Not awaited: a held request returns only once the replacement is up.
724
662
  const drainRequest = fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(WAIT_MS) }).then(
725
663
  async (response) => ({ status: response.status, html: await response.text() }),
726
664
  );
727
665
 
728
- // Wait until the replacement exists but is still doing its boot build. From here it cannot answer
729
- // anything for seconds — which is exactly the window a developer's reload lands in, and the point of
730
- // anchoring on the spawn rather than on the exit: the gap after it is wide and known, not a race.
666
+ // Anchored on the spawn: the replacement's boot build leaves a wide, known window rather than a race.
731
667
  await host.waitForLogSince(start, /exiting for recycle/, WAIT_MS);
732
668
  await host.waitForLogSince(start, /builder spawned pid=\d+ .*restart=1/, WAIT_MS);
733
669
 
734
670
  const holdMark = host.markLog();
735
671
  const startedAtMono = performance.now();
736
- // The second route, never requested before, so this one cannot be answered from the route cache the
737
- // request above just populated — it has to reach a builder that is not there yet.
672
+ // Never requested before, so no route cache can answer it; it has to reach a builder that is not there yet.
738
673
  const res = await fetch(`http://127.0.0.1:${port}/second`, { signal: AbortSignal.timeout(WAIT_MS) });
739
674
  const html = await res.text();
740
675
  const heldMs = Math.round(performance.now() - startedAtMono);
741
676
 
742
- // What the developer sees, asserted first because it is the whole point: with the hold removed this
743
- // same request is answered **500** by the dev error page, and nothing retries on its own — the tab
744
- // stays broken until it is reloaded by hand. Measured both ways before this guard was committed.
677
+ // Without the hold this is a 500 from the dev error page, which never retries on its own.
745
678
  expect(res.status).toBe(200);
746
679
  expect(html).toContain("marker-through-recycle");
747
680
  expect(html).not.toContain("reload after the builder is ready");
748
- // And this is what stops the test passing having tested nothing: a request that arrives after the
749
- // replacement is already ready never reaches the path under test, and everything above holds anyway.
681
+ // Proves the request reached the hold, rather than arriving after the replacement was already ready.
750
682
  expect(host.logs.join("").slice(holdMark)).toMatch(/holding build-route until the builder is ready/);
751
683
 
752
- // The one fired at the drain, whichever side of the exit it landed on.
753
684
  const drained = await drainRequest;
754
685
  expect(drained.status).toBe(200);
755
686
  expect(drained.html).toContain("marker-through-recycle");
756
687
  console.info(`[restart-guard] page held ${heldMs}ms across the recycle, then rendered`);
757
688
  });
758
689
 
759
- // The other half of a resource budget, and the half this plan spent: every megabyte above was bought
760
- // with latency — a worker process spawned per save, a boot build per recycle and per wake, a hold
761
- // window in front of requests that land in one. Nothing measured what that cost, so a change that
762
- // traded another second per save for another 50MB would have passed every guard in this block.
763
690
  budgetTest("keeps a save's round trip inside its budget", async () => {
764
691
  const harness = await createHarness();
765
692
  const host = await harness.startHost({ timeoutMs: BOOT_MS });
@@ -768,45 +695,37 @@ describe("dev resource budgets", () => {
768
695
  const samples: number[] = [];
769
696
  for (let i = 1; i <= 3; i++) {
770
697
  let savedAtMono = 0;
771
- // Timed from inside the mutate callback so a retried save — Bun drops watcher events, which is why
772
- // `editUntilSeen` exists — is measured from the attempt that actually landed, not from the first.
698
+ // Timed inside the mutate callback, so a retried save is measured from the attempt that landed.
773
699
  const { mark } = await harness.editUntilSeen(host, async (attempt) => {
774
700
  await harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-latency-${i}-${attempt}`);
775
701
  savedAtMono = performance.now();
776
702
  });
777
- // The moment the running dev server is serving the new code, which is everything the developer is
778
- // waiting for. Deliberately not the browser's refresh message: that one is only published when a
779
- // client is connected, so measuring it would measure a WebSocket probe's reconnects as well.
703
+ // Not the refresh message: that is published only while a client is connected.
780
704
  await host.waitForLogSince(mark, /\[hmr\] backend apply/, WAIT_MS);
781
705
  samples.push(performance.now() - savedAtMono);
782
706
  }
783
707
 
784
708
  const rounded = samples.map((ms) => Math.round(ms));
785
709
  console.info(`[latency-guard] save -> new code live ${rounded.join("ms, ")}ms`);
786
- // Per save, not summed: the interesting regression is one save getting slower, and the median would
787
- // hide a first save that pays for something the rest do not.
710
+ // The worst save, not a median, which would hide a first save that pays for something the rest do not.
788
711
  expect(Math.max(...rounded)).toBeLessThan(SAVE_LATENCY_BUDGET_MS);
789
712
  });
790
713
 
791
714
  budgetTest("suspends the builder when the dev server goes idle and wakes it on the next edit", async () => {
792
715
  const harness = await createHarness();
793
- // 3s stands in for the 5min default: the machinery is the same, and the guard needs the dev server
794
- // to actually reach idle inside a test.
716
+ // 3s stands in for the 5min default so the dev server reaches idle inside a test.
795
717
  const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
796
718
  const start = host.markLog();
797
719
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
798
720
 
799
- // Nothing periodic may keep the dev server "busy": if the builder reported metrics on a timer, or
800
- // the backend wrote a watched file, this would never fire.
721
+ // Never fires if anything periodic (timed metrics, a watched-file write) keeps the dev server busy.
801
722
  await host.waitForLogSince(start, /\[idle-suspend\] no build activity for \d+s; released the builder/, WAIT_MS);
802
723
  const suspendedBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
803
724
  expect(suspendedBuilder).toBeNull();
804
725
  // Only build capacity suspends — the backend keeps serving the preview URL.
805
726
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
806
727
 
807
- // The edit that has to wake it. A suspended host has just installed a fresh watcher over a tree the
808
- // suspend itself churned, so this is squarely inside Bun's drop window — it is what failed here in a
809
- // 3-way parallel run, on 60s of empty log output.
728
+ // A fresh watcher over a tree the suspend just churned: squarely inside Bun's drop window.
810
729
  const { mark } = await harness.editUntilSeen(
811
730
  host,
812
731
  (attempt) => harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-after-wake-${attempt}`),
@@ -821,7 +740,6 @@ describe("dev resource budgets", () => {
821
740
  await harness.waitForHttpText("marker-after-wake", WAIT_MS);
822
741
  console.info(`[idle-suspend-guard] woke in ${awake[1]}ms; builder back at pid=${wokenBuilder?.pid}`);
823
742
 
824
- // And it is a normal dev server again afterwards.
825
743
  const postWake = await harness.editUntilSeen(host, (attempt) =>
826
744
  harness.replaceText("ui/ClientMarker.tsx", /marker(-[\w-]+)?/, `marker-postwake-${attempt}`),
827
745
  );
@@ -838,8 +756,7 @@ describe("dev resource budgets", () => {
838
756
  await host.waitForLogSince(start, /\[idle-suspend\] .*released the builder/, WAIT_MS);
839
757
  expect(await DevStabilityHarness.builderProcess(host.proc.pid)).toBeNull();
840
758
 
841
- // A browser asking for something the backend cannot serve on its own is the other wake trigger, and
842
- // the request must not be answered with "builder is stopped" the way a dead builder's would be.
759
+ // A request only the builder can serve also wakes it, and must not be answered "builder is stopped".
843
760
  const mark = host.markLog();
844
761
  const port = await harness.resolvePort();
845
762
  const status = await fetch(`http://127.0.0.1:${port}/__csr`)
@@ -847,19 +764,13 @@ describe("dev resource budgets", () => {
847
764
  .catch(() => 0);
848
765
 
849
766
  await host.waitForLogSince(mark, /\[idle-suspend\] waking \(build-csr arrived while suspended\)/, WAIT_MS);
850
- // `[builder]`, not `[idle-suspend]`: the same queue now also holds requests across a builder restart,
851
- // so the replay says what it did rather than which of the two reasons put the request there. The wake
852
- // line above is what distinguishes them, and it is asserted first for that reason.
767
+ // `[builder]`: the same queue holds requests across a restart too; the wake line above tells them apart.
853
768
  await host.waitForLogSince(mark, /\[builder\] replaying 1 request\(s\) held while the builder was away/, WAIT_MS);
854
769
  expect(status).toBe(200);
855
770
  expect(await DevStabilityHarness.builderProcess(host.proc.pid)).not.toBeNull();
856
771
  });
857
772
 
858
- // The gap on the other side of the same window: requests held across it are answered, but for a while
859
- // nothing was *watching* across it. The suspend stops its watcher before the replacement builder has
860
- // primed its index, and the replacement primes from the disk it finds — so a save in between is
861
- // baseline to it, reported by nobody, and the backend goes on running the code it replaced. Phase 2
862
- // made this window routine by recycling the builder on every rss ceiling crossing.
773
+ // Nothing watches between the suspend stopping its watcher and the replacement priming its index from disk.
863
774
  budgetTest("restarts the backend for a save that lands while the builder is away", async () => {
864
775
  const harness = await createHarness();
865
776
  const host = await harness.startHost({ timeoutMs: BOOT_MS, env: { AKAN_DEV_IDLE_SUSPEND_MS: "3000" } });
@@ -869,13 +780,9 @@ describe("dev resource budgets", () => {
869
780
 
870
781
  const mark = host.markLog();
871
782
  const port = await harness.resolvePort();
872
- // This request is the clock: it wakes the dev server and is answered only once the replacement
873
- // builder is ready, so a write issued while it is in flight lands squarely inside the gap. Its own
874
- // status is not asserted here — the write below restarts the backend, which drops the connection.
783
+ // The clock: in flight until the replacement is ready; its status is moot once the backend restarts.
875
784
  const request = fetch(`http://127.0.0.1:${port}/__csr`).catch(() => null);
876
- // A service the backend imports, rather than this fixture's `srvkit/backendMarker.ts`: that marker is
877
- // orphaned once `akan start` regenerates `server.ts`, so it is not in the backend graph at all and an
878
- // edit to it restarts the backend by path role instead.
785
+ // Not `srvkit/backendMarker.ts`: the regenerated `server.ts` orphans it, so it restarts by path role only.
879
786
  await harness.writeFile(
880
787
  "lib/_fixture/fixture.service.ts",
881
788
  `import { serve } from "akanjs/service";
@@ -888,7 +795,6 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
888
795
 
889
796
  await host.waitForLogSince(mark, /\[builder-gap\] 1 backend file\(s\) changed while the builder was away/, WAIT_MS);
890
797
  await host.waitForLogSince(mark, /\[backend-reload\]/, WAIT_MS);
891
- // And back to a working dev server, rather than one stuck restarting.
892
798
  await host.waitForLogSince(mark, /backend ready pid=(\d+)|AkanApp gateway is running on port/, WAIT_MS);
893
799
  await harness.waitForHttpText("initial-client-marker", WAIT_MS);
894
800
  });