fix(session): fence sidecar writes by durable revision - #7036
fix(session): fence sidecar writes by durable revision#7036ruizanthony wants to merge 18 commits into
Conversation
|
| Filename | Overview |
|---|---|
| api/models.py | Adds the central sidecar revision, cross-process authority, backup preservation, state materialization, and deletion primitives. |
| api/session_recovery.py | Revalidates backup recovery and state database materialization while holding per-session authority. |
| api/session_discoverability.py | Makes discoverability repair create-only and rechecks authoritative state before publication. |
| api/routes.py | Reauthorizes stale session owners and routes explicit deletion through the fenced artifact-removal protocol. |
| api/streaming.py | Applies revision-aware ownership and guarded deletion to cancellation and hidden-session finalization paths. |
| tests/test_session_sidecar_revision_fence.py | Adds broad regression coverage for concurrent publication, stale writers, backups, tombstones, recovery, and cleanup. |
Sequence Diagram
sequenceDiagram
participant Caller
participant Owner as In-memory Session
participant Lock as Per-SID Authority
participant Sidecar as Session Sidecar
participant Backup as Backup/Archive
Caller->>Lock: Acquire SID authority
Lock->>Sidecar: Read generation and exact digest
Sidecar-->>Owner: Current durable revision
Owner->>Owner: Compare observed revision
alt Revision matches
Owner->>Backup: Preserve shrinking/incomparable predecessor
Owner->>Sidecar: Atomic publish with next generation
Sidecar-->>Owner: New revision receipt
else Revision differs
Owner-->>Caller: Reject stale mutation and reload
end
Caller->>Lock: Release SID authority
Reviews (16): Last reviewed commit: "test(webui): target Session context fiel..." | Re-trigger Greptile
nesquena-hermes
left a comment
There was a problem hiding this comment.
The regressions are resolved and the direction is right — but full-gate adversarial probes reproduced 5 new failure paths, including a Windows brick. SHIP ONLY WITH FIXES.
Strong recovery on the CI regressions — I re-ran all 243 tests across the nine files that were red on the first head and they all pass now, the full suite is green (14,570 passed), and I confirmed the "Align compression snapshot tests with CAS ownership" commit did not weaken its oracle: it still compares the exact persisted snapshot and continuation state. The fence direction (reject stale writers, preserve recoverable history before shrinking) is sound.
I then ran the full authoritative gate on this exact head (91746c1a5235) — full suite + adversarial production-path reproduction. The suite is green, but the adversarial pass reproduced five failures that the focused tests don't cover. Each was verified to pass at the base commit and fail at this head (or fail automatic recovery):
1. 🔴 BRICK — every native-Windows send fails before the provider runs
api/models.py:1916 (repeated in the workspace patch at :6036). Text-mode writes translate \n→\r\n, but the stored revision hashes the untranslated string. /api/chat/start saves at routes.py:21612, the worker immediately saves again at streaming.py:9955, and the second save raises StaleSessionGenerationError. A deterministic CRLF simulation failed at this head and succeeded at base. Fix: write both temp files with newline="\n", or hash the actual temp-file bytes before publication. Add a Windows second-save regression test.
2. 🟠 CORE — implicit workspace recovery rejects the user's turn
api/routes.py:23010 (callers at :22219, :22848). persist_recovered_workspace_binding() correctly returns a reloaded revision owner, but the helper discards it and the callers continue into chat-start with the invalidated alias → StaleSessionGenerationError in _prepare_chat_start_session_for_stream(). Fix: return and adopt the persisted Session at both callers before model resolution and _start_run().
3. 🟠 CORE — undo/truncate/clear permanently break on a malformed historical .json.bak
api/models.py:1847. The new code raises permanently instead of replacing an unusable backup. The same shrink succeeds at base and fails here. Fix: content-addressedly archive the raw malformed bytes, promote the valid live sidecar as the new primary backup, and fail only if preservation itself fails.
4. 🟠 CORE — first publication is non-atomic on filesystems without hard-link support
api/models.py:320. The O_EXCL fallback exposes the destination before copying bytes; a synchronized probe observed an existing zero-byte sidecar and a concurrent Session.load() raised JSONDecodeError. Fix: use an atomic no-replace rename primitive; if the platform provides none, fail closed rather than exposing a partial path.
5. 🟡 SILENT — a foreign-SID backup can authorize a shrink that recovery later refuses
api/models.py:1858. Dominance checks compare only messages, so a foreign backup containing the live rows is retained, but api/session_recovery.py:464 later rejects it — the probe saved the shrink and then failed automatic recovery (silent data loss). Fix: validate the embedded session_id before coverage checks; archive a foreign backup and replace it with the correctly-identified live generation, or fail closed.
Net
This is close and worth landing — the core CAS fence and the test alignment are correct. But #1 in particular means the fence would brick every Windows user's first turn, and #2/#3 add new turn-rejection / operation-break paths, so it can't ship as-is. Please address the five above (they're all edge-path hardening, not a redesign) and re-push — I'll re-run the full adversarial gate on the new head.
|
Addressed all five adversarial findings on exact head
Verification on this head: |
bdb0925 to
89ee841
Compare
|
Rebased without conflicts onto current |
89ee841 to
1368621
Compare
|
@nesquena-hermes please re-review final head |
|
Remediated the three exact-SHA blockers on final head |
0e22207 to
b042b46
Compare
|
@nesquena-hermes final exact head is now |
nesquena-hermes
left a comment
There was a problem hiding this comment.
All 5 prior defects are verified FIXED — strong convergence. But the sidecar-CAS hardening exposed 2 NEW same-class stale-revision paths. SHIP ONLY WITH FIXES (R3).
Re-gated the full authoritative pass on head b042b465 (Codex adversarial + full suite). The recovery is real and I want to be clear about it first:
✅ All 5 findings from the last round are FIXED (verified head-fail→now-pass):
- Windows CRLF brick — temp files now hash LF-consistent bytes; the chat-start→worker double-save no longer raises
StaleSessionGenerationError. - Implicit workspace recovery — both callers now adopt the persisted revision owner before model-resolve /
_start_run(). - Malformed historical
.json.bak— now archived (raw bytes preserved) + valid live sidecar promoted; the shrink succeeds. - First publication atomicity — now atomic no-replace (fail-closed where unsupported).
- Foreign-SID backup — embedded
session_idvalidated before coverage; archived/replaced or fail-closed.
(48 sidecar/workspace + 33 recovery/streaming focused tests pass; full suite green.)
🔴 But two NEW defects appeared in the CAS hardening — both the same shape: a save-path advances the durable revision but hands back a cached owner still carrying the OLD revision, so the next save/send throws StaleSessionGenerationError (verified head-fail / base-pass):
A. api/models.py:~4357 — state.db lost-response recovery returns an unsaveable cached session
The locked.save() advances the durable revision, but the recovered fields (messages, context_messages, …) are copied onto the caller's cached session WITHOUT its new revision. Chat-start receives that alias and throws StaleSessionGenerationError on its next save.
Fix: when the cached alias owned the exact pre-save revision, transfer the saved owner's post-save revision onto it; otherwise replace/return the freshly-saved owner (don't hand back a stale alias).
B. api/session_discoverability.py:~450 (_clear_sidecar_cli_flag, --repair-safe --apply) — sidecar rewrite doesn't advance generation or invalidate the cache
The flag-repair rewrites the sidecar without bumping _sidecar_generation_v1 or invalidating the cached owner, so generation-based freshness then serves the stale cache and the next save/send throws StaleSessionGenerationError.
Fix: perform the repair under SID authority, increment _sidecar_generation_v1, publish canonical bytes atomically, and invalidate-or-adopt the cached owner.
Net
This is close and converging well — the core CAS fence and all five original edge-paths are correct now. Both remaining issues are the identical "post-save revision not propagated to the returned cached owner" pattern on two more paths (state.db self-heal + CLI flag-repair), so they're a focused fix, not a redesign. Address A and B and re-push — I'll re-run the full adversarial gate on the new head.
(Release-manager overnight re-gate. Data-safety write-path PR — the ship decision is the maintainer's regardless of gate outcome.)
b042b46 to
faf43df
Compare
|
Exact final candidate: |
|
Final exact candidate after global-suite remediation: Evidence: the exact RED tests and 51 adjacent recovery/validation tests pass; the 18-file modified portfolio passes (497 passed, 2 skipped); all 15 Python matrix shards and all required checks are green. The single failed |
|
HOLD MERGE on current head |
|
Replacement candidate published: |
|
HOLD remains on |
|
Published candidate |
|
@nesquena-hermes please re-review exact head |
|
Additional HOLD on current head |
|
Status update on exact head 1. The two outstanding CHANGES_REQUESTED reviews are confirmed obsolete.
2. Independent exact-SHA review of
The CAS/revision fence this PR adds is correct and does not worsen any of them. Request: please dismiss the two outdated CHANGES_REQUESTED and re-review exact head |
ed2d93d to
28f6f79
Compare
|
Rebased onto current The outstanding CHANGES_REQUESTED reviews still target Local: 172 focused + 534 portfolio passed (2 agent-runtime skips); compileall and |
Thinking Path
WebUI session sidecars are durable conversation state. A writer that publishes from a stale in-memory snapshot can erase newer turns, so every mutation must be authorized by the exact revision and digest it observed.
What Changed
.bakrecovery,state.dbmaterialization, workspace metadata patching, and explicit session deletion;Why It Matters
Concurrent save, recovery, clear, delete, or workspace reconciliation must never turn a stale snapshot into the winning transcript. Ambiguous or unverifiable generations fail closed, while intentional shrinking writes retain a recoverable predecessor.
Legacy sidecars without
_sidecar_generation_v1remain readable as generation 0; their first fenced mutation advances them to generation 1. Cache freshness uses the bounded metadata prefix for generated sidecars rather than hashing a large transcript on every read.Scope Boundaries
This PR changes only the session sidecar durability layer and its regression coverage.
It does not include:
Current GitHub delta: 7 production/docs files (+1732 / -324) and 20 test files (+3107 / -144).
State layer and invariant
Layer: session JSON sidecar and
.json.bakrecovery snapshot._index.jsonremains a derived projection.Invariant: a writer may publish only while the exact generation and content digest it observed remain current. Missing-target publication is create-only. Any intentional history shrink must first preserve a recoverable generation; incomparable primary backups are archived before the latest snapshot is promoted. Recovery must still be authorized against durable delete/state evidence while holding the SID authority.
Contract Routing
docs/CONTRACTS.md, the repository run-state consistency contract, and the sidecar generation/digest invariants documented in this PR.Verification
ed2d93d1: all 24 GitHub checks are completed successfully, including the Python 3.11/3.12/3.13 matrix, browser smoke, live-to-final, lint, docs, and Greptile;533 passed, 2 skipped; the skipped cases are the two agent-dependent tests in that isolated review harness;1909336d3bf02c8c5e9b2c44e6775f06952fb35b);Review focus
Rollback
Revert the PR commits. The added generation field is ignorable metadata for older code, so rollback does not require a data migration. Keep any
.json.bakand.json.bak.archive-*files created by the fenced writer until the live sidecar has been inspected.Model Used
OpenAI Codex /
gpt-5.6-sol, with independent read-only adversarial review through Hermes delegation.Release note
Session sidecar writes now reject stale writers and preserve recoverable history before shrinking, reducing conversation loss during concurrent save, repair, recovery, workspace reconciliation, clear, and delete operations.