Skip to content

fix(store): make label seeding and session-state writes atomic - #4329

Open
TomeHirata wants to merge 1 commit into
mainfrom
fix/store-write-primitives
Open

fix(store): make label seeding and session-state writes atomic#4329
TomeHirata wants to merge 1 commit into
mainfrom
fix/store-write-primitives

Conversation

@TomeHirata

Copy link
Copy Markdown
Contributor

Related issue

Closes #3402

Summary

Two writes on ConversationStore decided what to write from a snapshot read before the write, rather than inside the write's own transaction, and each silently loses a concurrent update as a result.

ELI5: two tool calls on the same session each read "the counter is 3", each add 1 in their head, each write back "4" — the database ends up at 4, not 5, and nothing ever complains.

Thread A                          Thread B
read state {n: 3}
                                   read state {n: 3}
compute n+1 = 4
                                   compute n+1 = 4
write {n: 4}
                                   write {n: 4}   <- clobbers A's write silently
  1. Label seeding lost a concurrent policy write. Initial-label seeding read current labels, computed which declared defaults were missing, then upserted those. A policy write landing between the read and the upsert was overwritten back to its initial value. seed_labels_if_absent now does insert-if-absent in one statement, so the database — not a prior Python snapshot — decides which keys are missing, and a concurrent write survives.
  2. Session-state updates lost concurrent increments (the race above). State was persisted by writing the whole blob back from the caller's in-memory snapshot; two overlapping writers clobbered each other. mutate_session_state applies the caller's change inside a locked read-merge-write — SELECT ... FOR UPDATE where the dialect supports it, BEGIN IMMEDIATE on SQLite to take the write lock before the first read — so overlapping writers serialize instead of losing an update. The policy engine's in-memory hot-cache merge was reworked alongside this: it now tracks which keys the current batch of operations actually deleted, and only excludes those from what survives the old cache, rather than a blanket union (which can't tell "missing because deleted" from "missing because never part of this row" — a sub-agent's root-inherited approval key is never part of its own row at all).
  3. Neither path distinguished "the row is gone" from "the row is empty". increment_session_usage treated an absent conversation row as an empty one — an UPDATE matching nothing, with the mutated total returned as if persisted. Label seeding had the sharper version: labels carry no foreign key, so seeding against a gone conversation left an orphan row behind. Both now raise ConversationNotFoundError, existence checked in the same transaction as the write, for the same reason the insert is insert-if-absent — a check made earlier can already be stale. The contract is stated on the abstraction itself, so an out-of-tree implementation can't phantom-write while nominally conforming.

Two callers absorb that new exception rather than propagate it, and they ship here because the primitive that raises owns its callers' handling: usage accumulation on the relay path, and the cost-ask checkpoint mirrored to a session's tree root. A session deleted mid-turn has nothing to bill, and a lost approval checkpoint just re-prompts — neither should fail an in-flight streaming turn.

BREAKING: ConversationStore gains two abstract methods (seed_labels_if_absent, mutate_session_state), so an out-of-tree subclass must implement them before upgrading. There's no compatible default — the whole point of both is that the decision happens inside one statement, and a base-class fallback built from the existing primitives would reintroduce exactly the race being removed. SqlAlchemyConversationStore is the only in-tree implementation.

Test Plan

uv run --no-sync pytest tests/stores/test_conversation_store.py tests/runtime/policies/test_builder.py tests/runtime/policies/test_session_cost_ask_routing.py tests/server/integration/test_sessions_endpoints.py -q
uv run --no-sync ruff check omnigent/stores/conversation_store omnigent/runtime/policies omnigent/server/routes/_sessions
uv run --no-sync ruff format --check omnigent/stores/conversation_store omnigent/runtime/policies omnigent/server/routes/_sessions

Also ran the fresh benchmark pass from this round (dev/benchmarks/omnigent) on the write-adjacent journeys, to confirm the atomicity fix doesn't cost meaningful latency: create_session 45.3→48.0 ms SQLite / 21.5→22.8 ms Postgres, add_comment flat on both. Small, consistent, and expected — one existence-check query where the old unconditional upsert had none.

Demo

N/A — backend/store change, no UI surface.

Type of change

  • Bug fix
  • Feature
  • UI / frontend change
  • Refactor / chore
  • Docs
  • Test / CI
  • Breaking change

Test coverage

  • Unit tests added / updated
  • Integration tests added / updated
  • E2E tests added / updated
  • Manual verification completed
  • Existing tests cover this change
  • Not applicable

Coverage notes

Every oracle here is paired with the mutation that kills it. The seeding race is constructed rather than simulated — a store proxy commits a competing write while the helper reads its snapshot. The state race runs two threads whose transactions overlap via a threading.Barrier; removing PostgreSQL's row lock loses an increment, and so does replacing SQLite's BEGIN IMMEDIATE with a deferred session. Skipping the existence check leaves an orphan label row the test then finds. Reverting the cost-ask mirror to snapshot-then-write is caught by observing which primitive the engine reaches for, since the visible outcome is otherwise identical. A delete of a session's own state key, a delete of a sub-agent's inherited approval key, and a delete of that same key name on a top-level session are each pinned separately, since an exemption scoped by key name instead of by which keys the call actually deleted gets the last of those three wrong. Manual verification is the benchmark pass noted above, run against this branch specifically.

Changelog

Fixed a race where concurrent tool calls or policy writes on the same session could silently lose a label or session-state update instead of both landing.

Three writes on the conversation store decided from a snapshot taken before
the write, and lost or invented data when anything landed in between.

Label seeding read the current labels, worked out which declared initials
were missing, and upserted those. A policy write landing between the read and
the upsert was overwritten back to its initial value. seed_labels_if_absent
does insert-if-absent in one statement instead, so the database decides which
keys are missing and a concurrent write survives.

Session state was persisted by writing the whole blob back from the caller's
snapshot. Two parallel tool calls on one session each held their own copy, so
the second overwrote the first — two policies each incrementing a counter by
one persisted one. mutate_session_state applies the caller's change inside a
locked read-merge-write, serialised by SELECT ... FOR UPDATE where the dialect
supports it and by BEGIN IMMEDIATE on SQLite, which takes the write lock
before the first read.

All three share one row-missing contract: a write that reads before it writes
must not treat an absent row as empty. increment_session_usage did — absent
row treated as empty, an UPDATE matching nothing, and the mutated total
returned as persisted. Label seeding did too, differently: labels carry no
foreign key, so seeding for a conversation that is gone left orphan rows and
reported a snapshot nothing can read back. Both now raise, existence being
checked in the same transaction as the write for the same reason the insert
is insert-if-absent — a check the caller made earlier can already be stale.
The contract is stated on the abstraction, so another implementation cannot
phantom-write while nominally conforming.

Two callers absorb that exception rather than propagate it, and they ship
here because the primitive that raises owns its callers' handling: usage
accumulation on the relay path and the cost-ask checkpoint mirrored to a tree
root. A session deleted mid-turn has nothing to bill and a lost checkpoint
re-prompts, so neither should fail a streaming turn.

The engine's hot-state cache had the same "absence means something, but which
thing" problem one layer up. apply_state_updates merges a caller's change into
the persisted row and folds the authoritative result onto the in-memory cache
— but a key the caller just DELETEd is, correctly, simply absent from that
result, and a blanket union with the old cache put it right back: the delete
took effect in the database and nowhere else, and the next evaluation in the
same engine instance saw the pre-delete value again. The fix tracks which keys
this batch of operations actually deleted, and only those are excluded from
what survives from the old cache — a key missing from the merged result for
any other reason (a sub-agent's inherited root-approval key, which is never
part of its own row; a value seeded straight into the engine without ever
being persisted) still survives, exactly as before.

BREAKING: ConversationStore gains two abstract methods, so an out-of-tree
subclass must implement them before upgrading. There is no compatible
default — the whole point of both is that the decision happens inside one
statement, and a base-class fallback built from the existing primitives would
reintroduce exactly the race and the lost update being removed.
SqlAlchemyConversationStore is the only in-tree implementation.

Each oracle here is paired with the mutation that kills it. The seeding race
is constructed rather than simulated: a store proxy commits a competing write
while the helper reads its snapshot. The state race runs two threads whose
transactions overlap; removing PostgreSQL's row lock loses an increment, and
so does replacing SQLite's BEGIN IMMEDIATE with a deferred session. Skipping
the existence check leaves orphan label rows the test then finds. Reverting
the cost-ask mirror to snapshot-then-write is caught by observing which
primitive the engine reaches for, since the visible outcome is identical
either way. A delete of a session's own state key, a delete of a sub-agent's
inherited approval key, and a delete of that same key name on a top-level
session are each pinned separately, since an exemption scoped by key name
instead of by which keys this call deleted gets the last of those three wrong.

Signed-off-by: Andrew Reid <andrew@reid.ee>
Copilot AI lite review requested due to automatic review settings August 7, 2026 05:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added P3-low Priority: minor issue, cosmetic, nice-to-have size/XL Pull request size: XL labels Aug 7, 2026
strictly better than ``set_labels``, which performs no check at
all.
"""
...
:meth:`increment_session_usage` and
:meth:`seed_labels_if_absent`.
"""
...
:raises ConversationNotFoundError: When the conversation has no
metadata row.
"""
import json

try:
conversation_store.mutate_session_state(conv.id, _mutate)
except BaseException as exc:
@omnigent-ci

omnigent-ci Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Polly AI Review

Blocking issues

1. apply_state_updates per-conversation path now raises ConversationNotFoundError where it used to silently no-op — unguarded, on an in-flight approval/streaming path.

In omnigent/runtime/policies/engine.py, the per-conversation branch changes from set_session_state (a no-op UPDATE that matched nothing on a missing row) to self._store.mutate_session_state(self._conversation_id, _merge), which the PR now makes raise ConversationNotFoundError on a missing metadata row. Unlike the two callers the PR deliberately guards — _accumulate_session_usage (try/except … return None) and _record_root_cost_ask_approved (contextlib.suppress) — this call has no guard.

The reachable callers of apply_state_updates on the approved-ASK write-back path do not catch it either:

  • omnigent/runtime/policies/approval.py:163 (_await_elicitation, on approve)
  • omnigent/server/routes/_sessions/helpers.py:6752 (_apply_pending_policy_ask_writes, via asyncio.to_thread)
  • omnigent/server/routes/_sessions/orchestration.py:1918 (native ASK gate)

The higher-level handlers around these (_hold_native_ask_gate at routes_hooks.py:851 / orchestration.py:6266, and the elicitation/event routes) catch only ElicitationDeclinedError, not ConversationNotFoundError.

Net effect: if a session's metadata row is deleted mid-turn and an approved ASK carries per-conversation state_updates, this write now throws where the old code silently continued, which can fail/500 an in-flight approval or streaming turn. This is the exact failure mode the PR's own rationale argues against for the usage/root paths ("failing the turn over a write that can no longer land would be worse than silently dropping it"), but the reasoning wasn't applied to this third caller. Either guard the per-conversation call the same way, or document why an approved-ASK write to a vanished session should surface as an error rather than a dropped write.

Security vulnerabilities

None found. _insert_labels_if_absent builds parametrized statements (no SQL injection), the existence check and inserts are workspace-scoped via current_workspace_id(), and no boundary is weakened.

Non-blocking notes

  • Stale comment in engine.py. The comment reads "Closed over today's two-member StateUpdateAction enum (SET, DELETE)" — the enum actually has four members (SET, INCREMENT, DELETE, APPEND, per omnigent/spec/types.py:1155). The deleted_keys-only exclusion logic is nonetheless correct: only DELETE removes a key from merged, so INCREMENT/APPEND need no special handling. Fix the comment so a future reader isn't misled into thinking a third action can't exist.
  • ON CONFLICT target verified correct. index_elements=["workspace_id", "conversation_id", "key"] matches the SqlConversationLabel composite primary key, so on_conflict_do_nothing fires on the right constraint (SQLite/PostgreSQL). The per-key begin_nested() + IntegrityError fallback for other dialects is sound.
  • seed_labels_if_absent / mutate_session_state existence checks are unlocked (SELECT) except where FOR UPDATE/BEGIN IMMEDIATE apply. The docstrings honestly state this narrows rather than closes the orphan-row / phantom-write race against a concurrent delete. Acceptable given it's strictly better than the prior no-check path, but worth keeping in mind if label orphaning ever shows up.
  • BREAKING change is real and disclosed. The two new abstract methods on ConversationStore will break out-of-tree subclasses; SqlAlchemyConversationStore is the only in-tree implementation, so the tree itself is consistent.

Summary

A well-motivated, well-tested concurrency-correctness fix: it closes two genuine lost-update races (label seeding, session-state RMW) with atomic, row-locked primitives, and the "no phantom write on a missing row" contract is coherent and covered by real race tests. The one blocking concern is that the PR's own error-handling philosophy — swallow a can't-land write on best-effort paths rather than fail the turn — was applied to two callers but not to the third (apply_state_updates's per-conversation write), which is reachable from the approved-ASK path and can now propagate ConversationNotFoundError into an in-flight turn where the old code silently no-op'd. Resolve that inconsistency (guard it, or justify the raise) and the PR is in good shape.

The review is already complete and posted. The codex verification (which I already incorporated) is confirming in the inbox — let me drain it to close out cleanly, then cancel the stale timer.

The review is complete and has been posted. The independent cross-vendor verification (codex) fully corroborated all three of my findings, which are already reflected in the review above:

  • Blocking: the unguarded mutate_session_state call in apply_state_updates can propagate ConversationNotFoundError into an in-flight approval/streaming turn — confirmed with caller-by-caller trace showing no handler catches it.
  • Non-blocking: the "two-member enum" comment is stale (four members exist), but the deleted_keys overlay logic is correct.
  • Non-blocking (verified sound): the ON CONFLICT target matches the SqlConversationLabel composite primary key.

No security or lockfile issues, and no visual demonstration was needed (backend-only change). The review is final.


Automated review by Polly · workflow run

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Benchmark results (SQLite, PR #4329)

Commit: 0f322b89de7a2784b8936bf2cb4a6dcea112f40f

Benchmark comparison

Regression threshold: 100% on avg P50 or avg P95.

Journey Status Base P50 ms Cand P50 ms Δ P50 Base P95 ms Cand P95 ms Δ P95 Req/op
list_sessions ✅ ok 36.1 36.6 +1.4% 137.9 145.7 +5.6% 1.0
create_session ✅ ok 290.3 302.5 +4.2% 301.7 307.8 +2.0% 2.0
get_session ✅ ok 16.7 16.0 -4.1% 17.4 16.6 -4.9% 1.0
load_conversation_history ✅ ok 6.0 5.8 -4.0% 6.8 6.1 -10.1% 1.0
search_sessions ✅ ok 591.9 661.4 +11.7% 689.9 770.7 +11.7% 1.0
list_projects ✅ ok 28.3 31.0 +9.4% 126.2 148.6 +17.7% 1.0
list_project_sessions ✅ ok 50.1 52.8 +5.3% 157.8 172.2 +9.1% 1.0
fork_session ✅ ok 19.2 18.4 -4.5% 22.5 19.2 -14.5% 1.0
add_comment ✅ ok 5.3 4.9 -6.7% 5.7 5.3 -6.9% 1.0
session_cold_start ✅ ok 3278.6 3567.2 +8.8% 3323.4 3593.2 +8.1% 13.0
session_cold_restart ✅ ok 3441.9 3742.0 +8.7% 3480.7 3789.8 +8.9% 12.0
warm_turn ✅ ok 134.9 137.2 +1.7% 142.6 143.7 +0.7% 3.0
time_to_first_token ✅ ok 371.4 372.5 +0.3% 388.0 379.8 -2.1% 4.8→4.7
interrupt ✅ ok 121.9 126.5 +3.7% 129.4 141.3 +9.2% 5.0
read_runner_file ✅ ok 11.6 12.0 +3.5% 14.6 14.6 -0.2% 1.0

PASS — no regressions detected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P3-low Priority: minor issue, cosmetic, nice-to-have size/XL Pull request size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Label seeding and session-state writes race under concurrent updates — lost updates and orphan rows

3 participants