mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-09-14 14:01:28 +08:00
19e4277e12
* 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>
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
});
|
||
});
|