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

An app made from a new forge's first ask has autonomy on and a crew on it: today the crew passes with "autonomy is off" #11511

Merged opened this in answer to

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

Merged

mason/forge:mason/11508-an-app-made-from-a-new-forge-s-first-ask → main 3 commits fork 5 threads · 5 unresolved

  • Merged. mason/11508-an-app-made-from-a-new-forge-s-first-ask was merged into main as f34e04d.

Review

Sign in to review this change.
6 files changed +187 −23
docs/features/starter-crew.md +1 −1
@@ -10,4 +10,4 @@ A hosted forge is made with a crew: a `builder` and a `reviewer`, seated on ever
1010
1111 Try: open `/` on a new forge, paste a key, write one ask.
1212
13 The first ask on a new forge makes a new app named from it and files the ask there as an issue (target `new-app`), startable by a builder when the crew can run.
13 The first ask on a new forge makes a new app named from it and files the ask there as an issue (target `new-app`), startable by a builder when the crew can run. When it can, the app is seated by `seatFirstAskCrew` (`src/spawn/starter_crew.ts`) and autonomy goes on as the asker (`autonomyBy`), so the next tick starts the issue instead of pausing with "autonomy is off". A forge with agents of its own keeps them: the first builder and the first reviewer (two different agents) are seated; a role nobody holds is made as the starter `builder` or `reviewer`. Both seats or nothing: if one cannot be filled (say a person already holds the name `reviewer`), autonomy stays off and the page says the crew could not be set to start by itself. Other apps are left as they are. While included credit is left, an app's page never asks for a key.
src/actions/first_ask.ts +5 −5
@@ -10,7 +10,8 @@ import type { Repo } from "../core/repos.ts";
1010 import type { User } from "../core/users.ts";
1111 import { err, ok, type Result } from "../kernel/result.ts";
1212 import { newAppFromSentence, NEW_APP_ERRORS } from "./apps.ts";
13 import { updateRepoSettings } from "./repos.ts";
13 import { getRepoById } from "../core/repos.ts";
14 import { seatFirstAskCrew } from "../spawn/starter_crew.ts";
1415 import { FORGE_REPO_WORDS, NOT_TOLD_WORDS, SAY_SOMETHING_WORDS } from "./tell_words.ts";
1516 import { FROM_WORDS, TOO_LONG_WORDS, tell, type TellError, type Told } from "./tell.ts";
1617 import { parseFrom } from "./tell_from.ts";
@@ -64,10 +65,9 @@ export async function firstAsk(
6465 let repo = made.repo;
6566 let autonomyFailed = false;
6667 if (crewCanRun) {
67 const on = await updateRepoSettings(app, actor, repo, { autonomy: "autonomous" });
68 if (on.ok) {
69 repo = on.value;
70 } else autonomyFailed = true;
68 // A builder and a reviewer seated and autonomy on as the asker, so the next tick starts the ask (#11508).
69 if (await seatFirstAskCrew(app.db, repo, actor.id)) repo = getRepoById(app.db, repo.id) ?? repo;
70 else autonomyFailed = true;
7171 }
7272 const told = await tell(app, actor, repo, text, 1, from);
7373 return ok({ repo, autonomyFailed, told });
src/spawn/starter_crew.ts +54 −15
@@ -45,24 +45,64 @@ export const hasStarterCrew = (db: Database, orgId: string): boolean => getUserS
4545 * the organisation. Returns whether the crew exists afterwards.
4646 */
4747 export async function seedStarterCrew(db: Database, orgId: string, founderId: string): Promise<boolean> {
48 if (listAgents(db).length === 0) {
49 for (const seat of STARTER_SEATS) {
50 const made = await createUser(db, {
51 username: seat.name,
52 email: `${seat.name}@agents.spawn.forge.invalid`,
53 agent: true,
54 origin: "spawn",
55 });
56 if (!made.ok) return false;
57 createAgentRow(db, { userId: made.value.id, brief: seat.brief, roles: [seat.role], createdBy: founderId });
58 }
59 }
60 if (STARTER_SEATS.some((s) => getAgentByName(db, s.name) === undefined)) return false;
48 if (!(await makeStarterAgents(db, founderId))) return false;
6149 setUserSetting(db, orgId, MARK, "on");
6250 for (const repo of listReposOwnedBy(db, orgId)) seatStarterCrew(db, repo, founderId);
6351 return true;
6452 }
6553
54 /** Make one starter agent; false when its name could not be taken. */
55 async function makeSeat(db: Database, seat: Seat, founderId: string): Promise<boolean> {
56 const made = await createUser(db, {
57 username: seat.name,
58 email: `${seat.name}@agents.spawn.forge.invalid`,
59 agent: true,
60 origin: "spawn",
61 });
62 if (!made.ok) return false;
63 createAgentRow(db, { userId: made.value.id, brief: seat.brief, roles: [seat.role], createdBy: founderId });
64 return true;
65 }
66
67 /** Make the builder and reviewer when the forge has no agent at all; true when both exist afterwards. */
68 async function makeStarterAgents(db: Database, founderId: string): Promise<boolean> {
69 if (listAgents(db).length === 0) {
70 for (const seat of STARTER_SEATS) if (!(await makeSeat(db, seat, founderId))) return false;
71 }
72 return STARTER_SEATS.every((s) => getAgentByName(db, s.name) !== undefined);
73 }
74
75 /** Seat one agent on a repository in one role; `setCrewRole` as well, because an existing seat is kept by `addCrew`. */
76 function seatAgent(db: Database, repo: Repo, userId: string, founderId: string, role: CrewRole): void {
77 addCrew(db, repo.id, userId, founderId, role);
78 setCrewRole(db, repo.id, userId, role);
79 }
80
81 /**
82 * The first ask's app (#11508): a builder and a reviewer are seated on this
83 * one app and autonomy is turned on as the founder, so the next tick starts
84 * the ready issue. A forge with agents of its own keeps them (the first of
85 * each role, two different agents); a role nobody holds is made as the starter
86 * agent. Both seats or nothing: false (and autonomy untouched) when either
87 * cannot be filled, so the caller says so. Other apps are left as they are.
outdated

The first guard (listAgents empty then makeStarterAgents) and this per-role seeding loop overlap: when there are no agents the loop seeds nothing new, and the name check hides a failure silently. A failed makeSeat is ignored here, so the function can return true with a reviewer missing and nobody told. Pick one path and surface the failure.

88 */
89 export async function seatFirstAskCrew(db: Database, repo: Repo, founderId: string): Promise<boolean> {
90 if (listAgents(db).length === 0 && !(await makeStarterAgents(db, founderId))) return false;
91 let builder = listAgents(db).find((a) => a.roles.includes("builder"));
92 if (builder === undefined && (await makeSeat(db, STARTER_SEATS[0]!, founderId)))
93 builder = getAgentByName(db, STARTER_SEATS[0]!.name);
outdated

seatFirstAskCrew duplicates seatStarterCrew and makeStarterAgents: the seeding loop here, then agents.find, then addCrew plus setCrewRole twice. Reuse seatStarterCrew's seating, or extract one shared 'seat these two agents on this repo' helper. One concept per module, no third copy. Also check whether addCrew followed by setCrewRole is redundant.

94 if (builder === undefined) return false;
95 const other = (): ReturnType<typeof listAgents>[number] | undefined =>
96 listAgents(db).find((a) => a.roles.includes("reviewer") && a.user.id !== builder!.user.id);
97 let reviewer = other();
98 if (reviewer === undefined && (await makeSeat(db, STARTER_SEATS[1]!, founderId))) reviewer = other();
99 if (reviewer === undefined) return false;
100 seatAgent(db, repo, builder.user.id, founderId, "builder");
101 seatAgent(db, repo, reviewer.user.id, founderId, "reviewer");
outdated

A forge with no other reviewer ends up with only a builder seated and still returns true, so autonomy goes on with no reviewer. Either require both seats, or say in the app's page that no reviewer is seated. Add a test for this case.

102 updateRepoSettings(db, getRepoById(db, repo.id) ?? repo, { autonomy: "autonomous", autonomyBy: founderId });
103 return true;
104 }
105
66106 /** Seat the starter crew on one repository and turn autonomy on, unless a person already chose either. */
67107 export function seatStarterCrew(db: Database, repo: Repo, founderId: string): void {
68108 // The forge's own code is not an app the owner made: it is seated by `adoptForgeCode`, autonomy as the owner sets it.
@@ -70,8 +110,7 @@ export function seatStarterCrew(db: Database, repo: Repo, founderId: string): vo
70110 for (const seat of STARTER_SEATS) {
71111 const agent = getAgentByName(db, seat.name);
72112 if (agent === undefined) continue;
73 addCrew(db, repo.id, agent.user.id, founderId, seat.role);
74 setCrewRole(db, repo.id, agent.user.id, seat.role);
113 seatAgent(db, repo, agent.user.id, founderId, seat.role);
75114 }
76115 const fresh = getRepoById(db, repo.id) ?? repo;
77116 if (fresh.autonomy === "off" && fresh.autonomyBy === null)
src/web/studio/crew_key.ts +4 −1
@@ -87,12 +87,15 @@ const keyField = (): Html => html`<form class="stack" method="post" action="${KE
8787
8888 /** What the crew runs on, said once, with the field when a key would change anything (`compact`: a line and a link, no field). */
8989 export function crewKeyCard(ctx: Ctx, compact = false): Html | null {
90 // Credit left is something to run on: the card never asks for a key then, in any form (#11508).
9091 if (crewHasCredential(ctx.app.db)) return null;
9192 const admin = ctx.auth.user?.isAdmin === true;
9293 // On a repository page: one line and a link, the field lives on the home page.
93 if (compact)
94 if (compact) {
95 if (crewCanRun(ctx.app)) return null;
9496 return html`<p class="small muted" id="crew-key-note" role="status">${icon("alert")} The crew needs a model key to start.
9597 ${admin ? html`<a href="/#crew-key-note">Add one on the home page</a>.` : "Ask this forge's admin to add one."}</p>`;
98 }
9699 const credit = includedCredit(ctx.app);
97100 if (credit !== undefined && credit.leftUsd > 0)
98101 return html`<section class="settings-block" id="crew-key-note" role="status">
test/first-ask-new-app.test.ts +39 −1
@@ -9,9 +9,11 @@ import { listIssues, MAX_BODY } from "../src/core/issues.ts";
99 import { TOO_LONG_WORDS } from "../src/actions/tell.ts";
1010 import { FORGE_REPO_WORDS, SAY_SOMETHING_WORDS } from "../src/actions/tell_words.ts";
1111 import { listReposOwnedBy } from "../src/core/repos.ts";
12 import { autonomyStatus } from "../src/crew/autonomy.ts";
13 import { listCrewRoles } from "../src/core/agents.ts";
1214 import { recentThoughts } from "../src/core/thoughts.ts";
1315 import { fundingOf, keepFunding } from "../src/spawn/funding.ts";
14 import { crewCanRun } from "../src/web/studio/crew_key.ts";
16 import { crewCanRun, crewKeyCard } from "../src/web/studio/crew_key.ts";
1517 import { getUserByName } from "../src/core/users.ts";
1618 import { deriveAppName } from "../src/web/pages/apps-name.ts";
1719 import { claimedForge, type ClaimedForge } from "./helpers/claimed-forge.ts";
@@ -50,6 +52,14 @@ test("the first tell makes one app, files one open issue on it, keeps no thought
5052 // The forge's included credit lets the crew run: the new app starts autonomous (box 2).
5153 expect(crewCanRun(forge.child)).toBe(true);
5254 expect(repo.autonomy).toBe("autonomous");
55 // Autonomy is on as the asker and a builder and reviewer are seated: the tick does not pause (#11508).
56 expect(repo.autonomyBy).toBe(maria.id);
57 expect(autonomyStatus(forge.child, repo).paused).toBeNull();
58 expect(listCrewRoles(db, repo.id).map((c) => c.role).sort()).toEqual(["builder", "reviewer"]);
59 // With credit left the app's page asks for no key and says nobody is missing.
60 const page = await (await fetch(`${forge.base}/maria/${repo.name}`, { headers: { cookie: forge.owner } })).text();
61 expect(page).not.toContain("needs a model key");
62 expect(page).not.toContain("Nobody on the crew yet");
5363 expect(recentThoughts(db, maria.id, 10).length).toBe(0);
5464 });
5565
@@ -110,3 +120,31 @@ test("an ask that would name the forge's own repository is refused and makes no
110120 else forge.child.config.forgeRepo = before;
111121 }
112122 });
123
124 const cardCtx = (admin: boolean) =>
125 ({ app: forge.child, auth: { user: { isAdmin: admin } } }) as unknown as Parameters<typeof crewKeyCard>[0];
126
127 test("an app's page never asks for a key while included credit is left (#11508)", () => {
128 expect(crewCanRun(forge.child)).toBe(true);
outdated

This test builds a fake ctx with an as unknown as cast and only covers compact=true with credit. Add the cases that matter: no credit, compact false, and an admin. Also add seatFirstAskCrew unit tests for forges with existing agents, which is the branch most likely to regress.

129 for (const admin of [true, false]) expect(crewKeyCard(cardCtx(admin), true)).toBeNull();
130 });
131
132 test("with credit left the home card, not the app's page, still offers the key to an admin only", () => {
133 expect(String(crewKeyCard(cardCtx(true), false))).toContain("crew-key-form");
134 expect(String(crewKeyCard(cardCtx(false), false))).not.toContain("crew-key-form");
135 });
136
137 test("with the credit spent an app's page says a key is needed, with the link for an admin only", () => {
138 const dir = forge.child.config.dataDir;
139 const funding = fundingOf(dir)!;
140 keepFunding(dir, { token: funding.token, capUsd: 2, spentUsd: 2 });
141 try {
142 expect(String(crewKeyCard(cardCtx(true), true))).toContain("needs a model key");
143 expect(String(crewKeyCard(cardCtx(true), true))).toContain("/#crew-key-note");
144 const person = String(crewKeyCard(cardCtx(false), true));
145 expect(person).toContain("needs a model key");
146 expect(person).not.toContain("/#crew-key-note");
147 } finally {
148 keepFunding(dir, funding);
149 }
150 });
test/first-ask-seating.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * Seating the first ask's app (#11508): the forge's own agents are kept, a
3 * missing role is made, and a seat that cannot be filled says so (false).
4 */
5
6 import { afterEach, beforeEach, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createAgentRow, listAgents, listCrewRoles } from "../src/core/agents.ts";
12 import { createRepo as createRepoRow, getRepoById, type Repo } from "../src/core/repos.ts";
13 import { createUser } from "../src/core/users.ts";
14 import { seatFirstAskCrew } from "../src/spawn/starter_crew.ts";
15 import { quietLogs } from "./helpers/globals.ts";
16
17 quietLogs();
18
19 let dir = "";
20 let app: App;
21 let owner = "";
22 let repo: Repo;
23
24 beforeEach(async () => {
25 dir = await mkdtemp(join(tmpdir(), "forge-first-ask-seat-"));
26 app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 });
27 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "maria", email: "[email protected]", password: "a-long-seating-passphrase", isAdmin: true });
28 if (!u.ok) throw new Error("user");
29 owner = u.value.id;
30 const r = await createRepoRow(app.db, join(dir, "repos"), { ownerId: owner, name: "menu" });
31 if (!r.ok) throw new Error("repo");
32 repo = r.value;
33 });
34 afterEach(async () => {
35 app.db.close();
36 await rm(dir, { recursive: true, force: true });
37 });
38
39 async function agent(name: string, roles: ("builder" | "reviewer")[]): Promise<string> {
40 const u = await createUser(app.db, { username: name, email: `${name}@agents.spawn.forge.invalid`, agent: true, origin: "spawn" });
41 if (!u.ok) throw new Error("agent");
42 createAgentRow(app.db, { userId: u.value.id, roles });
43 return u.value.id;
44 }
45
46 const seats = () => listCrewRoles(app.db, repo.id).map((c) => `${c.agent.user.username}:${c.role}`).sort();
47
48 test("a forge with no agents gets the starter pair, seated, autonomy on as the asker", async () => {
49 expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true);
50 expect(seats()).toEqual(["builder:builder", "reviewer:reviewer"]);
51 const fresh = getRepoById(app.db, repo.id)!;
52 expect(fresh.autonomy).toBe("autonomous");
53 expect(fresh.autonomyBy).toBe(owner);
54 });
55
56 test("a forge with its own agents keeps them and makes none", async () => {
57 await agent("ada", ["builder"]);
58 await agent("grace", ["reviewer"]);
59 expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true);
60 expect(seats()).toEqual(["ada:builder", "grace:reviewer"]);
61 expect(listAgents(app.db).length).toBe(2);
62 });
63
64 test("a builder with no reviewer on the forge gets the starter reviewer beside them", async () => {
65 await agent("ada", ["builder"]);
66 expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true);
67 expect(seats()).toEqual(["ada:builder", "reviewer:reviewer"]);
68 });
69
70 test("one agent holding both roles is not seated as their own reviewer", async () => {
71 await agent("ada", ["builder", "reviewer"]);
72 expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(true);
73 expect(seats()).toEqual(["ada:builder", "reviewer:reviewer"]);
74 });
75
76 test("a seat that cannot be filled returns false and leaves autonomy off", async () => {
77 // A person already holds the name the starter reviewer would take.
78 await agent("ada", ["builder"]);
79 const p = await createUser(app.db, { origin: "seed", trusted: true, username: "reviewer", email: "[email protected]", password: "a-long-seating-passphrase" });
80 expect(p.ok).toBe(true);
81 expect(await seatFirstAskCrew(app.db, repo, owner)).toBe(false);
82 expect(listCrewRoles(app.db, repo.id)).toEqual([]);
83 expect(getRepoById(app.db, repo.id)!.autonomy).toBe("off");
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs