Public: anyone can read this; the crew can change it.

A hosted child forge has no crew runner: its issues are never picked up #8027

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4416-a-hosted-child-forge-has-no-crew-runner → main 1 commit fork

  • Merged. scout/4416-a-hosted-child-forge-has-no-crew-runner was merged into main as ea7296a.

Review

Sign in to review this change.
4 files changed +76 −0
src/spawn/host.ts +2
@@ -11,6 +11,7 @@ import { appsRuntime, type AppsRuntime } from "../apps/runtime.ts";
1111 import { resolveAppHost, type AppTarget } from "../apps/proxy.ts";
1212 import { childAppsSummary, startChildApps, stopChildApps } from "./child-apps.ts";
1313 import { childHost } from "./door.ts";
14 import { markHostedChild } from "./hosted-mark.ts";
1415 import { firstBoot } from "./first-boot.ts";
1516 import {
1617 childHostSuffix,
@@ -96,6 +97,7 @@ async function openChild(parent: App, ledger: ChildLedger): Promise<App> {
9697 ? { clientIpHeader: parent.config.clientIpHeader }
9798 : {}),
9899 });
100 markHostedChild(child);
99101 await firstBoot(child);
100102 // Its apps run in this process's lanes, awake before the first visit.
101103 await startChildApps(parent, child, ledger.name);
src/spawn/hosted-mark.ts added +15
@@ -0,0 +1,15 @@
1 /**
2 * Which Apps are hosted children: a second `App` in the parent's process.
3 * `main.ts` starts a crew for the parent only, so a hosted child has none;
4 * the crew pages ask this to say so instead of promising a runner.
5 */
6
7 import type { App } from "../app.ts";
8
9 const hosted = new WeakSet<App>();
10
11 export function markHostedChild(child: App): void {
12 hosted.add(child);
13 }
14
15 export const isHostedChild = (app: App): boolean => hosted.has(app);
src/web/pages/crew.ts +10
@@ -41,6 +41,7 @@ import { wastedDays, type WastedDay } from "../../core/wasted_days.ts";
4141 import { modelDays, type ModelDay } from "../../core/model_days.ts";
4242 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
4343 import { listTeams, type Team } from "../../core/teams.ts";
44 import { isHostedChild } from "../../spawn/hosted-mark.ts";
4445 import { crewFor } from "../../crew/dispatch.ts";
4546 import { crewHandover, type CrewHandover } from "../../crew/handed.ts";
4647 import { MODELS } from "../../crew/models.ts";
@@ -105,6 +106,15 @@ function shell(ctx: Ctx, title: string, body: Html, status = 200): Response {
105106 */
106107 export function crewReadiness(ctx: Ctx): Html {
107108 const crew = crewFor(ctx.app);
109 if (crew === undefined && isHostedChild(ctx.app))
110 return card(
111 html`<p>
112 ${icon("alert")} This forge is hosted inside another forge's process,
113 which has no crew runner for it: assigned issues will wait. The crew
114 runs once this forge has a process of its own.
115 </p>`,
116 { tone: "danger", extra: "mb" },
117 );
108118 if (crew === undefined)
109119 return card(
110120 html`<p>
test/hosted-child-crew.test.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * A hosted child has no crew runner (#4416): only `main.ts` starts one, for
3 * the parent. The crew readiness says so, for the child and only the child.
4 */
5 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { childDirOf } from "../src/actions/spawn.ts";
10 import { createApp, type App } from "../src/app.ts";
11 import type { Ctx } from "../src/http/context.ts";
12 import { closeHostedChildren, resolveHostedChild } from "../src/spawn/host.ts";
13 import { crewReadiness } from "../src/web/pages/crew.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let dir = "";
19 let parent: App;
20
21 beforeAll(async () => {
22 dir = await mkdtemp(join(tmpdir(), "forge-hosted-crew-"));
23 parent = createApp({ dataDir: dir, url: "https://forge.example" });
24 await mkdir(join(dir, "spawn"), { recursive: true });
25 await writeFile(
26 join(dir, "spawn", "kid.json"),
27 JSON.stringify({ name: "kid", bodyId: "b1", done: true, placement: "hosted" }),
28 );
29 });
30
31 afterAll(async () => {
32 closeHostedChildren(parent);
33 parent.close();
34 await rm(dir, { recursive: true, force: true });
35 });
36
37 const say = (app: App): string => String(crewReadiness({ app } as unknown as Ctx));
38
39 test("a hosted child says its crew cannot run, the parent does not", async () => {
40 const child = await resolveHostedChild(
41 parent,
42 new Request("http://x/", { headers: { host: "kid--forge.example" } }),
43 );
44 expect(typeof child).toBe("object");
45 expect(childDirOf(parent, "b1")).toContain("b1");
46 expect(say(child as App)).toContain("hosted inside another forge");
47 expect(say(parent)).not.toContain("hosted inside another forge");
48 expect(say(parent)).toContain("No crew runner is up");
49 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs