From dfab9ff80384f09c622d0f212b1d609d13f00c02 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Mon, 12 Oct 2026 15:32:56 +0530 Subject: [PATCH] feat(bets): live bets per goal is an operator setting, not a constant (v0.460.0) The 4-live-bet cap exists so a loop cannot run more experiments than a human can attribute. It cuts both ways: on 2026-10-12 expresstech's goal was regressing in a page family its agent had no slot left to bet on, so four running bets were holding the loop back rather than protecting it. - `settings.betsLiveCap` (key `bets_live_cap`) is the operator override; resolution (default 4, clamp 1..8) lives with the store in `resolveLiveBetCap`, the same split as the concurrency cap. `BetStore` takes a thunk and resolves per `create`, so a change takes effect with no restart, and the refusal names the cap in force. - `GET`/`PUT /api/settings/bets` (owner/admin, audited `settings.betsCap.updated`). Past the ceiling is a 400 naming it, never a silent clamp. - The goal room's Bets header shows "N/M live" and, for an owner/admin, a picker. - `bet_list` returns `cap` + `live` so an agent knows its slots instead of learning them from a refused propose. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 +++++++++ CLAUDE.md | 6 ++-- docs/agent-mcp-tools.md | 2 +- docs/bets-plan.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- scripts/bets-test.cjs | 32 +++++++++++++++++++++- src/governance/settings.ts | 25 +++++++++++++++++ src/kernel.ts | 2 +- src/memory/memory-mcp.ts | 13 +++++++-- src/server.ts | 56 ++++++++++++++++++++++++++++++++++++-- src/state/bets.ts | 34 ++++++++++++++++++++--- web/src/App.tsx | 27 +++++++++++++++++- web/src/lib/api.ts | 4 ++- 14 files changed, 201 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58555bfe..60cb2509 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ new version heading in the same commit. ## [Unreleased] +## [0.460.0] - 2026-10-12 +### Changed +- **Live bets per goal is now an operator setting, not a constant.** The cap (4) exists so a loop cannot + run more experiments than a human can attribute, but it cuts both ways: a goal that is regressing in a + lever with no free slot is held back by the cap rather than protected by it. It is now + `settings.betsLiveCap`, settable from the goal room's **Bets** header and bounded by a hard ceiling of 8; + `BetStore` resolves it per `create`, so a change takes effect with no restart. + **For admins:** the Bets header on a goal now shows "N/M live" and lets an owner or admin raise the + limit on how many bets one goal may run at once. [Open Goals](#/goals) +### Added +- **`bet_list` reports the slot count** (`cap` + `live`), so an agent knows whether it may open a bet + instead of discovering the answer as a refused `bet_propose`. + ## [0.459.1] - 2026-10-12 ### Fixed - **The "DataForSEO (v2 tools)" connector now authenticates.** The v2 server reads its login from diff --git a/CLAUDE.md b/CLAUDE.md index 43dbcc9f..a79e9438 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -562,8 +562,10 @@ Key modules: Judged off the scheduler tick by `src/edge/bet-review.ts` (spawn-free, one owner card per bet guarded on the `bet.judged` audit event, then the wake queue tells the agent to write its lesson). Agent tools `bet_propose`/`bet_list`/`bet_update`/`asset_record`/`asset_measure`; read side `GET /api/bets?goal=`. - Caps: 4 live bets per goal, 60 assets, and re-lengthening a window re-stamps the judge date off the - ORIGINAL start. See `docs/bets-plan.md`. + Caps: 4 live bets per goal (the DEFAULT — `settings.betsLiveCap` raises it to `MAX_LIVE_BETS_CEILING`=8, + set from the goal room's Bets header; the cap cuts both ways, and a goal regressing in a lever with no + free slot is held back by it, not protected), 60 assets, and re-lengthening a window re-stamps the judge + date off the ORIGINAL start. See `docs/bets-plan.md`. - `src/edge/dreaming.ts` — the **self-learning ("Dreaming")** engine: a periodic deterministic pass that reflects on recent episodes + outcomes + friction, **compounds** them into `settings: dreaming_state`, emits a living KB page + a tenant-shared memory Insight, and **closes the loop** — distilled guidance is diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index 9573d797..bf4b4ba0 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -69,7 +69,7 @@ scope/parity properties, but not the cost. | `host_propose` | `POST /api/hosts/propose` | `HostStore.propose` + messages | W | drafts an INACTIVE, credential-less org Host connection (`proposed=1, enabled=0` — excluded from every grant set until published) + posts a `host.proposed` inbox card to owner/admins; audited `host.proposed`. Human publishes via `POST /api/hosts/:id/publish` (owner/admin, flips it active) or dismisses via `DELETE /api/hosts/:id`. The agent CANNOT set a credential — the admin attaches the vault ref | | `policy_propose` | `POST /api/agent/policy/propose` | `TerminalManager.proposePolicy` → `applyProposal` + messages | W | proposes a **TIGHTEN-ONLY** change to the governance ruleset — `tighten` an existing rule to a stricter outcome, `reorder` a conditional rule above the unconditional allow rules (fixes a first-match hole), or `add` a new `ask`/`never` guardrail. `applyProposal` (`src/governance/policy.ts`) refuses anything that would LOOSEN a guardrail (checked by construction **and** an exhaustive monotonicity sweep over the ruleset's arg space), touch a hard-deny `never`, change the default, or add an `allow` — so an invalid proposal is rejected up front with a reason. A valid proposal posts a `policy.proposal` inbox card (owner-addressed) carrying the delta + a before→after `preview`; **applies nothing** until an **owner** approves via `POST /api/policy/proposals/:id/approve` (re-validated against the current doc; then `AgentOS.applyPolicyDocument` snapshots to `policy_revisions`, writes the override, hot-reloads) or rejects via `…/reject`. Deduped + capped (≤10 open/agent). Agents see the raw `rules` via `GET /api/agent/policy` (`list_capabilities`) to craft the delta. Audited `policy.proposed` / `policy.proposal.approved` / `policy.proposal.rejected`; every applied edit (console, always-approve, approved proposal) is revertable via `POST /api/policy/revisions/:rev/revert` (owner) | | `goal_measure` | `POST /api/goals/measure` | `GoalStore.addReading` + `metricStatus` | W | records one measured READING of a goal's metric — the OUTCOME record, as against `goal_events` (activity). This is the one goal write agents get besides proposing: they may report the number, they may not move the goalposts (metric/target/status stay human-owned). Every reading stores WHO took it (`agent:`), so a number reported by the same agent that did the work is visibly self-reported. Refuses a non-finite value and a goal with no metric set. The reading also lands on the goal timeline, so a diligently-measured goal is never reported "stuck" by the activity sweep. Returns the recomputed verdict (`measuring` / `flat` / `regressing` / `achieved` / `unmeasured` / `new`). Audited `goal.measured` | -| `bet_propose` | `POST /api/agent/bets/propose` | `BetStore.create` | W | opens a BET on a goal — one falsifiable attempt at its number: hypothesis, lever, `expectedLift`, `baseline`, `windowDays` (7–60, clamped). Max **4 live bets per goal** (`MAX_LIVE_BETS`) so a loop cannot run more experiments than it can attribute. `start:false` leaves it `proposed` for a human. Audited `bet.created` | +| `bet_propose` | `POST /api/agent/bets/propose` | `BetStore.create` | W | opens a BET on a goal — one falsifiable attempt at its number: hypothesis, lever, `expectedLift`, `baseline`, `windowDays` (7–60, clamped). Live bets per goal are capped (**4 by default**, `MAX_LIVE_BETS`; an owner may raise it to 8 in the goal room's Bets header) so a loop cannot run more experiments than it can attribute — `bet_list` returns `cap` + `live` so the slot count is known before a propose is refused. `start:false` leaves it `proposed` for a human. Audited `bet.created` | | `bet_list` | `POST /api/agent/bets/list` | `BetStore.list` + `assets` | R | bets with their assets and measurements in one call — read before proposing (slots are capped) and before measuring. `state` takes one state or `live` | | `bet_update` | `POST /api/agent/bets/update` | `BetStore.update` | W | the agent's half of the judgement: the `lesson` and the final state (`kept`/`expanded`/`killed`), or edits to a running bet's hypothesis/baseline/expected lift/window. A terminal state **requires a lesson** (the next bet is chosen from these). ⚠ `observedLift`/`verdict`/`verdictNote` are NOT in the input — only the judging sweep writes them, so an agent cannot grade its own experiment; a kill on a `met` bet is allowed and both numbers land in `bet.updated`. Re-lengthening a window re-stamps the judge date off the ORIGINAL start, so judgement cannot be postponed indefinitely | | `asset_record` | `POST /api/agent/bets/asset` | `BetStore.addAsset` | W | records a thing the bet shipped (page/post/listing/link) by URL — the attribution join. Idempotent per (bet, url); ≤60 per bet. An unrecorded asset cannot count for the bet, and a bet with no assets is judged `no_signal`, never failed. Audited `bet.asset.recorded` | diff --git a/docs/bets-plan.md b/docs/bets-plan.md index a1394e96..f986316c 100644 --- a/docs/bets-plan.md +++ b/docs/bets-plan.md @@ -69,7 +69,7 @@ publishing problem, not evidence against the hypothesis. Only then does `met` ap | Thing | Limit | Why | |---|---|---| -| live bets per goal | 4 (`MAX_LIVE_BETS`) | past this a loop runs more experiments than it can attribute — the trial's operator hit exactly this ceiling | +| live bets per goal | 4 by default (`MAX_LIVE_BETS`), raisable to 8 (`MAX_LIVE_BETS_CEILING`) via the `bets_live_cap` setting | past this a loop runs more experiments than it can attribute — the trial's operator hit exactly this ceiling. Tunable because it cuts both ways: on 2026-10-12 expresstech's goal was regressing in a page family its agent had no slot left to bet on, so the cap was holding the loop back rather than protecting it. Set from the goal room's Bets header (`PUT /api/settings/bets`, owner/admin, audited `settings.betsCap.updated`); `bet_list` reports `cap`/`live` so an agent knows its slots without being refused | | window | 7–60 days, clamped | shorter cannot show a content/search effect; longer is not a bet | | assets per bet | 60 | enough for a page family, few enough that "measure them all" stays cheap | | re-lengthening a window | judge date re-stamped off the **original** start | otherwise a bet postpones its own judgement forever | diff --git a/package-lock.json b/package-lock.json index d1a84805..d77d3936 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.459.1", + "version": "0.460.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.459.1", + "version": "0.460.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index ea0c515b..ce8a60ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.459.1", + "version": "0.460.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/bets-test.cjs b/scripts/bets-test.cjs index c32b22b5..60b28844 100644 --- a/scripts/bets-test.cjs +++ b/scripts/bets-test.cjs @@ -35,7 +35,7 @@ const DAY = 86_400_000; const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); const { reviewBets } = require(path.join(ROOT, 'dist/edge/bet-review.js')); - const { MAX_LIVE_BETS, MET_FRACTION } = require(path.join(ROOT, 'dist/state/bets.js')); + const { MAX_LIVE_BETS, MAX_LIVE_BETS_CEILING, MET_FRACTION } = require(path.join(ROOT, 'dist/state/bets.js')); const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json')); registry.bootAll(); const { os: aos, tm } = registry.default(); @@ -193,6 +193,36 @@ const DAY = 86_400_000; assert(board.counts && board.counts.kept === 1, 'and a count per state', board.counts); assert(board.canEdit === true, 'an owner is told they may edit', board.canEdit); + console.log('\n\x1b[1m11b) The live-bet cap is an operator number, not a constant\x1b[0m'); + // The cap cuts both ways: expresstech (2026-10-12) had four running bets while its goal regressed in a + // page family with no slot left, so the cap was holding the loop back. Raising it must change what the + // STORE enforces, not just what a settings page displays. + const setCap = (value) => fetch(`${base}/api/settings/bets`, { method: 'PUT', headers: { cookie, 'content-type': 'application/json' }, body: JSON.stringify({ value }) }).then((r) => r.json().then((j) => ({ status: r.status, ...j }))); + const anonCap = await fetch(`${base}/api/settings/bets`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value: 6 }) }); + assert(anonCap.status === 401, 'no cookie cannot change the cap', anonCap.status); + const raised = await setCap(6); + assert(raised.ok && raised.resolved === 6, 'an owner raises it to 6', raised); + assert(aos.bets.maxLiveBets() === 6, 'and the STORE enforces the new number, with no restart', aos.bets.maxLiveBets()); + while (aos.bets.liveCount(aos.tenant, goal.id) < 6) { + const r = await propose({ goalId: goal.id, title: `Extra ${aos.bets.liveCount(aos.tenant, goal.id)}`, expectedLift: 1, windowDays: 7 }); + if (!r.ok) break; + } + assert(aos.bets.liveCount(aos.tenant, goal.id) === 6, `the ${MAX_LIVE_BETS}-bet default no longer refuses the 5th and 6th`, aos.bets.liveCount(aos.tenant, goal.id)); + const over6 = await propose({ goalId: goal.id, title: 'One past the raised cap', expectedLift: 1 }); + assert(/max 6/.test(over6.error || ''), 'the raised cap holds, and the refusal names the number in force', over6); + // Out of range is a refusal rather than a silent clamp — an operator who asks for 99 must be told the + // ceiling, not handed 8 and left believing they got 99. + const tooBig = await setCap(99); + assert(tooBig.status === 400 && new RegExp(String(MAX_LIVE_BETS_CEILING)).test(tooBig.error || ''), 'past the ceiling is a 400 naming the ceiling, not a clamp', tooBig); + assert(aos.bets.maxLiveBets() === 6, 'and the refused write left the cap alone', aos.bets.maxLiveBets()); + // The agent learns its slot count from `bet_list` instead of from a refusal. + const listed = await call('/api/agent/bets/list', { goalId: goal.id }); + assert(listed.cap === 6 && listed.live === 6, 'bet_list reports cap + live so the agent knows its slots', { cap: listed.cap, live: listed.live }); + const boardCap = await (await fetch(`${base}/api/bets?goal=${goal.id}`, { headers: { cookie } })).json(); + assert(boardCap.cap === 6 && boardCap.live === 6 && boardCap.capCeiling === MAX_LIVE_BETS_CEILING, 'and the board carries the cap for the header control', { cap: boardCap.cap, live: boardCap.live, ceiling: boardCap.capCeiling }); + const cleared = await setCap(null); + assert(cleared.ok && cleared.value === null && cleared.resolved === MAX_LIVE_BETS, `clearing it returns to the ${MAX_LIVE_BETS}-bet default`, cleared); + console.log('\n\x1b[1m12) A human can decide on the board\x1b[0m'); // Clear the live slots this test has filled — the cap is real and case 10 left it at the ceiling. for (const b of aos.bets.list(aos.tenant, { goalId: goal.id, state: 'live' })) { diff --git a/src/governance/settings.ts b/src/governance/settings.ts index 8492ea2a..a7008986 100644 --- a/src/governance/settings.ts +++ b/src/governance/settings.ts @@ -97,6 +97,7 @@ const CHAT_ROUTER_KEY = 'chat_router_enabled'; // generic Slack/Discord `/agent` const TASK_PROPOSALS_KEY = 'task_proposals'; // agent-filed board items wait for a human accept ('off' = straight to todo) const ROUTER_CONFIG_KEY = 'router_config'; // auto-router tuning (JSON RouterConfig): enabled/minScore/margin/llm const CHAT_IDLE_MIN_KEY = 'chat_idle_timeout_min'; // resident (warm) chat session idle-kill, minutes +const BETS_LIVE_CAP_KEY = 'bets_live_cap'; // live bets per goal override; unset → the default in src/state/bets.ts const MAX_CONCURRENT_KEY = 'max_concurrent_sessions'; // whole-box concurrency cap override; unset → RAM-derived default, 0 → unlimited const INTERACTIVE_IDLE_HOURS_KEY = 'interactive_idle_timeout_hours'; // auto-close a detached member session idle past this; unset → 48h, 0 → off const UNATTENDED_MAX_HOURS_KEY = 'unattended_max_runtime_hours'; // hard runtime ceiling for a headless/unattended run (stuck-mid-turn backstop); unset → 24h, 0 → off @@ -1148,6 +1149,30 @@ export class SettingsStore { return this.chatIdleTimeoutMinutes(); } + // ── live bets per goal (docs/bets-plan.md) ─────────────────────────────────────── + // How many bets one goal may have running at once. The cap exists so a loop cannot run more + // experiments than a human can act on, but it is an OPERATOR number: a goal that is regressing in a + // lever with no free slot is held back by the cap, not protected by it. Resolution (default and + // clamp) lives with the store — `resolveLiveBetCap` in src/state/bets.ts — so this returns the raw + // override only, exactly like the concurrency cap above. + + /** The operator-set live-bet cap, or `null` when unset (→ `MAX_LIVE_BETS`). */ + betsLiveCap(): number | null { + const raw = this.getRow(BETS_LIVE_CAP_KEY)?.value; + if (raw == null || raw.trim() === '') return null; // unset → the default + const n = Number(raw); + if (!Number.isFinite(n) || n < 1) return null; // garbage → treat as unset + return Math.floor(n); + } + + /** Set (or clear, with `null`) the live-bet cap override. The store clamps to 1..ceiling. */ + setBetsLiveCap(n: number | null, by?: string): number | null { + const v = Number(n); + if (n == null || !Number.isFinite(v) || v < 1) this.set(BETS_LIVE_CAP_KEY, '', by); + else this.set(BETS_LIVE_CAP_KEY, String(Math.floor(v)), by); + return this.betsLiveCap(); + } + // ── whole-box concurrency cap (docs/concurrency-cap-plan.md) ───────────────────── // The max number of live sessions the scheduler will let run at once. Every live session holds a // tmux pane + a `claude` process (hundreds of MB), so enough of them swap/OOM the box. This is the diff --git a/src/kernel.ts b/src/kernel.ts index 692e1ad9..45fb2c0e 100644 --- a/src/kernel.ts +++ b/src/kernel.ts @@ -182,7 +182,7 @@ export class AgentOS { this.runtimeAccounts = new RuntimeAccountStore(this.db); // Goals are pure db-only structured state (no attachments/on-disk mirror) → the db alone. this.goals = new GoalStore(this.db); - this.bets = new BetStore(this.db); + this.bets = new BetStore(this.db, () => this.settings.betsLiveCap()); this.feed = new FeedStore(this.db); // In-flight video renders (async) — db-only control state, like tasks/goals. this.videoJobs = new VideoJobStore(this.db); diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index d09e5453..a8ee6c0d 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -1351,7 +1351,7 @@ const TOOLS = [ '`baseline` those things earn TODAY (0 for something new), and a `windowDays` long enough for the ' + 'effect to show (7-60). Then record what you ship with `asset_record` and its numbers with ' + '`asset_measure`. At the end of the window the server computes the verdict FROM YOUR ASSETS — you ' + - 'cannot grade your own bet, which is what makes a kept bet worth anything. Max 4 live bets per goal.', + 'cannot grade your own bet, which is what makes a kept bet worth anything. Live bets per goal are ' + 'capped (4 by default, raisable by an owner) — `bet_list` reports the cap and how many you are using.', inputSchema: { type: 'object', additionalProperties: false, @@ -2949,8 +2949,15 @@ async function betList(args: Record): Promise { limit: args.limit !== undefined ? Number(args.limit) : undefined, }); const bets = (d.bets as Array>) ?? []; - if (!bets.length) return 'No bets match. (A goal with no bets is one nobody is attempting — open one with `bet_propose`.)'; - return bets.map((b) => { + // Slot line: how many live bets this goal has against the cap, so the agent knows whether it may open + // one without discovering the answer as a refusal. + const cap = Number(d.cap ?? 0); + const live = d.live !== undefined ? Number(d.live) : undefined; + const slots = cap && live !== undefined + ? `slots: ${live}/${cap} live${live >= cap ? ' — full, judge or kill one before proposing' : ` (${cap - live} free)`}\n\n` + : ''; + if (!bets.length) return slots + 'No bets match. (A goal with no bets is one nobody is attempting — open one with `bet_propose`.)'; + return slots + bets.map((b) => { const assets = (b.assets as Array>) ?? []; const measured = assets.filter((a) => a.value !== undefined); const total = measured.reduce((s, a) => s + Number(a.value ?? 0), 0); diff --git a/src/server.ts b/src/server.ts index 92e3f4ef..90aa6687 100644 --- a/src/server.ts +++ b/src/server.ts @@ -92,6 +92,7 @@ import { PRESET_SOURCES, browseRepo, fetchSkill, searchSkillsh } from './governa import { extractSkillsFromZip } from './governance/skill-zip'; import { parseBundle } from './governance/bundle-import'; import { isCodingRuntime, runtimeSupports, CODING_RUNTIMES, CodingRuntimeId, RuntimeId, AgentManifest, AppManifest, ApprovalRequest, Branding, EmbeddingsConfig, ENV_NAME, IDENTITY_PROVIDERS, IdentityProvider, isValidAppSlug, Member, MemoryConfig, MemoryMaintenance, MemoryPreload, MemoryRanking, MemoryType, Role, Run, sanitizeAgentProposalTrust, sanitizeAppDomains, sanitizeBranding, sanitizeCategory, sanitizeExamplePrompts, sanitizeIcon, runtimeTuningPatch, sanitizeRuntimeTuning, sanitizeShellSecrets, sanitizeAgentSkills, sanitizeAgentTools, sanitizeUsableSubagents, Task, TaskStatus, TaskBlockedOn, TASK_BLOCKED_ON, TaskRunState, isDraftTask, GoalStatus, GoalMetric, BetState, BET_STATES, riskClassForLevel } from './types'; +import { MAX_LIVE_BETS, MAX_LIVE_BETS_CEILING, resolveLiveBetCap } from './state/bets'; import { AgentConfigSnapshot } from './state/agent-revisions'; import { FeedFilter } from './state/feed'; import { computeAgentStats, computeAgentStat } from './state/agent-stats'; @@ -2329,8 +2330,15 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: state: b.state != null ? String(b.state) as BetState | 'live' : undefined, limit: b.limit !== undefined ? Number(b.limit) : undefined, }); - // Assets ride along: "how is my bet doing" is one call, not one per bet. - return sendJson(res, 200, { bets: bets.map((bet) => ({ ...bet, assets: os.bets.assets(bet.id) })) }); + // Assets ride along: "how is my bet doing" is one call, not one per bet. The cap rides along too — + // the agent's actual decision is "have I got a slot", and it used to learn the answer only by being + // refused at `bet_propose`. + const goalId = b.goalId != null ? String(b.goalId) : undefined; + return sendJson(res, 200, { + bets: bets.map((bet) => ({ ...bet, assets: os.bets.assets(bet.id) })), + cap: os.bets.maxLiveBets(), + live: goalId ? os.bets.liveCount(os.tenant, goalId) : undefined, + }); } if (method === 'POST' && p === '/api/agent/bets/update') { const b = await readBody(req); @@ -4633,7 +4641,17 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: if (method === 'GET' && p === '/api/bets') { const goalId = url.searchParams.get('goal') || undefined; const bets = os.bets.list(os.tenant, { goalId, limit: 200 }); - return sendJson(res, 200, { bets: bets.map((bet) => ({ ...bet, assets: os.bets.assets(bet.id) })), counts: os.bets.counts(os.tenant, goalId), canEdit: isAdmin(me) }); + // The cap rides along with the board: "how many of my slots are used" is the question a human asks + // before deciding whether to raise it, and it belongs next to the lanes rather than in Settings. + return sendJson(res, 200, { + bets: bets.map((bet) => ({ ...bet, assets: os.bets.assets(bet.id) })), + counts: os.bets.counts(os.tenant, goalId), + canEdit: isAdmin(me), + cap: os.bets.maxLiveBets(), + live: goalId ? os.bets.liveCount(os.tenant, goalId) : undefined, + capCeiling: MAX_LIVE_BETS_CEILING, + capDefault: MAX_LIVE_BETS, + }); } // Console WRITE side for bets — owner/admin. The same two things an agent may do (decide + record the // lesson) and nothing more: `observed_lift`/`verdict` stay the sweep's, so the board cannot be used to @@ -5562,6 +5580,38 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: return sendJson(res, 200, { ok: true, auditRetentionDays: days, pruned: dropped }); } + // ── live bets per goal (docs/bets-plan.md) ── + // Owner/admin knob. The cap is an attribution guard, but a goal that is regressing in a lever with no + // free slot is held back by it — so it is tunable, bounded by MAX_LIVE_BETS_CEILING. Reports the raw + // override AND what the store will actually enforce, because those differ whenever a value is clamped. + if (method === 'GET' && p === '/api/settings/bets') { + if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); + return sendJson(res, 200, { + value: os.settings.betsLiveCap(), + resolved: os.bets.maxLiveBets(), + default: MAX_LIVE_BETS, + ceiling: MAX_LIVE_BETS_CEILING, + source: os.settings.betsLiveCap() != null ? 'setting' : 'default', + }); + } + if (method === 'PUT' && p === '/api/settings/bets') { + if (!isAdmin(me)) return sendJson(res, 403, { error: 'owner or admin required' }); + const b = await readBody(req) as { value?: unknown }; + if ('value' in b) { + const raw = b.value; + const clear = raw === null || raw === ''; + const n = clear ? null : Number(raw); + // Out of range is a 400 rather than a silent clamp: an operator who asks for 20 should be told the + // ceiling, not handed 8 and left believing they got 20. + if (!clear && (!Number.isFinite(n as number) || (n as number) < 1 || (n as number) > MAX_LIVE_BETS_CEILING)) { + return sendJson(res, 400, { error: `value must be an integer 1-${MAX_LIVE_BETS_CEILING}, or null to use the default (${MAX_LIVE_BETS})` }); + } + const saved = os.settings.setBetsLiveCap(clear ? null : (n as number), me.email); + os.audit.append({ ts: Date.now(), runId: '-', tenant: os.tenant, principal: me.email, type: 'settings.betsCap.updated', data: { value: saved, resolved: resolveLiveBetCap(saved) } }); + } + return sendJson(res, 200, { ok: true, value: os.settings.betsLiveCap(), resolved: os.bets.maxLiveBets(), default: MAX_LIVE_BETS, ceiling: MAX_LIVE_BETS_CEILING }); + } + // ── whole-box concurrency cap (docs/concurrency-cap-plan.md Phase 1) ── // The effective cap resolves env → operator Settings → RAM-derived default (single source of truth = // Automations.concurrencyCap). Reports the resolved value + its source + the live running count so the diff --git a/src/state/bets.ts b/src/state/bets.ts index 3f74b839..511a4002 100644 --- a/src/state/bets.ts +++ b/src/state/bets.ts @@ -42,9 +42,26 @@ import { newId } from '../id'; import { Db } from './db'; import { Bet, BetAsset, BetState, BetVerdict, BET_LIVE_STATES } from '../types'; -/** Live bets per goal. Past this, a loop is running more experiments than it can attribute — and the - * trial's own operator hit exactly this ceiling, which is why it is a number and not a vibe. */ +/** Live bets per goal — the DEFAULT, not a constant. Past this, a loop is running more experiments + * than it can attribute, and the trial's own operator hit exactly this ceiling, which is why it is a + * number and not a vibe. It is a workspace SETTING (`settings.betsLiveCap`) because the cap cuts both + * ways: on 2026-10-12 expresstech's goal was regressing in a page family its agent had no slot left to + * bet on, so four running bets held the loop back instead of protecting it. A cap that cannot be + * raised stops being an attribution guard and becomes the reason the number keeps falling. */ export const MAX_LIVE_BETS = 4; +/** Hard ceiling on that override. The reason for a cap at all is a HUMAN's attention: lift is measured + * per-bet so the arithmetic scales fine, but nobody acts well on more than a handful of concurrent + * experiments against one number. Raise the setting, never this. */ +export const MAX_LIVE_BETS_CEILING = 8; + +/** Resolve the effective live-bet cap from an operator override: unset/garbage → the default, else + * clamped to 1..ceiling. Shared by the store and the settings route so a value that round-trips + * through the API means exactly what the store will enforce. */ +export function resolveLiveBetCap(override?: number | null): number { + const n = Number(override); + if (override == null || !Number.isFinite(n)) return MAX_LIVE_BETS; + return Math.min(Math.max(Math.round(n), 1), MAX_LIVE_BETS_CEILING); +} /** A window shorter than this cannot show a search/content effect; longer than this is not a bet. */ export const MIN_WINDOW_DAYS = 7; export const MAX_WINDOW_DAYS = 60; @@ -159,14 +176,23 @@ export interface BetJudgement { } export class BetStore { - constructor(private db: Db) {} + /** `liveCap` is the operator override resolver (null = unset → the default). A thunk, not a number, + * so a cap change takes effect on the next `create` with no restart — same contract as the + * concurrency cap. */ + constructor(private db: Db, private liveCap: () => number | null = () => null) {} + + /** The cap this store will enforce right now. */ + maxLiveBets(): number { + return resolveLiveBetCap(this.liveCap()); + } create(input: BetCreateInput): Bet { const title = input.title.trim(); if (!title) throw new Error('a bet needs a title'); if (!input.goalId) throw new Error('a bet needs a goal'); const live = this.liveCount(input.tenant, input.goalId); - if (live >= MAX_LIVE_BETS) throw new Error(`that goal already has ${live} live bets (max ${MAX_LIVE_BETS}) — judge or kill one first`); + const cap = this.maxLiveBets(); + if (live >= cap) throw new Error(`that goal already has ${live} live bets (max ${cap}) — judge or kill one first`); const windowDays = Math.min(Math.max(Math.round(input.windowDays ?? 21), MIN_WINDOW_DAYS), MAX_WINDOW_DAYS); const now = Date.now(); const start = input.start !== false; diff --git a/web/src/App.tsx b/web/src/App.tsx index d6d5a90d..4149601d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10061,10 +10061,13 @@ function BetsBoard({ goalId, metric, readings, onCount }: { goalId: string; metr const [busy, setBusy] = useState('') const [lesson, setLesson] = useState>({}) const [hint, setHint] = useState>({}) + const [cap, setCap] = useState<{ cap: number; live: number; ceiling: number } | null>(null) + const [capHint, setCapHint] = useState('') const load = useCallback(async () => { const r = await api.bets(goalId) setBets(r.bets ?? []); setCounts(r.counts ?? {}); setCanEdit(!!r.canEdit); setLoaded(true) + setCap(r.cap != null && r.live != null ? { cap: r.cap, live: r.live, ceiling: r.capCeiling ?? r.cap } : null) onCount?.((r.bets ?? []).length) }, [goalId, onCount]) useEffect(() => { setLoaded(false); setLesson({}); setHint({}); void load() }, [load]) @@ -10223,8 +10226,30 @@ function BetsBoard({ goalId, metric, readings, onCount }: { goalId: string; metr
Bets
-
{bets.length} on this goal
+ {/* Slots, next to the lanes rather than in Settings: the cap is only ever interesting while + looking at a full board, and a loop that has run out of slots cannot bet on the cause of a + decline until a human either judges one or raises it. */} +
+ {cap && = cap.cap ? 'font-medium text-amber-600 dark:text-amber-500' : ''} title={cap.live >= cap.cap ? 'Every slot is in use — judge or kill a bet, or raise the cap, before a new one can open' : undefined}>{cap.live}/{cap.cap} live} + {cap && canEdit && ( + + )} + {bets.length} on this goal +
+ {capHint &&
⚠ {capHint}
} {/* Lanes size THEMSELVES (auto-fit, 250px floor) rather than snapping to a column count: this board lives in the goal room's main column, which is ~750px on a 1440 laptop — a fixed four-lane grid there gives 170px lanes full of truncated URLs. And an EMPTY lane is not drawn at all: most of the diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 23251f1e..8746ce1b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -2278,7 +2278,9 @@ export const api = { deleteGoal: (id: string) => call<{ ok: boolean; error?: string }>('DELETE', `/api/goals/${id}`), planGoal: (id: string, steer?: { guidance?: string; maxTasks?: number; autoDispatch?: boolean }) => call<{ ok: boolean; sessionId?: string; error?: string }>('POST', `/api/goals/${id}/plan`, steer ?? {}), /** The bets on a goal (or every bet when `goalId` is omitted), each with its assets, plus per-state counts. */ - bets: (goalId?: string) => call<{ bets: (Bet & { assets: BetAsset[] })[]; counts: Record; canEdit: boolean; error?: string }>('GET', '/api/bets' + (goalId ? '?goal=' + encodeURIComponent(goalId) : '')), + bets: (goalId?: string) => call<{ bets: (Bet & { assets: BetAsset[] })[]; counts: Record; canEdit: boolean; cap?: number; live?: number; capCeiling?: number; capDefault?: number; error?: string }>('GET', '/api/bets' + (goalId ? '?goal=' + encodeURIComponent(goalId) : '')), + /** The live-bet cap (owner/admin). `value` null = use the default; the server refuses anything past its ceiling. */ + saveBetsCap: (value: number | null) => call<{ ok: boolean; value?: number | null; resolved?: number; default?: number; ceiling?: number; error?: string }>('PUT', '/api/settings/bets', { value }), /** Decide a bet and record what it taught (owner/admin). A terminal state without a lesson is refused — * `verdict`/`observedLift` are the sweep's and are not writable here. */ patchBet: (id: string, b: { state?: BetState; lesson?: string; hypothesis?: string; expectedLift?: number; baseline?: number; windowDays?: number }) =>