feat(paging,#1222 PR-4): pressure-broker alert surface + ResourcePool→PressureSource adapter - #1245
Merged
Merged
Conversation
…→PressureSource adapter Closes the action-surface gap on Joel's "memory must be FULLY managed" directive (2026-05-14). After PR-3 the broker can ACT on Docker pressure; PR-4 makes it TELL operators what it did, AND brings the DockerTierPool into the broker via a clean adapter rather than forcing a duplicate trait implementation. ## Two pieces ### 1. ResourcePoolAdapter (paging/adapter.rs, 248 LOC, 9 tests) Bridges Arc<dyn ResourcePool> → impl PressureSource. Required because ResourcePool (sibling's #1228 — used by DockerTierPool, future HF cache, future system-RAM tier) and PressureSource (Phase 7 broker trait) are parallel traits covering the same conceptual ground. DockerTierPool only implements ResourcePool, so it couldn't register with the broker at all. Derivation rules (all tested): - pressure() = usage_bytes / capacity_bytes; 0 when capacity==0 (tier not under management — broker neither alerts nor acts on it) - evict_some() forwards to evict_at_least(want); want = max(overshoot, 10% of capacity) so a pool at exactly 100% gets a non-zero request - stats_snapshot() derives PoolStats; hit/miss/eviction/inflight default to 0 since ResourcePool doesn't expose them (broker uses pressure + name for decisions; rest is diagnostics) Filed follow-up issue tracking the trait-unification cleanup per Joel "code concurrency ONCE then incorporate it" — adapter is the safe NOW move; collapsing the two traits is the right LATER move. ### 2. PressureAlert + sink wiring (paging/broker.rs) - New typed PressureAlert (ts-rs export, camelCase wire format) with tier_name, pressure, tier label, bytes_freed, action_taken, at_ms - AlertSink type alias (Arc<dyn Fn(PressureAlert) + Send + Sync>) - PressureBroker::add_alert_sink() — register as many sinks as needed - emit_alert() — WARN log + every registered sink, called from relieve() once per pool the broker tried to relieve - PressureTier::label() — canonical lowercase strings for IPC - Closes the TODO at line 311 of broker.rs ("Future: emit IPC event or log when triggered=true") ## Why "even with 0 bytes freed" matters Alert fires with action_taken=true even when evict_some returns 0 (fully-pinned pool, docker daemon down, etc). Zero-byte alert IS the signal "this tier is hot AND stuck" — operator needs that distinct from no alert. ReliefReport.triggered stays false in that case (matches existing semantics: triggered tracks bytes-freed action), but the alert surfaces. ## Tests (24 total, all green) paging::adapter: 9 tests covering pressure derivation, capacity==0 short-circuits, evict_some 10% floor, overshoot semantics, forwarding, dyn-dispatch via PressureSource trait object. paging::broker: 6 new tests on top of existing 9 — alert per acted pool, alerts per over-budget pool in critical, no alerts below threshold, zero-byte alert when pool can't evict, PressureTier label canonical strings, PressureAlert serde camelCase round-trip. Existing broker tests pass unchanged (alert emission is additive — does not alter triggered/bytes_freed semantics). Clippy at baseline 162 (no drift). No unsafe, no async, no lock nesting. parking_lot::RwLock for the new alert_sinks slot — same discipline as everything else in the broker. ## #1222 status - ✅ PR-1 (#1229): docker_tier discovery probe + paths::docker - ✅ PR-2 (#1231): DockerTierPool impl ResourcePool (eviction stub) - 📥 PR-3 (#1243): real evict_at_least via docker system prune - 📥 PR-4 (this): pressure-broker alert surface + adapter Operators can now subscribe to PressureAlerts via add_alert_sink to forward into chat substrate / IPC / Grafana; until then alerts go to the WARN log via runtime::logger("pressure-broker"). TS render layer gets the typed wire format from shared/generated/paging/PressureAlert.ts. Refs #1222.
joelteply
added a commit
that referenced
this pull request
May 15, 2026
…ingle trait, drop adapter shim (#1264) Closes #1246. ## Smell `PressureSource` (broker.rs) and `ResourcePool` (pool.rs) were parallel traits covering the same conceptual ground from two angles: | Trait | Method shape | |--------------------|-------------------------------------------------------| | PressureSource | name, pressure (0..1), evict_some, stats_snapshot | | ResourcePool | tier_name, capacity_bytes, usage_bytes, evict_at_least, snapshot | `PagedResourcePool` implemented both via two manual impls. Tier pools that don't follow the per-key-page shape (DockerTierPool) only implemented `ResourcePool` and needed a `ResourcePoolAdapter` shim (#1245 PR-4) to plug into the broker. Two traits, one shape, an adapter to bridge them — exactly the "code concurrency / control surface ONCE then incorporate it" smell Joel flagged 2026-05-14. ## Fix ResourcePool absorbs `pressure()` and `stats_snapshot()` as default methods derived from the trait's existing core (capacity / usage / snapshot). Tier impls override only when they have richer telemetry (`PagedResourcePool` overrides `stats_snapshot()` to expose its internal hit/miss/eviction counters; everyone else inherits the defaults). PressureBroker now holds `Arc<dyn ResourcePool>` directly. The broker calls `evict_at_least(want)` instead of `evict_some()`, where `want` is computed by a new `evict_amount_for(pool)` helper that aims to drop pressure to `HEALTHY_TARGET_PRESSURE = 0.60` (matching the old `evict_under_pressure()` "drop until healthy" behavior). 10%-of-cap floor ensures non-zero ask even at exactly 100% pressure. ## Deletions - `paging/adapter.rs` (ResourcePoolAdapter — vestigial after the collapse; every tier now plugs into the broker directly) - `PressureSource` trait + `impl<K,V> PressureSource for PagedResourcePool` blanket impl — both replaced by direct ResourcePool consumption ## API change External callers that registered `Arc<dyn PressureSource>` with the broker now register `Arc<dyn ResourcePool>` instead — ergonomics are the same (any tier implementing ResourcePool plugs in directly), the trait name is the only churn. Currently no out-of-tree callers exist besides the broker tests. ## Tests 66/66 paging tests pass: 9 broker tests (including the broker-end-to-end on a real PagedResourcePool, the alert emission across the four states, and PressureTier/PressureAlert serde round-trips), 57 pool tests. The MockPool + StuckPool test fixtures got rewritten to implement ResourcePool directly. MockPool's settable pressure path stays via a `pressure()` override; capacity/usage are synthetic so the broker's `evict_amount_for` produces sane requests. ## Diff stats - `paging/pool.rs`: +44/−6 (default methods on ResourcePool + override on PagedResourcePool's stats_snapshot) - `paging/broker.rs`: heavy rewrite (PressureSource → ResourcePool, evict_some → evict_at_least + evict_amount_for, mock fixtures rewritten) - `paging/mod.rs`: drops adapter export, drops PressureSource export - `paging/adapter.rs`: deleted (252 LOC removed) - `modules/cognition.rs`: comment updated (PressureSource → ResourcePool) ## Clippy / baseline Clippy at 161 (was 162). The deleted adapter shed one warning. ## Why this matters Tier pools (Docker, KV cache, future HF cache, future system-RAM, future NVMe) now plug into the pressure broker via the SAME trait they already implement for capacity reporting. No more "do I need a shim?" question. The compression rule from CLAUDE.md applies: "For ANY decision (logic or data), can you point to exactly ONE place in the codebase?" — for "tier capacity + eviction + pressure", the answer is now ResourcePool, period. Co-authored-by: Test <test@test.com>
joelteply
added a commit
that referenced
this pull request
Jun 9, 2026
…task #143 slice 2) (#1561) * perf(concurrency,#1235): refcount-per-key cleanup so analyzer cancellation can't drop entry mid-flight (#1244) * perf(concurrency,#1235): refcount-per-key cleanup so analyzer cancellation can't drop entry mid-flight Pre-#1235 only the analyzer (first caller for a key) held the Drop guard for the in_flight entry. That correctly fixed the panic-cleanup case (#1232) but left a window during analyzer cancellation: T0: analyzer.single_flight("k") creates entry, holds guard T1: awaiter1.single_flight("k") clones the Shared, no guard T2: analyzer task is cancelled T3: analyzer's guard.drop fires, removes entry from in_flight T4: NEW caller.single_flight("k") finds no entry, starts FRESH work — duplicate inference for the same key, contract violated. awaiter1 still completes the original Shared. Codex flagged this on #1233. This change makes EVERY caller (analyzer + awaiters) hold a RefCountGuard. The HashMap value becomes KeyEntry { shared, refcount: Arc<AtomicUsize> }. Each caller bumps the refcount under the in_flight lock when constructing its guard; each guard drops decrement it. The entry is removed only when refcount hits zero — and only after a double-check under the lock to handle the race where a brand-new caller bumps the refcount between fetch_sub and lock acquisition. Behavior preserved: - Single producer for many waiters: same as before. - Panic cleanup (#1232): work-future panic re-raises through every Shared clone; all guards drop during unwind, refcount → 0, entry removed. Compiles clean. Tests follow in the next commit. * test(concurrency,#1235): two cancellation-race tests for refcount cleanup Two new tests proving the #1235 fix: 1. analyzer_cancellation_does_not_evict_entry_while_awaiters_hold_it - Analyzer + awaiter both register for the same key. - Analyzer task is cancelled (abort). - Awaiter is still holding the Shared. - A NEW caller arrives for the same key. - Asserts: in_flight_count stays 1 across the analyzer drop; work-future producer body runs EXACTLY ONCE across all three callers; new caller's result equals the analyzer's original result (joined the same Shared, didn't start fresh). Pre-#1235 this would have failed: analyzer's guard drop would have removed the in_flight entry, and the new caller would have started duplicate work (producers count == 2, not 1). 2. all_callers_cancelled_evicts_entry_for_fresh_start - Two callers register, both cancelled before completion. - Asserts: refcount → 0, entry evicted. - Fresh caller for the same key starts a fresh work future (the prior abandoned work is gone). Both tests run on tokio multi-threaded runtime (default) so abort + Shared interactions reflect production behavior. Full concurrency test suite: 6 passed (4 existing + 2 new), 0 failed. --------- Co-authored-by: Test <test@test.com> * feat(paging,#1222 PR-4): pressure-broker alert surface + ResourcePool→PressureSource adapter (#1245) Closes the action-surface gap on Joel's "memory must be FULLY managed" directive (2026-05-14). After PR-3 the broker can ACT on Docker pressure; PR-4 makes it TELL operators what it did, AND brings the DockerTierPool into the broker via a clean adapter rather than forcing a duplicate trait implementation. ## Two pieces ### 1. ResourcePoolAdapter (paging/adapter.rs, 248 LOC, 9 tests) Bridges Arc<dyn ResourcePool> → impl PressureSource. Required because ResourcePool (sibling's #1228 — used by DockerTierPool, future HF cache, future system-RAM tier) and PressureSource (Phase 7 broker trait) are parallel traits covering the same conceptual ground. DockerTierPool only implements ResourcePool, so it couldn't register with the broker at all. Derivation rules (all tested): - pressure() = usage_bytes / capacity_bytes; 0 when capacity==0 (tier not under management — broker neither alerts nor acts on it) - evict_some() forwards to evict_at_least(want); want = max(overshoot, 10% of capacity) so a pool at exactly 100% gets a non-zero request - stats_snapshot() derives PoolStats; hit/miss/eviction/inflight default to 0 since ResourcePool doesn't expose them (broker uses pressure + name for decisions; rest is diagnostics) Filed follow-up issue tracking the trait-unification cleanup per Joel "code concurrency ONCE then incorporate it" — adapter is the safe NOW move; collapsing the two traits is the right LATER move. ### 2. PressureAlert + sink wiring (paging/broker.rs) - New typed PressureAlert (ts-rs export, camelCase wire format) with tier_name, pressure, tier label, bytes_freed, action_taken, at_ms - AlertSink type alias (Arc<dyn Fn(PressureAlert) + Send + Sync>) - PressureBroker::add_alert_sink() — register as many sinks as needed - emit_alert() — WARN log + every registered sink, called from relieve() once per pool the broker tried to relieve - PressureTier::label() — canonical lowercase strings for IPC - Closes the TODO at line 311 of broker.rs ("Future: emit IPC event or log when triggered=true") ## Why "even with 0 bytes freed" matters Alert fires with action_taken=true even when evict_some returns 0 (fully-pinned pool, docker daemon down, etc). Zero-byte alert IS the signal "this tier is hot AND stuck" — operator needs that distinct from no alert. ReliefReport.triggered stays false in that case (matches existing semantics: triggered tracks bytes-freed action), but the alert surfaces. ## Tests (24 total, all green) paging::adapter: 9 tests covering pressure derivation, capacity==0 short-circuits, evict_some 10% floor, overshoot semantics, forwarding, dyn-dispatch via PressureSource trait object. paging::broker: 6 new tests on top of existing 9 — alert per acted pool, alerts per over-budget pool in critical, no alerts below threshold, zero-byte alert when pool can't evict, PressureTier label canonical strings, PressureAlert serde camelCase round-trip. Existing broker tests pass unchanged (alert emission is additive — does not alter triggered/bytes_freed semantics). Clippy at baseline 162 (no drift). No unsafe, no async, no lock nesting. parking_lot::RwLock for the new alert_sinks slot — same discipline as everything else in the broker. ## #1222 status - ✅ PR-1 (#1229): docker_tier discovery probe + paths::docker - ✅ PR-2 (#1231): DockerTierPool impl ResourcePool (eviction stub) - 📥 PR-3 (#1243): real evict_at_least via docker system prune - 📥 PR-4 (this): pressure-broker alert surface + adapter Operators can now subscribe to PressureAlerts via add_alert_sink to forward into chat substrate / IPC / Grafana; until then alerts go to the WARN log via runtime::logger("pressure-broker"). TS render layer gets the typed wire format from shared/generated/paging/PressureAlert.ts. Refs #1222. Co-authored-by: Test <test@test.com> * feat(modules,#1222 PR-3): real evict_at_least via docker system prune (#1243) * refactor(persona): split evaluator.rs (1231 LOC) into focused submodules (#1208) `persona/evaluator.rs` was a single 1231-LOC file mixing four independent concerns: persona sleep state, per-room rate limiting, post-inference adequacy check, and the `full_evaluate` gate orchestrator. Split into: evaluator/mod.rs (886 LOC) — gate orchestrator, FullEvaluate request/result types, 18 gate integration tests evaluator/sleep_state.rs (99 LOC) — SleepMode + SleepState + 2 unit tests evaluator/rate_limiter.rs (132 LOC) — RateLimiterState + RoomRateState + 2 unit tests (track_response, rate_limit_expired) evaluator/adequacy.rs (207 LOC) — RecentResponse + AdequacyResult + check_response_adequacy + 7 tests `persona/mod.rs` re-exports unchanged: `pub use evaluator::{...}` still exposes SleepMode, SleepState, RateLimiterState, AdequacyResult, RecentResponse, GateDetails, FullEvaluateRequest, FullEvaluateResult. External callers see no API change. Why these specific cuts: - SleepState is reused independently anywhere a persona's voluntary attention state matters (not just Gate 1). - RateLimiterState is a per-room cadence tracker that's a SIGNAL to the LLM, not a hard gate — independent of full_evaluate. - Adequacy check is a separate phase (post-inference, not pre-response) that happens to share the file because it was written together. Tests: - `cargo test --features metal,accelerate persona::evaluator` → 32 passed (every test from the original file, redistributed by domain). - Full `persona::*` test suite → 451 passed, 0 failed, 3 ignored (no other module's imports broke). Each new file < 250 LOC, mod.rs < 1000 LOC. Closes #1208 for the `evaluator.rs` slice; `admission.rs` (1225) and `model_resolver.rs` (1232) remain as separate cards. * feat(modules,#1222): real evict_at_least via docker system prune (PR-3, stacked on #1231) Stacks on PR-2 (#1231). Replaces the PR-2 stub (return 0) with real two-stage eviction. ## Strategy Two-stage escalation that frees only as much as needed: 1. **Soft (always tried first)**: docker system prune --force --filter "until=24h" Drops dangling images, stopped containers, unused networks older than 24h. Safe — does NOT touch in-use images, named volumes, or recent dev iteration artifacts. This is what a developer would manually run on a 'docker eats my disk' day. 2. **Aggressive (only if soft didn't free enough)**: docker system prune --force Same prune without the time filter. Frees ALL dangling artifacts regardless of age. Still does NOT touch in-use images or named volumes (Docker prune semantics). Returns the actual bytes freed (sum across both stages), parsed from Docker's stable 'Total reclaimed space: X.YYUNIT' summary line. Returns 0 when docker isn't installed / daemon down / command fails — broker treats as 'tier can't act, surface pressure to operator' (same shape as DockerTierProbe::Unsupported). ## Parser Standalone parse_reclaimed_bytes(output: &str) -> Option<u64>: - Handles all Docker units (B, kB, MB, GB, TB) with SI multipliers (1kB = 1000B per docker/cli convention) - Picks LAST 'Total reclaimed space:' line (Docker prints per-section totals during interactive runs; final line is the canonical total) - Returns None on missing line / unknown unit / unparseable number — distinct from Some(0) which means 'pruned successfully but nothing to free' ## Tests 8 tests pass (5 from PR-2 + 3 new): - parse_reclaimed_bytes_handles_all_units (B/kB/MB/GB/TB) - parse_reclaimed_bytes_returns_none_when_line_missing (5 malformed inputs — None vs Some(0) distinction) - parse_reclaimed_bytes_picks_last_summary_line (canonical-total semantics) evict_at_least_never_panics replaces the PR-2 stub-asserting test. Doesn't assert positive freed-bytes count because that requires a live Docker daemon with prunable artifacts (flaky in CI). The unit behavior is covered by the parser tests; live integration validation happens during PR-4 chat-substrate alert work. Clippy stays at baseline 162. ## Stacking Base = feat/docker-tier-pool-impl-1222 (NOT canary). Once PR-2 (#1231) merges, GitHub auto-rebases this PR's base to canary and the diff resolves to only PR-3 changes. ## Now-shipped under #1222 - PR-1 (#1229): docker_tier discovery probe + paths::docker - PR-2 (#1231): DockerTierPool impl ResourcePool (eviction stub) - PR-3 (this): real evict_at_least via docker system prune - PR-4 (still open): chat-substrate alerts on >90% capacity Joel directive 2026-05-14: 'memory in this system, including the docker allotment needs to be managed by the system, FULLY.' With PR-3, the system can actually ACT on Docker pressure (not just report it). Closes the action gap. Refs #1222. --------- Co-authored-by: Test <test@test.com> * fix(chat,#1159): URLCardAdapter HTML-escape every interpolation + safe-href guard (#1250) Closes #1159 (PR-3 of #1100). Closes the URL-metadata-XSS surface that PR-1 explicitly deferred (its doc comment named this slice). ## Vulnerability URLCardAdapter.renderContent built the card HTML by string-interpolating 9 attacker-controlled fields without escaping: - originalText (raw chat text — adversary types it) - url (raw URL string — `"><script>...` works, `javascript:` works) - title, description, siteName (async metadata fetch — server-attacker) - favicon (constructed from domain — domain itself is parsed-safe but the slot was unescaped) - domain (3 sites in template) Two attack classes were live: 1. HTML injection — any of the 9 fields could break out of its quoted attribute or text context and inject `<script>`, `<img onerror>`, etc. 2. Scheme injection — the fallback-link `<a href="${url}">` accepted `javascript:`, `data:`, `vbscript:` URLs. A click executed in the page origin. ## Fix 1. **escapeHtml(s)** — same canonical 5-char escape used in TextMessageAdapter.escapeHtml. Safe in both text and double-quoted attribute contexts (escapes both `"` and `'`). 2. **safeHref(url)** — whitelist neutralizer. Returns `#` for any scheme outside the audit-once safe set (http, https, mailto, tel, ftp, sftp). Also passes protocol-relative `//` and same-document `#fragment` URLs as-is. Whitelist not blacklist because blacklists miss `\tjavascript:`, case mixing, `
javascript:` HTML-entity smuggling, and any future code-executing scheme. 3. **Apply at every interpolation** in renderContent: - additionalText, url (×4 attribute sites + 1 anchor text), favicon, domain (×3), siteName, title, description → escapeHtml - href slot → safeHref then escapeHtml ## Tests 16 tests in tests/unit/url-card-adapter-xss.spec.ts, all pass. Organized into four describe blocks (per-field escape, attribute-context escape, href scheme neutralization, href whitelist preservation) so each stays under the 80-line/function limit. Each test asserts BOTH "raw injection string MUST NOT appear" and "escaped form MUST appear" so a future bug regressing escape is caught from both directions. ## Test-file naming + tsconfig discipline - File is `.spec.ts` not `.test.ts` so it bypasses the `tsconfig.eslint.json` exclude `**/*.test.ts` (which would otherwise cause a parse-error and bump the ESLint baseline). - Added the file to `tsconfig.eslint.json` include so it's parsed type-aware and lint-clean. Vitest discovers `.spec.ts` natively. - This avoids the "no baseline bumps or parse-error debt for new test code" rule called out in the airc-8a5e direction broadcast 2026-05-14. ESLint at 5461 baseline (no drift). TypeScript build clean. ## Path PR-1 (#1154) — closed innerHTML Lit-reactivity hole, deferred metadata XSS. PR-2 unrelated. PR-3 (this) — closes the deferred metadata XSS. URLCardAdapter is now safe against the full audit set called out in the original Joel review nit on #1154. No behavior change for safe input. Single-encode contract preserved. Refs #1100. Co-authored-by: Test <test@test.com> * refactor(persona): split admission.rs (1225 LOC) into mod + recipes (#1208) (#1251) `persona/admission.rs` was 1225 LOC mixing the structural admission gate, the IsMemorable trait, the v1 HeuristicIsMemorable recipe + its policy tests, helpers, and the gate test suite. Split: admission/mod.rs (985 LOC) — AdmissionGate::admit machinery, Candidate/Context/Config types, IsMemorable trait, envelope verification, seam recording, and the structural-gate test suite (replay, trust threshold, recipe error path, quarantine propagation, seam-emission invariants) admission/recipes.rs (326 LOC) — HeuristicIsMemorable struct + impl + 4 heuristic-policy tests (short_content, noise_phrase, duplicate, admit_synthesizes_engram) `HeuristicIsMemorable` re-exported at the parent path via `pub use recipes::HeuristicIsMemorable` — external callers see no API change. Engram types previously imported privately from `super::engram` are now re-exported `pub use` so submodules can reach them via `super::`. Tests: - `cargo check --features metal,accelerate -p continuum-core` clean. - `cargo test --features metal,accelerate -p continuum-core --lib persona::admission` → 37 passed, 0 failed. Closes #1208 — final slice. evaluator.rs done in #1242, model_resolver.rs done in #1249, admission.rs done now. Worktree-discipline note: this PR is the first work this session authored from a proper `airc lane create` worktree rather than the shared root checkout, after Joel called out that branch swaps in the shared root were stomping uncommitted work. Co-authored-by: Test <test@test.com> * fix(chat-widget): empty state cleared by hidden attr — :host([hidden]) override (#1254) Joel reported: chat widget's "Send your first message / Try @Helper..." empty-state placeholder doesn't clear when the room actually has messages. Visible after sending "my first message" into a room that already had two prior messages — the empty-state panel still shows below them. ## Root cause `EmptyStateWidget` (LitElement, custom element `<empty-state>`) defines: :host { display: flex; ... } ChatWidget toggles the empty state via the HTML `hidden` attribute (updateEntityCount → emptyState.toggleAttribute('hidden', !isEmpty)). The `hidden` attribute applies `display: none` via the user-agent stylesheet — but the more-specific author rule `:host { display: flex }` WINS the cascade, so `hidden` has zero visual effect. The toggle silently no-ops; the panel keeps rendering. This is the well-known custom-element-with-explicit-display gotcha documented in the HTML5 spec: https://html.spec.whatwg.org/multipage/interaction.html#the-hidden-attribute ## Fix Add an explicit `:host([hidden]) { display: none; }` rule to the component's static styles. Wins by being more specific than `:host` alone (attribute selector wraps the host pseudo-class). Other consumers of `<empty-state>` (UserListWidget, RoomListWidget, TrainingDashboardWidget, the various Reactive* widgets) avoided this bug by accident — they use `${this.isEmpty ? this.renderEmptyState() : nothing}` to conditionally include the element rather than always-in- DOM + toggle-hidden. ChatWidget chose the toggle-hidden pattern deliberately because of CSS sibling rules around .messages-container, so the right fix is to make `hidden` work as expected for the component. ## Verification - `npm run build:ts` clean. - Comment in code documents the gotcha + spec link so future readers understand why the rule is load-bearing (4 lines of CSS that look redundant alongside `:host { display: flex }` until you know the cascade history). CSS-only behavioral fix: zero functional changes, no test added (UI visual verification is the appropriate sign-off; will follow up after merge with `npm start` + screenshot of a freshly-loaded populated room showing no empty-state placeholder). Co-authored-by: Test <test@test.com> * feat(commands): add RustBackedCommand base + first refactor (#1198) (#1256) Per Joel's "TS moves DOWN into rust… if not UI/UX it is rust" rule (2026-05-14), every TS command in `src/commands/*` that exists only to route into a Rust IPC handler does the same five things: 1. Validate required params (throw ValidationError with consistent message + missing-field name) 2. Resolve the Rust IPC client singleton 3. Call the typed mixin method on the client 4. Translate the snake_case Rust response to camelCase Result via `createXResultFromParams` 5. Return the wrapped result Steps 1, 2, and 5 were ~30 LOC of pure boilerplate per command. Steps 3 and 4 are the only variable bits. Pre-#1198 status quo: every command re-wrote the boilerplate inline — exactly the uncompressed redundancy the compression principle in CLAUDE.md exists to prevent. This PR adds: - `RustBackedCommand<TParams, TResult, TRest>` base class (`daemons/command-daemon/shared/RustBackedCommand.ts`): - Subclass declares `requiredParams` (which fields must be non-empty). - Subclass implements `callRust(params, client)` (the variable mixin call) and `toResult(raw, params)` (the variable result wrapping). - Base class owns: validation loop, client resolution, error consistency, the `execute()` orchestration. - `validateParams()` is overridable — subclasses needing richer shape constraints (e.g., typeof-object checks) call super then add their own. - `TRest` generic threads the raw mixin response shape through to `toResult` for type safety (no `unknown` cast at the seam). - Canonical example refactor: `commands/cognition/admit-inbox-message/server/CognitionAdmitInboxMessageServerCommand.ts`. ~64 LOC → ~85 LOC, but most of the new lines are typed declarations (`requiredParams`, `AdmitInboxMessageRustResponse` type alias) that replace inline boilerplate. Every other command can adopt the same shape and lose ~30 LOC of envelope. This is **PR-1**: pattern + one example. Other ~50 Rust-backed commands adopt incrementally (don't churn-rewrite all in one PR). Verification: - `npm run build:ts` clean. - The refactored command preserves the existing custom message-shape validation (typeof-object check) via the `validateParams` override pattern. Closes #1198 for the pattern + first migration. Follow-ups can adopt the base class one command at a time. Co-authored-by: Test <test@test.com> * fix(channel,#1253): default tick DB to SQLite handle (#1259) * fix(channel,#1253): default tick DB to SQLite handle * chore(clippy): lock warning baseline at 161 --------- Co-authored-by: Test <test@test.com> * refactor(paging,#1246): collapse PressureSource into ResourcePool — single trait, drop adapter shim (#1264) Closes #1246. ## Smell `PressureSource` (broker.rs) and `ResourcePool` (pool.rs) were parallel traits covering the same conceptual ground from two angles: | Trait | Method shape | |--------------------|-------------------------------------------------------| | PressureSource | name, pressure (0..1), evict_some, stats_snapshot | | ResourcePool | tier_name, capacity_bytes, usage_bytes, evict_at_least, snapshot | `PagedResourcePool` implemented both via two manual impls. Tier pools that don't follow the per-key-page shape (DockerTierPool) only implemented `ResourcePool` and needed a `ResourcePoolAdapter` shim (#1245 PR-4) to plug into the broker. Two traits, one shape, an adapter to bridge them — exactly the "code concurrency / control surface ONCE then incorporate it" smell Joel flagged 2026-05-14. ## Fix ResourcePool absorbs `pressure()` and `stats_snapshot()` as default methods derived from the trait's existing core (capacity / usage / snapshot). Tier impls override only when they have richer telemetry (`PagedResourcePool` overrides `stats_snapshot()` to expose its internal hit/miss/eviction counters; everyone else inherits the defaults). PressureBroker now holds `Arc<dyn ResourcePool>` directly. The broker calls `evict_at_least(want)` instead of `evict_some()`, where `want` is computed by a new `evict_amount_for(pool)` helper that aims to drop pressure to `HEALTHY_TARGET_PRESSURE = 0.60` (matching the old `evict_under_pressure()` "drop until healthy" behavior). 10%-of-cap floor ensures non-zero ask even at exactly 100% pressure. ## Deletions - `paging/adapter.rs` (ResourcePoolAdapter — vestigial after the collapse; every tier now plugs into the broker directly) - `PressureSource` trait + `impl<K,V> PressureSource for PagedResourcePool` blanket impl — both replaced by direct ResourcePool consumption ## API change External callers that registered `Arc<dyn PressureSource>` with the broker now register `Arc<dyn ResourcePool>` instead — ergonomics are the same (any tier implementing ResourcePool plugs in directly), the trait name is the only churn. Currently no out-of-tree callers exist besides the broker tests. ## Tests 66/66 paging tests pass: 9 broker tests (including the broker-end-to-end on a real PagedResourcePool, the alert emission across the four states, and PressureTier/PressureAlert serde round-trips), 57 pool tests. The MockPool + StuckPool test fixtures got rewritten to implement ResourcePool directly. MockPool's settable pressure path stays via a `pressure()` override; capacity/usage are synthetic so the broker's `evict_amount_for` produces sane requests. ## Diff stats - `paging/pool.rs`: +44/−6 (default methods on ResourcePool + override on PagedResourcePool's stats_snapshot) - `paging/broker.rs`: heavy rewrite (PressureSource → ResourcePool, evict_some → evict_at_least + evict_amount_for, mock fixtures rewritten) - `paging/mod.rs`: drops adapter export, drops PressureSource export - `paging/adapter.rs`: deleted (252 LOC removed) - `modules/cognition.rs`: comment updated (PressureSource → ResourcePool) ## Clippy / baseline Clippy at 161 (was 162). The deleted adapter shed one warning. ## Why this matters Tier pools (Docker, KV cache, future HF cache, future system-RAM, future NVMe) now plug into the pressure broker via the SAME trait they already implement for capacity reporting. No more "do I need a shim?" question. The compression rule from CLAUDE.md applies: "For ANY decision (logic or data), can you point to exactly ONE place in the codebase?" — for "tier capacity + eviction + pressure", the answer is now ResourcePool, period. Co-authored-by: Test <test@test.com> * perf(coordination,#1260): track room activity for temperature decay (#1263) Co-authored-by: Test <test@test.com> * refactor(commands,#1198): migrate recall-engrams to RustBackedCommand (#1265) Sister command of cognition/admit-inbox-message (refactored in #1256). Same shape — validate, call mixin, wrap result — now expressed as RustBackedCommand subclass declarations rather than re-implemented boilerplate. - requiredParams = ['personaId'] - validateParams() override adds the kind-companion required-field checks (by_id needs id, by_keyword needs keyword, by_origin needs origin); calls super first - callRust delegates to the typed mixin - toResult shapes the snake_case Rust response into the camelCase result via the existing factory Behavior preserved: every original validation message + return shape matches. Net: 86 -> 100 LOC, but most new lines are typed declarations and the explicit per-field error messages — boilerplate is gone. npm run build:ts clean. Co-authored-by: Test <test@test.com> * docs(architecture,#1266): codify recipe activity graph semantics (#1268) Co-authored-by: Test <test@test.com> * docs(grid,#1267): clarify airc pipeline and transport contracts (#1269) Co-authored-by: Test <test@test.com> * refactor(live,#1247): migrate livekit_agent per-key single-flight to ConcurrencyPolicy (#1270) Closes #1247. ## Smell `live/transport/livekit_agent.rs:86` declared a hand-rolled per-key lock map: ```rust static AGENT_CREATION_LOCKS: std::sync::Mutex< Option<std::collections::HashMap<(String, String), Arc<tokio::sync::Mutex<()>>>>, > = std::sync::Mutex::new(None); ``` Used by `get_or_create_agent` to gate concurrent creation of the same (call_id, user_id) — TOCTOU prevention against 3 concurrent callers all calling `connect()` and creating 3 redundant agents+video loops. Two problems: 1. **Reimplements `ConcurrencyPolicy`** — the substrate already shipped the canonical primitive in #1230 (TokioConcurrencyPolicy single_flight), hardened with panic-safe Drop guards (#1232) + refcount-per-key cleanup (#1235). The livekit code carried the exact bug class the substrate already solved. 2. **Lock-map entries leaked** — the prior code only released a per-key lock entry inside `remove_agent`. Transient agents that errored on `connect()` (network blip, LiveKit down) never reached `remove_agent`, so their lock-map entries lived forever. ConcurrencyPolicy's refcount drops in-flight slots automatically when the last awaiter completes, regardless of whether the work succeeded or panicked. ## Fix Replace `AGENT_CREATION_LOCKS` with a module-level OnceLock holding `Arc<TokioConcurrencyPolicy<(String, String), Arc<LiveKitAgent>, String>>`. `get_or_create_agent`: - Fast path unchanged: `agents.read()` lookup, return early if found. - Slow path: construct an async work closure that does the post-policy re-check + `LiveKitAgent::connect()` + agents map insert + video loop spawn, then call `policy.single_flight(key, work)`. - Concurrent callers for the same key all await the SAME Shared future the policy returns, so `connect()` runs ONCE and the result is broadcast to every caller. `remove_agent`: - No more lock-map cleanup — the policy self-evicts in-flight slots via refcount. `remove_agent` only owns the steady-state agents map now (drop the agent, disconnect from LiveKit room). ## Validation - `cargo build --features metal,accelerate` — clean - `cargo test live::transport --features metal,accelerate` → 16/16 pass - `cargo clippy --features metal,accelerate` — 161 warnings (was 162; the deleted `#[allow(clippy::type_complexity)]` block shed one). ## Net diff - ~50 lines removed (the static + lock-map cleanup in remove_agent) - ~50 lines added (the module-level policy + work-closure shape in get_or_create_agent) - Zero behavior change for the steady state; meaningful improvement for the transient-agent leak case. ## Architectural alignment This is the second migration onto ConcurrencyPolicy after the analyzer's adoption that already lives in canary. Same primitive, same guarantees. Joel directive 2026-05-14 'code concurrency ONCE then incorporate it' — livekit was the last per-key single-flight reimplementation in the codebase that I'd flagged in #1247. Co-authored-by: Test <test@test.com> * fix(config): make SQLite the default main database (#1271) * fix(config): make sqlite the default main database * chore: lower eslint baseline * chore: lower eslint baseline after canary merge * chore: sync generated cognition bindings --------- Co-authored-by: Test <test@test.com> * fix(inference,#1262): delete dead compute_router.rs (#1277) inference/compute_router.rs declared a CPU-vs-GPU dispatch policy that sequential_always_cpu=true on Apple Silicon and routed any matmul under 500K flops to CPU. The file had ZERO callers anywhere in the crate (only its own tests use ComputeRouter). Production hot path goes through LlamaCppAdapter -> LlamaCppBackend -> llama.cpp Metal/CUDA which already loud-fails on no-GPU per inference/model.rs:82 ("CPU fallback is disabled."). Carrying dead code that contradicts the no-CPU-fallback alpha contract on paper but never executes is the same anti-pattern this card was filed against. Delete to remove the misleading signal; if a future tier-aware router is needed, build it then. Audit findings + 3 sibling cards (#1273 verify+delete Candle qwen3.5, #1274 delete metal_deltanet.rs, #1275 regression test) posted in https://github.com/CambrianTech/continuum/issues/1262#issuecomment-4461757997. Verified: - cargo check --features metal: clean (0 errors, pre-existing warnings) - cargo test --lib --features metal: 2092 passed, 0 failed Lane: alpha flywheel #1272 lane 6. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(airc): add realtime envelope contract (#1278) Co-authored-by: Test <test@test.com> * fix(inference,#1273): delete dead Candle Qwen3.5 GGUF backend (#1279) Remove the Candle-side Qwen3.5 inference path (the hybrid DeltaNet + Attention recurrence loop in vendored/quantized_qwen35.rs and its ModelBackend wrapper in backends/qwen35_gguf.rs). 1100+ LOC removed. Why it was dead: - AIProviderModule::register_adapters (modules/ai_provider.rs:221) only registers LlamaCppAdapter for local inference. CandleAdapter is imported but never instantiated. - Qwen35GgufBackend was only reachable via backends::load_gguf_backend, whose only callers were unregistered (CandleAdapter, ContinuumModel, bin/* utilities) — none in the production hot path. - Production Qwen3.5 chat goes through llama.cpp (vendored, statically linked) via LlamaCppAdapter → LlamaCppBackend. Scope-down from initial #1273 plan: The original plan was to delete the entire Candle inference chain (CandleAdapter, ContinuumModel, quantized.rs, vendored qwen2/llama backends). cargo check confirmed broader scope is entangled with plasticity LoRA training tests, which use compact_llama_safetensors + rebuild_with_stacked_lora. That broader deletion needs a separate audit of plasticity's production reachability and is deferred to a follow-up card. This PR keeps everything plasticity touches (model.rs, candle_adapter.rs, quantized.rs, llama_safetensors.rs, compact_llama_safetensors.rs, vendored qwen2/llama) and only deletes the qwen3.5-specific Candle path that has no plasticity dependency. Wire change: - backends::load_gguf_backend now returns a typed error for "qwen3"|"qwen35" architectures pointing callers at LlamaCppAdapter, rather than silently dispatching to the deleted Candle backend. Verified: - cargo check --features metal: clean (0 errors, 61 pre-existing warnings) - cargo test --lib --features metal: 2096 passed, 0 failed (4 more than baseline — vendored qwen35 module registration removed some dead-code warnings that were eating test discovery) Lane: alpha flywheel #1272 lane 6. Audit context: https://github.com/CambrianTech/continuum/issues/1262#issuecomment-4461757997 Verification: https://github.com/CambrianTech/continuum/issues/1273#issuecomment-4461839438 Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(inference,#1274): delete dead vendored/metal_deltanet.rs (+ shader) (#1281) `vendored/metal_deltanet.rs` was a stub for a never-wired Metal kernel. Its sole "implementation" was `bail!("Metal DeltaNet kernel not yet wired — use CPU path")` plus a doc comment "Returns Err to signal the caller to fall back to CPU." Greps confirm zero callers anywhere (only `vendored/mod.rs:8` declared the module). Also delete the companion shader `vendored/deltanet_recurrence.metal` which had no remaining call site after removing the stub Rust function. Carrying a "fall back to CPU" pattern in code that nothing reaches is the same anti-pattern this card was filed against (#1262 audit). Verified: - cargo check --features metal: clean - cargo test --lib --features metal: 2096 passed, 0 failed Lane: alpha flywheel #1272 lane 6. Audit: https://github.com/CambrianTech/continuum/issues/1262#issuecomment-4461757997 Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(inference,#1275): regression test for no-CPU-fallback alpha contract (#1282) Add `tests/no_cpu_fallback_contract.rs` — three forbidden-strings ratchets that fail the build if a future PR weakens the no-CPU-fallback contract: 1. `select_best_device_panics_loudly_on_no_gpu` — asserts `inference/model.rs::select_best_device` keeps the `panic!("No GPU device available for inference. CPU fallback is disabled.")` loud-fail and tries CUDA + Metal before panicking. 2. `ort_providers_documents_no_cpu_fallback_contract` — asserts `ort_providers.rs` keeps the "CPU fallback is forbidden" comment that documents the rule from source. 3. `llamacpp_adapter_uses_loud_fail_for_no_local_model` — asserts `LlamaCppAdapter` uses the typed `NoLocalModelLoadable` error (shipped in #1093 / lane A PR-2) rather than a silent skip. Pattern: same forbidden-strings ratchet shape as lane F PR-2 (#1129 TS persona forbidden-strings), applied to the Rust inference layer. A test failure points the future-PR-author at the exact contract they're about to weaken. Closes the acceptance criterion #3 of #1262 ("regression test per fallback path"). Final PR (4 of 4) for the silent CPU fallback audit. Verified: - cargo test --features metal --test no_cpu_fallback_contract: 3 passed, 0 failed Lane: alpha flywheel #1272 lane 6. Audit: https://github.com/CambrianTech/continuum/issues/1262#issuecomment-4461757997 Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(airc): add realtime replay adapter (#1283) * feat(airc): add realtime replay adapter * chore: ratchet clippy baseline --------- Co-authored-by: Test <test@test.com> * fix(scripts,#1257): add cargo-test.sh wrapper that auto-applies platform GPU features (#1285) Make the obvious developer command work on every platform without requiring contributors to memorize the per-platform Cargo feature incantation. Before: cd workers/continuum-core && cargo test tick_db_handle --lib → fails in vendored llama crate; "metal" or "cuda" feature required After: ./scripts/cargo-test.sh tick_db_handle --lib (anywhere from src/) npm run test:rust -- tick_db_handle --lib → auto-detects platform, appends --features metal,accelerate (Mac) / --features cuda,load-dynamic-ort (Linux+Nvidia) / etc. Implementation: - `scripts/cargo-test.sh` sources the existing `scripts/shared/cargo-features.sh` detector (single source of truth for platform→features, also used by build-with-loud-failure.sh and git-prepush.sh) and forwards arbitrary args to `cargo test`. - `npm run test:rust` alias added next to `test:precommit` / `test:prepush` for discoverability. - `workers/continuum-core/TESTING.md` documents the friction, the wrapper, the CARGO_TEST_NO_FEATURES escape hatch (for verifying the loud-fail guard itself), and the relationship to the other test entry points. The wrapper does NOT weaken the no-CPU-fallback compile guard — it just spares the dev from typing the platform-correct features every time. The guard still fires in CARGO_TEST_NO_FEATURES=1 mode. Verified: - ./src/scripts/cargo-test.sh --test generated_barrel_sync → 8 passed, 0 failed (8.5s, used --features metal,accelerate on this Mac). Closes #1257. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(chat,#1158): DRY adapter base default renderMessageElement (#1189) * refactor(chat,#1158): lift renderMessageElement default into AbstractMessageAdapter Three adapters (TextMessageAdapter, URLCardAdapter, ToolOutputAdapter) had byte-identical override bodies of the form: parseContent, createAdapterWrapper, renderContent, template.innerHTML, wrapper.appendChild(fragment). That is now the default body of AbstractMessageAdapter.renderMessageElement. The overrides are deleted; the live message-content slot still never sees innerHTML (the parse happens on a detached template), and Lit-managed reactive children inside the message bubble keep their state. ImageMessageAdapter retains its custom override -- it builds img nodes via property assignment to keep src and alt out of any HTML-parse path and does not go through renderContent to string. Net minus 61 lines. Closes #1158. * chore(ratchet): lock in -2 eslint from #1158 adapter DRY lift * chore(eslint-baseline): ratchet -2 from #1189 adapter base default lift * chore(eslint-baseline): linux ratchet to 5459 (match macOS baseline) Linux CI ratchet failed because eslint-baseline.linux.txt was still at 5461 while the macOS baseline (and current count on both platforms) is 5459. The ratchet requires CURRENT == BASELINE strictly, so the -2 improvement from #1189 needed to land in BOTH platform files. Sibling: 8b51729f5 (chore(eslint-baseline): ratchet -2) updated eslint-baseline.txt; this commit completes the platform symmetry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(eslint-baseline): re-ratchet -2 on both platforms after canary merge After merging origin/canary into the branch, baselines (mac=5455, linux=5456) need to drop by the #1189 deletion delta (-2) to mac=5453, linux=5454. macOS verified locally by precommit: "Current: 5453 errors". Linux value is +1 vs Mac per established platform skew; CI will surface the exact number if it's off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(precommit,#1186): chat-roundtrip persona-reply smoke test (#1199) * feat(precommit,#1186): add chat-roundtrip persona-reply smoke test Closes Joel beef: browser ping is pretty low bar (2026-05-14). New test tests/precommit/chat-roundtrip.test.ts: 1. Verifies at least one auto-responding user is seeded (catches BUG-105 family) 2. Sends a unique probe via collaboration/chat/send into general 3. Polls data/list collection=chat_messages with orderBy timestamp desc, limit 50 4. Anchors on the probe by content match (sender-id and room captured) 5. Asserts at least one reply appears in the same room, after the probe, from a different sender, with non-empty content Wires into PRECOMMIT_TESTS so it runs alongside browser-ping. Window is 55s to leave headroom under the 60s per-test cap that git-precommit.sh imposes. Uses an explicit-question probe text because local personas filter no-reply-needed messages aggressively (saves Metal cycles). What this catches that browser-ping does not: - Cognition pipeline silently broken (the highest-value catch) - chat-send rejecting the probe (room missing, attribution broken) - Persona seed step regressed (no AI users to reply) - chat_messages write path broken Validated live: Helper AI replied to the probe in 5s on a clean stack. Repeated back-to-back runs can be slow due to Metal queue depth on local inference; CI runs against a fresh stack and isn't affected. Followups (sub-cards): - 1186 PR-2: path-tier dispatcher (run heavy tests only when relevant paths touched). Wires on top of codex #1193 precommit-config loader. - 1186 PR-3: adapter unit tests when widgets/chat/adapters/ touched - Test reliability: clean local-inference queue between tests OR target a dedicated cloud persona for deterministic reply latency Refs 1186. * fix(precommit,#1186,#1199): wire chat-roundtrip into precommit-config.sh source of truth Codex shipped #1193 adding scripts/precommit-config.sh as the canonical source for PRECOMMIT_TESTS. My #1186 PR-1 (chat-roundtrip test) edited the legacy defaults branch in git-precommit.sh, which only fires when the config file is missing. This commit updates precommit-config.sh to include chat-roundtrip alongside browser-ping. The defaults branch is left in sync as belt-and-suspenders so the gate works on either path. Refs #1186, follow-up to codex #1193. --------- Co-authored-by: Test <test@test.com> * fix(inference,#1280): delete dead Candle adapter chain (Phase 1, ~2500 LOC) (#1288) Per the plasticity reachability audit on #1280 (https://github.com/CambrianTech/continuum/issues/1280#issuecomment-4462181316), production routes local inference exclusively through `LlamaCppAdapter`. The Candle-side chain — `CandleAdapter`, `ContinuumModel`, `select_best_device`, `load_model_by_id`, `quantized.rs::load_*_quantized`, `backends::generate`, `backends::load_gguf_backend` — was reachable only through itself or orphaned `bin/*` files. Plasticity's IPC handlers (`plasticity/{analyze,compact,compress,topology,pipeline}`) work on safetensors files via plasticity's own helpers and don't touch this chain. Deleted: - `inference/candle_adapter.rs` (1486 LOC) - `inference/quantized.rs` (287 LOC) - `inference/model.rs` collapsed from 857 → 167 LOC, retaining only `rebuild_with_stacked_lora` (used by `backends/llama_safetensors.rs::CompactLlamaSafetensorsBackend`, test-only, slated for Phase 2 deletion alongside the safetensors backends once plasticity LoRA training is migrated or retired) Wire updates: - `ai/mod.rs`: drop `pub use crate::inference::CandleAdapter` re-export - `inference/mod.rs`: drop `candle_adapter`/`quantized` modules + their re-exports; keep `model::rebuild_with_stacked_lora` only - `modules/ai_provider.rs`: drop dead `CandleAdapter` import (it was imported but never instantiated by `register_adapters`) Contract relocation (the audit's flagged risk): The no-CPU-fallback `panic!("...CPU fallback is disabled")` in `select_best_device` was deleted along with the rest of the dead chain. The contract's actual production enforcement was already on llama.cpp: `LlamaCppConfig::default()` sets `n_gpu_layers: -1` (= "all layers on GPU"), and llama.cpp's loader hard-fails when no GPU is available. `tests/no_cpu_fallback_contract.rs` is updated atomically to assert the `n_gpu_layers: -1` invariant in `backends/llamacpp.rs` rather than the deleted panic site. The `ort_providers` and `LlamaCppAdapter` assertions survive unchanged. Net: 7 files changed, +92 / -2546 LOC. Verified: - cargo check --features metal: clean (52 pre-existing warnings, 0 errors) - cargo test --test no_cpu_fallback_contract: 3 passed (new contract assertion `llamacpp_default_config_requires_full_gpu_offload` green) - cargo test --lib --features metal: 2166 passed, 0 failed Phase 2 (deferred): delete safetensors backends + vendored qwen2/llama backends + `rebuild_with_stacked_lora` once plasticity's production reachability allows. Audit: https://github.com/CambrianTech/continuum/issues/1262#issuecomment-4461757997 Mission: Joel 2026-05-15 — "eliminate slop and slowly oxidize this project" Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(chat): add AIRC migration inventory gates (#1296) * fix(config): make sqlite the default main database * chore: lower eslint baseline * chore: lower eslint baseline after canary merge * chore: sync generated cognition bindings * docs(chat): add airc migration inventory gates --------- Co-authored-by: Test <test@test.com> * fix(chat,#1260): track room activity for temperature decay (#1302) * fix(chat,#1260): track room activity for temperature decay * chore(lint): ratchet eslint baseline * chore(lint): ratchet linux eslint baseline --------- Co-authored-by: Test <test@test.com> * fix(install,#1035): create continuum-core socket directory (#1304) Co-authored-by: Test <test@test.com> * fix(ci,#1035): clear production npm audit gate (#1305) Co-authored-by: Test <test@test.com> * refactor(cognition,#1295): generate_recipe PR-1 — pure-functions slice in Rust (#1298) First slice of RecipeGenerateServerCommand.ts (371 LOC) → Rust per the oxidization mission (#1248 umbrella). Same shape as #1289 (rate_proposals): pure-functions slice first, IPC handler in PR-2, TS shim collapse in PR-3. Per the carrier-types design block on #1295: the runtime registry state that the TS prompt depends on (TemplateRegistry.list output, existing recipe IDs from RecipeLoader.getInstance().getAllRecipes()) crosses the IPC boundary as explicit RecipeGenerationRequest fields. Keeps the prompt builder + validator pure, testable, and parity-checkable. What's in this PR (4 modules, 40 tests): - types.rs (5 ts-rs exports) - RecipeTemplateInfo, RecipeGenerateHints, RecipeGenerationRequest, RecipeGenerationResponse, RecipeDefinitionShape - All camelCase serde + ts-rs auto-export to shared/generated/cognition/ - 5 round-trip / shape-acceptance tests - prompt.rs (build_recipe_system_prompt + build_recipe_user_prompt) - System prompt mirrors TS buildSystemPrompt byte-for-byte (schema block, available-templates list, standard-pipeline pattern, rules) - User prompt mirrors TS buildUserPrompt (description + optional hints rendered as bulleted "Hints:" block) - 8 tests covering anchors, template rendering with 0/N entries, all hint types, partial hints, empty-hints skip-block - parser.rs (parse_recipe_from_ai_response → RecipeDefinitionShape) - Same regex anchor as TS: /\{[\s\S]*\}/ extracts JSON envelope - Tolerates prose preamble + markdown fences (matches TS behavior) - Typed ParseError::NoJsonEnvelope / MalformedJson with raw_preview capped at 500 chars (mirrors TS slice(0, 500)) - 7 tests covering happy-path + prose preamble + fence + no-JSON + malformed + unknown-fields-tolerated + missing-optionals + cap - validator.rs (validate_recipe_structure → Vec<String>) - Mirrors TS validateRecipe checks: required fields, kebab-case uniqueId, pipeline shape, RAG template messageHistory, strategy enum + required arrays, role type + requires - In-request duplicate check via existing_recipe_ids carrier - Filesystem collision check + sentinel-template existence stay TS-side (PR-3 shim) — they're pure FS / runtime-registry concerns - 12 tests covering happy path, every required-field gap, kebab-case rejection, empty pipeline, malformed steps, invalid enums, missing strategy arrays, role schema, in-request duplicate ## Why no fallback Per #1262, the TS path's silent error-on-malformed-JSON returns { success: false, error: '...' }. Rust returns typed Err — PR-2 IPC handler maps it to validationErrors[] for the JTAG envelope. ## Next - PR-2: cognition/generate-recipe IPC command wiring AIProviderRegistry::generate_text + the prompt+parser+validator - PR-3: RecipeGenerateServerCommand.ts becomes thin shim that gathers templates + existing recipe IDs, calls Rust, FS collision-checks + saves on success Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cognition,#1289): rate_proposals PR-1 — pure-functions slice in Rust (#1290) First slice of ProposalRatingAdapter.ts (252 LOC TS) → Rust per the oxidization mission (#1248 umbrella). Pure-functions-first: types + prompt builder + parser shipped without IPC wiring or AI integration, so behavior parity is testable before the IPC layer lands in PR-2. What's in this PR: - cognition/rate_proposals/types.rs: RatingMessage, ResponseProposal, RatingContext, ProposalRating with serde camelCase + ts-rs auto-export to shared/generated/cognition/ - cognition/rate_proposals/prompt.rs: build_rating_prompt mirrors TS buildRatingPrompt byte-for-byte (header, conversation history, proposals with index/proposer/confidence, rating criteria, output format anchors, behavior nudges) - cognition/rate_proposals/parser.rs: parse_ratings_from_ai_response with ParseConfig defaults; regex anchors mirror TS exactly (same case-insensitive splits, same [0-9.]+ score capture that drops leading minus, same Reasoning: blank-line termination) 25/25 tests pass. ts-rs exports the four wire types so the TS shim in PR-3 can import generated definitions instead of hand-writing duplicates. Next: - PR-2: cognition/rate-proposals IPC handler wiring AIProviderRegistry::select + adapter.generate_text to the prompt+parser shipped here - PR-3: ProposalRatingAdapter.ts collapses to thin Commands.execute('cognition/rate-proposals', ...) shim Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(resources,#1239): Phase 1 — system/docker-tier-stats IPC + ts-rs DockerTierStats (#1297) Per Joel's "keep finding work" / mission to surface substrate pressure to operators. The audit on #1239 (https://github.com/CambrianTech/continuum/issues/1239#issuecomment-4464969871) found the gap is bigger than the card text suggests: PressureBroker is built but never instantiated, DockerTierPool never registered, continuum status is bash-side. Phasing the work so Phase 1 surfaces the data without the missing broker singleton. Phase 1 (this PR): - New `system/docker-tier-stats` IPC handler in `SystemResourceModule` calling `DockerTierPool::snapshot_stats()` (new convenience method, one probe per call) — returns typed `DockerTierStats` (capacityBytes, usedBytes, pressure, detected). - ts-rs export at `shared/generated/resources/DockerTierStats.ts`. - IPC mixin entry `dockerTierStats()` on the RustCoreIPC client. - TS server command at `commands/system/docker-tier-stats/` (generated via standard CommandGenerator + spec, then refactored to a thin rustClient.dockerTierStats() pass-through matching the SystemResourcesServerCommand pattern). - Unit test asserts the IPC always returns the expected shape regardless of whether Docker is installed (CI passes without). - Clippy baseline ratcheted -11 (157 → 146) — incidental cleanup. Phase 2 (separate card): bootstrap PressureBroker singleton at server startup, register DockerTierPool + future tiers, run the relief tick, add chat-substrate alert sink so >90% surfaces as a chat message. Phase 3 (separate card): typed `ResourceError::DiskCapacity` refusal at production hot paths (model pull, container start, image build, gguf download). Verified: - cargo test --lib --features metal docker_tier: 15 passed - npx tsc --noEmit -p tsconfig.json: clean - ESLint baseline holds at 5452 Mission: Joel 2026-05-15 — "eliminate slop and slowly oxidize" Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cognition,#1289): rate_proposals PR-2 — IPC handler + orchestrator (#1291) Wires the prompt+parser shipped in PR-1 (#1290) to AIProviderRegistry:: generate_text via the cognition/rate-proposals IPC command. Stacked on PR-1 (rebase to canary once PR-1 merges). Same architecture as cognition/should-respond shipped today by codex on #1284 (oxidizer pattern: native-truth Rust core, thin TS shim collapses in PR-3). Shares the AIProviderRegistry singleton with shared_analysis, so concurrent rater calls go through the same registry read-lock — no new contention surface. What's in this PR: - cognition/rate_proposals/orchestrator.rs — rate_proposals_with_ai() - Builds TextGenerationRequest with system+user messages - Calls global_registry().read().await + generate_text() - Parses response with parse_ratings_from_ai_response (PR-1 module) - Returns Vec<ProposalRating> - RateProposalsRequest / RateProposalsResponse — ts-rs camelCase exports to shared/generated/cognition/ for the future TS shim binding - modules/cognition.rs — new "cognition/rate-proposals" command branch delegating to the orchestrator - 6 new tests (4 orchestrator + 2 ts-rs export bindings) ## Why no fallback The TS createFallbackRatings helper that returns neutral 0.5 scores on AI failure is NOT ported. It masks real provider outages and was caught as a silent-success vector in the no-CPU-fallback audit (#1262). On inference failure this returns Err — the chat substrate already handles "no rater responded" by skipping peer-review for that round (no degraded scoring path). ## Test plan - cargo test cognition::rate_proposals — 31/31 pass (was 25 in PR-1, +6 new orchestrator tests + ts-rs exports) - cargo check --lib --features metal,accelerate — clean - ts-rs emits shared/generated/cognition/RateProposalsRequest.ts and RateProposalsResponse.ts on cargo test (verified) ## Next: PR-3 ProposalRatingAdapter.ts (252 LOC) collapses to a thin Commands.execute('cognition/rate-proposals', RateProposalsRequest) shim binding against the generated TS types. ESLint baseline drops by the deletion line count. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cognition,#1295): generate_recipe PR-2 — IPC handler + orchestrator (#1301) Wires the prompt+parser+validator shipped in PR-1 (#1298) to AIProviderRegistry::generate_text via the cognition/generate-recipe IPC command. Stacked on PR-1 (rebase to canary once PR-1 merges). Same shape as #1289 PR-2 (rate_proposals IPC). Shares the AIProviderRegistry singleton with shared_analysis + rate_proposals, so concurrent generator calls go through the same registry read-lock — no new contention surface. What's in this PR: - cognition/generate_recipe/orchestrator.rs — generate_recipe_with_ai() - Builds system + user prompts via PR-1 - Calls global_registry().read().await + generate_text() with Anthropic default + 0.4 temperature + 4000 max_tokens (matches TS RecipeGenerateServerCommand defaults exactly) - default_model_for_provider() mirrors TS switch lines 360-369 - Parses with PR-1 parser; on parse failure returns Err with the typed ParseError as string - Applies unique_id_override AFTER parse, BEFORE validation (matches TS sequence at lines 80-82 / 85) - Runs PR-1 validator with carrier existing_recipe_ids - Returns { recipe, validationErrors } - modules/cognition.rs — new "cognition/generate-recipe" command branch parsing { request, provider?, model?, temperature? } and delegating to the orchestrator - 4 new orchestrator tests covering default-model parity, pinned generation constants, unique_id_override semantics 44/44 cognition::generate_recipe tests pass (was 40 in PR-1, +4 new). ## Why no fallback Per #1262, the TS path returned { success: false, error: '...' } on AI failure, masking provider outages. This Rust path returns typed Err on inference failure — the JTAG shim in PR-3 maps it to a validationErrors[] entry, preserving the failure mode for debugging. ## Validation errors NOT propagated as Err Validation failures are returned in the response (not Err) so the shim can render them via the JTAG envelope. Mirrors TS behavior exactly: validationErrors go alongside the recipe; success: false reflects the validation gate, not a parse failure. ## Next: PR-3 RecipeGenerateServerCommand.ts (371 LOC) becomes thin shim that: - Gathers TemplateRegistry.list() + RecipeLoader.getInstance() .getAllRecipes().map(r => r.uniqueId) into RecipeGenerationRequest - Calls Commands.execute('cognition/generate-recipe', { request, ... }) - On success path: FS collision check + sentinel-template existence check + saveRecipe + RecipeLoader.clearCache + reload Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cognition,#1295): generate_recipe PR-3 — collapse TS to thin shim (-220 LOC, -3 ESLint) (#1303) * refactor(cognition,#1295): generate_recipe PR-3 — collapse TS to thin shim RecipeGenerateServerCommand.ts goes from 371 LOC (owning prompt build, AI dispatch, JSON parse, structural validation, FS I/O) to ~140 LOC (JTAG framework + carrier-state gathering + post-Rust FS I/O only). Per the oxidization mission (#1248 umbrella): everything that was duplicating the Rust truth-layer is gone. Stacked on PR-2 (#1301). What this PR does: - Replace RecipeGenerateServerCommand.execute() body with: 1. Validate JTAG `description` parameter 2. Gather TemplateRegistry.list() + RecipeLoader.getInstance() .getAllRecipes() into the carrier RecipeGenerationRequest 3. Commands.execute('cognition/generate-recipe', { request, ... }) 4. On post-Rust success: TS-side sentinel-template existence check (TemplateRegistry.has — runtime-registry state Rust can't see), saveRecipe to disk, RecipeLoader.clearCache + reload 5. Map response → existing RecipeGenerateResult JTAG envelope - Delete buildSystemPrompt() + buildUserPrompt() + parser + validator + defaultModelForProvider() (all moved to Rust in PR-1+PR-2). - Regenerate shared/generated/cognition/index.ts barrel to export the 5 new ts-rs types (RecipeTemplateInfo, RecipeGenerateHints, RecipeGenerationRequest, RecipeGenerationResponse, RecipeDefinitionShape). ## Wire format The IPC accepts a loose envelope { request, provider?, model?, temperature? }. RecipeGenerationRequest carries availableTemplates (from TemplateRegistry) + existingRecipeIds (from RecipeLoader) so the Rust prompt builder + validator stay pure (no global state). ## What stays TS-side intentionally - File I/O — JTAG framework concern, not cognition - Sentinel-template existence check — runtime-registry state the Rust validator can't see; runs AFTER Rust validation so the error list is comprehensive - RecipeLoader cache reload — persistence concern ## Test plan - npm run build:ts: clean (post-shim collapse) - 44/44 cognition::generate_recipe tests still pass (PR-1 + PR-2) - Behavior parity preserved: same JTAG envelope shape, same default provider/model/temperature/maxTokens, same validation error format Stacked on #1301 (PR-2). Will rebase to canary as PR-1 + PR-2 merge. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(#1303): lock linux eslint baseline win --------- Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(cognition,#1289): rate_proposals PR-3 — delete dead TS adapter (#1293) ProposalRatingAdapter.ts (252 LOC) and its unit test (501 LOC) had ZERO production callers — only the unit test imported the exported functions. PeerReviewManager.ts (the actual peer-review pipeline) does NOT import this adapter. So this is a clean DELETION, not a shim collapse. Per the oxidization mission (Joel 2026-05-15): "(1) eliminate slop — no half-finished work, no dead code, no parallel reimplementations." A thin TS shim that nobody calls IS slop — Rust IPC handler shipped in PR-2 (#1291) is the live truth; the cognition/rate-proposals command is available to any future TS caller via Commands.execute with full ts-rs typed bindings (RateProposalsRequest / RateProposalsResponse from #1290). Originally PR-1/PR-2 commit messages said PR-3 would collapse the TS adapter to a thin Commands.execute() shim. Investigation while drafting this PR found zero production callers — `grep -rn "ProposalRatingAdapter\\ |rateProposalsWithAI\\|createFallbackRatings"` returns: - ProposalRatingAdapter.ts (the file itself) - ProposalRatingAdapter.test.ts (unit test, mocking AIProviderDaemon) - nothing in PeerReviewManager.ts or any other production module - nothing in chat substrate, persona response generator, or recipe path A future TS caller wanting AI-driven proposal rating uses: Commands.execute<RateProposalsResponse>('cognition/rate-proposals', req) with `req: RateProposalsRequest` from shared/generated/cognition/. No intermediate shim layer adds value — it would just re-export the already-typed primitive. - Delete src/system/user/server/modules/cognition/ProposalRatingAdapter.ts - Delete src/tests/unit/ProposalRatingAdapter.test.ts (the parsing + prompt-building behavior is now covered by 31 tests in Rust under workers/continuum-core/src/cognition/rate_proposals/) - npm run build:ts — succeeded clean (no dangling imports) - The 31 Rust tests under cognition::rate_proposals stay green (PR-1+PR-2) - ESLint baseline drops by 753 LOC of dead TS Stacked on #1291 (PR-2). Will rebase to canary when PR-1+PR-2 merge. Co-authored-by: Test <test@test.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * oxidizer: move AI gating decision to Rust (#1294) * oxidize ai gating decision * chore(#1294): lock gating eslint baseline win --------- Co-authored-by: Test <test@test.com> * feat(cognition,#1276): migrate VisionInferenceProvider to Rust cognition/vision-describe (#1292) * feat(cognition,#1276): migrate VisionInferenceProvider to Rust cognition/vision-describe Per Joel 2026-05-15 ("mission to eliminate slop and slowly oxidize this project") and the #1248 oxidizer umbrella, move TS-side vision inference orchestration to Rust. TS becomes a thin shim. Outlier-validation pair with codex's #1284 (AIDecisionService.evaluateGating → cognition/should-respond, structured-decision shape); this card is the freeform-shape outlier. Same Rust+thin-TS-shim pattern as recall-engrams (#1265). ## Rust side (new) `workers/continuum-core/src/cognition/vision_describe.rs` — 337 LOC. Owns: 1. Vision-capable model selection (filter `model_registry` by `Capability::Vision`, prefer local providers). Single source of truth — no more `process.env.*_API_KEY` checks scattered in TS. 2. Prompt construction from option flags (detectObjects/Colors/Text, maxLength). Pure function; unit-tested. 3. Multimodal request assembly (text + base64 image content parts). 4. Inference dispatch via `runtime::execute_command_json("ai/generate", ...)` so the existing Rust adapters (Anthropic / OpenAI / LlamaCpp) shape the multimodal payload per their own native API contracts. 5. Response parsing into `VisionDescription`. Pure function; unit-tested. ts-rs auto-emits `VisionDescribeRequest`, `VisionDescribeOptions`, `VisionDescription` to `shared/generated/cognition/`. ## IPC wiring `modules/cognition.rs` — adds the `cognition/vision-describe` handler that parses params into `VisionDescribeRequest…
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the action-surface gap on Joel's "memory must be FULLY managed" directive. After PR-3 the broker can ACT on Docker pressure; PR-4 makes it TELL operators what it did.
Two pieces
1. ResourcePoolAdapter (paging/adapter.rs, 9 tests) — bridges Arc → impl PressureSource. Required because ResourcePool (sibling's #1228) and PressureSource (Phase 7 broker) are parallel traits covering the same conceptual ground; DockerTierPool only implements ResourcePool, so it couldn't register with the broker. Adapter derives pressure (usage/cap; 0 when cap==0 = unmanaged), evict_some (forwards with want=max(overshoot, 10% floor) so 100% pressure pools get a non-zero request), stats_snapshot.
2. PressureAlert + sink wiring (paging/broker.rs) — new typed PressureAlert with ts-rs camelCase export, AlertSink callback, add_alert_sink() registration, emit_alert() called from relieve() once per pool the broker tried. Closes the TODO at line 311 ("Future: emit IPC event or log when triggered=true"). Alert fires with action_taken=true even on 0 bytes freed (signal: this tier is hot AND stuck — operator needs that distinct from no alert).
#1222 status
Tests
24 total, all green: 9 adapter + 6 new broker tests + 9 existing broker tests. Clippy at baseline 162 (no drift). No unsafe, no async, no lock nesting. parking_lot::RwLock for the new alert_sinks slot.
Follow-up
Filing a separate issue tracking the ResourcePool/PressureSource trait-unification cleanup per Joel "code concurrency ONCE then incorporate it" 2026-05-14 — the adapter is the safe NOW move; collapsing the two traits is the right LATER move.
Refs #1222.