@akanjs/devkit 3.2.0-rc.7 → 3.2.0-rc.8

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@
4
4
 
5
5
  ### Minor Changes
6
6
 
7
+ - e973831: A CLI failure carries a code and the exit status its family takes, so a script branches on either instead of the message.
8
+
9
+ - Exit 2 (`usage`, `unknownTarget`): a flag, argument or command commander refuses, a value outside an option's choices, an argument left out where no terminal can ask for it, a run outside the workspace root, and an app, lib, package or module the workspace does not have. These exited 1 before, except for `akan migrate`, `akan pipeline` and `akan verify <name>`, which already exited 2.
10
+ - Exit 2 (`refused`) stays what it was: a pack that needs a new store build, and a release the Akan Cloud project rejected.
11
+ - Exit 3 (`checkFailed`): `akan verify`, `akan typecheck` and the typecheck of `akan build`, `akan preview` and `akan agent install --check` when they found problems. These exited 1 before.
12
+ - Exit 130 (`interrupted`) for a prompt closed with Ctrl+C, which exited 1.
13
+ - Exit 1 (`failed`, or an `AkanError`'s own code) for anything else, as before.
14
+ - `akan pack-update`, `akan push-update` and `akan register-binary` keep exiting 1 for a usage or unknown-target failure, so their 2 still means only the store-build verdict or the project's refusal. `akan agent` exits 1 for one too, and `akan agent hook` never exits 2, saying on stderr why the hook failed: Claude Code reads a hook's exit 2 as a block.
15
+
16
+ With `--json` or `--format json`, a failure now ends stdout with one line, `{"ok":false,"error":"<message>","code":"<code>"}`, and the message goes to stderr instead of stdout. An NDJSON stream (`akan code --json`, `akan logs --json`) ends with that line too. A command whose own JSON report says it failed, as `akan verify`'s does, adds no line. `akan migrate --json` answers `usage` or `notFound` for a command line or an app it cannot take before the app runs, matching its exit 2.
17
+
18
+ Upgrade: a script that tested for exit 1 after a usage error, a failed check or a closed prompt tests for non-zero, or for 2, 3 and 130. A CI step that branches on exit 2 from the OTA commands needs no change. `@akanjs/devkit/commandDecorators` exports `CliError(code, message, { exitCode })`, `ExitCodeError` is now one coded from its exit status (2 as `refused`, 3 as `checkFailed`), and a target's `usageExitCode` now also sets the exit of a `usage` or `unknownTarget` failure thrown while it runs.
19
+
7
20
  - 29756e1: `akan lint`, `akan lint-all` and `akan verify` now hold each package under a workspace's `pkgs/` to a structure budget, as they already hold apps and libs: a file up to 800 lines, a function up to 150 lines (`akan.size.function`) and a class up to 60 members (`akan.size.class`). Tests and their fixtures (`*.test`, `*.spec`, `*.fixture`, `*.instance` and `*.bench` files, and `test/`, `tests/`, `__tests__/`, `__fixtures__/` and `__mocks__/` folders), `.d.ts` files, re-export barrels and a package's `examples/` are left out, and a package in a git repository is listed through git, so ignored files are never measured. Each package keeps its own `akan.baseline.json`; `akan quality baseline` now seeds one for each package too, `akan quality scan` reports its exceptions, and packing a package that would publish one fails.
8
21
 
9
22
  A baseline only shrinks from the default branch's copy. Where the default branch has none, an app's, lib's or package's baseline may hold only the breaches the default branch's code already has, so a new one starts empty instead of skipping the check. A file's line count no longer includes the newline ending its last line, so a recorded size may drop by one on the next `akan lint`.
@@ -138,6 +151,15 @@
138
151
 
139
152
  **Behavior change:** where the shell exports a value the workspace `.env` also sets, such as `AKAN_PUBLIC_ENV`, `AKAN_PUBLIC_REPO_NAME` or `AKAN_PUBLIC_SERVE_DOMAIN`, `akan` now takes the shell's. Unset it, or match the `.env`, to build what you built before.
140
153
 
154
+ - af7c411: The five child-process restart loops now share one restart schedule, `RestartSchedule` from `akanjs/common`: the gateway's replicas, the RSC worker, the incremental builder, and `akan start`'s backend and builder recovery. It counts failed restarts, waits before the next one and cancels a pending one on stop. Each loop keeps its own delays (200ms–30s for the RSC worker, 2s–60s for `akan start`'s builder recovery, 1s–30s for the rest), its own give-up rule and its own log lines.
155
+
156
+ Two stop races in those loops are fixed:
157
+
158
+ - A replica whose restart timer had fired no longer respawns after the gateway stops.
159
+ - A builder `akan start` was still creating or booting when it stopped is now stopped too, instead of being started or retried after its ready timeout.
160
+
161
+ In `@akanjs/devkit`, `shouldAbandonBackendRecovery` is replaced by `backendRecoveryPolicy`, which gives up after five failed backend restarts as before.
162
+
141
163
  - e65172d: The CLI refreshes a cloud sign-in once less than an hour, or half the token's lifetime, is left, whichever is shorter. Before, a token that lived an hour or less read as due the moment it arrived, so every command rotated the refresh token and then called the cloud unsigned. Any unexpired token is now sent with the request, except the refresh call, which carries none. `akan login` signs in again when a session is due for refresh and has no refresh token left.
142
164
  - c3b5b3c: The framework no longer puts `@akanjs/native/core` on a web page. `isNativeApp()` and the other host checks read the platform the shell writes before the bundle runs, and a native shell still starts core with the page. A file that imports `akanjs/client/native` statically still brings core to every page that reaches it. On the minimal app this takes 15 KB raw (5.6 KB brotli) off the vendor floor and 20 KB raw (6.6 KB brotli) off every route entry. `akan build` also resolves the `production` export condition for the browser, SSR-client, CSR and MCP App view bundles, so a package such as Lexical ships its production build alone. On the minimal app the Agent editor chunk drops from 91 KB to 63 KB brotli. `akan start` is unchanged.
143
165
  - 6d5b544: A database service now has `create<Model>Many(rows)`. It creates the rows on the same path as `create<Model>`: `_preCreate` and `_postCreate`, the document's save and create hooks, and the live and pubsub events each row's commit fires. Every row's `_preCreate` and document pre hooks run first, so a check there that queries the table does not see the batch's own rows. The rows are then written in one transaction, joining the caller's when one is open, with one multi-row INSERT per 500 rows instead of one statement per row; if the database refuses one row, none is written. Every row's `_postCreate` runs once the transaction commits, as `create<Model>`'s runs once its row is stored, so a `_postCreate` that throws leaves the rows written. A service that already declares a method of that name, itself or in a lib service it extends, keeps its own at runtime, but its signature no longer typechecks against the generated one: rename it.
@@ -370,6 +392,30 @@
370
392
  - **`BuilderRpc.isUnavailable(error)`** tells such a failure apart, as `BuilderRpc.isTimeout` does for a request that times out. A bare, empty or unknown `/_akan/studio` view then lands on Runtime, and a view asked for by name still answers 503 with the reason.
371
393
  - **Mixed versions keep working.** A server reading a reply from an older dev host, with no kind, answers 503 as before, and an older server ignores the new field.
372
394
 
395
+ - 3b7247e: A failed tool call now names a code a client or a model can branch on, beside the text it already sent, which is unchanged. No upgrade step is needed: `AgentWireToolResult` and `use-agentic`'s `ToolCallResult` only gain an optional `code`, and `akanjs/common` adds `MCP_FAILURE_META`, `McpFailure` and `FailureCode`.
396
+
397
+ Every code is a bounded token, a dictionary key (`<module>.error.<key>`) or one camelCase identifier, never prose, so no text can pass for one.
398
+
399
+ - **An app's `/mcp`:**
400
+ - A tool error carries `_meta["akanjs/failure"]: { code, statusCode }`, beside the server's own `_meta` for a 2026-07-28 client. The code is the one an HTTP caller gets:
401
+ - a domain `Err` by its dictionary key, such as `base.error.documentNotFound`;
402
+ - an `AkanError` by its code, such as `invalidArgument` for an argument the call got wrong;
403
+ - a 401 or 403 by its status alone (`unauthorized`, `forbidden`);
404
+ - a 5xx by a dictionary key or this server's own `AkanError` code, else by its status (`internalError`, `serviceUnavailable`), never by a code a duck-typed error put in its body.
405
+ - A JSON-RPC error carries `data.code`, which tells apart what `-32602` covers:
406
+ - `unknownTool`, `unknownPrompt` (an unlisted page prompt included) and `unknownResource`;
407
+ - `invalidArgument` for bad arguments or a missing tool name, prompt name or resource uri;
408
+ - a refused screen's `forbidden` or `notFound`.
409
+ A rate-limited call names `tooManyRequests` and the catch-all names `internalError`.
410
+ - An MCP Apps view's call that fails without a domain `Err` restores as an error with that status and code, where it used to be a plain `Error`. A tool the view itself serves to the host names its code in the same `_meta` entry, without a `statusCode`.
411
+ - **`akan mcp`:** a tool error carries the same `_meta["akanjs/failure"]`, with one of these codes:
412
+ - `unknownTool` for a name no mode lists;
413
+ - `toolUnavailable` for a tool outside the server's mode;
414
+ - `invalidArgument` for a missing argument, an unknown guideline name, or a workflow path outside `.akan/workflows/`;
415
+ - `toolFailed` for anything else, with `statusCode` 500. A coded `AkanError` keeps its own code.
416
+ - **The code agent:** an MCP server's tool result keeps the code it named, from `_meta["akanjs/failure"]` or a JSON-RPC error's `data.code`, in the call's `details`.
417
+ - **The in-page agent:** a `toolResults` entry whose tool threw carries `code` beside `error` (see `use-agentic`'s WIRE.md): a dictionary `Err`'s key, else the thrown error's own `code`. A `taint: "source"` tool's result carries none, since its code would travel outside the page-content fence. The bundled Anthropic and OpenAI adaptors send `code` to the model inside the tool result, beside `error`.
418
+
373
419
  - 44149b1: A module named in a library that lacks it, such as `akan create-view util:nope`, is refused at once, as one named in an app is. Before, it fell through to the module picker in a terminal, and without one it failed as if no module had been named. Both are now refused, terminal or not, with the modules there are and the line to rerun: `` Unknown module 'util:nope': util has no such module, so rerun it as `akan create-view <sys:module>` (choices: pokedex:card, util:photo). `` An app's used to say only `Invalid module name: nope`.
374
420
  - 0d7294c: Narrowed vendor entries hold up during a rolling deploy and on very large builds.
375
421
 
@@ -391,6 +437,7 @@
391
437
  - The rule's own rewrite is removed. Under `--unsafe` it renamed a `#field` where it was called but not where it was declared.
392
438
  - **A raw `Error` in an `srvkit/` adapter names its fix.** `akan verify` prints the `Err` import path from that file, and where the key goes: the app's or lib's own service dictionary, `lib/_<name>/<name>.dictionary.ts`, as `<name>.error.<key>`.
393
439
 
440
+ - d4d84c6: `akan workflow validate` exits 0 when the only failures left are known baseline blockers (`overallStatus` `passed-with-baseline-blockers`); any other failed validation still exits 1. Over MCP, a passing `apply_workflow` dry run names `apply_workflow` with the same `planPath` and no `dryRun` as `next`, where it named `run_validation`, which had nothing to validate. A failed `akan workflow apply` reports no generated files queued for sync, no recommended validation command and no `akan verify` next action, and a failed field, mutation or slice write (`akan add-field` and its siblings) lists no generated file, validation command or next action; both used to list them as if the write had passed. With `-o json`, `akan workflow plan`, `apply`, `validate` and `report` print only the JSON report on stdout and send their log lines (`File Create:`, sync's warnings) to stderr; `create-module` and `create-scalar` applies used to print them before the report, so stdout did not parse. The text output is unchanged. `add-field` (the workflow and `akan add-field`) imports the type it writes: a scalar of the target from `../__scalar/<scalar>/<scalar>.constant`, a model from `../<model>/<model>.constant`, and `ID`, `Any` and the other `akanjs/base` types from `akanjs/base`, where it imported only `Int` and `Float` and wrote `field(Address)` with no import. A type that is not built in, not imported by the constant and not a scalar or model of the target is refused with `primitive-field-type-unknown`, at plan time and again at apply, before anything is written.
394
441
  - d2b2291: A failed workflow no longer reports success to its caller. `akan workflow plan` exits 1 when the plan has an error, `akan workflow apply` when the apply or its `--dry-run` fails, and `akan workflow validate` when its status is `failed` (which includes `passed-with-baseline-blockers`); all three exited 0 before. A dry run of a plan with an error, such as `add-field --type Number`, reports `failed` and lists no changed or generated files, where it used to list the plan's edits under "Automatically Modified". Over MCP, `plan_workflow` names `apply_workflow` in `next` and `approval.canApplyWith` only for a plan without an error, and `apply_workflow` names `run_validation` only when the apply did not fail; a refused plan also drops its "apply this plan" and "validate the apply report" recommendations. The workflows that write into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`) now refuse a service module, or any folder without its `<module>.constant.ts`, at plan time and again at apply. The `workflow-module-not-database` error lists the target's database modules and points at `create-module`, and for a field at `create-scalar`. Before, `add-field` on a service module passed its plan and dry run and failed only at apply, and `create-ui` wrote a misnamed `_<service>.View.tsx` into it. The `workflow-module-missing` error now lists only database modules too, says a name that matches a service module (with or without its `_`) is one instead of asking "Did you mean \_<service>?", and points at another app or library only when it holds a database module of that name.
395
442
  - adcdf3b: `akan workflow plan` and the `plan_workflow` MCP tool refuse a module the target app or library lacks, for every workflow that writes into an existing module (`add-field`, `add-enum-field`, `add-mutation`, `add-slice`, `create-ui`, `rename-key`). The `workflow-module-missing` error names the module, lists the modules the target has, and points at the module whose name differs only in case or by a leading `_`, at a scalar of that name, or at the app or library that holds it; only when none exists does it point at `create-module`. Before, the plan passed and its dry run reported `passed`. `add-mutation` used to plan a missing module with a warning so that a `create-module` plan could precede it; it now refuses too, so plan it after applying `create-module`. Every workflow now refuses an unknown `app` at plan time, listing the workspace's apps and libraries. `apply` and `--dry-run` check the target again before anything else, so a plan written earlier for a module that is gone fails instead of passing. `plan_workflow` now inspects the MCP server's workspace rather than the process directory.
396
443
  - Updated dependencies [bd198af]
@@ -414,8 +461,10 @@
414
461
  - Updated dependencies [ddd3d96]
415
462
  - Updated dependencies [3f231a7]
416
463
  - Updated dependencies [96f9ff6]
464
+ - Updated dependencies [af7c411]
417
465
  - Updated dependencies [1ebc57a]
418
466
  - Updated dependencies [c3b5b3c]
467
+ - Updated dependencies [dcb7e61]
419
468
  - Updated dependencies [6d5b544]
420
469
  - Updated dependencies [73f8830]
421
470
  - Updated dependencies [958f175]
@@ -427,6 +476,7 @@
427
476
  - Updated dependencies [e143263]
428
477
  - Updated dependencies [e4f7876]
429
478
  - Updated dependencies [cb0fd5b]
479
+ - Updated dependencies [256b6e3]
430
480
  - Updated dependencies [c48d780]
431
481
  - Updated dependencies [3537448]
432
482
  - Updated dependencies [c882491]
@@ -448,6 +498,7 @@
448
498
  - Updated dependencies [9a9e1fa]
449
499
  - Updated dependencies [9055634]
450
500
  - Updated dependencies [3537448]
501
+ - Updated dependencies [a373d51]
451
502
  - Updated dependencies [6c3a303]
452
503
  - Updated dependencies [27440aa]
453
504
  - Updated dependencies [e3de9ef]
@@ -470,6 +521,7 @@
470
521
  - Updated dependencies [e4c7e5c]
471
522
  - Updated dependencies [6291fec]
472
523
  - Updated dependencies [0c480c6]
524
+ - Updated dependencies [3b7247e]
473
525
  - Updated dependencies [e4c3009]
474
526
  - Updated dependencies [0d7294c]
475
527
  - Updated dependencies [d9eaacc]
@@ -194,3 +194,79 @@ describe("a builder request while no builder is running", () => {
194
194
  }
195
195
  });
196
196
  });
197
+
198
+ describe("a stop while the builder is starting", () => {
199
+ isolateEnv({ AKAN_PUBLIC_REPO_NAME: "repo", AKAN_PUBLIC_SERVE_DOMAIN: "localhost", AKAN_PUBLIC_ENV: "local" });
200
+ const track = tempRoots();
201
+ const fakeBuilder = (onStart: (options: { onReady?: () => void; onExit?: () => void }) => void = () => undefined) => {
202
+ const builder = {
203
+ status: "starting",
204
+ patcherOff: false,
205
+ started: 0,
206
+ stopped: 0,
207
+ start: (options: { onReady?: () => void; onExit?: () => void }) => {
208
+ builder.started += 1;
209
+ onStart(options);
210
+ },
211
+ send: () => false,
212
+ stop: () => {
213
+ builder.stopped += 1;
214
+ },
215
+ };
216
+ return builder;
217
+ };
218
+
219
+ test("stops a builder created after the stop, and never starts it", async () => {
220
+ const { app } = track(await createTempApp("demo"));
221
+ const builder = fakeBuilder();
222
+ const created = Promise.withResolvers<IncrementalBuilderHost>();
223
+ const create = IncrementalBuilderHost.create;
224
+ let creates = 0;
225
+ IncrementalBuilderHost.create = async () => {
226
+ creates += 1;
227
+ return await created.promise;
228
+ };
229
+ const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
230
+ try {
231
+ const starting = host.start();
232
+ await Bun.sleep(50);
233
+ await host.stop();
234
+ created.resolve(builder as unknown as IncrementalBuilderHost);
235
+ await expect(starting).rejects.toThrow("dev server stopped before the builder started");
236
+ expect({ creates, started: builder.started, stopped: builder.stopped }).toEqual({
237
+ creates: 1,
238
+ started: 0,
239
+ stopped: 1,
240
+ });
241
+ } finally {
242
+ IncrementalBuilderHost.create = create;
243
+ }
244
+ });
245
+
246
+ test("gives up a builder still booting at once instead of retrying after its ready timeout", async () => {
247
+ const { app } = track(await createTempApp("demo"));
248
+ let exitLate: (() => void) | undefined;
249
+ const builder = fakeBuilder(({ onExit }) => {
250
+ exitLate = onExit;
251
+ });
252
+ const create = IncrementalBuilderHost.create;
253
+ let creates = 0;
254
+ IncrementalBuilderHost.create = async () => {
255
+ creates += 1;
256
+ return builder as unknown as IncrementalBuilderHost;
257
+ };
258
+ const host = new AkanAppHost(app, { env: { ...process.env } as Record<string, string> });
259
+ try {
260
+ const starting = host.start();
261
+ await Bun.sleep(50);
262
+ expect(builder.started).toBe(1);
263
+ await host.stop();
264
+ await expect(starting).rejects.toThrow("dev server stopped before the builder was ready");
265
+ exitLate?.();
266
+ await Bun.sleep(50);
267
+ expect({ creates, stopped: builder.stopped }).toEqual({ creates: 1, stopped: 1 });
268
+ } finally {
269
+ IncrementalBuilderHost.create = create;
270
+ }
271
+ });
272
+ });
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { Logger } from "akanjs/common";
2
+ import { Logger, RestartSchedule } from "akanjs/common";
3
3
  import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildStatus } from "akanjs/server";
4
4
  import type { App } from "../commandDecorators";
5
5
  import { createTunnel } from "../createTunnel";
@@ -15,6 +15,7 @@ import {
15
15
  type BackendLifecycleState,
16
16
  type BackendRestartReason,
17
17
  BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
18
+ backendRecoveryPolicy,
18
19
  backendRestartReasonFromMessage,
19
20
  buildStatusReplaySequence,
20
21
  createBackendBuildStatus,
@@ -35,7 +36,6 @@ import {
35
36
  redisTunnelPolicy,
36
37
  resolveIdleSuspendMs,
37
38
  type SourceFingerprints,
38
- shouldAbandonBackendRecovery,
39
39
  shouldHoldForReturningBuilder,
40
40
  shouldKeepBuildFailure,
41
41
  shouldMarkBuildPhaseRecovered,
@@ -59,10 +59,6 @@ const BACKEND_RESTART_DEBOUNCE_MS = 120;
59
59
  // Above the gateway's ~5s child-shutdown wait: a gateway SIGKILLed mid-shutdown strands orphan replicas.
60
60
  const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
61
61
 
62
- const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
63
-
64
- const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
65
-
66
62
  const BACKEND_STDERR_TAIL_LIMIT = 40;
67
63
 
68
64
  const BUILDER_READY_TIMEOUT_MS = 150000;
@@ -77,11 +73,6 @@ const BUILDER_RSS_SETTLE_MS = 20_000;
77
73
 
78
74
  const PS_RSS_TIMEOUT_MS = 2_000;
79
75
 
80
- // The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
81
- const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
82
-
83
- const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
84
-
85
76
  interface LastGoodFrontendState {
86
77
  pages?: Extract<BuilderMessage, { type: "pages-updated" }>;
87
78
  css?: Extract<BuilderMessage, { type: "css-updated" }>;
@@ -101,8 +92,7 @@ export class AkanAppHost {
101
92
  #backendReady = false;
102
93
  #plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
103
94
  #restartTimer: ReturnType<typeof setTimeout> | null = null;
104
- #backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
105
- #backendRecoveryAttempts = 0;
95
+ readonly #backendRecovery = new RestartSchedule(backendRecoveryPolicy);
106
96
  #backendGaveUp = false;
107
97
  #backendLifecycleState: BackendLifecycleState = "stopped";
108
98
  #pendingRestartReason: BackendRestartReason | null = null;
@@ -119,8 +109,9 @@ export class AkanAppHost {
119
109
  //? its recovery, reported past the failure's generation so the scan overlay clears.
120
110
  #restartFailedAfterApply: number | null = null;
121
111
  #stopping = false;
122
- #builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
123
- #builderRecoveryAttempts = 0;
112
+ //? The builder is the file watcher, so unlike the backend its recovery never gives up; it only backs off.
113
+ readonly #builderRecovery = new RestartSchedule({ min: 2_000, max: 60_000 });
114
+ #abandonBuilderStart: ((error: Error) => void) | null = null;
124
115
  #backendStartStatus: { generation?: number; files: string[] } | null = null;
125
116
  #backendBuildStatusGeneration = 0;
126
117
  #backendStderrTail: string[] = [];
@@ -186,6 +177,8 @@ export class AkanAppHost {
186
177
  this.#cancelIdleSuspend();
187
178
  this.#stopIdleWatcher();
188
179
  this.#clearRestartTimers();
180
+ //? A stopped builder sends no exit: a start waiting for its ready would sit out the ready timeout, then retry.
181
+ this.#abandonBuilderStart?.(new Error("dev server stopped before the builder was ready"));
189
182
  this.#pendingRestartReason = null;
190
183
  // Before the backend goes away, while it can still receive the answer.
191
184
  this.#failPendingBuilderMessages("dev server is shutting down");
@@ -195,9 +188,10 @@ export class AkanAppHost {
195
188
  return this;
196
189
  }
197
190
  #clearRestartTimers() {
198
- for (const timer of [this.#restartTimer, this.#backendRecoveryTimer, this.#builderRecoveryTimer])
199
- if (timer) clearTimeout(timer);
200
- this.#restartTimer = this.#backendRecoveryTimer = this.#builderRecoveryTimer = null;
191
+ if (this.#restartTimer) clearTimeout(this.#restartTimer);
192
+ this.#restartTimer = null;
193
+ this.#backendRecovery.cancel();
194
+ this.#builderRecovery.cancel();
201
195
  }
202
196
  kill() {
203
197
  void this.stop();
@@ -239,7 +233,7 @@ export class AkanAppHost {
239
233
  if (!msg || typeof msg !== "object") return;
240
234
  if (msg.type === "backend-ready") {
241
235
  this.#backendReady = true;
242
- this.#backendRecoveryAttempts = 0;
236
+ this.#backendRecovery.reset();
243
237
  this.#setBackendLifecycleState("ready", `pid=${msg.pid}`);
244
238
  this.#recordBackendReadyStatus();
245
239
  this.logger.verbose(`backend ready pid=${msg.pid}`);
@@ -402,10 +396,7 @@ export class AkanAppHost {
402
396
  "restart-pending",
403
397
  `generation=${pending.generation ?? "(unknown)"} files=${pending.files.length} roles=${pending.roles.join(",") || "(none)"}`,
404
398
  );
405
- if (this.#backendRecoveryTimer) {
406
- clearTimeout(this.#backendRecoveryTimer);
407
- this.#backendRecoveryTimer = null;
408
- }
399
+ this.#backendRecovery.cancel();
409
400
  if (this.#restartTimer) clearTimeout(this.#restartTimer);
410
401
  this.#restartTimer = setTimeout(() => {
411
402
  this.#restartTimer = null;
@@ -426,16 +417,16 @@ export class AkanAppHost {
426
417
  this.logger.verbose(
427
418
  `[backend-reload] restarting backend generation=${reason.generation ?? "(unknown)"} files=${reason.files.length} roles=${reason.roles.join(",") || "(none)"}`,
428
419
  );
429
- this.#backendRecoveryAttempts = 0;
420
+ this.#backendRecovery.reset();
430
421
  await Promise.all([this.#stopBackend(), this.#backendGraph.refresh()]);
431
422
  this.#startBackend({ generation: reason.generation, files: reason.files });
432
423
  }
433
424
  #scheduleBackendRecovery(reason: string) {
434
- if (this.#backendRecoveryTimer || this.#backend) return;
435
- if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
436
- const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for an edit or a green build to retry.`;
425
+ if (this.#backendRecovery.pending || this.#backend) return;
426
+ if (this.#backendRecovery.exhausted) {
427
+ const message = `Backend exited ${this.#backendRecovery.attempts} times in a row (${reason}); waiting for an edit or a green build to retry.`;
437
428
  this.#backendGaveUp = true;
438
- this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
429
+ this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecovery.attempts} recovery attempts`);
439
430
  this.logger.error(`[backend-recovery] ${message}`);
440
431
  if (this.#backendStderrTail.length > 0) {
441
432
  this.logger.error(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
@@ -445,9 +436,7 @@ export class AkanAppHost {
445
436
  return;
446
437
  }
447
438
  this.#setBackendLifecycleState("recovering", reason);
448
- const attempt = this.#backendRecoveryAttempts;
449
- const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
450
- this.#backendRecoveryAttempts = attempt + 1;
439
+ const delay = this.#backendRecovery.next();
451
440
  const failureStatus = this.#recordBackendBuildStatus({
452
441
  ok: false,
453
442
  files: [],
@@ -455,18 +444,17 @@ export class AkanAppHost {
455
444
  });
456
445
  this.#sendOrQueueBuildStatus(failureStatus);
457
446
  this.logger.warn(
458
- `[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
447
+ `[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecovery.attempts})`,
459
448
  );
460
449
  if (this.#backendStderrTail.length > 0) {
461
450
  this.logger.warn(`[backend-recovery] recent backend stderr:\n${this.#backendStderrTail.join("\n")}`);
462
451
  }
463
- this.#backendRecoveryTimer = setTimeout(() => {
464
- this.#backendRecoveryTimer = null;
452
+ this.#backendRecovery.arm(delay, () => {
465
453
  if (this.#backend) return;
466
454
  void this.#backendGraph.refresh().finally(() => {
467
455
  if (!this.#backend) this.#startBackend({ generation: failureStatus.generation, files: failureStatus.files });
468
456
  });
469
- }, delay);
457
+ });
470
458
  }
471
459
  #enqueueBuilderMessage(message: BuilderMessage) {
472
460
  this.#builderMessageQueue = this.#builderMessageQueue
@@ -763,8 +751,8 @@ export class AkanAppHost {
763
751
  this.#pendingRecycle ||
764
752
  this.#restartTimer ||
765
753
  this.#backendRestart ||
766
- this.#backendRecoveryTimer ||
767
- this.#builderRecoveryTimer ||
754
+ this.#backendRecovery.pending ||
755
+ this.#builderRecovery.pending ||
768
756
  this.#rssRecycleReason
769
757
  );
770
758
  }
@@ -1024,28 +1012,25 @@ export class AkanAppHost {
1024
1012
  this.#scheduleBuilderRecovery({ generation, files: message.files });
1025
1013
  }
1026
1014
  #scheduleBuilderRecovery(reason: { generation?: number; files: string[] }): void {
1027
- if (this.#builderRecoveryTimer || this.#builder) return;
1028
- const attempt = this.#builderRecoveryAttempts;
1029
- const delay = Math.min(BUILDER_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BUILDER_RECOVERY_MAX_DELAY_MS);
1030
- this.#builderRecoveryAttempts = attempt + 1;
1015
+ if (this.#stopping || this.#builderRecovery.pending || this.#builder) return;
1016
+ const delay = this.#builderRecovery.schedule(() => {
1017
+ if (this.#stopping || this.#builder) return;
1018
+ void this.#recoverBuilder(reason);
1019
+ });
1031
1020
  this.logger.warn(
1032
- `[builder-recovery] builder is down; retrying start in ${delay}ms (attempt ${this.#builderRecoveryAttempts})`,
1021
+ `[builder-recovery] builder is down; retrying start in ${delay}ms (attempt ${this.#builderRecovery.attempts})`,
1033
1022
  );
1034
- this.#builderRecoveryTimer = setTimeout(() => {
1035
- this.#builderRecoveryTimer = null;
1036
- if (this.#builder) return;
1037
- void this.#recoverBuilder(reason);
1038
- }, delay);
1039
1023
  }
1040
1024
  async #recoverBuilder(reason: { generation?: number; files: string[] }): Promise<void> {
1041
1025
  try {
1042
1026
  await this.#startBuilder();
1043
1027
  } catch (err) {
1028
+ if (this.#stopping) return;
1044
1029
  this.logger.warn(`[builder-recovery] builder start failed: ${err instanceof Error ? err.message : String(err)}`);
1045
1030
  this.#scheduleBuilderRecovery(reason);
1046
1031
  return;
1047
1032
  }
1048
- this.#builderRecoveryAttempts = 0;
1033
+ this.#builderRecovery.reset();
1049
1034
  this.logger.info("[builder-recovery] builder recovered");
1050
1035
  void this.#restartBackendForGapChanges();
1051
1036
  const failedAfterApply = this.#restartFailedAfterApply;
@@ -1062,14 +1047,14 @@ export class AkanAppHost {
1062
1047
  };
1063
1048
  this.#recordBuildStatus(status);
1064
1049
  this.#sendOrQueueBuildStatus(status);
1065
- if (!this.#backend && !this.#backendRecoveryTimer) {
1050
+ if (!this.#backend && !this.#backendRecovery.pending) {
1066
1051
  this.#startBackend({ generation: reason.generation, files: reason.files });
1067
1052
  }
1068
1053
  }
1069
1054
  #reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
1070
- if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
1055
+ if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecovery.pending) return;
1071
1056
  this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
1072
- this.#backendRecoveryAttempts = 0;
1057
+ this.#backendRecovery.reset();
1073
1058
  this.#startBackend({ generation: status.generation, files: status.files });
1074
1059
  }
1075
1060
  async #restartDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
@@ -1097,7 +1082,7 @@ export class AkanAppHost {
1097
1082
  ): Promise<void> {
1098
1083
  const generation = message.devPlan?.generation ?? message.generation;
1099
1084
  this.#clearRestartTimers();
1100
- this.#builderRecoveryAttempts = 0;
1085
+ this.#builderRecovery.reset();
1101
1086
  this.#pendingRestartReason = null;
1102
1087
  await this.#backendRestart;
1103
1088
  this.#lastGoodFrontend = {};
@@ -1232,7 +1217,7 @@ export class AkanAppHost {
1232
1217
  this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
1233
1218
  let lastError: unknown;
1234
1219
  for (let attempt = 1; attempt <= BUILDER_START_MAX_ATTEMPTS; attempt++) {
1235
- this.#builder = await IncrementalBuilderHost.create(
1220
+ const builder = await IncrementalBuilderHost.create(
1236
1221
  this.app,
1237
1222
  this.env,
1238
1223
  (msg) => {
@@ -1244,6 +1229,11 @@ export class AkanAppHost {
1244
1229
  patcherOff: this.#patcherOff,
1245
1230
  },
1246
1231
  );
1232
+ if (this.#stopping) {
1233
+ builder.stop();
1234
+ throw new Error("dev server stopped before the builder started");
1235
+ }
1236
+ this.#builder = builder;
1247
1237
  try {
1248
1238
  await this.#waitForBuilderReady(attempt, { announceBootState });
1249
1239
  this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
@@ -1251,7 +1241,7 @@ export class AkanAppHost {
1251
1241
  } catch (err) {
1252
1242
  lastError = err;
1253
1243
  this.#stopBuilder();
1254
- if (attempt >= BUILDER_START_MAX_ATTEMPTS) break;
1244
+ if (attempt >= BUILDER_START_MAX_ATTEMPTS || this.#stopping) break;
1255
1245
  this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
1256
1246
  }
1257
1247
  }
@@ -1269,8 +1259,11 @@ export class AkanAppHost {
1269
1259
  if (settled) return;
1270
1260
  settled = true;
1271
1261
  clearTimeout(timeout);
1262
+ if (this.#abandonBuilderStart === abandon) this.#abandonBuilderStart = null;
1272
1263
  fn();
1273
1264
  };
1265
+ const abandon = (error: Error) => settle(() => reject(error));
1266
+ this.#abandonBuilderStart = abandon;
1274
1267
  const timeout = setTimeout(() => {
1275
1268
  settle(() => reject(new Error("[cli] builder timed out before emitting builder-ready")));
1276
1269
  }, BUILDER_READY_TIMEOUT_MS);
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { RestartSchedule } from "akanjs/common";
2
3
  import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeAction } from "akanjs/server";
3
4
  //? The source itself, not the package: the published akanjs exports no such subpath, and tests are not bundled.
4
5
  import { DevBuildRecovery } from "../../../akanjs/server/artifact/devBuildRecovery";
5
6
  import {
7
+ backendRecoveryPolicy,
6
8
  backendRestartReasonFromMessage,
7
9
  buildStatusReplaySequence,
8
10
  createBackendBuildStatus,
@@ -18,7 +20,6 @@ import {
18
20
  normalizeBackendReportedGeneration,
19
21
  redisTunnelPolicy,
20
22
  resolveIdleSuspendMs,
21
- shouldAbandonBackendRecovery,
22
23
  shouldHoldForReturningBuilder,
23
24
  shouldKeepBuildFailure,
24
25
  shouldMarkBuildPhaseRecovered,
@@ -489,16 +490,15 @@ describe("legacy backend graph fallback", () => {
489
490
  });
490
491
 
491
492
  describe("backend recovery abandonment", () => {
492
- test("keeps retrying below the attempt ceiling and abandons at it", () => {
493
- expect(shouldAbandonBackendRecovery(0)).toBe(false);
494
- expect(shouldAbandonBackendRecovery(4)).toBe(false);
495
- expect(shouldAbandonBackendRecovery(5)).toBe(true);
496
- expect(shouldAbandonBackendRecovery(9)).toBe(true);
497
- });
498
-
499
- test("honors a custom attempt ceiling", () => {
500
- expect(shouldAbandonBackendRecovery(2, 3)).toBe(false);
501
- expect(shouldAbandonBackendRecovery(3, 3)).toBe(true);
493
+ test("backs off from 1s and gives up after five failed restarts in a row", () => {
494
+ const recovery = new RestartSchedule(backendRecoveryPolicy);
495
+ const delays = [1, 2, 3, 4].map(() => recovery.next());
496
+ expect(recovery.exhausted).toBe(false);
497
+ delays.push(recovery.next());
498
+ expect(delays).toEqual([1_000, 2_000, 4_000, 8_000, 16_000]);
499
+ expect(recovery.exhausted).toBe(true);
500
+ recovery.reset();
501
+ expect(recovery.exhausted).toBe(false);
502
502
  });
503
503
  });
504
504
 
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import type { RestartPolicy } from "akanjs/common";
2
3
  import type {
3
4
  BuilderMessage,
4
5
  BuildPhase,
@@ -20,7 +21,7 @@ export const redisTunnelPolicy = ({
20
21
  }): "none" | "optional" | "required" =>
21
22
  environment === "local" ? "none" : databaseMode === "single" ? "optional" : "required";
22
23
 
23
- const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
24
+ export const backendRecoveryPolicy: RestartPolicy = { min: 1_000, max: 30_000, maxAttempts: 5 };
24
25
 
25
26
  // A page load asks for several routes, but a builder that never returns must not grow the hold queue unbounded.
26
27
  const HELD_BUILDER_REQUEST_LIMIT = 64;
@@ -57,9 +58,6 @@ export const shouldRestartBackendByDevPlan = (
57
58
  export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
58
59
  message.devPlan?.actions.includes("restart-builder") ?? false;
59
60
 
60
- export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
61
- attempts >= maxAttempts;
62
-
63
61
  /** The gateway reports backend failures with `generation: -1`; the host assigns its own counter then. */
64
62
  export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
65
63
  generation >= 0 ? generation : undefined;
@@ -2,6 +2,7 @@ import { lstatSync, realpathSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isRecord } from "akanjs/common";
4
4
  import { AkanContextAnalyzer, type AkanDiagnostic, type AkanModuleContext } from "./akanContext";
5
+ import { AkanMcpToolError } from "./akanMcpToolError";
5
6
  import type { Workspace } from "./commandDecorators";
6
7
  import type {
7
8
  AkanContextEvidence,
@@ -172,7 +173,8 @@ export const workflowArtifactPath = (workspace: Workspace, filePath: string) =>
172
173
  ? path.join(root, "runs", `${filePath}.json`)
173
174
  : path.resolve(workspace.workspaceRoot, filePath);
174
175
  if (!isInside(root, resolved) || !staysInside(workspace.workspaceRoot, root, resolved))
175
- throw new Error(
176
+ throw new AkanMcpToolError(
177
+ "invalidArgument",
176
178
  `MCP workflow files live under ${workflowArtifactDir}/ of the workspace, not ${JSON.stringify(filePath)}.`,
177
179
  );
178
180
  return resolved;
@@ -213,7 +215,8 @@ export const applyFirstPolicy = {
213
215
 
214
216
  export const stringArg = (args: Record<string, unknown>, key: string) => {
215
217
  const value = args[key];
216
- if (typeof value !== "string" || !value) throw new Error(`MCP tool argument "${key}" is required.`);
218
+ if (typeof value !== "string" || !value)
219
+ throw new AkanMcpToolError("invalidArgument", `MCP tool argument "${key}" is required.`);
217
220
  return value;
218
221
  };
219
222
 
@@ -226,13 +229,20 @@ export const workflowInputsArg = (args: Record<string, unknown>) => {
226
229
  export const inspectAkanContextPropsArg = (args: Record<string, unknown>): InspectAkanContextProps => {
227
230
  const question = stringArg(args, "question");
228
231
  const review = stringArg(args, "review");
229
- if (!isRecord(args.draft)) throw new Error('MCP tool argument "draft" is required.');
230
- if (!isRecord(args.request)) throw new Error('MCP tool argument "request" is required.');
232
+ if (!isRecord(args.draft)) throw new AkanMcpToolError("invalidArgument", 'MCP tool argument "draft" is required.');
233
+ if (!isRecord(args.request))
234
+ throw new AkanMcpToolError("invalidArgument", 'MCP tool argument "request" is required.');
231
235
  if (!isInspectAkanContextRequestType(args.draft.type)) {
232
- throw new Error('MCP tool argument "draft.type" must be a supported inspect_akan_context request type.');
236
+ throw new AkanMcpToolError(
237
+ "invalidArgument",
238
+ 'MCP tool argument "draft.type" must be a supported inspect_akan_context request type.',
239
+ );
233
240
  }
234
241
  if (!isInspectAkanContextRequestType(args.request.type)) {
235
- throw new Error('MCP tool argument "request.type" must be a supported inspect_akan_context request type.');
242
+ throw new AkanMcpToolError(
243
+ "invalidArgument",
244
+ 'MCP tool argument "request.type" must be a supported inspect_akan_context request type.',
245
+ );
236
246
  }
237
247
  const base = { question, draft: { reason: stringArg(args.draft, "reason"), type: args.draft.type }, review };
238
248
  const request = args.request;
@@ -384,7 +394,7 @@ export const applyMcpTools: McpToolDefinition[] = [
384
394
  {
385
395
  name: "apply_workflow",
386
396
  description:
387
- "Apply a stored workflow plan before direct edits and return validationTarget, plus next.tool=run_validation unless the apply failed.",
397
+ "Apply a stored workflow plan before direct edits and return validationTarget. Unless it failed, next.tool is run_validation after an apply and apply_workflow without dryRun after a dry run.",
388
398
  inputSchema: {
389
399
  type: "object",
390
400
  properties: { planPath: stringProperty, dryRun: booleanProperty },
@@ -0,0 +1,16 @@
1
+ import { AkanError } from "akanjs/base";
2
+ import type { McpFailure } from "akanjs/common";
3
+
4
+ export type AkanMcpToolErrorCode = "invalidArgument" | "toolUnavailable" | "unknownTool";
5
+
6
+ export class AkanMcpToolError extends AkanError<AkanMcpToolErrorCode> {
7
+ constructor(code: AkanMcpToolErrorCode, message: string) {
8
+ super(code, message, { statusCode: 400 });
9
+ }
10
+
11
+ static failureOf(error: unknown): McpFailure {
12
+ return AkanError.is(error)
13
+ ? { code: error.code, statusCode: error.statusCode }
14
+ : { code: "toolFailed", statusCode: 500 };
15
+ }
16
+ }
@@ -9,7 +9,7 @@ description: "Check and debug a running Akan app: akan preview for what a page s
9
9
 
10
10
  `akan preview <app> [paths]` opens pages of the app's dev server in a headless browser and prints, per page,
11
11
  the HTTP status, the title, a slice of the visible text, the uncaught errors and rejections the page reported,
12
- the server errors logged meanwhile, console errors and warnings, and layout smells. It exits 1 when a page failed.
12
+ the server errors logged meanwhile, console errors and warnings, and layout smells. It exits 3 when a page failed.
13
13
 
14
14
  - It uses the dev server that answers for this app and checkout. With none, it starts one for the run and
15
15
  stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
@@ -23,7 +23,8 @@ an endpoint with a real guard is published, one with none is refused, and so is
23
23
  - `option.setMcp({ … })` in the app's `lib/option.ts`, never `main.ts`; `AKAN_MCP=false` takes the surface off.
24
24
  - `JWT_SECRET` is required outside `local`.
25
25
  - Every published entry needs a `.desc()`; model-facing text is English.
26
- - Never make a refusal message more helpful.
26
+ - Never make a refusal message more helpful. A client branches on the code a failure carries
27
+ (`_meta["akanjs/failure"]`, or a JSON-RPC error's `data.code`), never on its text.
27
28
 
28
29
  ## Pages for the host
29
30