Public: anyone can read this; the crew can change it.
/boards: forges made, time to live, and failures #2950
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2932-boards-forges-made-time-to-live-and-fail → main 3 commits fork 2 threads · 2 unresolved
15 files
changed +471
−13
README.md +1 −1
| @@ -1555,7 +1555,7 @@ work here. | ||
| 1555 | 1555 | `forge/boards`, a repo the forge owns and makes on the first boot with an admin; every save is a commit |
| 1556 | 1556 | by the admin who made it, and a file that does not validate is not shown. Make, rename, |
| 1557 | 1557 | duplicate, delete, add/edit/remove/reorder panels, or pin the query on `/boards/data`. |
| 1558 | The first boot also writes six starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security** and **Throughput**. Apps' per-app panels show repo ids until a source exposes a safe name; Security (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`) shows no token text, hash, payload or IP; Throughput is items entering each stage from issue to live, per hour, from the `stages` source. Each is written once: `.offered` in that repo records them, so an edit stays and a deletion sticks. | |
| 1558 | The first boot also writes seven starting boards, **Usage**, **Crew**, **Releases**, **Apps**, **Security**, **Throughput** and **Forges**. Apps' per-app panels show repo ids until a source exposes a safe name; Security (sign-ins, failed sign-ins, tokens by scope, admin actions, from the admin-only `signins`, `tokens` and `adminActions` sources and `usage`) shows no token text, hash, payload or IP; Throughput is items entering each stage from issue to live, per hour, from the `stages` source. Each is written once: `.offered` in that repo records them, so an edit stays and a deletion sticks. | |
| 1559 | 1559 | A live board (any `refresh` but `off`; the form offers off or on, and `10s` and `1m` are still accepted and mean the same) redraws its grid when a run, pull, check, issue or usage event arrives, through the live-region swap |
| 1560 | 1560 | (`/boards/:slug/live`, etag'd, one fetch per burst). The same doors are in the API: `GET /api/v1/boards`, `GET|PUT|DELETE /api/v1/boards/:slug` |
| 1561 | 1561 | (an agent or anyone but an admin is 403; a panel whose query cannot run is refused with 422). The same four doors are MCP tools (`boards`, `board_write`, `board_delete`; `boards` with a `slug` reads one) and, for the same person, a **Boards** screen in the terminal that lists boards and shows a board's panels as text. No dependency was added. |
docs/APPS.md +13
| @@ -1201,6 +1201,19 @@ leaves its series absent, never zero, and `/boards/data?source=machine` | ||
| 1201 | 1201 | names each one. Its rows belong to no repo, so only an every-repo scope |
| 1202 | 1202 | reads them; the sources list carries each column's unit in `docs`. |
| 1203 | 1203 | |
| 1204 | The `spawns` and `spawnTime` sources (admin only) read the one-click path's | |
| 1205 | events: `forge.spawn.started`, `forge.spawn.live` (`ms` since started, when the | |
| 1206 | child's `/healthz` first answers; `spawn/live.ts`) and `forge.spawn.failed` | |
| 1207 | (`step`: place, copy or health). A payload `drill: 1` makes it the `drill` | |
| 1208 | series, so the nightly drill never mixes into a person's numbers. The Forges | |
| 1209 | starting board shows forges made per day, p50/p95 time to live against the | |
| 1210 | 3-minute budget, and failures by step. The watcher that waits for `/healthz` | |
| 1211 | lives in the process: a restart mid-spawn leaves a `started` with no outcome, | |
| 1212 | and that spawn is simply never counted live or failed. A retry of an | |
| 1213 | unfinished child appends its own `started`, so every `failed` has one. The | |
| 1214 | watchers poll every second, then every five after 30 seconds, stop when the | |
| 1215 | forge closes, and at most 20 run at once; a spawn past that is made, not timed. | |
| 1216 | ||
| 1204 | 1217 | The `stages` source counts items moving from issue to live: one row per |
| 1205 | 1218 | item entering a stage, with `stage` one of `1 issue` (opened), `2 run` (a |
| 1206 | 1219 | builder first started on the issue; a retry is not a second item), `3 pull` |
src/actions/spawn.ts +13 −2
| @@ -94,6 +94,8 @@ export interface SpawnDeps { | ||
| 94 | 94 | readonly modelKey?: string; |
| 95 | 95 | /** Anthropic-compatible API base URL sealed with the starter key (OpenRouter, etc.). */ |
| 96 | 96 | readonly modelEndpoint?: string; |
| 97 | /** The nightly drill's own forge: its spawn events carry `drill`, so the boards keep it as its own series. */ | |
| 98 | readonly drill?: boolean; | |
| 97 | 99 | } |
| 98 | 100 | |
| 99 | 101 | interface Ledger { |
| @@ -123,6 +125,12 @@ const SAFE_ID = /^[A-Za-z0-9_-]{1,80}$/; | ||
| 123 | 125 | export const STARTER_KEY = "starter"; |
| 124 | 126 | const inFlight = new Set<string>(); |
| 125 | 127 | |
| 128 | /** A failure the boards count: the step that broke (a fixed few), never its words. */ | |
| 129 | function failed(app: App, deps: SpawnDeps, step: "place" | "copy" | "health", message: string) { | |
| 130 | app.bus.append({ kind: "forge.spawn.failed", payload: { step, ...(deps.drill === true ? { drill: 1 } : {}) } }); | |
| 131 | return fail(message); | |
| 132 | } | |
| 133 | ||
| 126 | 134 | const fail = (message: string) => err<SpawnError>({ kind: "failed", message }); |
| 127 | 135 | |
| 128 | 136 | export const childDirOf = (app: App, bodyId: string): string => |
| @@ -228,12 +236,14 @@ async function make( | ||
| 228 | 236 | ledger.beatKey ??= secret(); |
| 229 | 237 | await writeLedger(app, ledger); |
| 230 | 238 | } |
| 239 | // A retry of an unfinished child is a new attempt: its failure has a start. | |
| 240 | app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } }); | |
| 231 | 241 | } else { |
| 232 | 242 | const placed = await deps.placement.place(plan); |
| 233 | 243 | if (!placed.ok) return err({ kind: "refused", ...placed.error }); |
| 234 | 244 | if (!SAFE_ID.test(placed.value.bodyId)) { |
| 235 | 245 | await deps.placement.retire(placed.value.bodyId); |
| 236 | return fail("placement returned an unusable body id"); | |
| 246 | return failed(app, deps, "place", "placement returned an unusable body id"); | |
| 237 | 247 | } |
| 238 | 248 | ledger = { |
| 239 | 249 | name: plan.name, |
| @@ -248,10 +258,11 @@ async function make( | ||
| 248 | 258 | beatKey: secret(), |
| 249 | 259 | }; |
| 250 | 260 | await writeLedger(app, ledger); |
| 261 | app.bus.append({ kind: "forge.spawn.started", payload: { ...(deps.drill === true ? { drill: 1 } : {}) } }); | |
| 251 | 262 | } |
| 252 | 263 | |
| 253 | 264 | const copied = await copyInto(app, owner, plan, ledger, deps.modelKey, deps.modelEndpoint); |
| 254 | if (!copied.ok) return fail(copied.error); | |
| 265 | if (!copied.ok) return failed(app, deps, "copy", copied.error); | |
| 255 | 266 | const claimUrl = claimLink(liveChildUrl(app, plan.name), copied.value); |
| 256 | 267 | |
| 257 | 268 | ledger.done = true; |
src/app.ts +5
| @@ -204,6 +204,8 @@ export interface App { | ||
| 204 | 204 | readonly hookAddress: { current: string }; |
| 205 | 205 | /** Per-boot secret every internal request must carry (`FORGE_HOOK_SECRET` in a hook's env). */ |
| 206 | 206 | readonly hookSecret: string; |
| 207 | /** Aborted by `close()`: background pollers stop on it and write nothing after. */ | |
| 208 | readonly closing: AbortController; | |
| 207 | 209 | /** What `vitals` turns into needs: this forge's own, not the process's. */ |
| 208 | 210 | readonly signals: { errors: ErrorLedger; refused: RefusedLedger }; |
| 209 | 211 | close(): void; |
| @@ -228,6 +230,7 @@ export function createApp(config: ForgeConfig): App { | ||
| 228 | 230 | // Idempotent — a forge without them changes nothing. |
| 229 | 231 | dropLegacyNewAccountGuards(db); |
| 230 | 232 | const bus = new EventBus(db); |
| 233 | const closing = new AbortController(); | |
| 231 | 234 | |
| 232 | 235 | // Every event durably enqueues webhook deliveries; the worker (main.ts) |
| 233 | 236 | // sends them. Listener throws are swallowed by the bus, so this can never |
| @@ -251,7 +254,9 @@ export function createApp(config: ForgeConfig): App { | ||
| 251 | 254 | socketPath, |
| 252 | 255 | hookAddress: { current: socketPath }, |
| 253 | 256 | hookSecret: randomBytes(32).toString("hex"), |
| 257 | closing, | |
| 254 | 258 | close: () => { |
| 259 | closing.abort(); | |
| 255 | 260 | // The pooled `cat-file` readers for this forge's repos are processes, |
| 256 | 261 | // and processes outlive a closed database unless somebody says |
| 257 | 262 | // otherwise. One data dir is one forge, so closing the forge is where |
src/boards/sources.ts +55
| @@ -43,6 +43,23 @@ const percentile = (name: string, column: string, p: number) => | ||
| 43 | 43 | `COUNT(*) OVER (PARTITION BY route, day) AS n FROM page_vital WHERE ${column} IS NOT NULL) ` + |
| 44 | 44 | `WHERE rn = (n * ${p} + 99) / 100`; |
| 45 | 45 | |
| 46 | /** The budget a spawned forge has to answer its `/healthz`: three minutes (#2914). */ | |
| 47 | export const SPAWN_BUDGET_MS = 180_000; | |
| 48 | ||
| 49 | /** A spawn event's payload fields, as a drill or a person's forge, read in place of the `event` table. */ | |
| 50 | const SPAWN_SERIES = "CASE WHEN json_extract(payload, '$.drill') = 1 THEN 'drill' ELSE 'forge' END"; | |
| 51 | const SPAWN_KINDS = "kind IN ('forge.spawn.started', 'forge.spawn.live', 'forge.spawn.failed')"; | |
| 52 | const SPAWN_DAY = "substr(created_at, 1, 10) || 'T00:00:00.000Z'"; | |
| 53 | ||
| 54 | /** Nearest-rank percentile `p` of the ms to live, per series per day, as rows of the `spawnTime` source. */ | |
| 55 | const spawnPercentile = (name: string, p: number) => | |
| 56 | `SELECT '${name}' AS metric, series, at, ms AS value FROM (` + | |
| 57 | `SELECT ${SPAWN_SERIES} AS series, ${SPAWN_DAY} AS at, json_extract(payload, '$.ms') AS ms, ` + | |
| 58 | `ROW_NUMBER() OVER (PARTITION BY ${SPAWN_SERIES}, ${SPAWN_DAY} ORDER BY json_extract(payload, '$.ms')) AS rn, ` + | |
| 59 | `COUNT(*) OVER (PARTITION BY ${SPAWN_SERIES}, ${SPAWN_DAY}) AS n ` + | |
| 60 | `FROM event WHERE kind = 'forge.spawn.live' AND json_extract(payload, '$.ms') IS NOT NULL) ` + | |
| 61 | `WHERE rn = (n * ${p} + 99) / 100`; | |
| 62 | ||
| 46 | 63 | export const SOURCES: Readonly<Record<string, Source>> = { |
| 47 | 64 | runs: { |
| 48 | 65 | table: "run", |
| @@ -232,6 +249,44 @@ export const SOURCES: Readonly<Record<string, Source>> = { | ||
| 232 | 249 | value: "the count, or the percentile (nearest rank) of that route that day", |
| 233 | 250 | }, |
| 234 | 251 | }, |
| 252 | // Forges made by the one-click path (#2914): one row per spawn event | |
| 253 | // (`actions/spawn.ts`, `spawn/live.ts`). The forge's name and the failure's | |
| 254 | // words are not listed; the step is one of a fixed few. | |
| 255 | spawns: { | |
| 256 | table: "spawn", | |
| 257 | owner: "admin", | |
| 258 | select: | |
| 259 | `SELECT substr(kind, 13) AS outcome, ${SPAWN_SERIES} AS series, json_extract(payload, '$.step') AS step, ` + | |
| 260 | `json_extract(payload, '$.ms') AS ms, created_at AS at FROM event WHERE ${SPAWN_KINDS}`, | |
| 261 | time: "at", | |
| 262 | columns: { outcome: "text", series: "text", step: "text", ms: "number", at: "time" }, | |
| 263 | docs: { | |
| 264 | outcome: "started, live (its /healthz first answered) or failed", | |
| 265 | series: "forge (a person's) or drill (the nightly drill's own forge)", | |
| 266 | step: "failed only: place, copy or health", | |
| 267 | ms: "live only: milliseconds from started to the first /healthz answer", | |
| 268 | }, | |
| 269 | }, | |
| 270 | // Time to live per day: p50 and p95 of the spawns that went live, and the | |
| 271 | // budget as a third row so a line chart shows the margin. | |
| 272 | spawnTime: { | |
| 273 | table: "spawn_time", | |
| 274 | owner: "admin", | |
| 275 | select: [ | |
| 276 | spawnPercentile("p50", 50), | |
| 277 | spawnPercentile("p95", 95), | |
| 278 | `SELECT 'budget', series, at, ${SPAWN_BUDGET_MS} FROM (SELECT DISTINCT ${SPAWN_SERIES} AS series, ${SPAWN_DAY} AS at ` + | |
| 279 | `FROM event WHERE ${SPAWN_KINDS})`, | |
| 280 | ].join(" UNION ALL "), | |
| 281 | time: "at", | |
| 282 | columns: { metric: "text", series: "text", at: "time", value: "number" }, | |
| 283 | docs: { | |
| 284 | metric: "p50 or p95 (nearest rank) of the ms to live that day, or budget", | |
| 285 | series: "forge (a person's) or drill (the nightly drill's own forge)", | |
| 286 | at: "the day, UTC", | |
| 287 | value: "milliseconds; the budget is 180000 (three minutes)", | |
| 288 | }, | |
| 289 | }, | |
| 235 | 290 | // Items entering each stage from issue to live, one row per entry, read |
| 236 | 291 | // from issue, run and pull as `flow/throughput.ts` reads them: an issue |
| 237 | 292 | // opens, a builder first starts on it (a retry is not a second item), its |
src/boards/starting.ts +21 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security and Throughput. They are plain | |
| 2 | * The boards the forge offers a new install: Usage, Crew, Releases, Apps, Security, Throughput and Forges. They are plain | |
| 3 | 3 | * boards in `forge/boards` once written, so a person edits or deletes them |
| 4 | 4 | * like any other; `.offered` (a JSON list of slugs, committed with the |
| 5 | 5 | * boards) records what was offered so a deletion sticks and an edit is |
| @@ -17,6 +17,13 @@ const panel = (title: string, chart: Panel["chart"], query: Record<string, unkno | ||
| 17 | 17 | }); |
| 18 | 18 | const MINUTE = { bucket: "minute" }; |
| 19 | 19 | const HOUR = { bucket: "hour" }; |
| 20 | const DAY = { bucket: "day" }; | |
| 21 | const SPAWNS = { source: "spawns", aggregate: "count" }; | |
| 22 | const started = { column: "outcome", op: "eq", value: "started" }; | |
| 23 | const timeToLive = (series: string) => ({ | |
| 24 | source: "spawnTime", aggregate: "max", column: "value", groupBy: DAY, series: "metric", | |
| 25 | filter: [{ column: "series", op: "eq", value: series }], | |
| 26 | }); | |
| 20 | 27 | const STAGES = { source: "stages", aggregate: "count" }; |
| 21 | 28 | const machine = (op: "eq" | "starts", value: string, column: string) => ({ |
| 22 | 29 | source: "machine", aggregate: "avg", column, groupBy: MINUTE, filter: [{ column: "target", op, value }], |
| @@ -105,6 +112,19 @@ export const STARTING_BOARDS: Readonly<Record<string, Board>> = { | ||
| 105 | 112 | panel("Stages by hour", "heat", { ...STAGES, groupBy: HOUR, series: "stage" }, 2), |
| 106 | 113 | ], |
| 107 | 114 | }, |
| 115 | forges: { | |
| 116 | title: "Forges", | |
| 117 | range: "30d", | |
| 118 | refresh: "1m", | |
| 119 | panels: [ | |
| 120 | panel("Forges made per day", "bar", { ...SPAWNS, groupBy: DAY, series: "series", filter: [started] }, 2), | |
| 121 | panel("Forges live per day", "bar", { ...SPAWNS, groupBy: DAY, series: "series", filter: [{ column: "outcome", op: "eq", value: "live" }] }, 2), | |
| 122 | panel("Time to live (ms): p50, p95, budget 3 min", "line", timeToLive("forge"), 2), | |
| 123 | panel("The drill's time to live (ms): p50, p95, budget 3 min", "line", timeToLive("drill"), 2), | |
| 124 | panel("Failures by step", "bar", { ...SPAWNS, groupBy: "step", series: "series", filter: [{ column: "outcome", op: "eq", value: "failed" }] }, 2), | |
| 125 | panel("Failures per day", "bar", { ...SPAWNS, groupBy: DAY, series: "step", filter: [{ column: "outcome", op: "eq", value: "failed" }] }, 2), | |
| 126 | ], | |
| 127 | }, | |
| 108 | 128 | }; |
| 109 | 129 | |
| 110 | 130 | /** The slugs already offered, from the repo; none when it has no such file yet. */ |
src/spawn/door.ts +12 −1
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | * the caller says `false` — returns the plan and changes nothing. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { startWatch, probeOf, type Probe } from "./live.ts"; | |
| 9 | 10 | import { listChildren, reissueClaim, spawnForge, type SpawnError } from "../actions/spawn.ts"; |
| 10 | 11 | import type { App } from "../app.ts"; |
| 11 | 12 | import { maxChildren } from "../core/capacity.ts"; |
| @@ -37,6 +38,12 @@ export interface Door { | ||
| 37 | 38 | |
| 38 | 39 | const MAX_CREW = 100; |
| 39 | 40 | const placements = new WeakMap<App, Placement>(); |
| 41 | const probes = new WeakMap<App, (url: string) => Probe>(); | |
| 42 | ||
| 43 | /** Tests swap how a new child is asked whether it is live. */ | |
| 44 | export function useProbe(app: App, probe: (url: string) => Probe): void { | |
| 45 | probes.set(app, probe); | |
| 46 | } | |
| 40 | 47 | |
| 41 | 48 | /** Tests (and an external placement, one day) swap where children live. */ |
| 42 | 49 | export function usePlacement(app: App, placement: Placement): void { |
| @@ -264,6 +271,7 @@ export async function spawnDoor( | ||
| 264 | 271 | }, |
| 265 | 272 | }; |
| 266 | 273 | } |
| 274 | const startedAt = Date.now(); | |
| 267 | 275 | const made = await spawnForge(ctx.app, user, input, source, { |
| 268 | 276 | placement: placementFor(ctx.app), |
| 269 | 277 | request: ctx.request, |
| @@ -274,7 +282,10 @@ export async function spawnDoor( | ||
| 274 | 282 | if (!made.ok) return spawnWords(made.error); |
| 275 | 283 | if (anon && !made.value.existed) noteAnonSpawn(ctx.request); |
| 276 | 284 | // Its apps come up now, so the first visit after a claim finds them awake. |
| 277 | if (!made.value.existed) void bootHostedChild(ctx.app, made.value.name); | |
| 285 | if (!made.value.existed) { | |
| 286 | void bootHostedChild(ctx.app, made.value.name); | |
| 287 | startWatch(ctx.app, { name: made.value.name, startedAt, probe: (probes.get(ctx.app) ?? probeOf)(made.value.url) }); | |
| 288 | } | |
| 278 | 289 | return { |
| 279 | 290 | status: made.value.existed ? 200 : 201, |
| 280 | 291 | body: { |
src/spawn/live.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** | |
| 2 | * Time to live (#2932): the moment a spawned child's `/health` first | |
| 3 | * answers, recorded on the outbox as `forge.spawn.live` with the ms since | |
| 4 | * the spawn began, or `forge.spawn.failed` at step `health` when it never | |
| 5 | * does. The boards read both (`boards/sources.ts`). One watcher per child, | |
| 6 | * polling its public address; the probe is injected so a test needs no network. | |
| 7 | * The watcher lives in this process: a restart mid-spawn leaves a `started` | |
| 8 | * with no `live` or `failed`, and the boards show that gap as unfinished. | |
| 9 | */ | |
| 10 | import type { App } from "../app.ts"; | |
| 11 | ||
| 12 | export const POLL_MS = 1000; | |
| 13 | /** After this long without an answer, poll every `SLOW_POLL_MS` instead. */ | |
| 14 | export const SLOW_AFTER_MS = 30_000; | |
| 15 | export const SLOW_POLL_MS = 5000; | |
| 16 | /** At most this many watchers at once per forge; a spawn past it is made but not timed. */ | |
| 17 | export const MAX_WATCHERS = 20; | |
| 18 | const open = new WeakMap<App, number>(); | |
| 19 | /** Give up after this long: well past the 3-minute budget, so a slow forge still counts as live, late. */ | |
| 20 | export const GIVE_UP_MS = 10 * 60_000; | |
| 21 | ||
| 22 | export type Probe = () => Promise<boolean>; | |
| 23 | ||
| 24 | /** A probe of `<url>/healthz`: true on a 200, false on anything else. Never throws. */ | |
| 25 | export function probeOf(url: string): Probe { | |
| 26 | return async () => { | |
| 27 | try { | |
| 28 | const res = await fetch(`${url.replace(/\/+$/, "")}/healthz`, { | |
| 29 | signal: AbortSignal.timeout(2000), | |
| 30 | redirect: "manual", | |
| 31 | }); | |
| 32 | await res.body?.cancel(); | |
| 33 | return res.status === 200; | |
| 34 | } catch { | |
| 35 | return false; | |
| 36 | } | |
| 37 | }; | |
| 38 | } | |
| 39 | ||
| 40 | export interface Watch { | |
| 41 | readonly name: string; | |
| 42 | /** When the spawn began, ms since the epoch. */ | |
| 43 | readonly startedAt: number; | |
| 44 | readonly probe: Probe; | |
| 45 | readonly drill?: boolean; | |
| 46 | readonly pollMs?: number; | |
| 47 | readonly giveUpMs?: number; | |
| 48 | readonly now?: () => number; | |
| 49 | } | |
| 50 | ||
| 51 | /** Start a watcher unless this forge already runs `MAX_WATCHERS`; true when started. */ | |
| 52 | export function startWatch(app: App, w: Watch): boolean { | |
outdated
The loop has no abort signal and no cap on concurrent watchers. Each new spawn, anonymous ones included, adds a poller that does a fetch every second for 10 minutes. Take an |
||
| 53 | const n = open.get(app) ?? 0; | |
| 54 | if (n >= MAX_WATCHERS) return false; | |
| 55 | open.set(app, n + 1); | |
| 56 | void watchLive(app, w).finally(() => open.set(app, (open.get(app) ?? 1) - 1)); | |
| 57 | return true; | |
| 58 | } | |
| 59 | ||
| 60 | /** | |
| 61 | * Poll until the child answers or time runs out; appends exactly one event, | |
| 62 | * or none when the forge closes first. Never throws. | |
| 63 | */ | |
| 64 | export async function watchLive(app: App, w: Watch): Promise<"live" | "failed" | "stopped"> { | |
| 65 | const now = w.now ?? Date.now; | |
| 66 | const poll = w.pollMs ?? POLL_MS; | |
| 67 | const until = w.startedAt + (w.giveUpMs ?? GIVE_UP_MS); | |
| 68 | const drill = w.drill === true ? { drill: 1 } : {}; | |
| 69 | const sleep = (ms: number) => | |
| 70 | new Promise<void>((done) => { | |
| 71 | const t = setTimeout(done, ms); | |
| 72 | app.closing.signal.addEventListener("abort", () => { clearTimeout(t); done(); }, { once: true }); | |
| 73 | }); | |
| 74 | while (true) { | |
| 75 | if (app.closing.signal.aborted) return "stopped"; | |
| 76 | let up = false; | |
| 77 | try { | |
| 78 | up = await w.probe(); | |
| 79 | } catch { | |
| 80 | up = false; | |
| 81 | } | |
| 82 | if (app.closing.signal.aborted) return "stopped"; | |
| 83 | if (up) { | |
| 84 | const ms = Math.max(0, Math.round(now() - w.startedAt)); | |
| 85 | app.bus.append({ kind: "forge.spawn.live", payload: { ms, ...drill } }); | |
| 86 | return "live"; | |
| 87 | } | |
| 88 | const t = now(); | |
| 89 | if (t >= until) break; | |
| 90 | await sleep(t - w.startedAt >= SLOW_AFTER_MS ? Math.max(poll, SLOW_POLL_MS) : poll); | |
| 91 | } | |
| 92 | app.bus.append({ kind: "forge.spawn.failed", payload: { step: "health", ...drill } }); | |
| 93 | return "failed"; | |
| 94 | } |
test/boards-starting.test.ts +4 −4
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The forge offers the six starting boards on boot: every panel runs, an | |
| 2 | * The forge offers the seven starting boards on boot: every panel runs, an | |
| 3 | 3 | * edit survives the next boot, and a deletion is not undone. |
| 4 | 4 | */ |
| 5 | 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| @@ -36,10 +36,10 @@ test("a forge with no person to found the repo writes nothing", async () => { | ||
| 36 | 36 | expect(await offerStartingBoards(bare)).toEqual([]); |
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | test("a fresh boot writes the six starting boards, and every panel runs", async () => { | |
| 40 | expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "releases", "security", "throughput", "usage"]); | |
| 39 | test("a fresh boot writes the seven starting boards, and every panel runs", async () => { | |
| 40 | expect((await offerStartingBoards(app)).sort()).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]); | |
| 41 | 41 | const boards = await listBoards(app.reposRoot); |
| 42 | expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "releases", "security", "throughput", "usage"]); | |
| 42 | expect(boards.map((b) => b.slug)).toEqual(["apps", "crew", "forges", "releases", "security", "throughput", "usage"]); | |
| 43 | 43 | let panels = 0; |
| 44 | 44 | for (const { board } of boards) { |
| 45 | 45 | for (const p of board.panels) { |
test/new-forge.test.ts +3 −1
| @@ -16,7 +16,7 @@ import { createUser } from "../src/core/users.ts"; | ||
| 16 | 16 | import { commitFiles } from "../src/git/write.ts"; |
| 17 | 17 | import { repoDir } from "../src/git/repos.ts"; |
| 18 | 18 | import { serve, type Servers } from "../src/http/server.ts"; |
| 19 | import { usePlacement } from "../src/spawn/door.ts"; | |
| 19 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 20 | 20 | import { childrenForMemory, maxChildren, setCapacity } from "../src/core/capacity.ts"; |
| 21 | 21 | import { hostedPlacement, memoryBodies } from "../src/spawn/placement.ts"; |
| 22 | 22 | import { quietLogs } from "./helpers/globals.ts"; |
| @@ -302,6 +302,7 @@ describe("+ New forge", () => { | ||
| 302 | 302 | }); |
| 303 | 303 | |
| 304 | 304 | test("a forge at its depth limit shows no door, and its page says why", async () => { |
| 305 | useProbe(app, () => async () => true); | |
| 305 | 306 | usePlacement(app, hostedPlacement({ host: "forge.test", depth: 2 })); |
| 306 | 307 | expect(await (await get(admin, "/")).text()).not.toContain('href="/forges/new"'); |
| 307 | 308 | const html = await (await get(admin, "/forges/new")).text(); |
| @@ -312,6 +313,7 @@ describe("+ New forge", () => { | ||
| 312 | 313 | test("at the room limit the door stays, disabled, with the sentence; raising the setting reopens it", async () => { |
| 313 | 314 | const store = memoryBodies(); |
| 314 | 315 | store.add({ bodyId: "b1", name: "one", url: "https://one--forge.test", depth: 1, agents: 1, ssh: false }); |
| 316 | useProbe(app, () => async () => true); | |
| 315 | 317 | usePlacement(app, hostedPlacement({ host: "forge.test", store, maxChildren: () => maxChildren(app.db) })); |
| 316 | 318 | setCapacity(app.db, "children.max", "1", "system"); |
| 317 | 319 | const sentence = "1 of 1 forges sent on this node — retire one, or raise the room in settings"; |
test/spawn-anon.test.ts +2 −1
| @@ -10,7 +10,7 @@ import { tmpdir } from "node:os"; | ||
| 10 | 10 | import { join } from "node:path"; |
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | import { usePlacement } from "../src/spawn/door.ts"; | |
| 13 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 14 | 14 | import { resetAnonSpawns } from "../src/spawn/rate.ts"; |
| 15 | 15 | import { createToken } from "../src/core/tokens.ts"; |
| 16 | 16 | import { createUser } from "../src/core/users.ts"; |
| @@ -69,6 +69,7 @@ describe("anonymous spawn", () => { | ||
| 69 | 69 | }); |
| 70 | 70 | const priv = await createRepo(app, a.value, { name: "diary", visibility: "private" }); |
| 71 | 71 | if (!priv.ok) throw new Error("private repo"); |
| 72 | useProbe(app, () => async () => true); | |
| 72 | 73 | usePlacement(app, { |
| 73 | 74 | place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }), |
| 74 | 75 | retire: async () => ok(), |
test/spawn-api.test.ts +2 −1
| @@ -10,7 +10,7 @@ import { join } from "node:path"; | ||
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | import { createAgent } from "../src/actions/crew.ts"; |
| 12 | 12 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 13 | import { spawnWords, usePlacement } from "../src/spawn/door.ts"; | |
| 13 | import { spawnWords, usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 14 | 14 | import { createToken } from "../src/core/tokens.ts"; |
| 15 | 15 | import { createUser } from "../src/core/users.ts"; |
| 16 | 16 | import { serve, type Servers } from "../src/http/server.ts"; |
| @@ -61,6 +61,7 @@ describe("spawn over the API and MCP", () => { | ||
| 61 | 61 | await commitToBranch(app, a.value, repo.value, { message: "seed", files: [{ path: "a.txt", content: "a" }] }); |
| 62 | 62 | const agent = await createAgent(app, a.value, { username: "scout", brief: "ship small" }); |
| 63 | 63 | if (!agent.ok) throw new Error(`agent ${JSON.stringify(agent.error)}`); |
| 64 | useProbe(app, () => async () => true); | |
| 64 | 65 | usePlacement(app, { |
| 65 | 66 | place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId: `fake-${++placed}` }), |
| 66 | 67 | retire: async () => ok(), |
test/spawn-events.test.ts added +130
| @@ -0,0 +1,130 @@ | ||
| 1 | /** | |
| 2 | * The events spawn emits (#2932), driven through `spawnForge` and the door | |
| 3 | * with a fake placement: removing an append in either fails a test here. | |
| 4 | */ | |
| 5 | import { afterEach, beforeEach, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createRepo } from "../src/actions/repos.ts"; | |
| 10 | import { spawnForge, type SpawnDeps } from "../src/actions/spawn.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createUser, type User } from "../src/core/users.ts"; | |
| 13 | import { repoDir } from "../src/git/repos.ts"; | |
| 14 | import { commitFiles } from "../src/git/write.ts"; | |
| 15 | import { ok } from "../src/kernel/result.ts"; | |
| 16 | import { spawnDoor, usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 17 | import type { Placement } from "../src/spawn/placement.ts"; | |
| 18 | import type { SpawnSource } from "../src/spawn/plan.ts"; | |
| 19 | import type { Ctx } from "../src/http/context.ts"; | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let alice: User; | |
| 24 | let source: SpawnSource; | |
| 25 | let retired: string[]; | |
| 26 | let bodyId: string; | |
| 27 | const input = { name: "kid", org: "kids", apps: ["alice/notes"], carryBriefs: false, carrySubscriptions: false }; | |
| 28 | ||
| 29 | const placement = (): Placement => ({ | |
| 30 | place: async (plan) => ok({ url: `https://${plan.name}.fake.test`, bodyId }), | |
| 31 | retire: async (id) => { retired.push(id); return ok(); }, | |
| 32 | }); | |
| 33 | const deps = (extra: Partial<SpawnDeps> = {}): SpawnDeps => ({ | |
| 34 | placement: placement(), | |
| 35 | request: new Request("https://forge.test/spawn", { method: "POST", headers: { origin: "https://forge.test" } }), | |
| 36 | url: new URL("https://forge.test/spawn"), | |
| 37 | ...extra, | |
| 38 | }); | |
| 39 | const events = () => | |
| 40 | app.db.query("SELECT kind, payload FROM event WHERE kind LIKE 'forge.spawn.%' ORDER BY seq").all() | |
| 41 | .map((r) => ({ kind: (r as any).kind as string, payload: JSON.parse((r as any).payload) })); | |
| 42 | ||
| 43 | beforeEach(async () => { | |
| 44 | dir = await mkdtemp(join(tmpdir(), "forge-spawn-events-")); | |
| 45 | app = createApp({ dataDir: dir, url: "https://forge.test" }); | |
| 46 | retired = []; | |
| 47 | bodyId = "body-1"; | |
| 48 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", isAdmin: true }); | |
| 49 | if (!made.ok) throw new Error("user"); | |
| 50 | alice = made.value; | |
| 51 | const repo = await createRepo(app, alice, { name: "notes" }); | |
| 52 | if (!repo.ok) throw new Error("repo"); | |
| 53 | const c = await commitFiles(repoDir(app.reposRoot, "alice", "notes"), { | |
| 54 | branch: "main", message: "one", files: [{ path: "a.txt", content: "a" }], author: { name: "a", email: "[email protected]" }, | |
| 55 | }); | |
| 56 | if (!c.ok) throw new Error("commit"); | |
| 57 | source = { catalog: [{ ref: "alice/notes", readable: true, bytes: 10, forgeJsonBytes: 0 }], briefs: [], subscriptions: [] }; | |
| 58 | }); | |
| 59 | afterEach(async () => { | |
| 60 | app.close(); | |
| 61 | await rm(dir, { recursive: true, force: true }); | |
| 62 | }); | |
| 63 | ||
| 64 | test("a good spawn appends exactly one started, and a retry of it none", async () => { | |
| 65 | expect((await spawnForge(app, alice, input, source, deps())).ok).toBe(true); | |
| 66 | expect(events()).toEqual([{ kind: "forge.spawn.started", payload: {} }]); | |
| 67 | const again = await spawnForge(app, alice, input, source, deps()); | |
| 68 | expect(again.ok && again.value.existed).toBe(true); | |
| 69 | expect(events().length).toBe(1); | |
| 70 | }); | |
| 71 | ||
| 72 | test("a retry after a failed copy starts again, so each failed has a started", async () => { | |
| 73 | const gone = repoDir(app.reposRoot, "alice", "notes"); | |
| 74 | await Bun.$`mv ${gone} ${gone}.away`.quiet(); | |
| 75 | await spawnForge(app, alice, input, source, deps()); | |
| 76 | await spawnForge(app, alice, input, source, deps()); | |
| 77 | expect(events().map((e) => e.kind)).toEqual([ | |
| 78 | "forge.spawn.started", "forge.spawn.failed", "forge.spawn.started", "forge.spawn.failed", | |
| 79 | ]); | |
| 80 | await Bun.$`mv ${gone}.away ${gone}`.quiet(); | |
| 81 | expect((await spawnForge(app, alice, input, source, deps())).ok).toBe(true); | |
| 82 | expect(events().map((e) => e.kind).slice(4)).toEqual(["forge.spawn.started"]); | |
| 83 | }); | |
| 84 | ||
| 85 | test("an unsafe body id fails at place and retires the body", async () => { | |
| 86 | bodyId = "../escape"; | |
| 87 | const r = await spawnForge(app, alice, input, source, deps()); | |
| 88 | expect(r.ok).toBe(false); | |
| 89 | expect(retired).toEqual(["../escape"]); | |
| 90 | expect(events()).toEqual([{ kind: "forge.spawn.failed", payload: { step: "place" } }]); | |
| 91 | }); | |
| 92 | ||
| 93 | test("a copy that breaks fails at copy, after started, with no words of the failure", async () => { | |
| 94 | await rm(repoDir(app.reposRoot, "alice", "notes"), { recursive: true, force: true }); | |
| 95 | const r = await spawnForge(app, alice, input, source, deps()); | |
| 96 | expect(r.ok).toBe(false); | |
| 97 | expect(events()).toEqual([ | |
| 98 | { kind: "forge.spawn.started", payload: {} }, | |
| 99 | { kind: "forge.spawn.failed", payload: { step: "copy" } }, | |
| 100 | ]); | |
| 101 | }); | |
| 102 | ||
| 103 | test("deps.drill marks started and failed as the drill", async () => { | |
| 104 | bodyId = "../escape"; | |
| 105 | await spawnForge(app, alice, input, source, deps({ drill: true })); | |
| 106 | expect(events()).toEqual([{ kind: "forge.spawn.failed", payload: { step: "place", drill: 1 } }]); | |
| 107 | bodyId = "body-2"; | |
| 108 | await spawnForge(app, alice, { ...input, name: "kid2", org: "kids2" }, source, deps({ drill: true })); | |
| 109 | expect(events()[1]).toEqual({ kind: "forge.spawn.started", payload: { drill: 1 } }); | |
| 110 | }); | |
| 111 | ||
| 112 | test("the door starts a watcher for a new child and not for an existing one", async () => { | |
| 113 | usePlacement(app, placement()); | |
| 114 | const probed: string[] = []; | |
| 115 | useProbe(app, (url) => async () => { probed.push(url); return true; }); | |
| 116 | const ctx = { app, request: deps().request, url: deps().url } as unknown as Ctx; | |
| 117 | const raw = { name: "kid", org: "kids", apps: ["alice/notes"], dryRun: false }; | |
| 118 | const live = () => events().filter((e) => e.kind === "forge.spawn.live").length; | |
| 119 | const until = async (n: number) => { for (let i = 0; i < 100 && live() < n; i++) await Bun.sleep(20); }; | |
| 120 | ||
| 121 | expect((await spawnDoor(ctx, alice, raw)).status).toBe(201); | |
| 122 | await until(1); | |
| 123 | expect(live()).toBe(1); | |
| 124 | expect(probed.length).toBe(1); | |
| 125 | ||
| 126 | expect((await spawnDoor(ctx, alice, raw)).status).toBe(200); | |
| 127 | await Bun.sleep(100); | |
| 128 | expect(probed.length).toBe(1); | |
| 129 | expect(live()).toBe(1); | |
| 130 | }); |
test/spawn-live.test.ts added +114
| @@ -0,0 +1,114 @@ | ||
| 1 | /** | |
| 2 | * Forges made, time to live and failures (#2932): the spawn events land on | |
| 3 | * the outbox, and the Forges board reads them, the drill as its own series. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 7 | import { tmpdir } from "node:os"; | |
| 8 | import { join } from "node:path"; | |
| 9 | import { createApp } from "../src/app.ts"; | |
| 10 | import { runQuery } from "../src/boards/query.ts"; | |
| 11 | import { STARTING_BOARDS } from "../src/boards/starting.ts"; | |
| 12 | import { MAX_WATCHERS, startWatch, watchLive } from "../src/spawn/live.ts"; | |
| 13 | ||
| 14 | let dir: string; | |
| 15 | let app: ReturnType<typeof createApp>; | |
| 16 | const all = { repoIds: "all" } as const; | |
| 17 | const kinds = () => app.db.query("SELECT kind FROM event WHERE kind LIKE 'forge.spawn.%' ORDER BY seq").all().map((r) => (r as { kind: string }).kind); | |
| 18 | ||
| 19 | beforeAll(async () => { | |
| 20 | dir = await mkdtemp(join(tmpdir(), "forge-spawn-live-")); | |
| 21 | app = createApp({ dataDir: dir, port: 0 }); | |
| 22 | }); | |
| 23 | afterAll(async () => { | |
| 24 | await rm(dir, { recursive: true, force: true }); | |
| 25 | }); | |
| 26 | ||
| 27 | test("a child that answers is live, with the ms since the spawn began", async () => { | |
| 28 | let calls = 0; | |
| 29 | let t = 1000; | |
| 30 | const got = await watchLive(app, { | |
| 31 | name: "x", startedAt: 1000, pollMs: 1, now: () => (t += 500), | |
| 32 | probe: async () => ++calls === 3, | |
| 33 | }); | |
| 34 | expect(got).toBe("live"); | |
| 35 | const row = app.db.query("SELECT payload FROM event WHERE kind = 'forge.spawn.live'").get() as { payload: string }; | |
| 36 | expect(JSON.parse(row.payload)).toEqual({ ms: 1500 }); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a child that never answers fails at step health, once", async () => { | |
| 40 | let t = 0; | |
| 41 | const got = await watchLive(app, { name: "y", startedAt: 0, pollMs: 1, giveUpMs: 1000, now: () => (t += 400), probe: async () => false, drill: true }); | |
| 42 | expect(got).toBe("failed"); | |
| 43 | const row = app.db.query("SELECT payload FROM event WHERE kind = 'forge.spawn.failed'").get() as { payload: string }; | |
| 44 | expect(JSON.parse(row.payload)).toEqual({ step: "health", drill: 1 }); | |
| 45 | }); | |
| 46 | ||
| 47 | test("a probe that throws counts as not yet", async () => { | |
| 48 | let calls = 0; | |
| 49 | const got = await watchLive(app, { name: "z", startedAt: Date.now(), pollMs: 1, probe: async () => { if (++calls < 2) throw new Error("boom"); return true; } }); | |
| 50 | expect(got).toBe("live"); | |
| 51 | }); | |
| 52 | ||
| 53 | test("a day with only failures still has a budget line", async () => { | |
| 54 | const fresh = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-fail-")), port: 0 }); | |
| 55 | fresh.bus.append({ kind: "forge.spawn.failed", payload: { step: "copy" } }); | |
| 56 | const r = runQuery(fresh.db, { source: "spawnTime", aggregate: "max", column: "value", groupBy: "metric" }, all); | |
| 57 | expect(r).toEqual({ ok: true, rows: [{ group: "budget", value: 180000 }] }); | |
| 58 | fresh.close(); | |
| 59 | }); | |
| 60 | ||
| 61 | test("the spawns source separates the drill, and the time source gives p50, p95 and the budget", () => { | |
| 62 | for (const [ms, drill] of [[60000, 0], [90000, 0], [120000, 0], [150000, 0], [30000, 1]] as const) | |
| 63 | app.bus.append({ kind: "forge.spawn.live", payload: { ms, ...(drill ? { drill: 1 } : {}) } }); | |
| 64 | app.bus.append({ kind: "forge.spawn.started", payload: {} }); | |
| 65 | const made = runQuery(app.db, { source: "spawns", aggregate: "count", groupBy: "series", filter: [{ column: "outcome", op: "eq", value: "live" }] }, all); | |
| 66 | expect(made).toEqual({ ok: true, rows: [{ group: "drill", value: 1 }, { group: "forge", value: 6 }] }); | |
| 67 | const failures = runQuery(app.db, { source: "spawns", aggregate: "count", groupBy: "step", series: "series", filter: [{ column: "outcome", op: "eq", value: "failed" }] }, all); | |
| 68 | expect(failures).toEqual({ ok: true, rows: [{ group: "health", value: 1, series: "drill" }] }); | |
| 69 | const time = runQuery(app.db, { source: "spawnTime", aggregate: "max", column: "value", groupBy: "metric", filter: [{ column: "series", op: "eq", value: "drill" }] }, all); | |
| 70 | expect(time.ok && time.rows.map((r) => r.group)).toEqual(["budget", "p50", "p95"]); | |
| 71 | const forge = runQuery(app.db, { source: "spawnTime", aggregate: "max", column: "value", groupBy: "metric", filter: [{ column: "series", op: "eq", value: "forge" }] }, all); | |
| 72 | expect(forge.ok && forge.rows.map((r) => r.value)).toEqual([180000, expect.any(Number), expect.any(Number)]); | |
| 73 | expect(kinds()).toEqual([ | |
| 74 | "forge.spawn.live", "forge.spawn.failed", "forge.spawn.live", | |
| 75 | "forge.spawn.live", "forge.spawn.live", "forge.spawn.live", "forge.spawn.live", "forge.spawn.live", | |
| 76 | "forge.spawn.started", | |
| 77 | ]); | |
| 78 | }); | |
| 79 | ||
| 80 | test("only a scope of every repo reads the spawn sources", () => { | |
| 81 | expect(runQuery(app.db, { source: "spawns", aggregate: "count" }, { repoIds: ["r"] })).toEqual({ ok: true, rows: [] }); | |
| 82 | }); | |
| 83 | ||
| 84 | test("the Forges board is offered, with a panel for made, time to live and failures", () => { | |
| 85 | const b = STARTING_BOARDS["forges"]!; | |
| 86 | expect(b.panels.map((p) => p.title).join("|")).toMatch(/made.*Time to live.*Failures by step/); | |
| 87 | for (const p of b.panels) expect(runQuery(app.db, p.query, all).ok).toBe(true); | |
| 88 | }); | |
| 89 | ||
| 90 | test("closing the app stops a watcher, and it appends nothing", async () => { | |
| 91 | const mine = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-close-")), port: 0 }); | |
| 92 | const pending = watchLive(mine, { name: "c", startedAt: Date.now(), pollMs: 10_000, probe: async () => false }); | |
| 93 | await Bun.sleep(20); | |
| 94 | mine.close(); | |
| 95 | expect(await pending).toBe("stopped"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("at most MAX_WATCHERS run at once per forge", async () => { | |
| 99 | const mine = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-cap-")), port: 0 }); | |
| 100 | const w = { name: "c", startedAt: Date.now(), pollMs: 10_000, probe: async () => false }; | |
| 101 | const started = Array.from({ length: MAX_WATCHERS + 3 }, () => startWatch(mine, w)); | |
| 102 | expect(started.filter(Boolean).length).toBe(MAX_WATCHERS); | |
| 103 | mine.close(); | |
| 104 | }); | |
| 105 | ||
| 106 | test("after 30 seconds the poll slows to five", async () => { | |
| 107 | const mine = createApp({ dataDir: await mkdtemp(join(tmpdir(), "forge-spawn-slow-")), port: 0 }); | |
| 108 | let calls = 0; | |
| 109 | const pending = watchLive(mine, { name: "s", startedAt: 0, pollMs: 5, now: () => 31_000, giveUpMs: 10 ** 9, probe: async () => { calls++; return false; } }); | |
| 110 | await Bun.sleep(100); | |
| 111 | mine.close(); | |
| 112 | await pending; | |
| 113 | expect(calls).toBe(1); | |
| 114 | }); |
test/tui-newforge.test.ts +2 −1
| @@ -9,7 +9,7 @@ import { join } from "node:path"; | ||
| 9 | 9 | import { createRepo } from "../src/actions/repos.ts"; |
| 10 | 10 | import { createApp, type App } from "../src/app.ts"; |
| 11 | 11 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | import { usePlacement } from "../src/spawn/door.ts"; | |
| 12 | import { usePlacement, useProbe } from "../src/spawn/door.ts"; | |
| 13 | 13 | import { hostedPlacement } from "../src/spawn/placement.ts"; |
| 14 | 14 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; |
| 15 | 15 | import { newForgeScreen } from "../src/tui/screens/newforge.ts"; |
| @@ -57,6 +57,7 @@ beforeAll(async () => { | ||
| 57 | 57 | bob = await mk("bob", false); |
| 58 | 58 | const repo = await createRepo(app, admin, { name: "bakery", visibility: "public" }); |
| 59 | 59 | if (!repo.ok) throw new Error("repo"); |
| 60 | useProbe(app, () => async () => true); | |
| 60 | 61 | usePlacement(app, hostedPlacement({ host: "forge.test" })); |
| 61 | 62 | }); |
| 62 | 63 | afterAll(async () => { |
new above
offline
This is
void watchLive(...)with no handle. It polls for up to 10 minutes, and nothing cancels it when the app closes.app.bus.appendon a closed db then throws inside an unawaited promise, which is an unhandled rejection. Scout says the existing spawn-api tests now hit the real probe, so this already happens in the suite. Fix: keep the watchers in a per-app set, stop them onapp.close(), and have the door tests useuseProbeso no test touches the network.