Skip to content

fix: Make crystallize idempotent for session summary pages#118

Closed
jony376 wants to merge 1 commit into
vouchdev:mainfrom
jony376:fix/vouch-crystallize-idempotency-summary-page
Closed

fix: Make crystallize idempotent for session summary pages#118
jony376 wants to merge 1 commit into
vouchdev:mainfrom
jony376:fix/vouch-crystallize-idempotency-summary-page

Conversation

@jony376

@jony376 jony376 commented May 27, 2026

Copy link
Copy Markdown

Related issues

Closes #117

What changed

crystallize now builds the session summary page from all approved proposals in the session (not just approvals from the current run), and create-or-updates pages/session-<session_id>.md instead of calling put_page and failing on reruns. Added KBStore.update_page() for safe in-place page updates, plus a regression test covering partial first crystallize followed by a successful retry.

Why

vouch crystallize <session_id> is used in agent workflows and retry loops. After a first successful run (or a partial run that already wrote the summary page), a second call could abort with page session-<id> already exists -- choose a different slug, leaving automation stuck even when pending proposals still needed approval. Making summary-page handling convergent lets operators safely rerun crystallize until all session proposals are approved.

What might break

  • File moves: none.
  • On-disk field shape changes: none.
  • Behaviour changes: yes, intentionally.
    • Re-running crystallize on a session that already has a summary page now succeeds and updates that page body/claims list to reflect all approved session artifacts.
    • Sessions with previously approved artifacts but no pending proposals will still get (or refresh) the summary page when write_summary_page=True.
    • put_page() remains exclusive-create; only the crystallize path uses the new update behaviour.

Not a breaking change for typical first-run crystallize flows. Existing .vouch/ directories are compatible without migration.

VEP

Not required — internal reliability fix to existing session crystallization behaviour; no new surface, transport, or schema contract.

Tests

  • make check passes locally (lint + mypy + pytest)
  • New / changed behaviour has a test (test_crystallize_retry_updates_existing_summary_page in tests/test_sessions.py)
  • CHANGELOG.md updated under ## [Unreleased]

Summary by CodeRabbit

  • New Features

    • Session summaries now intelligently reuse and update existing pages rather than recreating them.
  • Improvements

    • Session crystallization retries are now more resilient, preserving progress across attempts and handling transient failures gracefully.

Review Change Stack

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Session crystallization is made idempotent by deriving summary page artifacts from APPROVED proposals and adopting a conditional upsert pattern (try get, create if missing, update if exists) for the summary page. KBStore gains update_page() to support in-place page updates, and a test validates retry convergence.

Changes

Idempotent Session Crystallization

Layer / File(s) Summary
Page update storage method
src/vouch/storage.py
KBStore.update_page() overwrites an existing page file, refreshes its embedding, and raises ArtifactNotFoundError if the page does not exist.
Session crystallization with approved-artifact derivation and upsert pattern
src/vouch/sessions.py
Crystallize derives summary page artifacts from APPROVED proposals via a new _approved_artifact_ids_for_session() helper, and switches from unconditional put_page() to a get_page() / put_page() / update_page() upsert. Module imports ProposalKind and ArtifactNotFoundError to enable the new logic.
Crystallization idempotency test
tests/test_sessions.py
Test simulates flaky approval across retries: first crystallize yields partial approval and a transient failure, second retry completes remaining approval and reuses the same summary_page_id with converged state.

Sequence Diagram

sequenceDiagram
  participant crystallize
  participant _approved_artifact_ids_for_session
  participant KBStore as KBStore<br/>(get_page/put_page/update_page)
  crystallize->>_approved_artifact_ids_for_session: scan APPROVED proposals
  _approved_artifact_ids_for_session-->>crystallize: artifact_ids
  crystallize->>KBStore: get_page(summary_page_id)
  alt page exists
    KBStore-->>crystallize: existing Page
    crystallize->>KBStore: update_page(updated_page)
    KBStore-->>crystallize: updated Page
  else ArtifactNotFoundError
    crystallize->>KBStore: put_page(new_page)
    KBStore-->>crystallize: created Page
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Possibly related PRs

  • vouchdev/vouch#61 — Both PRs modify the session-summary page creation/update flow in crystallize(), with the main PR implementing upsert logic and the related PR adding FTS5 indexing for the same summary page.
  • vouchdev/vouch#77 — Both PRs modify crystallize()'s session-summary page generation and persistence, with the main PR changing artifact derivation and upsert behavior while the related PR restricts content and updates audit object_ids.

Poem

A rabbit hops through crystal halls,
Where pages once would double-fall,
Now upsert wisdom saves the day—
Retries converge, no plans go astray!
🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #117: derive summary artifacts from approved proposals [sessions.py], implement update_page for idempotent behavior [storage.py], add regression test for retry scenarios [test_sessions.py], and ensure crystallize succeeds on reruns without failing on existing pages.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the linked issue #117: modifying crystallize to use approved proposals, adding update_page support, and adding a regression test for idempotent behavior. No unrelated changes detected.
Title check ✅ Passed The title directly addresses the main change: making crystallize idempotent for session summary pages, which is the core objective of the PR and the primary functional change across all modified files.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 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 `@src/vouch/sessions.py`:
- Around line 110-115: The current get-then-put path using
store.get_page(page.id) followed by store.put_page(page) is TOCTOU-prone;
instead try to create the page directly and, if the create fails because the
page already exists, fall back to update. Replace the try/except/else block
around store.get_page/store.put_page/store.update_page with a single attempt:
call store.put_page(page) inside a try, catch the specific "already exists"
error returned by put_page (e.g., ArtifactAlreadyExistsError or the
store-specific conflict exception) and call store.update_page(page) in that
except handler; if you don't have a typed conflict exception, catch the error
thrown by put_page when the page exists and then call store.update_page(page).
Ensure page.id and the same semantics are preserved.

In `@src/vouch/storage.py`:
- Around line 353-357: update_page currently writes and embeds a Page without
validating that any referenced Claim exists (unlike put_page), allowing updates
to introduce dangling claim references; modify update_page (and before calling
_embed_and_store) to perform the same claim-existence validation logic used in
put_page (check each claim id's existence via the same helper/path check, e.g.,
_claim_path or the claim existence function used by put_page) and raise
ArtifactNotFoundError for missing claims so referential integrity is preserved.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 316a7a50-dd5c-4879-a5c9-c3bd6e6d6af0

📥 Commits

Reviewing files that changed from the base of the PR and between 3beb821 and fb3b0f0.

📒 Files selected for processing (3)
  • src/vouch/sessions.py
  • src/vouch/storage.py
  • tests/test_sessions.py

Comment thread src/vouch/sessions.py
Comment on lines +110 to +115
try:
store.get_page(page.id)
except ArtifactNotFoundError:
store.put_page(page)
else:
store.update_page(page)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Make summary-page upsert race-safe for concurrent retries.

The Line 111→113 check/create path is TOCTOU-prone: a parallel crystallize can create the page after get_page() fails, making put_page() raise and fail this run.

Suggested fix
-        try:
-            store.get_page(page.id)
-        except ArtifactNotFoundError:
-            store.put_page(page)
-        else:
-            store.update_page(page)
+        try:
+            store.update_page(page)
+        except ArtifactNotFoundError:
+            try:
+                store.put_page(page)
+            except ValueError:
+                # Lost create race to another worker; converge by updating.
+                store.update_page(page)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
store.get_page(page.id)
except ArtifactNotFoundError:
store.put_page(page)
else:
store.update_page(page)
try:
store.update_page(page)
except ArtifactNotFoundError:
try:
store.put_page(page)
except ValueError:
# Lost create race to another worker; converge by updating.
store.update_page(page)
🤖 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 `@src/vouch/sessions.py` around lines 110 - 115, The current get-then-put path
using store.get_page(page.id) followed by store.put_page(page) is TOCTOU-prone;
instead try to create the page directly and, if the create fails because the
page already exists, fall back to update. Replace the try/except/else block
around store.get_page/store.put_page/store.update_page with a single attempt:
call store.put_page(page) inside a try, catch the specific "already exists"
error returned by put_page (e.g., ArtifactAlreadyExistsError or the
store-specific conflict exception) and call store.update_page(page) in that
except handler; if you don't have a typed conflict exception, catch the error
thrown by put_page when the page exists and then call store.update_page(page).
Ensure page.id and the same semantics are preserved.

Comment thread src/vouch/storage.py
Comment on lines +353 to +357
def update_page(self, page: Page) -> Page:
if not self._page_path(page.id).exists():
raise ArtifactNotFoundError(f"page {page.id}")
self._page_path(page.id).write_text(_serialize_page(page))
self._embed_and_store(kind="page", id=page.id, text=f"{page.title}\n\n{page.body}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve page claim referential integrity on updates.

update_page() skips the claim-existence validation that put_page() performs, so Line 356 can persist pages referencing missing claims.

Suggested fix
 def update_page(self, page: Page) -> Page:
+    for cid in page.claims:
+        if not self._claim_path(cid).exists():
+            raise ValueError(f"page {page.id} references unknown claim {cid}")
     if not self._page_path(page.id).exists():
         raise ArtifactNotFoundError(f"page {page.id}")
     self._page_path(page.id).write_text(_serialize_page(page))
     self._embed_and_store(kind="page", id=page.id, text=f"{page.title}\n\n{page.body}")
     return page
🤖 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 `@src/vouch/storage.py` around lines 353 - 357, update_page currently writes
and embeds a Page without validating that any referenced Claim exists (unlike
put_page), allowing updates to introduce dangling claim references; modify
update_page (and before calling _embed_and_store) to perform the same
claim-existence validation logic used in put_page (check each claim id's
existence via the same helper/path check, e.g., _claim_path or the claim
existence function used by put_page) and raise ArtifactNotFoundError for missing
claims so referential integrity is preserved.

@jony376 jony376 changed the title Make crystallize idempotent for session summary pages fix: Make crystallize idempotent for session summary pages May 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants