@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20

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 (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
@@ -3,8 +3,7 @@ import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildS
3
3
  import type { App } from "../commandDecorators";
4
4
  import { createTunnel } from "../createTunnel";
5
5
  import { WorkspaceExecutor } from "../executors";
6
- // Imported by module path, not through `../frontendBuild`: that barrel pulls in `typescript` and the
7
- // tailwind stack, which is exactly what a suspended dev host must not be holding.
6
+ // Not via `../frontendBuild`: that barrel loads typescript and tailwind, which a suspended dev host must not hold.
8
7
  import { HmrWatcher } from "../frontendBuild/hmrWatcher";
9
8
  import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
10
9
  import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
@@ -48,10 +47,11 @@ import {
48
47
 
49
48
  const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
50
49
 
50
+ const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
51
+
51
52
  const BACKEND_RESTART_DEBOUNCE_MS = 120;
52
53
 
53
- // Must exceed the gateway's child-wait budget (AkanApp child shutdown, ~5s in dev) so the gateway
54
- // is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
54
+ // Above the gateway's ~5s child-shutdown wait: a gateway SIGKILLed mid-shutdown strands orphan replicas.
55
55
  const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
56
56
 
57
57
  const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
@@ -64,23 +64,15 @@ const BUILDER_READY_TIMEOUT_MS = 150000;
64
64
 
65
65
  const BUILDER_START_MAX_ATTEMPTS = 3;
66
66
 
67
- // Save-on-keystroke arrives as a burst of batches. Recycling mid-burst would drop the watcher events
68
- // still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
67
+ // Recycling mid-burst of saves would drop watcher events still on their way to the builder.
69
68
  const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
70
69
 
71
- // Linux hands the bundler arenas back on its own after ~10-15s idle — measured at 46-59% of the
72
- // builder's peak (`local/optimize-resource/09-linux-retention-measurement.md`) — while macOS returns
73
- // none of it. The builder only reports RSS at work-completion points, so the sample a recycle is armed
74
- // from is the peak. Waiting out the purge and re-reading before committing is what stops the host
75
- // paying a cold boot build for memory the OS was about to return anyway. On macOS the re-read returns
76
- // the same value, so this only ever costs the delay.
70
+ // Linux returns about half of the bundler arenas after ~10-15s idle (macOS none), so re-read the peak before recycling.
77
71
  const BUILDER_RSS_SETTLE_MS = 20_000;
78
72
 
79
- /** Reading one process's rss is a millisecond of work; anything near this is a `ps` that is stuck. */
80
73
  const PS_RSS_TIMEOUT_MS = 2_000;
81
74
 
82
- // The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
83
- // backend the recovery loop never gives up — it only backs off.
75
+ // The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
84
76
  const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
85
77
 
86
78
  const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
@@ -118,7 +110,6 @@ export class AkanAppHost {
118
110
  #lastRssRecycleAtMono: number | null = null;
119
111
  #rssCeilingTightReports = 0;
120
112
  #rssCeilingTightWarned = false;
121
- /** Invalidates an in-flight settle check when anything else moves the builder underneath it. */
122
113
  #rssSettleToken = 0;
123
114
  #rssRecycleOver: { rssBytes: number; ceilingBytes: number } | null = null;
124
115
  #rssCeilingAbandoned = false;
@@ -132,9 +123,7 @@ export class AkanAppHost {
132
123
  #wokeAtMono: number | null = null;
133
124
  #idleWatcher: HmrWatcher | null = null;
134
125
  #suspendedChanges: ChangeBatch | null = null;
135
- /** The stat sweep taken when the builder went away, awaited by the take; see `#openBuilderGap`. */
136
126
  #builderGapStamp: Promise<SourceFingerprints | null> | null = null;
137
- /** Requests that arrived while suspended, answered by the builder that the wake brings up. */
138
127
  #pendingBuilderMessages: BuilderMessage[] = [];
139
128
  readonly #builderRequests = new BuilderRequestRouter();
140
129
  constructor(
@@ -171,24 +160,18 @@ export class AkanAppHost {
171
160
  async stop() {
172
161
  this.#cancelIdleSuspend();
173
162
  this.#stopIdleWatcher();
174
- if (this.#restartTimer) {
175
- clearTimeout(this.#restartTimer);
176
- this.#restartTimer = null;
177
- }
178
- if (this.#backendRecoveryTimer) {
179
- clearTimeout(this.#backendRecoveryTimer);
180
- this.#backendRecoveryTimer = null;
181
- }
182
- if (this.#builderRecoveryTimer) {
183
- clearTimeout(this.#builderRecoveryTimer);
184
- this.#builderRecoveryTimer = null;
185
- }
163
+ this.#clearRestartTimers();
186
164
  // Before the backend goes away, while it can still receive the answer.
187
165
  this.#failPendingBuilderMessages("dev server is shutting down");
188
166
  await this.#stopBackend();
189
167
  this.#stopBuilder();
190
168
  return this;
191
169
  }
170
+ #clearRestartTimers() {
171
+ for (const timer of [this.#restartTimer, this.#backendRecoveryTimer, this.#builderRecoveryTimer])
172
+ if (timer) clearTimeout(timer);
173
+ this.#restartTimer = this.#backendRecoveryTimer = this.#builderRecoveryTimer = null;
174
+ }
192
175
  kill() {
193
176
  void this.stop();
194
177
  }
@@ -199,8 +182,7 @@ export class AkanAppHost {
199
182
  return await createTunnel(type, { app: this.app, environment });
200
183
  }
201
184
  #startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
202
- // Before the spawn: from here on, a builder answer for the departing backend must not be delivered to
203
- // this one, which numbers its requests from 1 all over again.
185
+ // Before the spawn: the new backend numbers its requests from 1 again, so old answers must not reach it.
204
186
  this.#builderRequests.startGeneration();
205
187
  this.#discardPendingBuilderMessages("the backend restarted while the builder was away");
206
188
  this.#backendStartStatus = startStatus;
@@ -224,8 +206,7 @@ export class AkanAppHost {
224
206
  return;
225
207
  }
226
208
  if (msg.type === "build-status") {
227
- // The gateway reports replica boot failures (crash loops, port conflicts) this way so
228
- // they reach the build-status log and the HMR overlay like any other build failure.
209
+ // The gateway reports replica boot failures (crash loops, port conflicts) as build-status.
229
210
  const status = this.#recordBackendBuildStatus({
230
211
  generation: normalizeBackendReportedGeneration(msg.data.generation),
231
212
  ok: msg.data.ok,
@@ -251,8 +232,7 @@ export class AkanAppHost {
251
232
  this.#backend = backend;
252
233
  this.logger.verbose(`backend spawned pid=${backend.pid}`);
253
234
  if (this.stdio === "pipe") {
254
- // A piped backend writes to nobody unless the pipes are drained; surface them through the logger
255
- // so a runtime error is not silently swallowed, and so the tail kept for a boot failure still fills.
235
+ // Undrained pipes swallow runtime errors and leave the crash-loop stderr tail empty.
256
236
  void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
257
237
  void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
258
238
  }
@@ -265,12 +245,7 @@ export class AkanAppHost {
265
245
  this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
266
246
  }
267
247
  }
268
- /**
269
- * A piped child is written through verbatim rather than re-logged: a level floor here would swallow
270
- * what the runtime printed, and re-rendering would double the timestamp the child already wrote. So
271
- * `"pipe"` looks exactly like `"inherit"` to whoever owns this process's stdout — the only difference
272
- * is `#backendStderrTail`, which is what the crash-loop diagnostic reads.
273
- */
248
+ // Verbatim, not re-logged: a level floor would swallow runtime output and re-rendering doubles the timestamp.
274
249
  async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
275
250
  if (!stream) return;
276
251
  const decoder = new TextDecoder();
@@ -480,11 +455,7 @@ export class AkanAppHost {
480
455
  }
481
456
  this.#sendToBackend(message);
482
457
  }
483
- /**
484
- * A recycled builder re-announces the artifact it booted with, because the backend read
485
- * `base-artifact.json` once and never re-reads it. Dropping the announcement when the hashes match
486
- * is what keeps the common case — a recycle with no concurrent edit — invisible to browsers.
487
- */
458
+ // The backend reads `base-artifact.json` once, so a recycled builder re-announces it; unchanged hashes are dropped.
488
459
  #shouldRelayRecycledState(
489
460
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
490
461
  ): boolean {
@@ -499,7 +470,6 @@ export class AkanAppHost {
499
470
  #handleBuilderMetrics(metrics: BuilderMetrics): void {
500
471
  if (this.#rssCeilingAbandoned) return;
501
472
  const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
502
- const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
503
473
  const decision = decideBuilderRssRecycle({
504
474
  rssBytes: metrics.rssBytes,
505
475
  ceilingBytes,
@@ -521,9 +491,7 @@ export class AkanAppHost {
521
491
  this.#rssCeilingTightReports += 1;
522
492
  if (this.#rssCeilingTightWarned || !shouldWarnBuilderRssCeilingTight(this.#rssCeilingTightReports)) return;
523
493
  this.#rssCeilingTightWarned = true;
524
- // Said once, and only as information: the builder is still being replaced, at most once per
525
- // interval, because that bound is the only thing standing between the bundler's arenas and the
526
- // sandbox's memory limit.
494
+ // Warn only: the per-interval recycle is the one bound between the bundler's arenas and the sandbox limit.
527
495
  this.logger.warn(
528
496
  `[builder-recycle] the builder is back at ${asMib(metrics.rssBytes)}MiB within ${Math.round(BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS / 1000)}s of a recycle, so the ${asMib(ceilingBytes ?? 0)}MiB ceiling costs about one boot build per interval while you keep building. Raise AKAN_BUILDER_MAX_RSS_MB if that trade is wrong for this app, or set it to 0 to leave the builder unbounded.`,
529
497
  );
@@ -534,14 +502,7 @@ export class AkanAppHost {
534
502
  { rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 },
535
503
  );
536
504
  }
537
- /**
538
- * Ask the replacement, the moment it is ready, whether this ceiling is reachable at all.
539
- *
540
- * Read from the OS rather than from a metrics report, because the report the host would otherwise
541
- * judge on only arrives after the builder has built something — by which point what is being measured
542
- * is the work, not the floor. A floor over the ceiling means every replacement lands over it, so the
543
- * recycle loop can only ever cost boot builds.
544
- */
505
+ // Read from the OS at ready: a metrics report arrives only after a build, measuring the work, not the floor.
545
506
  async #checkRecycledBuilderFloor(): Promise<void> {
546
507
  if (this.#rssCeilingAbandoned || this.#lastRssRecycleAtMono === null) return;
547
508
  const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
@@ -550,18 +511,15 @@ export class AkanAppHost {
550
511
  const freshRssBytes = await AkanAppHost.readProcessRssBytes(pid);
551
512
  if (!isRssCeilingUnreachable(freshRssBytes, ceilingBytes)) return;
552
513
  this.#rssCeilingAbandoned = true;
553
- const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
554
514
  this.logger.error(
555
515
  `[builder-recycle] a freshly recycled builder is already at ${asMib(freshRssBytes ?? 0)}MiB with nothing built on demand, so the ${asMib(ceilingBytes)}MiB ceiling cannot be met for this app; no longer enforcing it this session. Raise AKAN_BUILDER_MAX_RSS_MB, or set it to 0 to leave the builder unbounded.`,
556
516
  );
557
517
  }
558
- /** Waits for the builder to go quiet, so a recycle never lands in the middle of a burst of saves. */
559
518
  #armRssRecycle(reason: string, over?: { rssBytes: number; ceilingBytes: number }): void {
560
519
  if (this.#rssRecycleReason !== reason)
561
520
  this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
562
521
  this.#rssRecycleReason = reason;
563
- // Held in a field, not the closure: `#handleInvalidate` re-arms mid-burst with the reason alone, and
564
- // losing the sample there would silently skip the settle check for exactly the bursty case.
522
+ // A field, not the closure: `#handleInvalidate` re-arms with the reason alone and must keep the sample.
565
523
  if (over) this.#rssRecycleOver = over;
566
524
  if (this.#rssRecycleTimer) clearTimeout(this.#rssRecycleTimer);
567
525
  this.#rssRecycleTimer = setTimeout(() => {
@@ -578,22 +536,15 @@ export class AkanAppHost {
578
536
  void this.#recycleBuilderForRssWhenStillOver(pendingReason, pendingOver);
579
537
  }, BUILDER_RSS_RECYCLE_QUIET_MS);
580
538
  }
581
- /**
582
- * Confirms the builder is *still* over the ceiling before replacing it. The armed sample was taken
583
- * the instant the builder went idle, which is its peak; where the allocator returns arenas during
584
- * idle, that number is stale within seconds and recycling on it is pure cost.
585
- */
586
539
  async #recycleBuilderForRssWhenStillOver(
587
540
  reason: string,
588
541
  { rssBytes, ceilingBytes }: { rssBytes: number; ceilingBytes: number },
589
542
  ): Promise<void> {
590
- const asMib = (bytes: number) => Math.round(bytes / 1024 / 1024);
591
543
  if (decideBuilderRssSettle({ rssBytes, ceilingBytes }) === "recycle-now") {
592
544
  this.#recycleBuilderForRss(reason);
593
545
  return;
594
546
  }
595
547
  const pid = this.#builder?.pid;
596
- // Without a readable pid there is nothing to re-check, so keep the original behaviour.
597
548
  if (!pid) {
598
549
  this.#recycleBuilderForRss(reason);
599
550
  return;
@@ -604,7 +555,6 @@ export class AkanAppHost {
604
555
  `[builder-recycle] holding ${Math.round(BUILDER_RSS_SETTLE_MS / 1000)}s to see whether the allocator returns it (${reason})`,
605
556
  );
606
557
  await Bun.sleep(BUILDER_RSS_SETTLE_MS);
607
- // Anything that touched the builder meanwhile — a new batch, a recycle, a suspend — invalidates this.
608
558
  if (token !== this.#rssSettleToken || this.#builder?.pid !== pid || this.#suspended || this.#waking) {
609
559
  this.logger.verbose("[builder-recycle] settle check abandoned; the builder moved on");
610
560
  return;
@@ -620,11 +570,7 @@ export class AkanAppHost {
620
570
  settledBytes === null ? reason : `${reason}; still ${asMib(settledBytes)}MiB after settling`,
621
571
  );
622
572
  }
623
- /**
624
- * Another process's RSS, read from the OS rather than asked of the process. `tasklist` on Windows, `/proc`
625
- * where it exists, `ps` otherwise (macOS has no `/proc`). Null when it cannot be read, which callers treat
626
- * as "no new information" rather than as zero.
627
- */
573
+ /** Null when unreadable, meaning "no new information", never zero. */
628
574
  static async readProcessRssBytes(pid: number): Promise<number | null> {
629
575
  if (process.platform === "win32")
630
576
  return await AkanAppHost.#readRssKbVia(
@@ -638,12 +584,7 @@ export class AkanAppHost {
638
584
  if (vmRssKb) return Number(vmRssKb) * 1024;
639
585
  return await AkanAppHost.#readRssKbVia(["ps", "-o", "rss=", "-p", String(pid)], (output) => Number(output.trim()));
640
586
  }
641
- /**
642
- * `ps` or `tasklist`, bounded. An absent tool is already handled — it answers `null`, which callers read as
643
- * "no new information" — but a `ps` that never answers was not: the only caller awaits it after a 20s settle,
644
- * so the recycle it was about to commit simply never happened, silently. The harness has hit exactly
645
- * this hang while shelling out to `ps` under load.
646
- */
587
+ // Bounded: a `ps` that hangs under load would silently cancel the recycle awaiting it.
647
588
  static async #readRssKbVia(
648
589
  command: string[],
649
590
  parseKb: (output: string) => number,
@@ -667,7 +608,7 @@ export class AkanAppHost {
667
608
  clearTimeout(killer);
668
609
  }
669
610
  }
670
- /** `Mem Usage` is the working set — what `process.memoryUsage.rss()` reports on Windows — in locale-grouped KB. */
611
+ // `Mem Usage` is the working set (what `process.memoryUsage.rss()` reports on Windows) in locale-grouped KB.
671
612
  static #tasklistRssKb(output: string): number {
672
613
  const memUsage = /"([^"]*)"\s*$/m.exec(output)?.[1];
673
614
  return memUsage ? Number(memUsage.replace(/\D/g, "")) : Number.NaN;
@@ -681,14 +622,10 @@ export class AkanAppHost {
681
622
  clearTimeout(this.#rssRecycleTimer);
682
623
  this.#rssRecycleTimer = null;
683
624
  }
684
- /**
685
- * How long the dev server may sit unused before its build capacity is dropped. Set
686
- * `AKAN_DEV_IDLE_SUSPEND_MS=0` to keep the builder resident for the whole session.
687
- */
625
+ /** `AKAN_DEV_IDLE_SUSPEND_MS=0` keeps the builder resident for the whole session. */
688
626
  static idleSuspendMs(): number | null {
689
627
  return resolveIdleSuspendMs(process.env.AKAN_DEV_IDLE_SUSPEND_MS);
690
628
  }
691
- /** Every builder message and every request for one counts as the dev server being in use. */
692
629
  #markDevActivity(): void {
693
630
  if (this.#suspended || this.#waking) return;
694
631
  this.#armIdleSuspend();
@@ -722,8 +659,7 @@ export class AkanAppHost {
722
659
  this.#armIdleSuspend();
723
660
  return;
724
661
  }
725
- // Watch before stopping, never after: an edit landing in the gap would be lost, and nothing
726
- // would wake the dev server until the next one.
662
+ // Watch before stopping: an edit in the gap would be lost, and nothing would wake the dev server.
727
663
  if (!(await this.#startIdleWatcher())) {
728
664
  this.#armIdleSuspend();
729
665
  return;
@@ -756,13 +692,11 @@ export class AkanAppHost {
756
692
  void this.#wakeFromIdle(`${batch.files.length} file(s) changed`);
757
693
  },
758
694
  });
759
- // Awaited so the mtime baseline exists before the first edit: `#recordSuspendedChange` replays this
760
- // batch's file list to the woken builder, and a file missing from it is never rebuilt at all.
695
+ // Awaited so the mtime baseline exists before the first edit; a file missing from the batch is never rebuilt.
761
696
  await watcher.start();
762
697
  this.#idleWatcher = watcher;
763
698
  return true;
764
699
  } catch (err) {
765
- // Better to keep paying for the builder than to suspend into a dev server that cannot notice edits.
766
700
  this.logger.warn(
767
701
  `[idle-suspend] could not install the idle watcher; staying awake: ${err instanceof Error ? err.message : String(err)}`,
768
702
  );
@@ -785,10 +719,6 @@ export class AkanAppHost {
785
719
  kinds: new Set([...current.kinds, ...batch.kinds]),
786
720
  };
787
721
  }
788
- /**
789
- * Bring build capacity back. Reuses the paths an ordinary change would take, so a change made while
790
- * suspended lands the same way it would have while awake.
791
- */
792
722
  async #wakeFromIdle(reason: string): Promise<void> {
793
723
  if (!this.#suspended || this.#waking) return;
794
724
  this.#waking = true;
@@ -802,8 +732,6 @@ export class AkanAppHost {
802
732
  await this.#applyIdleWake(batch);
803
733
  this.logger.info(`[idle-suspend] awake in ${Math.round(performance.now() - startedAtMono)}ms`);
804
734
  } catch (err) {
805
- // Never leave the dev server without a builder: fall back to the ordinary recovery loop, which
806
- // keeps retrying, rather than sitting suspended with no watcher.
807
735
  this.logger.error(
808
736
  `[idle-suspend] wake failed; recovering the builder: ${err instanceof Error ? err.message : String(err)}`,
809
737
  );
@@ -814,8 +742,7 @@ export class AkanAppHost {
814
742
  this.#wokeAtMono = performance.now();
815
743
  this.#flushPendingBuilderMessages();
816
744
  this.#armIdleSuspend();
817
- // A wake often leaves the backend's own state untouched, so nothing else would report that the
818
- // app came back and a supervisor would show it suspended for the rest of the session.
745
+ // A wake often leaves the backend state untouched, so nothing else would tell a supervisor it came back.
819
746
  this.#emitDevEvent(devHostStateOf(this.#backendLifecycleState, this.#backendGaveUp));
820
747
  }
821
748
  }
@@ -823,8 +750,7 @@ export class AkanAppHost {
823
750
  const files = batch?.files ?? [];
824
751
  if (shouldRefreshConfigOnIdleWake(batch)) {
825
752
  this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
826
- // This replaces the backend along with the builder, so whatever moved during the suspend is
827
- // already covered — and a baseline carried past its own gap costs a restart at the next one.
753
+ // Replaces the backend too, and a baseline kept past its own gap costs a restart at the next one.
828
754
  this.#discardBuilderGap("config change replaces the backend anyway");
829
755
  await this.#recycleDevChildren(
830
756
  { type: "invalidate", kinds: [...(batch?.kinds ?? [])], files },
@@ -837,39 +763,18 @@ export class AkanAppHost {
837
763
  // Refresh before deciding: a file created while suspended is not in the graph yet.
838
764
  if (files.length > 0) await this.#backendGraph.refresh();
839
765
  await this.#startBuilder({ announceBootState: true });
840
- // Merged rather than restarted for separately: the batch is what the watcher managed to report, the
841
- // stamps are what actually moved, and they overlap on the ordinary case of one save during a suspend.
766
+ // Merged: the watcher's batch and the stamps overlap on the ordinary one-save-during-suspend case.
842
767
  const missed = await this.#takeBuilderGapChanges();
843
- const backendFiles = [...new Set([...files.filter((file) => this.#isBackendFile(file)), ...missed])];
768
+ const backendFiles = [...new Set([...files.filter((file) => this.#backendGraph.has(file)), ...missed])];
844
769
  if (backendFiles.length === 0) return;
845
770
  this.logger.verbose(`[idle-suspend] ${backendFiles.length} backend file(s) changed while suspended`);
846
771
  this.#scheduleBackendRestart({ files: backendFiles, roles: [] });
847
772
  }
848
- /**
849
- * Remember what the backend is running, because from here until a builder is back nothing is watching.
850
- *
851
- * The suspend path installs its own watcher and the restart path has none at all, but neither is a
852
- * complete answer: Bun's recursive `fs.watch` reports roughly one path per window
853
- * (`local/optimize-resource/06-watcher-dropped-event.md`), and a replacement builder primes its index
854
- * from the disk it finds, so anything saved in between looks original to it. A stat taken now and
855
- * compared when the builder is back does not depend on an event arriving.
856
- *
857
- * Scoped to the import graph — the files the backend actually runs. A backend-shaped file outside it
858
- * changes nothing about the running server, so missing it costs nothing, and enumerating candidates
859
- * by path role instead would mean walking the tree.
860
- *
861
- * The earliest open wins: a baseline from further back can only over-report, and over-reporting costs
862
- * a backend restart while under-reporting costs a server running code the developer already deleted.
863
- *
864
- * Held as the in-flight sweep rather than as its result, so a take cannot read a baseline that is
865
- * still being written — the sweep is milliseconds and the gap it covers is a boot build, but "usually
866
- * finishes first" is the kind of guarantee this whole mechanism exists to replace.
867
- */
773
+ // Until a builder is back nothing watches (Bun's `fs.watch` drops events, a new builder primes from disk), so
774
+ // stamp the backend graph now. Earliest open wins: over-reporting costs a restart, under-reporting stale code.
868
775
  #openBuilderGap(reason: string): void {
869
776
  if (this.#builderGapStamp) return;
870
777
  if (!this.#backendGraph.ready) {
871
- // No graph means no stamps at all: the host is on path-role fallback rules and does not know which
872
- // files the backend runs. Said out loud, because a hole nobody can see reads like coverage.
873
778
  this.logger.verbose(`[builder-gap] no backend graph yet; a save during this ${reason} goes unnoticed`);
874
779
  return;
875
780
  }
@@ -886,7 +791,6 @@ export class AkanAppHost {
886
791
  return null;
887
792
  });
888
793
  }
889
- /** Which backend files moved while the builder was away. Consumes the baseline. */
890
794
  async #takeBuilderGapChanges(): Promise<string[]> {
891
795
  const stamping = this.#builderGapStamp;
892
796
  this.#builderGapStamp = null;
@@ -894,8 +798,6 @@ export class AkanAppHost {
894
798
  if (!before) return [];
895
799
  const moved = filesChangedSince(before, await this.#backendGraph.fingerprint());
896
800
  if (moved.length === 0) {
897
- // Said out loud even when the answer is "nothing", because the alternative — silence — is also
898
- // what a stamp that was never taken looks like.
899
801
  this.logger.verbose(`[builder-gap] none of the ${before.size} stamped backend file(s) moved`);
900
802
  return moved;
901
803
  }
@@ -904,19 +806,12 @@ export class AkanAppHost {
904
806
  );
905
807
  return moved;
906
808
  }
907
- /**
908
- * Drop the stamps without acting on them, for a path that is replacing the backend anyway.
909
- *
910
- * Not merely tidy: a baseline left open outlives the gap it was taken for and is compared against the
911
- * *next* one, where everything saved in between reads as changed — one backend restart for work that
912
- * has already been done.
913
- */
809
+ // A baseline left open is compared against the next gap, where everything saved since reads as changed.
914
810
  #discardBuilderGap(reason: string): void {
915
811
  if (!this.#builderGapStamp) return;
916
812
  this.#builderGapStamp = null;
917
813
  this.logger.verbose(`[builder-gap] stamps dropped (${reason})`);
918
814
  }
919
- /** The restart path's half of the above: the wake path merges its own file list in instead. */
920
815
  async #restartBackendForGapChanges(): Promise<void> {
921
816
  const moved = await this.#takeBuilderGapChanges();
922
817
  if (moved.length === 0) return;
@@ -929,69 +824,45 @@ export class AkanAppHost {
929
824
  for (const message of pending) this.#sendToBuilder(message);
930
825
  }
931
826
 
932
- #holdUntilBuilderReady(message: BuilderMessage): void {
933
- this.#pendingBuilderMessages.push(message);
934
- this.logger.verbose(
935
- `[builder] holding ${message.type} until the builder is ready (${this.#pendingBuilderMessages.length} waiting)`,
936
- );
937
- }
938
-
939
- /**
940
- * Drop everything held for a backend generation that has been replaced, answering nobody.
941
- *
942
- * A held request carries the id the *departing* backend gave it, and `startGeneration` only clears the
943
- * router — so a replay after the restart would issue that id under the new generation and settle the
944
- * builder's answer to an id the new backend is itself handing out from 1. That is exactly the
945
- * misdelivery `BuilderRequestRouter` exists to prevent, reached through the hold queue instead: the
946
- * new backend gets another route's manifest delta, and its real answer is dropped as unclaimed.
947
- *
948
- * Nothing is answered because every caller of `#startBackend` has already stopped the old backend, so
949
- * a failure reply would only be dropped as undeliverable.
950
- */
827
+ // Held requests carry the departing backend's ids, which the new one reissues from 1, so a replay would
828
+ // misdeliver answers; nothing is answered because the old backend is already stopped.
951
829
  #discardPendingBuilderMessages(reason: string): void {
952
830
  const held = this.#pendingBuilderMessages.splice(0);
953
831
  if (held.length === 0) return;
954
832
  this.logger.verbose(`[builder] dropped ${held.length} held builder request(s): ${reason}`);
955
833
  }
956
- /**
957
- * Answer everything still waiting on a builder that is not coming back. Held requests are otherwise
958
- * invisible to the backend, which would sit on them until its own timeout with no reason given.
959
- */
960
834
  #failPendingBuilderMessages(reason: string): void {
961
835
  const held = this.#pendingBuilderMessages.splice(0);
962
836
  if (held.length === 0) return;
963
837
  this.logger.warn(`failing ${held.length} held builder request(s): ${reason}`);
964
- for (const message of held) {
965
- if (message.type === "build-route")
966
- this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error: reason });
967
- else if (message.type === "build-csr")
968
- this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error: reason });
969
- }
838
+ for (const message of held) this.#failBuilderRequest(message, reason);
839
+ }
840
+ #failBuilderRequest(message: BuilderMessage, error: string): boolean {
841
+ if (message.type === "build-route")
842
+ this.#sendToBackend({ type: "build-route-res", id: message.id, ok: false, error });
843
+ else if (message.type === "build-csr")
844
+ this.#sendToBackend({ type: "build-csr-res", id: message.id, ok: false, error });
845
+ else return false;
846
+ return true;
970
847
  }
971
848
  #recycleBuilderForRss(reason: string): void {
972
- // A config or runtime-metadata change already replaces the builder along with the backend, and a
973
- // pending backend restart is disruption enough on its own; either way, dropping the recycle here
974
- // costs nothing — the next build re-reports an over-ceiling rss and arms it again.
849
+ // Dropping the recycle costs nothing: the next build re-reports an over-ceiling rss and re-arms it.
975
850
  if (this.#pendingRecycle || this.#restartTimer) {
976
851
  this.logger.verbose(`[builder-recycle] skipped (${reason}); a dev restart is already pending`);
977
852
  return;
978
853
  }
979
854
  if (!this.#builder?.recycle(reason)) return;
980
855
  this.#lastRssRecycleAtMono = performance.now();
981
- // Stamped at the request rather than at the exit, because a builder that is draining has already
982
- // stopped taking work: whether its watcher still gets an event out is not something to rely on.
856
+ // Stamped at the request, not the exit: a draining builder has already stopped taking work.
983
857
  this.#openBuilderGap("builder recycle");
984
858
  }
985
859
  async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
986
860
  this.#logDevPlan(message);
987
- // More batches are on the way, so push the recycle out until the dev server settles.
988
861
  if (this.#rssRecycleReason) this.#armRssRecycle(this.#rssRecycleReason);
989
- // Config changes subsume builder restarts: the dev-host restart recycles builder and backend
990
- // AND re-runs the prepare step, so check it first when a batch carries both actions.
862
+ // Checked first: a dev-host restart also recycles the builder and backend.
991
863
  const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
992
864
  const pending = this.#pendingRecycle;
993
- // A pending (deferred) recycle rides along on the next code batch — that batch is where the
994
- // fix lands; css-only batches cannot heal a compile error, so they never resume it.
865
+ // A deferred recycle resumes on the next code batch; a css-only batch cannot heal a compile error.
995
866
  if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || (pending && message.kinds.includes("code"))) {
996
867
  const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
997
868
  const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
@@ -1035,11 +906,7 @@ export class AkanAppHost {
1035
906
  this.#recordBuildStatus(status);
1036
907
  this.#sendOrQueueBuildStatus(status);
1037
908
  }
1038
- /**
1039
- * A failed recycle must never leave the dev server dead: bring the backend back up on the
1040
- * last-good artifact so the error overlay stays reachable, and keep retrying the builder —
1041
- * the builder is the file watcher, so without it no edit could ever trigger a recovery.
1042
- */
909
+ // The backend returns on the last-good artifact so the error overlay stays reachable.
1043
910
  #resurrectDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
1044
911
  const generation = message.devPlan?.generation ?? message.generation;
1045
912
  if (!this.#backend) this.#startBackend({ generation, files: message.files });
@@ -1069,8 +936,6 @@ export class AkanAppHost {
1069
936
  }
1070
937
  this.#builderRecoveryAttempts = 0;
1071
938
  this.logger.info("[builder-recovery] builder recovered");
1072
- // The other way a builder comes back: a wake that failed, or a start that had to be retried. Either
1073
- // way the tree went unwatched, and this is the first moment there is something to act on it.
1074
939
  void this.#restartBackendForGapChanges();
1075
940
  const status: DevBuildStatus = {
1076
941
  generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
@@ -1085,7 +950,6 @@ export class AkanAppHost {
1085
950
  this.#startBackend({ generation: reason.generation, files: reason.files });
1086
951
  }
1087
952
  }
1088
- /** A backend that gave up recovering on broken code gets one fresh chance whenever a build goes green. */
1089
953
  #reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
1090
954
  if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
1091
955
  this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
@@ -1099,13 +963,8 @@ export class AkanAppHost {
1099
963
  );
1100
964
  await this.#recycleDevChildren(message);
1101
965
  }
1102
- /**
1103
- * Controlled dev-host restart for config changes (akan.config.ts, tsconfig, package.json):
1104
- * re-runs the prepare step so env and codegen reflect the new config, then recycles the builder
1105
- * and backend. The config module is re-imported with a cache-busting query; modules it imports
1106
- * keep their cached instances, so a change inside an imported plugin file still needs a manual
1107
- * `akan start` restart.
1108
- */
966
+ // The config is re-imported with a cache-busting query, but modules it imports stay cached: a change inside an
967
+ // imported plugin file still needs a manual `akan start` restart.
1109
968
  async #restartDevHost(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
1110
969
  const generation = message.devPlan?.generation ?? message.generation;
1111
970
  this.logger.warn(
@@ -1118,18 +977,7 @@ export class AkanAppHost {
1118
977
  { refreshConfig = false }: { refreshConfig?: boolean } = {},
1119
978
  ): Promise<void> {
1120
979
  const generation = message.devPlan?.generation ?? message.generation;
1121
- if (this.#restartTimer) {
1122
- clearTimeout(this.#restartTimer);
1123
- this.#restartTimer = null;
1124
- }
1125
- if (this.#backendRecoveryTimer) {
1126
- clearTimeout(this.#backendRecoveryTimer);
1127
- this.#backendRecoveryTimer = null;
1128
- }
1129
- if (this.#builderRecoveryTimer) {
1130
- clearTimeout(this.#builderRecoveryTimer);
1131
- this.#builderRecoveryTimer = null;
1132
- }
980
+ this.#clearRestartTimers();
1133
981
  this.#builderRecoveryAttempts = 0;
1134
982
  this.#pendingRestartReason = null;
1135
983
  this.#lastGoodFrontend = {};
@@ -1139,8 +987,7 @@ export class AkanAppHost {
1139
987
  this.#stopBuilder();
1140
988
  if (refreshConfig) {
1141
989
  await this.app.getConfig({ refresh: true });
1142
- // Merge instead of replace: start() enriched this.env with values prepare doesn't produce
1143
- // (e.g. REDIS_HOST from the tunnel), and the spawned children must keep seeing them.
990
+ // Merge, not replace: `start()` added values prepare does not produce (REDIS_HOST from the tunnel).
1144
991
  const { env } = await this.app.prepareCommand("start");
1145
992
  Object.assign(this.env, env);
1146
993
  }
@@ -1148,12 +995,7 @@ export class AkanAppHost {
1148
995
  await this.#startBuilder();
1149
996
  this.#startBackend({ generation, files: message.files });
1150
997
  }
1151
- /**
1152
- * `supersede` bypasses the generation check for a builder that just replaced another one. Its
1153
- * generation counter restarts at 0, so its announcement looks stale to `shouldReplaceLastGoodMessage`
1154
- * — and leaving the old payload cached would make the next backend restart replay an artifact the
1155
- * builder that produced it no longer serves.
1156
- */
998
+ // `supersede`: a replacement builder's generation restarts at 0, so its announcement would look stale.
1157
999
  #recordLastGood(
1158
1000
  message: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
1159
1001
  { supersede = false }: { supersede?: boolean } = {},
@@ -1216,7 +1058,6 @@ export class AkanAppHost {
1216
1058
  this.#sendToBackend({ type: "build-status", data: status });
1217
1059
  }
1218
1060
  }
1219
- /** One log line per planned generation, regardless of which action branch handles it. */
1220
1061
  #logDevPlan(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
1221
1062
  if (!message.devPlan) return;
1222
1063
  const { generation, roles, actions, reasonByFile } = message.devPlan;
@@ -1233,7 +1074,7 @@ export class AkanAppHost {
1233
1074
  return shouldRestart;
1234
1075
  }
1235
1076
  if (message.kinds.includes("code")) await this.#backendGraph.refresh();
1236
- if (message.files.some((file) => this.#isBackendFile(file))) return true;
1077
+ if (message.files.some((file) => this.#backendGraph.has(file))) return true;
1237
1078
  if (!this.#backendGraph.lastRefreshSucceeded) {
1238
1079
  const fallbackFiles = message.files.filter((file) =>
1239
1080
  isLegacyBackendFallbackFile(file, this.app.workspace.workspaceRoot),
@@ -1247,9 +1088,6 @@ export class AkanAppHost {
1247
1088
  }
1248
1089
  return false;
1249
1090
  }
1250
- #isBackendFile(file: string): boolean {
1251
- return this.#backendGraph.has(file);
1252
- }
1253
1091
  async #startBuilder({
1254
1092
  announceBootState = false,
1255
1093
  }: {
@@ -1281,7 +1119,6 @@ export class AkanAppHost {
1281
1119
  this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
1282
1120
  }
1283
1121
  }
1284
- // Out of attempts: no builder is coming, so anything held for one is waiting on nothing.
1285
1122
  this.#failPendingBuilderMessages("builder failed to start");
1286
1123
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1287
1124
  }
@@ -1326,54 +1163,31 @@ export class AkanAppHost {
1326
1163
  #sendToBuilder(message: BuilderMessage): void {
1327
1164
  this.#markDevActivity();
1328
1165
  if (this.#suspended || this.#waking) {
1329
- // A navigation must not fail just because the sandbox was idle — hold the request and let the
1330
- // builder the wake brings up answer it.
1331
1166
  this.#pendingBuilderMessages.push(message);
1332
1167
  void this.#wakeFromIdle(`${message.type} arrived while suspended`);
1333
1168
  return;
1334
1169
  }
1335
- // The builder skips dev CSR artifacts until a `?csr=true` request needs one. Remember that this
1336
- // session armed it and pass the flag through `env`, which is re-read on every builder spawn, so a
1337
- // builder restart re-arms itself instead of silently breaking an in-progress mobile dev session.
1170
+ // In `env`, which every builder spawn re-reads, so a restarted builder stays armed for a mobile dev session.
1338
1171
  if (message.type === "build-csr" && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
1339
1172
  Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
1340
1173
  this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
1341
1174
  }
1342
- // Renumbered on the way out so a builder answer can be matched back to the backend generation that
1343
- // asked; the failure replies below still use `message.id`, which is that backend's own.
1175
+ // Renumbered per backend generation; the failure replies below use that backend's own `message.id`.
1344
1176
  if (message.type === "build-route" || message.type === "build-csr") {
1345
1177
  const outgoing = this.#builderRequests.issue(message);
1346
1178
  if (this.#builder?.send(outgoing)) return;
1347
1179
  this.#builderRequests.withdraw(outgoing.id);
1348
1180
  } else if (this.#builder?.send(message)) return;
1349
1181
  const status = this.#builder?.status ?? "stopped";
1350
- // A recycle or a crash-restart is a gap, not a failure: the builder is on its way back, and the
1351
- // request that landed in that window is the page a developer is waiting on. Failing it here is what
1352
- // produced a dead tab telling the reader to reload, with nothing retrying on its own — while the
1353
- // suspend path a few lines up has always held requests for exactly this reason. `BuilderRpc`'s own
1354
- // timeout still bounds the wait, so holding cannot hang a request forever.
1182
+ // A returning builder is a gap, not a failure: hold the request (`BuilderRpc`'s timeout still bounds it).
1355
1183
  if (shouldHoldForReturningBuilder({ status, heldCount: this.#pendingBuilderMessages.length })) {
1356
- this.#holdUntilBuilderReady(message);
1357
- return;
1358
- }
1359
- if (message.type === "build-route") {
1360
- this.#sendToBackend({
1361
- type: "build-route-res",
1362
- id: message.id,
1363
- ok: false,
1364
- error: `builder is ${status}; reload after the builder is ready`,
1365
- });
1366
- return;
1367
- }
1368
- if (message.type === "build-csr") {
1369
- this.#sendToBackend({
1370
- type: "build-csr-res",
1371
- id: message.id,
1372
- ok: false,
1373
- error: `builder is ${status}; reload after the builder is ready`,
1374
- });
1184
+ this.#pendingBuilderMessages.push(message);
1185
+ this.logger.verbose(
1186
+ `[builder] holding ${message.type} until the builder is ready (${this.#pendingBuilderMessages.length} waiting)`,
1187
+ );
1375
1188
  return;
1376
1189
  }
1190
+ if (this.#failBuilderRequest(message, `builder is ${status}; reload after the builder is ready`)) return;
1377
1191
  this.logger.warn("akanAppHost builder is not running");
1378
1192
  }
1379
1193
  #stopBuilder(): void {