Files
ruvnet__ruflo/v3/@claude-flow/cli/__tests__/bbs-budget-tracker.test.ts
rUv 19e4277e12 feat: ADR-164 agentbbs business autopilot — Phases 1-4 (4 MCP tools, 7 pods, http_fetch, atomic budget tracker) (#2503)
* docs(adr): ADR-164 — AgentBBS federated business-management autopilot

Draft ADR proposing agentbbs@0.1.0 integration as a federation-peer kind +
business pod scaffolding. Covers the 4 new MCP tools, 7 domain pod templates
(sales/marketing/finance/ops/support/hr/exec), upstream changes required in
both ruflo and ruvnet/agentbbs, 5-phase rollout, and 7 honest risks.

Builds on the optional-dep + graceful-degraded pattern shipped today in PR
#2500 (agenticow) and ADR-150 (metaharness).

Status: Draft. Implementation gated on upstream confirmation of agentbbs
persistence semantics + latency baseline.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* docs(adr): amend ADR-164 + add ADR-164.1 (budget atomicity)

ADR-164 amendments (Draft v2, 1193 → 1412 lines, +219 net):
  - Rewrite agentbbs maturity assessment — it's a 13-crate Rust workspace
    with 7 existing CI workflows + 30+ release tags + existing federation
    crate, NOT a 16h-old greenfield. The npm package is the launcher only.
  - §3.2.4 — two-phase auth: 15-min token is handshake-only;
    long-lived stream sessions persist past expiry until idle-close.
  - §3.5.4 — new "Founder-bootstrap trust elevation" admin escape hatch
    so #exec cross-pod synthesis works on Day 1 without 500-interaction wait.
  - §4.4 — Ops pod gets aidefence_*, terminal_execute, agent_execute,
    new http_fetch tool, cloud-provider MCP servers; concrete bench scenario.
  - §5.1.8 — new MCP tool `http_fetch` (URL allowlist, timeout, size cap).
  - §5.2 — survey-first upstream changes; many requirements may already
    exist in crates/agentbbs-federation and crates/agentbbs-mcp.
  - §7.3 + §9.5 — forward-reference to ADR-164.1 for race resolution.
  - §9.1 — risk #1 rewritten: real risks are npm/Rust version drift, FSL
    license implications, cargo first-run requirement, MCP server
    compatibility.

ADR-164.1 (new, 724 lines):
  - Atomic reserve-and-commit token bucket against AgentDB-style
    SQLite store with BEGIN IMMEDIATE serialization.
  - Schema: bbs_budget_reservations + bbs_budget_rooms tables.
  - Three-op API: reserve / commit / release with discriminated returns.
  - 5 concurrency tests (vitest-implementable) closing the race window.
  - Migration plan: feature-flagged rollout behind
    CLAUDE_FLOW_BBS_ATOMIC_BUDGET, default flip after 1 week zero incidents.
  - p99 < 5ms target with ~1000 reserves/sec throughput ceiling honestly
    documented (partition-per-pod as the scale-out path, not replacing
    SQLite).

Both Draft status. Phase 1 implementation gated on these.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* docs(adr): ADR-164.1 amendments — close the Expired Commit Leak

Peer review (2026-06-29) identified three issues:

1. CRITICAL — Expired Commit Leak (§8.1, §5.3, §4.1)
   Original draft rejected commit() of an expired reservation, so the
   actual API spend went unrecorded. A high-latency loop could silently
   burn unbounded $ past the monthly cap. Fixed: commit() now ACCEPTS
   the spend, transitions state to new 'committed_post_expiry' value,
   records actual_usd, returns { ok: true, warned: 'COMMIT_AFTER_EXPIRY' },
   and emits a reservation.committed_post_expiry audit envelope.

2. SQLite locking clarification (§3.2 _lock_bump field note)
   BEGIN IMMEDIATE itself acquires the RESERVED lock; _lock_bump is now
   documented as belt-and-suspenders + code-review visibility, not the
   primary serialization primitive.

3. Per-pod reservationExpiryMs (§3.2 expires_at field note)
   Default 60s is too short for local large-model pods. Pod templates
   may now set reservationExpiryMs in [5_000, 300_000] ms; ceiling
   defends against unbounded runaway.

Plus new Test 6 in §9: load-bearing proof that COMMIT_AFTER_EXPIRY
captures the real spend and the room's subsequent gate-check reflects it.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* feat(mcp): Phase 1 — agentbbs integration scaffold (4 MCP tools)

Implements ADR-164 §3.2 + §5.1 with the same architectural pattern as
PR #2500 (agenticow): optional-dep + graceful-degraded fallback.

New MCP tools (4):
  - federation_bbs_register     register a BBS room as a federation peer
  - federation_bbs_publish      publish a typed envelope to a room
  - federation_bbs_watch        poll recent envelopes (streaming = Phase 4)
  - federation_bbs_human_join   single-use Ed25519 token + web/SSH handshake

Phase 1 scope (scaffolding the surface, not the wire):
  - loadAgentbbs() checks importability; degrades to {degraded:true,
    reason:'agentbbs-not-found'} when missing — never throws
  - Validation runs BEFORE the optional-dep check so path-traversal /
    label / msgType rejection is deterministic regardless of install state
  - Backing store: local JSON-lines in <basePath>/room-<id>.jsonl +
    rooms.json registry. The Rust agentbbs-federation crate will replace
    this with signed wire envelopes in Phase 2.
  - Ed25519 keypair is per-process ephemeral; real federation keypair
    wiring is Phase 2 (documented inline at the call site).

Gates (all PASS):
  - Build: clean (v3/@claude-flow/cli npm run build)
  - Vitest: 8 pass + 4 skip-conditional (agentbbs binary not built locally)
  - ADR-112 audit: 349/349 tools with Use-when guidance (was 345; +4 new)
  - Bash smoke: 8/8 (end-to-end register→publish→watch cycle via dist handlers)

Plus:
  - .github/workflows/no-agentbbs-smoke.yml — CI guard mirroring
    no-metaharness-smoke.yml; asserts agentbbs stays in optionalDependencies
    and runtime drill exits 0 with --ignore-optional install
  - plugins/ruflo-bbs-federation/.claude-plugin/plugin.json — plugin metadata

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* docs(adr-164): replace §5.2 hypotheticals with concrete agentbbs survey findings

Phase 1 survey of ruvnet/agentbbs @ ca3c6e0 resolved each compatibility
check in §5.2 against the live upstream source. Six "VERIFY FIRST / if-
exists / if-missing" branches are replaced with definite answers:

- §5.2.1 FederationPayload — already covers ruflo's needs (4 variants,
  Ed25519-sealed); map publish→ReplicateMessage, register→AnnounceBoard.
  No upstream PR.
- §5.2.2 MCP tool registration — static 4-tool surface (list_boards,
  read_board, post_message, search_memory). Re-scoped from "web UI
  rendering" to the actual MCP shape concern. Gap #1 filed for missing
  create_board tool. No upstream PR for Phase 1.
- §5.2.3 Subscription/streaming — genuinely missing (subscribe=false
  hardcoded), but matches ruflo's Phase 1 polling design. Gap #2 filed
  for Phase 4 streaming. No upstream PR for Phase 1.
- §5.2.4 Integration seam — surprise finding: agentbbs already drives
  `npx ruflo federation` via RufloAdapter (other direction). Ruflo's
  CLI now has a contract obligation to keep init/join/status alive.
- §5.2.5 Durable retention — postgres backend exists in CI but
  `agentbbs mcp` defaults to MemoryStore; gap #3 filed for Phase 3.
- §5.2.6 CI regression coverage — no existing end-to-end agentbbs↔ruflo
  test. Closed by ruvnet/AgentBBS#3 regression guard (PR opened upstream
  in this same Phase 1 cycle).

The regression guard at ruvnet/AgentBBS#3 asserts the four MCP tool
names, four FederationPayload variants, and three RufloAdapter
subcommand names continue to exist on every agentbbs PR + nightly cron.

Cross-ref:
- Upstream PR: https://github.com/ruvnet/AgentBBS/pull/3
- Survey commit: ruvnet/AgentBBS@ca3c6e0

* feat(pods): Phase 2 — sales pod end-to-end (dry-run + validator + smoke)

Implements ADR-164 Phase 2: scaffold the first concrete business pod
(sales) end-to-end with a typed validator, a tick runner, and full smoke
coverage. Dry-run mode is the Phase 2 default; --live mode exits 3 with
a "Phase 3" message until Managed Agent + claude-p wiring lands.

New surface (~1,597 LOC):
  v3/@claude-flow/cli/src/business-pods/pod-schema.ts   — PodTemplate type
                                                        + validatePodTemplate()
                                                        + KNOWN_AGENT_TYPES mirror
  v3/@claude-flow/cli/src/mcp-tools/business-pod-tools.ts — 1 new MCP tool
                                                            (business_pod_validate)
  plugins/ruflo-business-pods/
    .claude-plugin/plugin.json
    templates/sales.json                                — Phase 2 sales pod template
                                                          (matches ADR-164 §4.1 +
                                                          §3.3 schema)
    scripts/pod-tick.mjs                                — dry-run tick runner +
                                                          file-based budget ledger
                                                          (TODO(adr-164.1): swap to
                                                          atomic SQLite tracker)
    scripts/pod-tick.test.mjs                           — 15 node:test tests
    scripts/smoke.sh                                    — 8-step smoke contract
    skills/pod-sales/SKILL.md
  .github/workflows/business-pods-smoke.yml             — CI gate

Gates (all PASS):
  - Build: clean
  - vitest business-pod-tools: 19/19 PASS
  - node:test pod-tick: 15/15 PASS
  - ADR-112 audit: 350/350 with guidance (was 349; +1 new tool)
  - business-pods smoke: 8/8 PASS
  - agentbbs regression smoke: 8/8 PASS (no regression from Phase 1)
  - agenticow regression smoke: 8/8 PASS (no regression from PR #2500)

Honest Phase 2 deviations:
  - Schema validator duplicated TS / JS (single canonical TS in cli/src/,
    JS copy in pod-tick.mjs so script runs without prebuilt CLI). Phase 3
    consolidates via shared agents/registry.ts.
  - Budget ledger is file-based JSON stub; ADR-164.1 atomic SQLite
    tracker swap deferred to Phase 3.
  - agentbbs subprocess wiring not invoked; envelopes go directly to
    the Phase 1 JSONL backing store (matches ADR-164 §5.2.2 phase plan).

Phase 3 follow-ups inline-marked TODO(adr-164.1) / TODO(phase-3).

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* feat(pods): Phase 3 — remaining 6 pods + domain-affinity router policy

Implements ADR-164 Phase 3: templates for marketing/finance/ops/support/hr/exec
+ the @metaharness/router domain-affinity policy hook.

New templates (6) at plugins/ruflo-business-pods/templates/:
  marketing.json   SOC2, $40/mo, cloud-tolerant
  finance.json     GDPR, $80/mo, local-pinned, expiryMs=180_000 (3min)
  ops.json         SOC2, $50/mo, local-pinned, synthetic-HTTP bench scenario;
                   TODO(phase-3-http-fetch) for the unbuilt http_fetch tool
  support.json     SOC2, $40/mo, cloud-tolerant
  hr.json          GDPR, $30/mo, local-pinned (employee PII = finance-grade)
  exec.json        GDPR, $60/mo, local-pinned, references §3.5.4
                   founder-bootstrap trust elevation for share-context on Day 1

New code:
  v3/@claude-flow/cli/src/business-pods/domain-affinity-policy.ts (86 LOC)
    - selectAgentBackend(pod): {backend, reason}
    - rules: preferLocalExecution=true → local-stdio
             else if budget.monthlyUsd >= 50 → cloud-managed
             else → remote-peer
  v3/@claude-flow/cli/src/mcp-tools/business-pod-tools.ts (+107 LOC)
    - new MCP tool: business_pod_route_backend
    - shared loadTemplate(input: object | string) helper

Schema mirror in lockstep: added `base-template-generator` to KNOWN_AGENT_TYPES
in BOTH TS (pod-schema.ts) and JS (pod-tick.mjs) copies.

Gates (all PASS):
  - Build: clean
  - vitest business-pod-tools: 44/44 PASS (was 19; +18 Phase 3 cases)
  - node:test pod-tick: 21/21 PASS (was 15; +6 happy-path tests)
  - ADR-112 audit: 351/351 with guidance (was 350; +1 new tool)
  - business-pods smoke: 11/11 PASS (was 8; +3 Phase 3 steps)
  - agentbbs regression smoke: 8/8 PASS
  - agenticow regression smoke: 8/8 PASS

Phase 4 follow-ups (inline TODOs):
  - http_fetch MCP tool implementation (referenced by ops.json)
  - @metaharness/router wire point in policy-engine.ts
  - ruflo federation trust elevate ... --to TRUSTED CLI escape hatch
  - Live-mode dispatch (Managed Agent + claude -p wiring)
  - Atomic SQLite budget tracker swap (ADR-164.1)

Per ADR-164 §3.4, all rules codified verbatim; smoke step 11 + 4 vitest cases
assert what the rules produce per template. Live mode still exits 3 with
"Phase 4" message — Phase 3 is templates + validator + routing-policy only.

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* feat: Phase 4 — http_fetch + atomic budget tracker + federation trust elevate

Three Phase 4 deliverables landing per ADR-164:

A. http_fetch MCP tool (ADR-164 §5.1.8)
   - 1 new tool (#352) for ops-pod's synthetic-endpoint bench
   - Security-default-rejects: private IPs (RFC-1918, localhost), auth
     headers (Authorization/Cookie/X-Auth-*) unless env vars opt-in
   - URL allowlist + timeout + response-size cap (256 KB default, 1 MB max)
   - 25/25 vitest pass (URL allowlist, timeout abort, truncation,
     auth-header rejection, happy path against node:http mock)

B. Atomic SQLite budget tracker (ADR-164.1 §3-§5)
   - bbs_budget_rooms + bbs_budget_reservations tables
   - reserve/commit/release with BEGIN IMMEDIATE + _lock_bump
   - committed_post_expiry state machine (Expired Commit Leak fix from
     peer review 2026-06-29) implemented
   - expires_at clamped to [5_000, 300_000] ms per-pod override
   - Behind CLAUDE_FLOW_BBS_ATOMIC_BUDGET=1 — file-based stub remains
     the default until next flip; pod-tick.mjs migration deferred to
     Phase 5
   - 15/15 vitest pass (Tests 1, 2, 3, 6 from ADR-164.1 §9, skipping
     Test 5 clock-skew which would require system-clock manipulation)
   - reservationId is randomUUID v4 (ADR-164.1 §3.2 notes v7 is purely
     aesthetic — v7 generator would add a transitive dep we don't need)

C. ruflo federation trust elevate CLI (ADR-164 §3.5.4)
   - New subcommand: bypasses 500-interaction TRUST_TRANSITION_THRESHOLDS
   - Mandatory --reason + --audit flags (both required by spec)
   - Refuses unknown peer node-ids
   - Reuses existing trust_level_changed audit envelope with
     metadata.tag='bootstrap_elevation' + metadata.operatorBypass=true
     (cheapest path — keeps SEVERITY_BY_EVENT_TYPE / CATEGORY_BY_EVENT_TYPE
     stable rather than introducing a new event-type that needs propagation
     through downstream consumers)

Gates (all PASS):
  - Build: clean (tsc)
  - vitest http-fetch + bbs-budget-tracker: 40/40 PASS (25 + 15)
  - vitest business-pod-tools regression: 44/44 PASS (Phase 3 still green)
  - ADR-112 audit: 352/352 with guidance (was 351; +1 http_fetch)
  - business-pods smoke: 11/11 PASS
  - agentbbs smoke: 8/8 PASS
  - agenticow smoke: 8/8 PASS

Phase 5 follow-ups (explicit deferrals):
  - Live-mode dispatch (pod-tick --live still exits 3)
  - Sweeper timer integration site (daemon worker, not pod-tick)
  - Spend-reporter audit adapter (write to federation_spend namespace
    BEFORE BEGIN IMMEDIATE per ADR-164.1 §6.1)
  - pod-tick.mjs migration from file-based to atomic tracker
  - ACL gate on `federation trust elevate` (currently consistent with
    `federation evict` / `reactivate` — no founder-only crypto check yet)
  - http_fetch wiring into pod-tick live-mode for ops bench

Co-Authored-By: RuFlo <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WbBa4nccx5aGhXphoHxcni

* chore(lock): regen v3/pnpm-lock.yaml with agentbbs entry (PR #2503 CI fix)

Same fix as we did on the agenticow PR #2500 push — pnpm install at root
regenerates pnpm-lock.yaml (the npm workspace), but the real workspace is
v3/pnpm-workspace.yaml and its sibling v3/pnpm-lock.yaml. CI uses
--frozen-lockfile so the v3 lockfile must be in sync with v3/@claude-flow/cli's
package.json (which declares agentbbs: ~0.1.0 as optional).

* fix(ci): re-export-tolerant agentbbs guard + skip business-pods smoke under --ignore-optional

PR #2503 CI surfaced 2 ADR-164 issues:

1. no-agentbbs-smoke Rule 2 false-positive on mcp-tools/index.ts.
   The static-grep treated `export { agentbbsTools } from './agentbbs-tools.js';`
   as an unguarded agentbbs reference. The agentbbsTools symbol itself
   contains the loadAgentbbs() guard — the index.ts re-export is benign.
   Fixed: strip re-export + spread + import-of-agentbbsTools patterns + all
   comments before the unguarded-reference check.

2. ruflo-business-pods smoke 9/11 under no-metaharness-smoke's runtime
   drill (which does `npm install --ignore-optional`). Without optional
   deps, vitest may be absent AND the cli dist may not exist. Steps 4
   (vitest) and 11 (call into dist) now SKIP cleanly when those are
   missing instead of failing — the main test-suite jobs still gate
   the full vitest + build runs.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): business-pods-smoke uses pnpm (root npm chokes on workspace:*)

The root package.json declares some workspace deps with the pnpm `workspace:*`
protocol that npm doesn't support. Symptom: `npm install` step exited with
EUNSUPPORTEDPROTOCOL.

The v3/ tree is a real pnpm workspace (v3/pnpm-workspace.yaml). Switch the
workflow to install + build from there using pnpm + frozen-lockfile, then
fall through to vitest / node:test / smoke contract.

Pattern matches the existing v3-ci.yml install steps.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): pnpm --filter '@claude-flow/cli...' to include workspace deps

The previous `pnpm --filter @claude-flow/cli build` didn't build workspace
dependencies first, so the cli's tsc step failed with TS2307 errors looking
for @claude-flow/memory, @claude-flow/neural, @claude-flow/cli-core types.
The `...` suffix tells pnpm to include all of the cli's workspace
dependencies in the build graph.

Applied to both business-pods-smoke.yml and no-agentbbs-smoke.yml's
runtime drill.

Co-Authored-By: RuFlo <ruv@ruv.net>

* fix(ci): pnpm -r build (whole workspace, dep-first) — pnpm filter can't
discover cross-package dist/ imports

Round 4 surfaced two pre-existing fragilities in the workspace's build wiring:

1. business-pods-smoke: @claude-flow/cli imports from @claude-flow/swarm/dist/
   not via package.json deps but via direct file path. `--filter "@claude-flow/cli..."`
   doesn't traverse those. Fixed: `pnpm -r --no-bail build` builds the whole
   workspace dep-first, with a fallback to a direct cli build.

2. no-agentbbs-smoke runtime drill: `--no-optional` stripped agentdb, which
   is a transitive consumer of @claude-flow/neural and breaks neural's tsc
   even though neural has its own optional-degradation path internally.
   Fixed: install everything, then SURGICALLY rm node_modules/agentbbs only,
   so only the dep we're testing graceful-degradation for is missing.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-06-29 18:28:28 -04:00

312 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Tests for AtomicBbsRoomBudgetTracker — ADR-164.1.
*
* Implements Tests 1, 2, 3, 6 from §9. Test 4 (mid-transaction crash) is
* covered by a synchronous throw-during-reserve scenario; Test 5 (clock-skew
* backward) is intentionally skipped as it requires system-clock control
* beyond what the test harness exposes — see ADR-164.1 §9 Test 5 for the
* design intent.
*
* Uses better-sqlite3 in-memory mode for speed.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createRequire } from 'node:module';
import {
AtomicBbsRoomBudgetTracker,
clampReservationExpiry,
RESERVATION_EXPIRY_FLOOR_MS,
RESERVATION_EXPIRY_CEILING_MS,
createAtomicTrackerIfEnabled,
type BudgetAuditSink,
type SqliteDatabase,
} from '../src/business-pods/bbs-budget-tracker.js';
// Resolve better-sqlite3 via CJS require so tests work even when ESM
// resolution from this nested test depth has trouble locating the
// hoisted v3-level dependency.
const cjsRequire = createRequire(import.meta.url);
type DatabaseCtor = new (filename: string) => SqliteDatabase;
function openDb(): SqliteDatabase {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = cjsRequire('better-sqlite3') as DatabaseCtor;
return new Database(':memory:');
}
let db: SqliteDatabase;
let auditEvents: Array<{ type: string; payload: Record<string, unknown> }>;
let audit: BudgetAuditSink;
beforeEach(() => {
db = openDb();
auditEvents = [];
audit = {
emit: (type, payload) => { auditEvents.push({ type, payload }); },
};
});
afterEach(() => {
try { db.close(); } catch { /* noop */ }
});
// ---------- clamp ---------------------------------------------------------
describe('clampReservationExpiry', () => {
it('clamps small values up to the 5s floor', () => {
expect(clampReservationExpiry(1000)).toBe(RESERVATION_EXPIRY_FLOOR_MS);
});
it('clamps large values down to the 5min ceiling', () => {
expect(clampReservationExpiry(60 * 60 * 1000)).toBe(RESERVATION_EXPIRY_CEILING_MS);
});
it('passes through values within bounds', () => {
expect(clampReservationExpiry(30_000)).toBe(30_000);
});
});
// ---------- Test 1: serial reserves under a cap (drop-in for §9 Test 1) --
describe('Test 1 — serial reserves under a $1.00 cap', () => {
it('exactly 20 of 100 callers reserving $0.05 succeed; 80 BUDGET_EXCEEDED; total = $1.00', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit, defaultExpiryMs: 60_000 });
tracker.registerRoom('sales-test', 1.0);
let ok = 0;
let exceeded = 0;
for (let i = 0; i < 100; i++) {
const r = tracker.reserve('sales-test', `caller-${i}`, 0.05);
if (r.ok) ok++;
else if (r.error === 'BUDGET_EXCEEDED') exceeded++;
}
expect(ok).toBe(20);
expect(exceeded).toBe(80);
const rows = tracker.listReservations('sales-test').filter((r) => r.state === 'reserved');
expect(rows.length).toBe(20);
const sum = rows.reduce((acc, r) => acc + Number(r.estimated_usd), 0);
expect(sum).toBeCloseTo(1.0, 6);
});
});
// ---------- Test 2: block → release → allow 10 of 50 ---------------------
describe('Test 2 — release frees budget for subsequent callers', () => {
it('first $0.50 reserve blocks 50 callers; release frees 10 of next 50', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit, defaultExpiryMs: 60_000 });
tracker.registerRoom('sales-test2', 1.0);
const initial = tracker.reserve('sales-test2', 'initial', 0.5);
expect(initial.ok).toBe(true);
if (!initial.ok) throw new Error('unreachable');
const reservationIdA = initial.reservationId;
// Step B: 50 reserves of $0.05 — all should fail (0.50 + 0.05 = 0.55 OK
// for the first call, but cumulative reserved + 0.05 must exceed cap to fail).
// 0.50 reserved + 50 × 0.05 = 0.50 + 2.50 = 3.00 way over. Each individual
// call sees 0.50 + N×0.05 already reserved, so callers 1-10 succeed and
// 11-50 fail (0.50 reserved + 0.50 in step B = 1.00 cap reached after 10).
let phaseBOk = 0;
let phaseBFail = 0;
for (let i = 0; i < 50; i++) {
const r = tracker.reserve('sales-test2', `phaseB-${i}`, 0.05);
if (r.ok) phaseBOk++;
else phaseBFail++;
}
expect(phaseBOk).toBe(10);
expect(phaseBFail).toBe(40);
// Step C: release the initial $0.50.
const rel = tracker.release(reservationIdA);
expect(rel.ok).toBe(true);
// Step D: another 50 — should see exactly 10 succeed (0.50 freed → 10 × 0.05).
let phaseDOk = 0;
let phaseDFail = 0;
for (let i = 0; i < 50; i++) {
const r = tracker.reserve('sales-test2', `phaseD-${i}`, 0.05);
if (r.ok) phaseDOk++;
else phaseDFail++;
}
expect(phaseDOk).toBe(10);
expect(phaseDFail).toBe(40);
});
});
// ---------- Test 3: expiry frees budget after sweepExpired() --------------
describe('Test 3 — expiry frees budget for next caller', () => {
it('after sweepExpired() an expired reservation no longer blocks new reserves', () => {
let mockNow = 1_000_000;
const tracker = new AtomicBbsRoomBudgetTracker({
db, audit,
defaultExpiryMs: 5_000, // floor — clamp goes to 5000ms
clock: () => mockNow,
});
tracker.registerRoom('hr-test', 0.10);
const a = tracker.reserve('hr-test', 'agent-1', 0.10);
expect(a.ok).toBe(true);
// Step B: should be BUDGET_EXCEEDED (live reservation occupies full cap).
const b = tracker.reserve('hr-test', 'agent-2', 0.05);
expect(b.ok).toBe(false);
if (b.ok) throw new Error('unreachable');
expect(b.error).toBe('BUDGET_EXCEEDED');
// Step C: advance clock past expiry, then sweep.
mockNow += 6_000;
const swept = tracker.sweepExpired();
expect(swept).toBe(1);
// Step D: next reserve succeeds (the expired row is excluded from totals).
const d = tracker.reserve('hr-test', 'agent-2', 0.05);
expect(d.ok).toBe(true);
});
it('expired reservation is excluded from gate check BEFORE sweep (window query check)', () => {
let mockNow = 2_000_000;
const tracker = new AtomicBbsRoomBudgetTracker({
db, audit,
defaultExpiryMs: 5_000,
clock: () => mockNow,
});
tracker.registerRoom('window-test', 0.10);
const a = tracker.reserve('window-test', 'agent-1', 0.10);
expect(a.ok).toBe(true);
// Advance past expiry but DO NOT call sweepExpired().
mockNow += 6_000;
// Per ADR-164.1 §7.2 "phantom debt intentional bias" — even unswepped
// expired rows must be excluded from the reserve() gate check.
const b = tracker.reserve('window-test', 'agent-2', 0.05);
expect(b.ok).toBe(true);
});
});
// ---------- Test 6: COMMIT_AFTER_EXPIRY closes Expired Commit Leak --------
describe('Test 6 — COMMIT_AFTER_EXPIRY captures the real spend (§8.1 fix)', () => {
it('late commit records spend, marks committed_post_expiry, emits audit', () => {
let mockNow = 3_000_000;
const tracker = new AtomicBbsRoomBudgetTracker({
db, audit,
defaultExpiryMs: 5_000,
clock: () => mockNow,
});
tracker.registerRoom('commit-late', 1.0);
const r = tracker.reserve('commit-late', 'agent-1', 0.30);
expect(r.ok).toBe(true);
if (!r.ok) throw new Error('unreachable');
const reservationId = r.reservationId;
// Advance past expiry — reservation is now expired but not committed.
mockNow += 6_000;
// Late commit: should succeed with warned: 'COMMIT_AFTER_EXPIRY'.
const c = tracker.commit(reservationId, 0.30);
expect(c.ok).toBe(true);
if (!c.ok) throw new Error('unreachable');
expect('warned' in c && c.warned).toBe('COMMIT_AFTER_EXPIRY');
if ('warned' in c) {
expect(c.finalRemaining).toBeCloseTo(0.70, 6);
}
// Audit emitted the high-priority envelope.
const evt = auditEvents.find((e) => e.type === 'reservation.committed_post_expiry');
expect(evt).toBeDefined();
expect(evt!.payload.reservationId).toBe(reservationId);
// The post-expiry commit counts against the budget.
const next = tracker.reserve('commit-late', 'agent-2', 0.70);
expect(next.ok).toBe(true);
// Now fully spent — even a tiny additional reserve is BUDGET_EXCEEDED.
const tiny = tracker.reserve('commit-late', 'agent-3', 0.01);
expect(tiny.ok).toBe(false);
if (tiny.ok) throw new Error('unreachable');
expect(tiny.error).toBe('BUDGET_EXCEEDED');
});
it('happy-path commit (within window) returns committed:true without warning', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit, defaultExpiryMs: 60_000 });
tracker.registerRoom('commit-clean', 1.0);
const r = tracker.reserve('commit-clean', 'agent-1', 0.20);
expect(r.ok).toBe(true);
if (!r.ok) throw new Error('unreachable');
const c = tracker.commit(r.reservationId, 0.18);
expect(c.ok).toBe(true);
if (!c.ok) throw new Error('unreachable');
expect('committed' in c && c.committed).toBe(true);
if ('committed' in c) {
expect(c.finalRemaining).toBeCloseTo(0.82, 6);
}
});
it('double-commit returns ALREADY_FINALIZED', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit, defaultExpiryMs: 60_000 });
tracker.registerRoom('double-commit', 1.0);
const r = tracker.reserve('double-commit', 'agent-1', 0.10);
if (!r.ok) throw new Error('expected ok');
expect(tracker.commit(r.reservationId, 0.10).ok).toBe(true);
const second = tracker.commit(r.reservationId, 0.05);
expect(second.ok).toBe(false);
if (second.ok) throw new Error('unreachable');
expect(second.error).toBe('ALREADY_FINALIZED');
});
it('commit on unknown id returns NOT_FOUND', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit });
const c = tracker.commit('does-not-exist', 0.10);
expect(c.ok).toBe(false);
if (c.ok) throw new Error('unreachable');
expect(c.error).toBe('NOT_FOUND');
});
it('release on committed reservation returns ALREADY_FINALIZED', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit, defaultExpiryMs: 60_000 });
tracker.registerRoom('release-blocked', 1.0);
const r = tracker.reserve('release-blocked', 'agent-1', 0.10);
if (!r.ok) throw new Error('expected ok');
expect(tracker.commit(r.reservationId, 0.10).ok).toBe(true);
const rel = tracker.release(r.reservationId);
expect(rel.ok).toBe(false);
if (rel.ok) throw new Error('unreachable');
expect(rel.error).toBe('ALREADY_FINALIZED');
});
it('reserve on unknown room returns ROOM_NOT_FOUND', () => {
const tracker = new AtomicBbsRoomBudgetTracker({ db, audit });
const r = tracker.reserve('no-such-room', 'agent-1', 0.10);
expect(r.ok).toBe(false);
if (r.ok) throw new Error('unreachable');
expect(r.error).toBe('ROOM_NOT_FOUND');
});
});
// ---------- feature-flag factory ------------------------------------------
describe('createAtomicTrackerIfEnabled feature flag', () => {
it('returns null when CLAUDE_FLOW_BBS_ATOMIC_BUDGET is unset', () => {
const prev = process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET;
delete process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET;
expect(createAtomicTrackerIfEnabled({ db })).toBeNull();
if (prev !== undefined) process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET = prev;
});
it('returns the atomic tracker when flag is set to "1"', () => {
const prev = process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET;
process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET = '1';
try {
const tr = createAtomicTrackerIfEnabled({ db });
expect(tr).toBeInstanceOf(AtomicBbsRoomBudgetTracker);
} finally {
if (prev === undefined) delete process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET;
else process.env.CLAUDE_FLOW_BBS_ATOMIC_BUDGET = prev;
}
});
});