feat(playbook): refresh incremental cluster centroids - #410
Conversation
Bound hourly aggregation to new cluster deltas, use the generated agent playbook embedding as the next centroid, and rebuild invalidated clusters from the newest retained sources. Add durable SQLite state transitions and retry behavior for scheduled incremental runs.
📝 WalkthroughWalkthroughThe playbook aggregation pipeline now drains invalidations in fixed batches, performs bounded incremental refreshes and rebuilds, stores canonical agent centroids, and tracks retry and intake state. Tests and documentation cover the updated scheduling, persistence, prompt, and lifecycle behavior. ChangesPlaybook aggregation lifecycle
Estimated code review effort: 5 (Critical) | ~120 minutes Sequence Diagram(s)sequenceDiagram
participant AggregationScheduler
participant SQLiteStorage
participant PlaybookAggregator
participant LLM
AggregationScheduler->>SQLiteStorage: Fetch up to 101 invalidations
AggregationScheduler->>SQLiteStorage: Apply up to 100 invalidations
AggregationScheduler->>PlaybookAggregator: Run with residual_batch_limit
PlaybookAggregator->>SQLiteStorage: Load bounded aggregation work
PlaybookAggregator->>LLM: Generate refresh and rebuild outcomes
PlaybookAggregator->>SQLiteStorage: Persist agents, centroids, attachments, and residuals
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (5)
reflexio/server/services/storage/sqlite_storage/playbook/_aggregation.py (2)
144-161: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAdd an index on
playbook_aggregation_cluster(agent_playbook_id).Each new trigger runs four statements that filter
playbook_aggregation_clusterbyagent_playbook_id. The declared indexes on that table covercluster_id(primary key),index_rowid(unique),(agent_version, state, cluster_id), and(agent_version, rebuild_next_attempt_at, cluster_id). None of them serveagent_playbook_id, so every statement performs a full table scan.The delete trigger has no
WHENguard, so it fires per row.delete_all_agent_playbooksanddelete_agent_playbooks_by_idsdelete in one statement but pay the scan cost per deleted id, which makes the cost O(deleted agent playbooks × clusters). The update trigger adds the same cost to routine content, trigger, rationale, and embedding edits.⚡ Proposed index addition in AGGREGATION_DDL
CREATE INDEX IF NOT EXISTS idx_playbook_aggregation_cluster_version ON playbook_aggregation_cluster(agent_version, state, cluster_id); +CREATE INDEX IF NOT EXISTS idx_playbook_aggregation_cluster_agent + ON playbook_aggregation_cluster(agent_playbook_id) + WHERE agent_playbook_id IS NOT NULL;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@reflexio/server/services/storage/sqlite_storage/playbook/_aggregation.py` around lines 144 - 161, Add an index on playbook_aggregation_cluster(agent_playbook_id) in the AGGREGATION_DDL schema definition. Ensure the index is declared alongside the table’s existing indexes so all trigger statements filtering by agent_playbook_id, including the delete and update trigger paths, can use it.
656-681: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winDrop the now-dead
vector_sumaccumulation in legacy adoption.The centroid now comes from
centroid_embedding, and completion writesvector_sum=NULL. Nothing else readsvector_sumfor this cluster: the only reader is this same method resuming its own partial sum on the next page. The per-member vector add and the per-page JSON encode of a full-dimension list therefore produce a value that is always discarded.Keep
member_count, which is still persisted and used. Remove the sum to cut allocation and JSON work from the bounded adoption loop.♻️ Proposed simplification
- if inserted.rowcount == 1: - vector_sum = [ - left + right - for left, right in zip(vector_sum, embedding, strict=True) - ] - member_count += 1 + if inserted.rowcount == 1: + member_count += 1 centroid = centroid_embedding if complete and member_count else None self.conn.execute( - "UPDATE playbook_aggregation_cluster SET vector_sum=?, centroid=?, " + "UPDATE playbook_aggregation_cluster SET vector_sum=NULL, centroid=?, " "member_count=?, rebuild_cursor=?, state=? WHERE cluster_id=?", ( - None if complete else json.dumps(vector_sum), json.dumps(centroid) if centroid is not None else None,The
member_embeddingsdimension validation at Line 609 stays useful, because it still rejects a provenance change. Thevector_sumread at Line 650 and its initialization can be removed with the same change.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@reflexio/server/services/storage/sqlite_storage/playbook/_aggregation.py` around lines 656 - 681, Remove the obsolete vector_sum read, initialization, per-member accumulation, and per-page JSON persistence from the legacy adoption method surrounding member_embeddings. Preserve the member_count increment and persistence, retain the existing dimension validation, and continue writing vector_sum as NULL for the cluster update.reflexio/server/services/storage/storage_base/playbook/_aggregation.py (1)
269-302: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDocument the fenced failure contract for rebuild transitions.
The SQLite implementations of
defer_playbook_aggregation_cluster_rebuild,complete_playbook_aggregation_cluster_rebuild, anddiscard_playbook_aggregation_cluster_rebuildraiseRuntimeErrorwhen the expected agent no longer owns arebuildingcluster, andcomplete_...also raisesValueErroron an embedding-dimension change andRuntimeErrorwhen no residual members remain. The aggregator relies on those exceptions to abort a run. Other methods in this package document such failure modes in aRaisessection. Add the same for these three methods so an alternative backend implements the identical fencing behavior.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@reflexio/server/services/storage/storage_base/playbook/_aggregation.py` around lines 269 - 302, Update the docstrings for defer_playbook_aggregation_cluster_rebuild, complete_playbook_aggregation_cluster_rebuild, and discard_playbook_aggregation_cluster_rebuild to add Raises sections documenting RuntimeError when the expected agent no longer owns a rebuilding cluster; additionally document ValueError for embedding-dimension changes and RuntimeError when no residual members remain in complete_playbook_aggregation_cluster_rebuild. Preserve the existing method behavior and signatures.tests/server/services/playbook/test_playbook_aggregator.py (1)
1898-1918: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider covering the non-refresh prompt bound too.
This test passes
current_agent_playbooks, so it exercises only the incremental-refresh branch of_select_generation_prompt_sources.reflexio/server/services/playbook/README.mdlines 126-128 also state that new-cluster generation uses at most 100 sources while retaining the complete discovered membership. That branch stays uncovered here.Parametrize on the presence of
current_agent_playbooksto pin both bounds.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/server/services/playbook/test_playbook_aggregator.py` around lines 1898 - 1918, Extend test_generation_prompt_is_bounded_without_truncating_membership to run with and without current_agent_playbooks, covering both incremental-refresh and new-cluster paths in _select_generation_prompt_sources. Assert each invocation captures at most 100 sources while preserving the expected selected IDs and the complete cluster in outcomes[0].source_cluster.tests/server/services/storage/test_playbook_aggregation_state_integration.py (1)
917-1072: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffConsider splitting this end-to-end test into staged tests.
This test now asserts five sequential lifecycle stages in one function: first generation, delta refresh, revision rebuild, single-member rebuild, and empty-cluster retirement. Each stage depends on the state left by the previous stage. A failure in an early stage hides every later assertion, and the failure message does not identify the stage.
The assertions themselves are correct. Extract the later stages into separate tests that build their own starting state, or add stage markers so a failure names the stage.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/server/services/storage/test_playbook_aggregation_state_integration.py` around lines 917 - 1072, Split test_incremental_run_refreshes_agent_and_centroid_after_match into staged tests covering initial generation, delta refresh, revision rebuild, single-member rebuild, and empty-cluster retirement; have each test establish its own required state so failures identify the affected lifecycle stage without depending on earlier assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@reflexio/server/services/playbook/components/aggregator.py`:
- Around line 1010-1017: Update the legacy centroid resolution in the
aggregation method to recover when legacy_agents[0].embedding is missing: reuse
the already-resolved embed_fn to re-embed the legacy agent’s content and use
that result as centroid_embedding. If re-embedding fails, log a warning
containing agent_playbook_id, skip only the affected fingerprint, and allow
bootstrap to continue processing remaining fingerprints without returning the
permanent “legacy cluster adoption pending” state.
- Around line 823-873: The aggregation run currently aborts its shared
transaction when one refresh or rebuild loses its cluster fence, discarding
unrelated generated results. Update the orchestration around the refresh loop
and _apply_rebuild_outcomes to isolate each cluster mutation with its own
savepoint or equivalent error boundary; on attach, replacement, or rebuild fence
loss, mark only that cluster’s members residual with reason
cluster_agent_unavailable, skip that cluster, and allow other refreshes,
rebuilds, and new clusters to commit.
In `@reflexio/server/services/storage/sqlite_storage/_base.py`:
- Around line 931-956: Update the migration query in the surrounding migrate()
logic to require c.index_rowid IS NOT NULL, and skip embeddings whose length
differs from self.embedding_dimensions before any database writes. Preserve
valid-row migration while preventing int(row[1]) and vector-table insertion
failures from aborting initialization.
In `@reflexio/server/services/storage/sqlite_storage/playbook/_aggregation.py`:
- Around line 1206-1301: Update defer_playbook_aggregation_cluster_rebuild and
complete_playbook_aggregation_cluster_rebuild to roll back the connection when
an exception occurs and _own_transaction() is true, covering all mutations and
fence-check failures. Preserve existing commit behavior on success, and mirror
the explicit rollback pattern used by attach_playbook_aggregation_items and
replace_playbook_aggregation_cluster_agent.
- Around line 124-190: Add guarded cleanup for playbook_aggregation_clusters_vec
in the retirement paths of retire_playbook_aggregation_cluster_on_agent_update
and retire_playbook_aggregation_cluster_on_agent_delete. Remove vector rows
whose rowids no longer have a matching playbook_aggregation_cluster, either
before cluster deletion or through the existing periodic/application maintenance
path, while preserving the current residual-item and aggregation-state updates.
---
Nitpick comments:
In `@reflexio/server/services/storage/sqlite_storage/playbook/_aggregation.py`:
- Around line 144-161: Add an index on
playbook_aggregation_cluster(agent_playbook_id) in the AGGREGATION_DDL schema
definition. Ensure the index is declared alongside the table’s existing indexes
so all trigger statements filtering by agent_playbook_id, including the delete
and update trigger paths, can use it.
- Around line 656-681: Remove the obsolete vector_sum read, initialization,
per-member accumulation, and per-page JSON persistence from the legacy adoption
method surrounding member_embeddings. Preserve the member_count increment and
persistence, retain the existing dimension validation, and continue writing
vector_sum as NULL for the cluster update.
In `@reflexio/server/services/storage/storage_base/playbook/_aggregation.py`:
- Around line 269-302: Update the docstrings for
defer_playbook_aggregation_cluster_rebuild,
complete_playbook_aggregation_cluster_rebuild, and
discard_playbook_aggregation_cluster_rebuild to add Raises sections documenting
RuntimeError when the expected agent no longer owns a rebuilding cluster;
additionally document ValueError for embedding-dimension changes and
RuntimeError when no residual members remain in
complete_playbook_aggregation_cluster_rebuild. Preserve the existing method
behavior and signatures.
In `@tests/server/services/playbook/test_playbook_aggregator.py`:
- Around line 1898-1918: Extend
test_generation_prompt_is_bounded_without_truncating_membership to run with and
without current_agent_playbooks, covering both incremental-refresh and
new-cluster paths in _select_generation_prompt_sources. Assert each invocation
captures at most 100 sources while preserving the expected selected IDs and the
complete cluster in outcomes[0].source_cluster.
In
`@tests/server/services/storage/test_playbook_aggregation_state_integration.py`:
- Around line 917-1072: Split
test_incremental_run_refreshes_agent_and_centroid_after_match into staged tests
covering initial generation, delta refresh, revision rebuild, single-member
rebuild, and empty-cluster retirement; have each test establish its own required
state so failures identify the affected lifecycle stage without depending on
earlier assertions.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 8a7ed081-6b28-4214-b7e2-e6dd109c39b7
📒 Files selected for processing (15)
reflexio/server/README.mdreflexio/server/prompt/prompt_bank/playbook_aggregation/v2.4.0.prompt.mdreflexio/server/services/playbook/README.mdreflexio/server/services/playbook/aggregation_scheduler.pyreflexio/server/services/playbook/components/aggregator.pyreflexio/server/services/storage/sqlite_storage/_base.pyreflexio/server/services/storage/sqlite_storage/_lineage.pyreflexio/server/services/storage/sqlite_storage/playbook/_agent.pyreflexio/server/services/storage/sqlite_storage/playbook/_aggregation.pyreflexio/server/services/storage/storage_base/playbook/__init__.pyreflexio/server/services/storage/storage_base/playbook/_agent.pyreflexio/server/services/storage/storage_base/playbook/_aggregation.pytests/server/services/playbook/test_aggregation_scheduler.pytests/server/services/playbook/test_playbook_aggregator.pytests/server/services/storage/test_playbook_aggregation_state_integration.py
Keep each cluster mutation atomic so a stale agent fence cannot roll back healthy peers. Recover missing legacy centroids, harden SQLite migration and repair paths, and cover the review regressions.
|
CodeRabbit review follow-up (commit 1d4dc49):\n\nImplemented:\n- Added a partial cluster-to-agent index for the current-agent lookup.\n- Removed obsolete legacy vector_sum accumulation; the canonical agent embedding is now the centroid.\n- Documented transaction/fence RuntimeError behavior on rebuild disposition methods.\n- Parameterized the prompt-bound test to cover both refresh and new-cluster generation paths.\n\nDeclined:\n- I kept the sequential lifecycle E2E test intact. Its purpose is to prove that one persistent cluster evolves correctly through attach, refresh, invalidation, and rebuild transitions. Splitting it would remove that cross-transition invariant and duplicate substantial setup; focused unit/regression tests cover the individual branches separately.\n\nAll five inline findings were also fixed and replied to on their exact threads. |
## Summary - Revert #407 and restore session outcomes, governance erasure, billing, and search behavior to the pre-open-world-evidence contracts. - Also revert #408 and #409 because their finalization-receipt and exposure-retention changes depend entirely on APIs introduced by #407. - Preserve the independent incremental aggregation work from #405 and #410. - Address every valid CodeRabbit finding, including SQLite downgrade compatibility and retry-safe metering. - Fix callback drop-rate anomaly emission on hosts with less than one hour of monotonic uptime, discovered by the full validation run. ## Changes ### Evidence foundation rollback - Remove search-exposure recording and session-outcome identity helpers. - Restore the prior session outcome schemas, client surface, and SQLite/storage contracts. - Restore the prior governance erase/claim flow and retention behavior. - Restore the prior resumable extraction and learning-billing behavior. ### Dependent follow-ups - Remove receipt-winner finalization behavior from #408. - Remove the exposure ownership and protected-retention behavior from #409. ### Review follow-ups - Rebuild #407-era SQLite `session_outcomes` tables into the restored schema, preserving `success`/`failure` rows, backfilling governance subject references, and explicitly dropping unrepresentable `unknown` outcomes with a warning. - Restore the SQLite 3.35 minimum required by existing `RETURNING` and `DROP COLUMN` usage. - Make outcome erasure resilient to governance-secret rotation and return a stable `session_outcomes` deletion count. - Acquire SQLite governance write locks before state checks, serialize idempotent purge begin/prepare flows across connections, and roll back failed target writes so SQLite cannot retain a stale writer transaction.\n- Reject legacy session-outcome schemas with empty governance-subject defaults and rebuild them with derived subject references.\n- Make synchronous playbook/profile persistence atomic while keeping scheduler dispatch strictly post-commit. - Meter resumable extraction from persisted survivors only, use retry-stable fallback keys, and emit learning billing from incremental aggregation. - Treat post-persist optimization and aggregation scheduling failures as best-effort side effects. ### Validation follow-up - Represent the callback executor's last anomaly time with an explicit unset sentinel so the first threshold crossing is never suppressed by low system uptime. ## Test Plan - `uv run ruff check reflexio tests` - `uv run ruff format --check reflexio tests` - Pyright on all 23 staged Python files: 0 errors, 0 warnings - Latest affected review files: 301 passed - OSS non-E2E suite: 5,535 passed, 73 skipped, 6 subtests passed - OSS E2E suite: 47 passed, 51 skipped - `npm --prefix docs run lint`: 0 errors (3 existing warnings) - `cd docs && npx tsc --noEmit` - `python -c "import reflexio"` Reverts `85a4b2255a96ef2a5b50f4cbe7c10758439e76b3`, plus dependent follow-ups `785a9e053ff771f40704bb7b0b5bbbe36048806a` and `eb88f44fd3b53457b76e8500ac1a30ba7d4ab16e`. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **Changes** - Session outcomes now support only success or failure, with simpler responses and retry behavior. - Governance data erasure workflows have streamlined retry and completion handling, including session-outcome removal. - Search exposure event recording has been removed; search results and metering remain available. - Learning-generation billing supports durable per-record tracking, retry-stable keys, and count-based fallback. - Scheduler failures during playbook processing are logged without preventing other scheduled actions. - **Documentation** - Quick Start prerequisites now list Node.js without the previous SQLite verification step. - Billing and extraction guidance has been updated. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
…centroid (#436) ## Problem 4 e2e tests fail on `main`: ``` RuntimeError: rerun agent playbook has no centroid embedding aggregator.py:1773 ``` - `test_playbook_workflows.py` — 3 tests - `test_openclaw_integration.py` — 1 test They pass at `b0e0755` and fail from `c24e1e7` onward, so this is a regression, not an environment quirk. ## Root cause Two changes that were each fine alone. **`159d8ab` (#410)** added the invariant: on the rerun path, a saved agent playbook must carry an embedding to persist as a cluster centroid. Safe at the time — local embeddings were computed **in-process**, so `saved_fb.embedding` was always populated and the raise was unreachable outside a genuine bug. **`c24e1e7` (#425)** removed in-process local inference ("service-only inference boundary"). `embedding_provider_mode` now returns `local_service` for any local model with no env vars set, so embedding goes over HTTP to `127.0.0.1:8072`. With the embedder unreachable, `SQLiteStorage` deliberately degrades: ``` Embedding unavailable for document text; continuing without vector ``` …and saves the playbook with `embedding=None`. The invariant then fires and rolls back the entire aggregation. An assertion written to catch a *programming error* now trips on an *infrastructure state*. This is not narrow: `reflexio/lib/_generation.py:73` sets `rerun=True` for every `run_playbook_aggregation()` call, so the branch is the normal path, not an edge case. ## Why it wasn't caught - This repo runs no CI workflows. - Enterprise `ci-fast.yml:104` runs `--ignore=tests/e2e_tests/`. - The e2e tier only runs in `release.yml`. - The invariant had **no test coverage at all** — `grep 'no centroid embedding' tests/` returns nothing. ## Fix Mock mode is the case that has no centroid *by construction*: it clusters by trigger rather than by vector (the `MOCK_LLM_RESPONSE` branch in `get_clusters`), so a centroid was never meaningful there. Skip the cluster bookkeeping instead of aborting. Every other caller still raises — a centroid-less cluster row would silently break the incremental re-aggregation that table exists to feed. Production behaviour outside mock mode is unchanged. Adds the three cases the invariant never had: - the raise still fires outside mock mode - mock mode reaches the save, then skips the centroid write - the happy path still records the cluster Verified the new tests fail against the pre-fix aggregator with the exact `RuntimeError`. ## Verification - OSS e2e tier: **47 passed, 87 skipped** (was 4 failed / 43 passed) - OSS unit tier: **4368 passed, 9 skipped** - ruff + pyright clean ## Worth a second opinion The mock-mode carve-out fixes the tests, but the underlying mismatch is broader: storage treats a missing embedding as *degrade and continue*, while the aggregator treats it as *fatal*. In any deployment where the embedding service is unreachable, `run_playbook_aggregation()` now hard-fails and rolls back rather than degrading. That may well be intended — failing loudly beats silently writing a useless centroid — but it changed behaviour without discussion when #425 landed, so the owners of #410/#425 should confirm which semantics they want.
Summary
Changes
Aggregation pipeline
Scheduling and storage
CodeRabbit follow-up
vector_sumaccumulation and expands both refresh and new-cluster prompt-bound coverage.Documentation and tests
Diagrams
The hourly run advances clusters without reading their complete membership history.
flowchart LR A["Newest unclustered playbooks<br/>up to 20,000"] --> B{"Nearest active centroid?"} B -->|"match"| C["Current agent playbook<br/>+ this run's new members"] B -->|"no match"| D["Create bounded new clusters"] C --> E["Generate replacement agent playbook"] E --> F["Embed replacement text"] F --> G["Atomically replace agent playbook<br/>and centroid"] H["Edited, archived, or deleted source"] --> I["Newest retained members<br/>up to 100"] I --> ETest Plan
uv run ruff checkanduv run ruff formaton every changed Python file.uv run pyrighton every changed Python file: 0 errors.Summary by CodeRabbit
Improvements
Documentation