Skip to content

fix(sessions): make crystallize retry idempotent for summary pages - #256

Merged
plind-junior merged 1 commit into
vouchdev:testfrom
Yurii214:fix/139-crystallize-retry-idempotency
Jul 6, 2026
Merged

fix(sessions): make crystallize retry idempotent for summary pages#256
plind-junior merged 1 commit into
vouchdev:testfrom
Yurii214:fix/139-crystallize-retry-idempotency

Conversation

@Yurii214

@Yurii214 Yurii214 commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fixes [bug] crystallize crashes on retry after partial approval #139: partial crystallize() runs that already wrote a session-{id} summary page no longer fail on retry with page session-... already exists.
  • Summary page artifact list is derived from all approved session proposals (not only approvals from the current run).
  • Adds KBStore.update_page() and upserts the summary page (put_page on first write, update_page on retry).

Why

Operators retry crystallize after transient approval failures. The first partial run could approve one proposal and create the summary page; a second run then called put_page() again and crashed before completing.

What might break

  • On-disk layout: none — same pages/session-{id}.md path and shape.
  • Behaviour change (intentional): re-running crystallize on a session that already has approved proposals but no pending ones will now create or refresh the summary page when write_summary_page=True. put_page() remains exclusive-create everywhere else.

Tests

  • PYTHONPATH=src uv run pytest tests/test_sessions.py
  • PYTHONPATH=src uv run pytest --ignore=tests/embeddings (full suite minus optional embedding/MCP extras)
  • PYTHONPATH=src uv run ruff check src/vouch/sessions.py src/vouch/storage.py tests/test_sessions.py
  • Regression: test_crystallize_retry_updates_existing_summary_page (partial approve → retry → same summary page lists all claims)

Fixes #139

Summary by CodeRabbit

  • Bug Fixes

    • Fixed an issue where retrying session crystallization after a partial approval run would fail. The crystallization process now properly updates the existing summary page rather than aborting with a duplicate page error.
  • Tests

    • Added test coverage for session crystallization retry scenarios.

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b5c89610-6dad-46ad-bc3c-b45b42172171

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

KBStore gains an update_page method. crystallize() is updated to derive approved artifact IDs from stored proposals via a new private helper and to conditionally call put_page or update_page based on whether a summary page already exists. A new test validates the retry path.

Changes

crystallize() retry upsert fix

Layer / File(s) Summary
KBStore.update_page method
src/vouch/storage.py
Adds update_page(page) that asserts the on-disk file exists (raising ArtifactNotFoundError if not), overwrites the file, re-embeds, and returns the updated Page.
crystallize() upsert logic and approved-artifact helper
src/vouch/sessions.py
Adds imports for ProposalKind, ArtifactNotFoundError, and approve; adds _approved_artifact_ids_for_session which filters APPROVED proposals by session and kind; replaces the in-loop approved set with the session-derived set for summary-page content; changes persistence from unconditional put_page to a get_pageput_page/update_page conditional.
Retry crystallize test and changelog
tests/test_sessions.py, CHANGELOG.md
Adds test_crystallize_retry_updates_existing_summary_page which patches approve to fail once, then calls crystallize twice, asserting summary_page_id is reused, failures cleared, approved count correct, and summary page claims match current store claims. Adds corresponding changelog entry.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related issues

Possibly related PRs

  • vouchdev/vouch#61: Modifies the same crystallize() summary-page persistence path in src/vouch/sessions.py, making it directly adjacent to the put/update logic changed here.

Poem

🐇 Hoppity-hop through the session once more,
No "page already exists" crash at the door.
With update_page ready, the summary stays,
Retrying crystallize now earns my praise!
Two hops, one page—idempotent days!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'fix(sessions): make crystallize retry idempotent for summary pages' accurately and concisely describes the main change - making the crystallize function idempotent for summary page creation to fix retry failures.
Linked Issues check ✅ Passed The PR fully addresses issue #139 by implementing idempotent summary page creation through the new update_page method, allowing safe retries of partial crystallize runs.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the crystallize retry idempotency issue: the new update_page method, the revised summary page creation logic, and comprehensive test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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 check-then-act pattern in the store.get_page,
store.put_page, and store.update_page sequence creates a race condition where
concurrent calls can both pass the get_page check and then fail when put_page
tries to create an already-existing page. Reverse the logic to attempt put_page
first and catch the "already exists" error (likely a specific exception), then
fall back to store.update_page in the exception handler instead of the
try-except-else structure. This makes the upsert operation race-safe by handling
the concurrent creation case gracefully.

In `@src/vouch/storage.py`:
- Around line 353-358: The update_page method is missing claim existence
validation that is enforced in put_page(), allowing pages to be persisted with
dangling claims references. Add the same claim validation logic used in
put_page() to the update_page method before the page is written to storage
(before the _page_path write_text call). This ensures that all claims referenced
in the page being updated are verified to exist, maintaining page-claim
integrity.
🪄 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: f0af788b-afb8-4c55-ab8e-4f8457a86a0b

📥 Commits

Reviewing files that changed from the base of the PR and between 18faf17 and 5ee0be0.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • 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.

Lines 110-115 use a check-then-create flow. Concurrent crystallize() calls can both miss on get_page(), then one put_page() fails with “already exists”, breaking retry safety.

Suggested patch
-        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; page now exists.
+                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 check-then-act
pattern in the store.get_page, store.put_page, and store.update_page sequence
creates a race condition where concurrent calls can both pass the get_page check
and then fail when put_page tries to create an already-existing page. Reverse
the logic to attempt put_page first and catch the "already exists" error (likely
a specific exception), then fall back to store.update_page in the exception
handler instead of the try-except-else structure. This makes the upsert
operation race-safe by handling the concurrent creation case gracefully.

Comment thread src/vouch/storage.py
Comment on lines +353 to +358
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}")
return 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

Preserve page-claim integrity checks in update_page.

Line 353 adds an update path that skips the claim existence validation enforced by put_page(). That allows persisting pages with dangling claims references.

Suggested patch
 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
📝 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
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}")
return page
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 - 358, The update_page method is
missing claim existence validation that is enforced in put_page(), allowing
pages to be persisted with dangling claims references. Add the same claim
validation logic used in put_page() to the update_page method before the page is
written to storage (before the _page_path write_text call). This ensures that
all claims referenced in the page being updated are verified to exist,
maintaining page-claim integrity.

@plind-junior
plind-junior changed the base branch from main to test June 22, 2026 07:24
@plind-junior

Copy link
Copy Markdown
Member

Thanks for your contribution! Fix the conflict

@Yurii214

Yurii214 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

@plind-junior Thanks for the earlier note on the conflict — rebased onto latest test (0f9687e), resolved merge conflicts in CHANGELOG / sessions / tests, and CI is green again.

Working through the CodeRabbit race/validation notes next; happy to push an update if you’d rather review the core retry fix first.

Appreciate the review 🙏

Partial crystallize runs that already wrote session-{id} summary pages
failed on retry with "page already exists". Upsert the summary page
and derive its artifact list from all approved session proposals.

Fixes vouchdev#139

Co-authored-by: Cursor <cursoragent@cursor.com>
@Yurii214
Yurii214 force-pushed the fix/139-crystallize-retry-idempotency branch from 8440927 to 0f9687e Compare June 29, 2026 02:37
@github-actions github-actions Bot added docs documentation, specs, examples, and repo guidance tests tests and fixtures size: S 50-199 changed non-doc lines labels Jun 29, 2026
@plind-junior
plind-junior merged commit 363bfd8 into vouchdev:test Jul 6, 2026
8 checks passed
dripsmvcp added a commit to dripsmvcp/vouch that referenced this pull request Jul 13, 2026
five fixes to code merged on the test branch, all variants of "a failure
or not-yet-ready state reported as success":

- install-mcp (codex toml_merge): a config.toml the minimal serializer
  can't round-trip (non-bmp string value, nan/inf float) was bucketed as
  skipped and printed "(already present)" with a clean "Done", so the user
  believed vouch was wired into codex when it wasn't. add a distinct
  `failed` bucket, report it, and exit non-zero.
- install-mcp: reject a manifest `dst` that escapes the target tree
  (`..`/absolute) instead of writing outside it — defense in depth for the
  manifest file writer. shipped adapters use only contained relative paths.
- dual-solve review-ui: `_serialize` computed the recommendation over the
  empty candidate list on every poll, so a running job showed "neither
  engine produced a usable diff" for its whole duration. omit the hint
  until candidates exist.
- session.crystallize: retrying on a not-yet-ended session rewrote the
  summary page with a fresh wall-clock "Ended:" stamp each time (and
  re-embedded), so vouchdev#256's "idempotent retry" wasn't. render a stable
  marker for an open session.
- capture ingest-codex: cap rollout parsing at 64 MiB so an oversized or
  newline-free-blob rollout can't be read whole into memory, matching the
  byte caps on other untrusted reads.

each fix has a regression test; make check green.
plind-junior pushed a commit that referenced this pull request Jul 13, 2026
…#402)

five fixes to code merged on the test branch, all variants of "a failure
or not-yet-ready state reported as success":

- install-mcp (codex toml_merge): a config.toml the minimal serializer
  can't round-trip (non-bmp string value, nan/inf float) was bucketed as
  skipped and printed "(already present)" with a clean "Done", so the user
  believed vouch was wired into codex when it wasn't. add a distinct
  `failed` bucket, report it, and exit non-zero.
- install-mcp: reject a manifest `dst` that escapes the target tree
  (`..`/absolute) instead of writing outside it — defense in depth for the
  manifest file writer. shipped adapters use only contained relative paths.
- dual-solve review-ui: `_serialize` computed the recommendation over the
  empty candidate list on every poll, so a running job showed "neither
  engine produced a usable diff" for its whole duration. omit the hint
  until candidates exist.
- session.crystallize: retrying on a not-yet-ended session rewrote the
  summary page with a fresh wall-clock "Ended:" stamp each time (and
  re-embedded), so #256's "idempotent retry" wasn't. render a stable
  marker for an open session.
- capture ingest-codex: cap rollout parsing at 64 MiB so an oversized or
  newline-free-blob rollout can't be read whole into memory, matching the
  byte caps on other untrusted reads.

each fix has a regression test; make check green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs documentation, specs, examples, and repo guidance size: S 50-199 changed non-doc lines tests tests and fixtures

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] crystallize crashes on retry after partial approval

3 participants