fix(memory): reject reused branch IDs when creating a branch - #4152
fix(memory): reject reused branch IDs when creating a branch#4152hsusul wants to merge 1 commit into
Conversation
AdvancedSQLiteSession.create_branch_from_turn copied history into whatever branch ID it was given without checking whether that branch already existed, so an existing name silently appended the copied turns to that branch. The generated default name only has one-second resolution, so branching twice from the same turn within the same second hit the same path with no user error involved. Resolve the target branch ID inside the locked connection that performs the copy: reject an explicit ID that is already in use and disambiguate a generated one.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8c48e2f26c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| SELECT 1 FROM message_structure | ||
| WHERE session_id = ? AND branch_id = ? | ||
| LIMIT 1 |
There was a problem hiding this comment.
Track branch identity independently of message rows
When a branch is created from turn 1, it copies no messages, so this query records no evidence that its ID exists and a subsequent explicit or same-second generated creation can reuse that ID. The same check is also racy when separate processes share a file-backed database: the lock is explicitly process-local in src/agents/memory/sqlite_session.py, and without a write transaction or unique database key both processes can observe the ID as absent and then merge copied histories under it. Persist branch identity under a unique (session_id, branch_id) constraint and allocate it transactionally so the documented uniqueness guarantee applies to empty branches and concurrent writers.
AGENTS.md reference: AGENTS.md:L134-L134
Useful? React with 👍 / 👎.
seratch
left a comment
There was a problem hiding this comment.
Please resolve all the review feedback from codex first.
|
Thanks again for working on this issue here. Your contribution will be included in #4186 |
Summary
AdvancedSQLiteSession.create_branch_from_turn()never checked whether the target branch ID was already in use._copy_messages_to_new_branch()insertedmessage_structurerows tagged with that ID unconditionally, so creating a branch whose ID already existed appended the copied history to that branch instead of creating a new one. The call still returned the ID and switched to it, so the caller got no signal.Two ways to hit it:
branch_nameomitted the ID isf"branch_from_turn_{turn_number}_{int(time.time())}". That is one-second resolution, so branching twice from the same turn within the same second — the documentedawait session.create_branch_from_turn(2)form — reuses the ID and silently duplicates the copied history. No user error is involved."main", which duplicated the base branch's own history into itself.This resolves the target branch ID inside the locked connection that performs the copy: an explicit ID that is already in use raises
ValueError, and a generated ID is disambiguated with a numeric suffix.Affected component:
src/agents/extensions/memory/advanced_sqlite_session.py(AdvancedSQLiteSessionconversation branching).create_branch_from_content()inherits the fix since it delegates.Minimal reproduction (no API key, no model call, no network):
Current behavior: prints
True 8. Both calls returnbranch_from_turn_3_1700000000, the branch holds the first two turns twice, andlist_branches()reports one branch where the caller created two. Separately,create_branch_from_turn(2, "main")growsmainfrom 6 items to 8.Corrected behavior: prints
False 4. The two calls produce distinct IDs and each branch holds exactly one copy of the pre-branch history.create_branch_from_turn(2, "main")now raisesValueErrorand leavesmainuntouched.Root cause
_copy_messages_to_new_branch()selected the rows to copy and inserted them undernew_branch_idwith no existence check on that ID, andcreate_branch_from_turn()derived its default ID from a one-second timestamp without consulting existing branches.Implementation
_branch_id_exists()— a small helper that asks whether anymessage_structurerow already uses a branch ID, matching howswitch_to_branch(),delete_branch(), andlist_branches()already define branch existence._generate_branch_id()— appends a numeric suffix to the timestamp-based base name until it is unused._copy_messages_to_new_branch()now takesstr | Noneand returns the resolved branch ID. Both the existence check and the ID generation run against the cursor that performs the inserts, inside the same_locked_connection(), so the check cannot be separated from the write by a concurrent branch creation. Doing this in a separateasyncio.to_thread()step would release the lock in between.create_branch_from_turn()drops its local ID generation and uses the returned ID for the branch pointer, the debug log, and its return value.Why this is minimal: no public API signature changes, no schema change, and the copy semantics (which rows are copied, sequence numbering, shared message rows) are untouched. The rejection uses
ValueError, which the method already documented for invalid branch requests and whichdelete_branch()andswitch_to_branch()already raise for branch-state errors. TheValueErroris raised before any DML, so a rejected call performs no write and leaves the current-branch pointer alone.Test plan
Six regression tests in
tests/extensions/memory/test_advanced_sqlite_session.py. All five that assert the corrected behavior fail onupstream/mainat c546ca1 and pass with the fix; the sixth is a boundary guard that passes both before and after.test_create_branch_rejects_an_existing_branch_idtest_create_branch_rejects_the_main_branch_idmainraises instead of duplicating it into itselftest_generated_branch_ids_stay_unique_within_the_same_secondtest_generated_branch_ids_stay_unique_across_many_callstest_create_branch_from_content_rejects_an_existing_branch_idcreate_branch_from_content()inherits the contracttest_create_branch_from_the_first_turn_still_succeedsThe clock is frozen with
monkeypatch.setattr(time, "time", ...), so the collision is deterministic rather than timing-dependent. No API key, model call, network access, or sleep is involved; the sessions are in-memory SQLite.Before the fix:
After the fix:
The focused module was run 5x with
-p no:randomlyand the memory suite 3x under the default random ordering; results were identical each time.Full verification stack via
.agents/skills/code-change-verification/scripts/run.sh— all commands passed:The docs line was added after that run, so
uv run ruff format --check .(847 files already formatted),uv run ruff check .(All checks passed),uv run pytest tests/test_doc_parsing.py tests/extensions/memory/test_advanced_sqlite_session.py -q(77 passed), andmake build-docs(built successfully, no new warnings) were re-run afterwards.git diff --checkis clean.Compatibility
Callers that previously reused a branch ID now get a
ValueErrorinstead of a silently corrupted branch. That path never produced a usable new branch, so no working behavior is lost. Generated IDs keep their existingbranch_from_turn_{turn}_{timestamp}shape and only gain a_2,_3, ... suffix when that exact ID is already taken. The stored schema, the copy semantics, and every public signature are unchanged.Non-goals
from_turn_number.switch_to_branch(),delete_branch(), orclear_session().Limitations
ValueErrorcovers a branch that already holds messages. Branching from turn 1 copies no rows, so such a branch leaves nomessage_structurerow and is not "existing" by the definition the rest of the class already uses; that behavior is unchanged and is covered bytest_create_branch_from_the_first_turn_still_succeeds.docs/sessions/advanced_sqlite_session.mdwas updated; the generateddocs/ja,docs/ko, anddocs/zhpages were intentionally left alone.Issue number
Closes #4150
Checks
.agents/skills/code-change-verification/scripts/run.sh/reviewbefore submitting this PR