From 699ddf14adb88bef18df4417e42de0d0d62a2120 Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:09:03 -0700 Subject: [PATCH 1/2] The main bot's free Bops computer is made after a plan read Orgo didn't answer, never one on the plan in its place When compute-limits failed or timed out once as the main bot's computer was made, Bops didn't know the free Bops computer was still to be made, so it asked Orgo for an ordinary computer on the plan. With the plan full (Hacker with its one computer used outside Bops), Orgo refused it and the setup said the plan was full; with room, it took the plan's computer and the free one was never made. Now the plan says when Orgo didn't answer for it (OrgoPlan.limitsUnread), and makeMainComputer asks again (freeKnown), after the main bot's workspace is found or made. Still unknown, nothing is made and the setup says why; the next task tries again. Not signed in (a self-hosted install on its own key), nothing changes. scripts/test-plan.mjs covers both plans (full and with room) and Orgo not answering. Co-Authored-By: Claude Opus 5.5 --- lib/orgo-plans.ts | 10 ++++++++-- lib/server/plan.ts | 26 ++++++++++++++++++++++---- scripts/test-plan.mjs | 35 +++++++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/lib/orgo-plans.ts b/lib/orgo-plans.ts index 3c4e95589..ac882fa75 100644 --- a/lib/orgo-plans.ts +++ b/lib/orgo-plans.ts @@ -33,10 +33,16 @@ export type OrgoPlan = { deal?: boolean; /** * The user's one free Bops computer (orgo-web's bops_free, not counted in `inUse`): its id, or null - * while there's none (Bops makes it next, for a main bot). Unknown when Orgo didn't say (an Orgo - * from before free Bops computers): then every computer is on the plan, as before. + * while there's none (Bops makes it next, for a main bot). Unknown when Orgo didn't say: an Orgo + * from before free Bops computers, where every computer is on the plan as before, or one that + * didn't answer (limitsUnread). */ freeComputerId?: string | null; + /** + * Orgo didn't answer for the counts, the memory and the free Bops computer (compute-limits failed or + * timed out, or the user owns no workspace to ask about): unknown for now, not missing from Orgo. + */ + limitsUnread?: true; /** * The Bops plan's computers, when the Bops plan decides them (Bops Cloud's plan limits are on, and * Orgo counts them: compute-limits' bops_computers_limit): the plan, how many Bops computers it diff --git a/lib/server/plan.ts b/lib/server/plan.ts index 56c6652da..6d5917339 100644 --- a/lib/server/plan.ts +++ b/lib/server/plan.ts @@ -6,7 +6,7 @@ import { botChatId, freeComputerBot, workspaceOf, type Bot } from "@/lib/types"; import { bopsComputerShort, bopsComputers, computerRam, mainOwnShort, ownComputerShort, planComputers, planFix, planName, planShort, planShortText, type OrgoPlan, type PlanShort } from "@/lib/orgo-plans"; import { orgoHeaders } from "./app-version"; import { cloudOn, cloudSessionNow } from "./cloud"; -import { callOrgo, computerChanges, orgo, OrgoError, ownedWorkspace } from "./orgo"; +import { callOrgo, computerChanges, orgo, orgoProjectFor, OrgoError, ownedWorkspace } from "./orgo"; import { loadOrgoKey, orgoOrigin } from "./orgo-auth"; import { addMessage, getState, stateEpoch, update } from "./store"; @@ -294,7 +294,7 @@ async function readPlan(key: string): Promise { session?.limits && typeof l?.bops_computers_limit === "number" ? { bops: { tier: session.tier, limit: l.bops_computers_limit, extras: Array.isArray(l.bops_max_computer_ids) ? l.bops_max_computer_ids.filter((x): x is string => typeof x === "string") : [] } } : {}; - return { tier, name: tier ? planName(tier) : undefined, computers, inUse: counted?.inUse, ...(memory ? { memory } : {}), ...(deal ? { deal } : {}), ...free, ...bops }; + return { tier, name: tier ? planName(tier) : undefined, computers, inUse: counted?.inUse, ...(memory ? { memory } : {}), ...(deal ? { deal } : {}), ...free, ...bops, ...(l ? {} : { limitsUnread: true as const }) }; } /** Orgo's codes for a computer turned down for the plan (orgo-web lib/quota.ts validateComputerLimits, the create, fork and clone routes). */ @@ -428,6 +428,23 @@ async function planNow(short: (plan: OrgoPlan | null) => PlanShort | null | unde return { plan, short: short(plan) }; } +/** + * The plan, with whether the user's free Bops computer is made known. When Orgo didn't answer for it + * (limitsUnread, or nothing came back), it's asked again, after the main bot's workspace is found or + * made, in case the user owned none to ask about. Still unknown, nothing is made: a computer on the + * plan in its place would take the plan's room, or be refused for it, while the free one is still + * theirs. Not signed in (a self-hosted install on its own key), Orgo's own limit decides, as before. + */ +async function freeKnown(main: Bot, epoch: number) { + const plan = await orgoPlan(); + if (!(await loadOrgoKey()) || (plan && !plan.limitsUnread)) return plan; + await orgoProjectFor(workspaceOf(main)); + sameUser(epoch); + const again = await orgoPlan({ fresh: true }); + if (again && !again.limitsUnread) return again; + throw new Error("Orgo didn't say whether your free Bops computer is made yet"); +} + /** * Work for one user's state (stateEpoch) stops once a hosted server has swapped in another's: their bots * have the same ids, and Orgo is asked with their key now. @@ -446,12 +463,13 @@ export type MainComputer = { id: string; free?: boolean }; * - Else one on the plan, at the plan's size (computerRam), when the plan has room for it. * - Else, when another workspace's main bot has the free computer, this one works there too (null, * and it says so in its chat). Only one free computer is ever made. - * Throws a PlanLimit, in plain words, when none of those can be, or Orgo turns it down for the plan. + * Throws a PlanLimit, in plain words, when none of those can be, or Orgo turns it down for the plan, + * and an Error when Orgo doesn't say whether the free one is made (freeKnown). */ export function makeMainComputer(main: Bot, name: string, epoch = stateEpoch()): Promise { return oneAtATime(async () => { sameUser(epoch); - const first = await orgoPlan(); + const first = await freeKnown(main, epoch); if (first?.freeComputerId === null) return makeFree(main, name, epoch); const kept = first?.freeComputerId; if (kept && !getState().bots.some((b) => b.computerId === kept)) return holdFree(main, kept, epoch); diff --git a/scripts/test-plan.mjs b/scripts/test-plan.mjs index 5cfab038e..cf3813a79 100644 --- a/scripts/test-plan.mjs +++ b/scripts/test-plan.mjs @@ -139,7 +139,7 @@ assert.deepEqual(plain(await L.orgoPlan({ fresh: true })), { computers: 1_000_00 for (const [tier, deal, computers] of [["hacker_v2", null, 1], ["hacker", null, 5], ["team", null, 10], ["enterprise", { maxDesktops: 3 }, 3], ["free", null, 0]]) { onPlan(tier, 0, 0, { deal, replies: { "GET /api/billing/compute-limits": () => json(503, { error: "Compute limits are unavailable" }) } }); const p = await L.orgoPlan({ fresh: true }); - assert.deepEqual(plain(p), { tier, name: P.planName(tier), computers, ...(deal ? { deal: true } : {}) }, tier); + assert.deepEqual(plain(p), { tier, name: P.planName(tier), computers, ...(deal ? { deal: true } : {}), limitsUnread: true }, tier); assert.equal(P.planShort(p), undefined, `${tier}: unknown in use holds nothing back`); } onPlan("hacker_v2", 1, 1, { replies: { "GET /api/billing/compute-limits": () => json(200, { max_cpu: 1, max_ram_gb: 8 }) } }); @@ -163,7 +163,7 @@ await L.orgoPlan({ fresh: true }); assert.equal(calls.filter((c) => c.path === "/api/billing/compute-limits").at(-1).query, "?workspace_id=ws_mine"); onPlan("hacker_v2", 1, 0, { replies: { "GET /api/workspaces": () => json(200, { workspaces: [{ id: "ws_shared", name: "Team", role: "member" }] }) } }); n = asked("GET", "/api/billing/compute-limits"); -assert.deepEqual(plain(await L.orgoPlan({ fresh: true })), { tier: "hacker_v2", name: "Hacker", computers: 1 }, "owns no workspace: the plan's own count"); +assert.deepEqual(plain(await L.orgoPlan({ fresh: true })), { tier: "hacker_v2", name: "Hacker", computers: 1, limitsUnread: true }, "owns no workspace: the plan's own count"); assert.equal(asked("GET", "/api/billing/compute-limits"), n); assert.equal(asked("POST", "/api/workspaces"), 0, "reading the plan never makes a workspace"); @@ -987,6 +987,37 @@ await X.ensureComputer("kai"); assert.equal(kai().computer, "shared"); assert.equal(workBotOf(kai()).id, "boppy"); +// One read of the plan that Orgo didn't answer (compute-limits failed, or the 8 s wait ran out) when the +// main bot's computer is made: Orgo is asked again, and the free one is made, never one on the plan in +// its place. Before, Hacker with its one computer in use outside Bops had that one refused, and the setup +// said the plan was full; with room, it took the plan's computer, and the free one was never made. +for (const inUse of [1, 0]) { + S.update(() => { + Object.assign(main(), { computerId: undefined, freeComputer: undefined, computerStatus: "none" }); + Object.assign(kai(), { computer: undefined, computerId: undefined, computerStatus: "none" }); + }); + fo = freeOrgo("hacker_v2", 1, { inUse }); + const limits = fo.replies["GET /api/billing/compute-limits"]; + let read = 0; + fo.replies["GET /api/billing/compute-limits"] = (c) => (read++ ? limits(c) : json(503, { error: "Failed to load compute limits" })); + n = calls.length; + assert.deepEqual(plain(await L.makeMainComputer(main(), "boppy-unread")), { id: "c-free-1", free: true }, `${inUse} in use outside Bops`); + assert.deepEqual( + calls.slice(n).filter((c) => c.method === "POST" && c.path === "/api/computers").map((c) => c.body.bops_free === true), + [true], + "only the free one is asked for", + ); + assert.equal(fo.inUse, inUse, "nothing on the plan"); +} +// Orgo doesn't answer again: nothing is made, and the setup says why (the next task tries again). +S.update(() => Object.assign(main(), { computerId: undefined, freeComputer: undefined, computerStatus: "none" })); +fo = freeOrgo("hacker_v2", 1); +fo.replies["GET /api/billing/compute-limits"] = () => json(503, { error: "Failed to load compute limits" }); +n = asked("POST", "/api/computers"); +await assert.rejects(L.makeMainComputer(main(), "boppy-unread"), { message: "Orgo didn't say whether your free Bops computer is made yet" }); +assert.equal(asked("POST", "/api/computers"), n, "nothing made"); +assert.equal(fo.inUse, 0); + /* ---------------- A computer deleted on Orgo's site: the bot moves on, once ---------------- */ const V = await import(`${root}/app/api/vnc/route.ts`); From b0f226abbbd79727201eb78091bc3be606557fdd Mon Sep 17 00:00:00 2001 From: Abhay Padmanabhan <58100924+abhaypadmanabhan@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:09:03 -0700 Subject: [PATCH 2/2] freeKnown checks the account before finding or making the main bot's workspace Review finding: when another user's state came in while the plan was read (a hosted server), freeKnown went on to find or make the main bot's workspace under the new state before sameUser stopped it. Now sameUser runs first. scripts/test-plan.mjs swaps the state during that read for a user with no workspace yet and checks nothing is made. Co-Authored-By: Claude Opus 5.5 --- lib/server/plan.ts | 2 ++ scripts/test-plan.mjs | 15 +++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/lib/server/plan.ts b/lib/server/plan.ts index 6d5917339..a0d2f7bde 100644 --- a/lib/server/plan.ts +++ b/lib/server/plan.ts @@ -438,6 +438,8 @@ async function planNow(short: (plan: OrgoPlan | null) => PlanShort | null | unde async function freeKnown(main: Bot, epoch: number) { const plan = await orgoPlan(); if (!(await loadOrgoKey()) || (plan && !plan.limitsUnread)) return plan; + // Found or made for this user only: another's state may have come in while Orgo was asked. + sameUser(epoch); await orgoProjectFor(workspaceOf(main)); sameUser(epoch); const again = await orgoPlan({ fresh: true }); diff --git a/scripts/test-plan.mjs b/scripts/test-plan.mjs index cf3813a79..30206c2a1 100644 --- a/scripts/test-plan.mjs +++ b/scripts/test-plan.mjs @@ -1017,6 +1017,21 @@ n = asked("POST", "/api/computers"); await assert.rejects(L.makeMainComputer(main(), "boppy-unread"), { message: "Orgo didn't say whether your free Bops computer is made yet" }); assert.equal(asked("POST", "/api/computers"), n, "nothing made"); assert.equal(fo.inUse, 0); +// Another user's state swapped in (a hosted server) while that read is out, for a user who owns no +// workspace yet: nothing is made for either, the main bot's workspace included. +fo = freeOrgo("hacker_v2", 1); +fo.replies["GET /api/workspaces"] = () => json(200, { workspaces: [] }); +const subscription = fo.replies["GET /api/user/subscription"]; +const unswapped = S.getState(); +fo.replies["GET /api/user/subscription"] = (c) => { + if (S.getState() === unswapped) swap(JSON.parse(JSON.stringify(unswapped))); + return subscription(c); +}; +globalThis.bopsOrgoWorkspace = undefined; +n = calls.length; +await assert.rejects(L.makeMainComputer(main(), "boppy-swapped"), { message: "Another Orgo account signed in meanwhile" }); +assert.deepEqual(calls.slice(n).filter((c) => c.method === "POST").map((c) => c.path), [], "no workspace or computer made"); +swap(unswapped); /* ---------------- A computer deleted on Orgo's site: the bot moves on, once ---------------- */