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

An agent asking `forges` sees each child's size and usage, as the machines page shows them #10917

Parked opened this
Closed without merging

torque/forge:torque/10903-an-agent-asking-forges-sees-each-child-s → main 4 commits fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
6 files changed +108 −5
docs/MCP.md +1 −1
@@ -111,7 +111,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`;
111111 | `following` | — | Where a fork stands against what it follows: in step, behind, ahead, diverged | 243 |
112112 | `forge_proposals` | — | What this forge proposed to its parent; `reachable` false if it is silent | 245 |
113113 | `forge_view` | — | The forge as one picture: counts, apps, runs with stations, health | 189 |
114 | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd | 205 |
114 | `forges` | — | Forges sent from here (admin): name, address, apps, claimed, size, usage, spendTodayUsd, ceilingUsd | 218 |
115115 | `fork` | — | Fork a repository into your account; for a guest, a copy to send changes back from | 232 |
116116 | `fork_tree` | — | The fork tree around a repo: ancestors, siblings and descendants you may read | 347 |
117117 | `forks` | — | A repo's forks you may see, paged by limit and cursor (next_cursor), each with commits ahead and behind | 345 |
src/contract/openapi/routes/getApiV1Forges.ts +1 −1
@@ -1,3 +1,3 @@
11 import type { Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt, running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true };
3 export const route: Route = { method: "get", path: "/api/v1/forges", summary: "List the forges sent from here (a site admin's, ro scope): the client list /forges and the MCP forges tool show. answers { items, next_cursor: null }; each item has name, url, for, apps, sentAt, made, claimed (null when it did not answer), lastActivityAt, running, reach, attention (silent, down, unclaimed or null), spendTodayUsd, ceilingUsd, size, use, usage ({ memory, cpu, disk } each with used and limit, MiB or cores; null when shared or not running), lastHeartbeatAt and nextStep, and beside them what the list always carried: bodyId, done, createdAt, placement, placementNote and template (when one was chosen). what needs attention comes first", auth: true };
src/http/mcp/tools/forges.ts +1 −1
@@ -5,7 +5,7 @@ export const tool: Tool = {
55 name: "forges",
66 doc: "an admin's list of the forges sent from here: name, address, whom for, apps, claimed or not, last activity, whether each public address answers",
77 description:
8 "Forges sent from here (admin): name, address, apps, claimed, spendTodayUsd, ceilingUsd.",
8 "Forges sent from here (admin): name, address, apps, claimed, size, usage, spendTodayUsd, ceilingUsd.",
99 inputSchema: { type: "object", properties: {} },
1010 writes: false,
1111 async run(ctx, user) {
src/spawn/clients.ts +6 −2
@@ -21,7 +21,7 @@ import { hasAdmin, linkOf, spawnedOrg, type LinkInfo, type LinkState } from "./c
2121 import { lastHeard, type Beat } from "./heartbeat.ts";
2222 import { reachOf, reachWords, type ChildReach } from "./reach.ts";
2323 import type { Size } from "./sizes.ts";
24 import { forgeUse, type ForgeUse } from "./usage.ts";
24 import { forgeUse, usageOf, type ForgeUse, type UsageFigures } from "./usage.ts";
2525
2626 /** What a spawned forge says about itself to anyone: nothing more. */
2727 export interface ForgeState {
@@ -63,6 +63,8 @@ export interface ClientRow {
6363 readonly size: Size;
6464 /** What it uses against that size: its cell's reading, or "shared" when it is hosted here. */
6565 readonly use: ForgeUse;
66 /** The same reading as figures an agent can compare (MiB, cores) against the size's; null when it is shared, not running or unknown. */
67 readonly usage?: UsageFigures | null;
6668 /** Whether its data is a filesystem of its own, and that filesystem's size (`spawn/disk_mount.ts`). */
6769 readonly disk: ChildDisk;
6870 /** When its last heartbeat reached this forge, ISO; null when none has. */
@@ -225,6 +227,7 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
225227 const beats = await Promise.all(children.map((c) => lastHeard(app, c.name)));
226228 // One read of the mount table for every row.
227229 const mountinfo = children.length === 0 ? "" : readMountinfo();
230 const use = children.map((c) => forgeUse(c, childDirOf(app, c.bodyId)));
228231 return orderRows(
229232 children.map((c, i) => ({
230233 name: c.name,
@@ -242,7 +245,8 @@ export async function clientRows(app: App): Promise<ClientRow[]> {
242245 ceilingUsd: beats[i]?.beat.ceilingUsd ?? null,
243246 ...creditView(app, c.name),
244247 size: c.size,
245 use: forgeUse(c, childDirOf(app, c.bodyId)),
248 use: use[i]!,
249 usage: usageOf(use[i]!),
246250 disk: liveChildDisk(app.config.dataDir, c.bodyId, mountinfo),
247251 lastHeartbeatAt: beats[i] === undefined ? null : new Date(beats[i]!.receivedAt).toISOString(),
248252 link: states[i]?.link ?? null,
src/spawn/usage.ts +19
@@ -240,3 +240,22 @@ export function childrenUse(children: readonly Child[], dirOf: (bodyId: string)
240240
241241 /** The share of a limit used, percent, for a meter; 0 when unknown. */
242242 export const shareOf = (used: number | null, limit: number): number => (used === null || limit <= 0 ? 0 : (used / limit) * 100);
243
244 /** A cell's use beside its size's limits, as numbers: what `forges` over MCP and the API carry. */
245 export interface UsageFigures {
246 readonly memory: { readonly usedMiB: number; readonly limitMiB: number };
247 /** Cores used lately; usedCores null until the watch can say. */
248 readonly cpu: { readonly usedCores: number | null; readonly limitCores: number };
249 /** usedMiB null until the first disk reading lands. */
250 readonly disk: { readonly usedMiB: number | null; readonly limitMiB: number };
251 }
252
253 /** The figures of a running cell (the same reading the machines page shows); null for a shared or idle child. */
254 export function usageOf(u: ForgeUse): UsageFigures | null {
255 if (u.kind !== "cell") return null;
256 return {
257 memory: { usedMiB: u.memoryMiB, limitMiB: u.limits.memoryMiB },
258 cpu: { usedCores: u.cores, limitCores: u.limits.cpu },
259 disk: { usedMiB: u.diskMiB, limitMiB: u.limits.diskMiB },
260 };
261 }
test/mcp-forges-usage.test.ts added +80
@@ -0,0 +1,80 @@
1 /** MCP `forges` and GET /api/v1/forges carry each child's size and usage (#10903). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { createToken } from "../src/core/tokens.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { watchLabelled, cellCounters } from "../src/apps/contain.ts";
11 import { SIZES } from "../src/spawn/sizes.ts";
12 import { quietLogs } from "./helpers/globals.ts";
13
14 quietLogs();
15
16 let dataDir = "";
17 let app: App;
18 let servers: Servers;
19 let token = "";
20 let stop = () => {};
21 let watched = false;
22
23 beforeAll(async () => {
24 dataDir = mkdtempSync(join(tmpdir(), "forge-mcp-forges-usage-"));
25 app = createApp({ dataDir, port: 0 });
26 mkdirSync(join(dataDir, "spawn"), { recursive: true });
27 const kid = (name: string, placement: string) =>
28 writeFileSync(join(dataDir, "spawn", `${name}.json`), JSON.stringify({ name, bodyId: `b-${name}`, done: true, placement, size: "small" }));
29 kid("busy", "cell");
30 kid("asleep", "cell");
31 kid("hosted", "hosted");
32 servers = await serve(app);
33 const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "correct horse battery 9", isAdmin: true });
34 if (!u.ok) throw new Error("alice");
35 token = createToken(app.db, u.value.id, "t", "admin").token;
36 stop = watchLabelled(process.pid, "forge:busy");
37 const until = Date.now() + 5_000;
38 while (!cellCounters().some((c) => c.label === "forge:busy") && Date.now() < until) await Bun.sleep(50);
39 // Where /proc cannot be read (some sandboxes) the watch never sees the cell: the running-cell figures are then not asserted.
40 watched = cellCounters().some((c) => c.label === "forge:busy");
41 }, 30_000);
42
43 afterAll(() => {
44 stop();
45 servers.stop();
46 app.close();
47 rmSync(dataDir, { recursive: true, force: true });
48 });
49
50 const headers = () => ({ "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` });
51
52 test("each child's size and usage are read through MCP, null when it is shared or not running", async () => {
53 const res = await fetch(`${servers.url}/mcp`, {
54 method: "POST",
55 headers: headers(),
56 body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "forges", arguments: {} } }),
57 });
58 const out = JSON.parse(((await res.json()) as any).result.content[0].text);
59 const by = Object.fromEntries(out.forges.map((f: any) => [f.name, f]));
60 expect(by.busy.size).toBe("small");
61 if (watched) {
62 expect(by.busy.usage.memory.usedMiB).toBeGreaterThan(0);
63 expect(by.busy.usage.memory.limitMiB).toBe(SIZES.small.memoryMiB);
64 expect(by.busy.usage.cpu.limitCores).toBe(SIZES.small.cpu);
65 expect(by.busy.usage.disk.usedMiB).toBeNull();
66 expect(by.busy.usage.disk.limitMiB).toBe(by.busy.use.limits.diskMiB);
67 }
68 expect(by.asleep.usage).toBeNull();
69 expect(by.hosted.usage).toBeNull();
70 expect(by.hosted.size).toBe("small");
71 });
72
73 test("the REST route returns the same fields", async () => {
74 const res = await fetch(`${servers.url}/api/v1/forges`, { headers: { authorization: `Bearer ${token}` } });
75 const items = ((await res.json()) as any).items;
76 const busy = items.find((i: any) => i.name === "busy");
77 expect(busy.size).toBe("small");
78 if (watched) expect(busy.usage.memory.usedMiB).toBeGreaterThan(0);
79 expect(items.find((i: any) => i.name === "asleep").usage).toBeNull();
80 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs