Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions docs/verification/spend-cap.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
## Sub-features

- Refuse every new turn once the month's reported cost reaches the workspace
cap: the message routes answer 409 with `code: "spend_cap"`, and a routine,
peer hop or webhook stops in `startTurn` with the same refusal.
cap: the message routes answer 409 with `code: "spend_cap"`; routines, peer
hops and webhooks stop in `startTurn`; and every room member, chained mention,
goal step, retry, queued send or calendar call checks again at dispatch.
- Count a turn the moment it settles, not when the ledger's append lands or a
cache expires.
cache expires, including each provider-backed room or calendar member.
- Price turns from the operator's list (`driver/model`, then model, then
`default`) into a billable column in `/api/usage` and its CSV.
- Do nothing at all without the `budgets` / `billing` entitlements.
Expand All @@ -25,12 +26,14 @@ pnpm exec vitest run --no-file-parallelism server/spend-cap-api.test.ts

The test writes a stand-in enterprise layer (the folder shape core loads,
granting `budgets` and `billing`) and launches the `control-omb` fixture with
it through `launchVerificationServer(..., { dir, licenseKey })`. It sets a
$0.015 cap and a default price list, sends two turns that the fake engine
books at $0.01 each, and checks the third is refused with 409 `spend_cap`,
that `/api/usage` reports the cap exceeded, warned, and priced, that the CSV
carries `billable_usd`, and that raising the cap lets the next turn through.
It prints the fixture's server log path and removes its temporary homes.
it through `launchVerificationServer(..., { dir, licenseKey })`. It covers the
direct 409, pricing and raised-cap path, then proves that room and calendar
members are booked individually, the next room member is stopped at the
provider boundary, room-goal spend is attributed to its routine, each queued
user keeps their own origin, calendar operations remain owner-attributed when
user work queues behind them, and a capped scheduled goal is blocked rather
than retried as a provider failure. It prints each fixture's server log path
and removes its temporary homes.

For the same by hand, launch a fixture with `OMB_ENTERPRISE_DIR` pointing at a
folder whose `server/index.js` exports such a `register()`, and
Expand Down
8 changes: 6 additions & 2 deletions server/channel-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ describe("channel queue", () => {
});
const first = queueChannelMessage("group-a", "thread-a", "first follow-up", {
sendId: "send_first_123456",
usageTrigger: { kind: "user", label: "Alice device" },
});
queueChannelMessage("group-a", "thread-a", "second follow-up", {
sendId: "send_second_123456",
mode: "goal",
usageTrigger: { kind: "user", label: "Bob device" },
});

drainChannelMessages(() => working, run);
Expand All @@ -35,6 +37,7 @@ describe("channel queue", () => {
threadId: "thread-a",
text: "first follow-up",
mode: "chat",
usageTrigger: { kind: "user", label: "Alice device" },
}));
expect(_queuedChannelCount("thread-a")).toBe(1);

Expand All @@ -44,13 +47,14 @@ describe("channel queue", () => {
expect(run).toHaveBeenLastCalledWith(expect.objectContaining({
text: "second follow-up",
mode: "goal",
usageTrigger: { kind: "user", label: "Bob device" },
}));
expect(_queuedChannelCount("thread-a")).toBe(0);
});

it("cancels only the requested channel message", () => {
const keep = queueChannelMessage("group-b", "thread-b", "keep");
const drop = queueChannelMessage("group-b", "thread-b", "drop");
const keep = queueChannelMessage("group-b", "thread-b", "keep", { usageTrigger: { kind: "owner" } });
const drop = queueChannelMessage("group-b", "thread-b", "drop", { usageTrigger: { kind: "owner" } });

expect(cancelChannelMessage("group-b", drop.id)).toBe(true);
expect(cancelChannelMessage("group-b", drop.id)).toBe(false);
Expand Down
7 changes: 6 additions & 1 deletion server/channel-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,16 @@
// never saw.

import { newId } from "./contracts.ts";
import type { UsageTrigger } from "./usage-ledger.ts";

interface ChannelQueueItem {
id: string;
text: string;
replyToId?: string;
sendId?: string;
mode: "chat" | "goal";
/** Origin captured when the request entered the queue. */
readonly usageTrigger: UsageTrigger;
/** kept so the drain appends it with the same provenance it arrived with */
via?: "api";
}
Expand All @@ -38,7 +41,8 @@ export function queueChannelMessage(
sendId?: string;
mode?: "chat" | "goal";
via?: "api";
} = {},
usageTrigger: UsageTrigger;
},
): QueuedChannelMessage {
const entry = queues.get(threadId) ?? { groupId, items: [] };
if (entry.groupId !== groupId) throw new Error("queued task belongs to another channel");
Expand All @@ -48,6 +52,7 @@ export function queueChannelMessage(
replyToId: options.replyToId,
sendId: options.sendId,
mode: options.mode ?? "chat",
usageTrigger: { ...options.usageTrigger },
via: options.via,
};
entry.items.push(item);
Expand Down
Loading
Loading