Skip to content

fix(short-term-memory): protect shared summarization, clear state on reset, close reliably - #1720

Open
xiongzubiao wants to merge 1 commit into
mainfrom
fix/stm-lifecycle-and-rwlock-reentrancy
Open

xiongzubiao wants to merge 1 commit into
mainfrom
fix/stm-lifecycle-and-rwlock-reentrancy

Conversation

@xiongzubiao

@xiongzubiao xiongzubiao commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Purpose of the change

Fixes three ShortTermMemory lifecycle bugs, one of which affects deletion.

Description

1. A cancelled query killed summarization for every waiter. wait_until_done() awaited the shared worker directly, so one cancelled query cancelled the worker, and its batch was lost. The worker is now shielded.

2. Clearing kept the summary of the cleared content, in memory and in the persisted copy: after delete_session_episodes() it still reached QueryResponse as episode_summary, and a new instance for the session restored it. clear_memory() now cancels in-flight summarization (its result would summarize the cleared content), drops the queued episodes and the summary, and overwrites the persisted copy. If a query is already waiting on that summarization, the query holds the lock, so the clear waits for it and then discards the result. It finishes even if the call is cancelled, holding the write lock until the cancelled worker has stopped, so no query or new batch meets the stopping worker. If overwriting the persisted copy fails, the error propagates after memory has been cleared. close() drops the in-memory summary too, but keeps the persisted copy for the next instance. In the server, session deletion closes the instance right after the clear and deletes the persisted row along with the session, so there this matters when a deletion fails before that row is deleted, which on main left the summary in the database.

3. A cancelled close() left the instance open and uncleared. close() now marks the instance closed before awaiting anything and runs its cleanup in a shielded task, so a cancelled close still completes, and a repeated close waits for the same cleanup. Calls made before close() still complete first, and get_summary() now waits for summarization and reads the summary under one hold of the lock, so no writer, the cleanup included, can run between the two.

Fixes/Closes

Fixes #1719

Type of change

  • Bug fix (non-breaking change which fixes an issue)

How Has This Been Tested?

  • Unit Test

Each bug test fails without its fix:

  • Fix 1: test_cancelled_query_does_not_kill_shared_summarization
  • Fix 2: test_clear_memory_discards_the_summary, test_clear_memory_clears_the_persisted_summary, test_clear_memory_cancels_in_flight_summarization, test_clear_memory_failed_save_still_clears_memory, test_clear_behind_a_waiting_query_discards_the_new_summary, test_cancelled_clear_does_not_strand_the_next_batch, test_cancelled_clear_does_not_cancel_the_next_query, test_clear_cancelled_during_cleanup_still_finishes_it, test_clear_queued_before_close_still_runs
  • Fix 3: test_close_cancelled_while_waiting_for_the_lock_still_closes, test_close_cancelled_during_cleanup_still_finishes_it

Two guards pass on main and must keep passing: test_close_persists_in_flight_summarization (see below) and test_calls_queued_before_close_still_run.

Changes to existing tests: #1517's regression test now uses a shared gate_summarization() helper with bounded waits, and its deadlock timeout goes from 1 s to 30 s so a slow runner isn't mistaken for the deadlock. A wrong comment in the message-length test is corrected.

Test Results:

Server suite: 2004 passed, 3 skipped. The short-term memory test file: 30 passed. ruff is clean, and ty reports no new diagnostics.

Checklist

  • I have signed the commit(s) within this pull request
  • My code follows the style guidelines of this project (See STYLE_GUIDE.md)
  • I have performed a self-review of my own code
  • I have commented my code
  • My changes generate no new warnings
  • I have added unit tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I have checked my code and corrected any misspellings

Maintainer Checklist

  • Confirmed all checks passed
  • Contributor has signed the commit(s)
  • Reviewed the code
  • Run, Tested, and Verified the change(s) work as expected

Screenshots/Gifs

N/A

Further comments

Why close() waits for summarization instead of cancelling it. With the default instance_cache_size of 0, the instance is closed at the end of nearly every request, including the request whose add_episodes() started summarization, so a close() that cancelled summarization would cancel it in nearly every request that starts one; test_close_persists_in_flight_summarization pins this. The trade-off: cancelling a close() no longer interrupts a hung model call, since its cleanup keeps waiting, as an uncancelled close always did.

Known gap. delete_episode() removes an episode but not its contribution to a summary it was already folded into; undoing that needs re-summarization, which is out of scope.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-30T16:07:22.329230Z e585c72 New commits
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@xiongzubiao
xiongzubiao requested a balanced review from Copilot September 29, 2026 16:15

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8ae0480679

ℹ️ 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".

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 review overview

🟡 Changes recommended

Cancelled closes can leave cleanup incomplete, and deleted episode summaries remain in persistent storage.

Review effort: Balanced
Findings: 2 High severity

Open (2)
What changed in this PR

This PR addresses cancellation and reset defects in the server’s short-term memory lifecycle and clarifies its read-lock contract.

Changes:

  • Shields shared summarization from a cancelled query.
  • Clears the in-memory summary on reset and marks a cancelled close as closed.
  • Adds regression tests and clarifies documentation about nested read locks.
File Description
packages/​server/​src/​memmachine_server/​episodic_memory/​short_term_memory/​short_term_memory.py Changes summarization, reset, and close behavior.
packages/​server/​src/​memmachine_server/​common/​rw_locks.py Documents the nested-lock hazard.
packages/​server/​server_tests/​memmachine_server/​episodic_memory/​short_term_memory/​test_short_term_memory.py Adds lifecycle regression tests.
packages/​server/​server_tests/​memmachine_server/​common/​test_rw_locks.py Qualifies nested-read test expectations.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

xiongzubiao added a commit that referenced this pull request Sep 29, 2026
…sted summary

Review on #1720 found gaps in the close() and reset fixes from its
first commit.

close() set _closed in a finally inside the write lock. A close
cancelled while waiting for the lock never reached it, and a close
cancelled while waiting for summarization set it without clearing
anything, after which every later close() returned early from
_do_reset(), so the cleanup could never finish.

close() now sets _closed before any await, and runs its cleanup in a
task it shields: the cleanup runs to completion even if close() is
cancelled, and a repeated close() awaits the same task. The cleanup
still waits for in-flight summarization rather than cancelling it, so
the result is persisted for the next instance. That is deliberate:
with instance_cache_size at 0 the instance cache closes an instance as
soon as its last reference is released, which is at the end of nearly
every request, so a close that cancelled summarization would cancel it
in nearly every request that starts one.

clear_memory() dropped only the in-memory summary. The copy saved
through save_short_term_memory() survived, so a new instance for the
session restored a summary of what had been cleared, including after
delete_session_episodes(). clear_memory() now cancels in-flight
summarization, whose result would summarize the cleared content (no
query can be awaiting the worker while the write lock is held), and
overwrites the persisted copy with an empty one. close() leaves the
persisted copy alone: releasing a cached instance must keep it for the
next one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9d24fdbb12

ℹ️ 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".

xiongzubiao added a commit that referenced this pull request Sep 29, 2026
Review on #1720 found a window in clear_memory(). It cancels the
summarization worker and waits for it to stop, but if clear_memory()
was itself cancelled during that wait, it released the write lock while
the worker was still running.

An add_episodes() that evicted in that window queued its batch behind
the stopping worker: summarize() starts no worker while one is running,
and the cancelled worker exits without draining the queue. The batch
then waited for a later eviction to start a worker, and was discarded
if close() ran first. With instance_cache_size at 0, close() runs at
the end of nearly every request. A query in that window failed with
CancelledError although nothing had cancelled it, because the shield in
wait_until_done() passes the worker's cancellation on to its waiters.
The cancelled clear also left behind the episodes and the summary it
was meant to clear. The cancellation came with the previous commit;
before it, clear_memory() waited for summarization instead.

clear_memory() now runs in a task it shields, as close() does, so it
holds the write lock until the worker has stopped, and finishes
clearing even when the call is cancelled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7385887331

ℹ️ 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".

xiongzubiao added a commit that referenced this pull request Sep 29, 2026
Review on #1720 found that a clear_memory() already waiting for the
lock did nothing if close() was called meanwhile: close() sets _closed
before it takes the lock, and clear_memory() checked _closed only once
it had the lock, so the queued clear returned normally without
clearing anything, the persisted summary included. add_episodes(),
get_summary() and get_short_term_memory_context() made the same check
and failed with ShortTermMemoryClosedError in that position, where on
main they ran before the close. Both came with an earlier commit on
this branch, the one that made close() set _closed first.

Each of these methods now checks _closed when it is called, before it
takes the lock, so a call made before close() queues ahead of the
cleanup and completes first. A call made after close() is still
rejected, and clear_memory() still does nothing then.

test_close_cancelled_while_waiting_for_the_lock_still_closes now waits
for the cleanup before checking that it cleared: the add it tries is
rejected at once, before the cleanup has had a turn, where it used to
wait for the lock behind it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
@xiongzubiao
xiongzubiao requested a balanced review from Copilot September 30, 2026 00:03

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 review overview

🟡 Changes recommended

An active query can still prevent reset from cancelling summarization, and a persistence failure can leave cleared episodes readable.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
Resolved since last review (2)

xiongzubiao added a commit that referenced this pull request Sep 30, 2026
Review on #1720 found that _clear_memory() overwrote the persisted
summary before clearing the episodes, so a failing
save_short_term_memory() raised with the summary already cleared but
the episodes still readable. The episodes are now cleared first, and
the error still propagates, so the caller knows the stored copy may
remain.

Review also found that clear_memory() cannot cancel summarization that
a query is already waiting on: the query holds the lock, so the clear
waits for that summary and then discards it. On main, clear_memory()
always waited. This behavior is unchanged; the docstring now says so,
and a new test pins that the summary does not survive the clear.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
@xiongzubiao
xiongzubiao requested a balanced review from Copilot September 30, 2026 13:11

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 review overview

🔵 Needs a closer look

Shared-task cancellation, lock ordering, and persisted state require final maintainer verification.

Review effort: Balanced
Findings: None

Resolved since last review (2)
Previously missed (1)

In code that hasn't changed since last review

Medium severity Correct close() docstring to reflect operations allowed after closure

packages/​server/​src/​memmachine_server/​episodic_memory/​short_term_memory/​short_term_memory.py:244

The new close() docstring says the instance rejects all new work, but delete_episode() can still acquire the write lock after close and get_summary_length() can still read. That makes the lifecycle contract misleading to callers. Limit the claim to additions and summary/context reads, which check _closed before taking the lock.

xiongzubiao added a commit that referenced this pull request Sep 30, 2026
Review on #1720 pointed out that close()'s docstring said the instance
rejects new work as soon as close() is called, while delete_episode()
and get_summary_length() still run after it. The docstring now names
what happens: add_episodes(), get_summary() and
get_short_term_memory_context() raise ShortTermMemoryClosedError, and
clear_memory() does nothing. No behavior changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
Three defects in the ShortTermMemory lifecycle (#1719):

- A cancelled query cancelled summarization for every waiter:
  wait_until_done() awaited the shared worker directly, so one
  cancelled query cancelled the worker and lost its batch. The worker
  is now shielded.

- Clearing kept the summary of the cleared content, in memory and in
  the persisted copy, so after delete_session_episodes() it still
  reached QueryResponse and a new instance for the session restored
  it. clear_memory() now cancels in-flight summarization, drops the
  queued episodes and the summary, and overwrites the persisted copy.
  If a query is already waiting on that summarization, the query holds
  the lock, so the clear waits for it and then discards the result.
  The clear runs in a shielded task and holds the write lock until the
  cancelled worker has stopped, so a cancelled clear still finishes
  and no query or new batch meets the stopping worker. The episodes are
  cleared before the persisted copy is overwritten, so a failed save
  still leaves memory cleared, and the error still propagates. close()
  drops the in-memory summary too, but keeps the persisted copy for
  the next instance.

- A cancelled close() left the instance open and uncleared. close()
  now marks the instance closed before awaiting anything and runs its
  cleanup in a shielded task, so a cancelled close still completes and
  a repeated close waits for the same cleanup. add_episodes(),
  clear_memory() and both query methods check for a close before
  taking the lock, so calls made before close() still complete, and
  get_summary() waits for summarization and reads the summary under
  one hold of the lock.

close() keeps waiting for in-flight summarization rather than
cancelling it: with instance_cache_size at 0, an instance is closed at
the end of nearly every request, including the one whose
add_episodes() started summarization, so a close() that cancelled it
would cancel summarization in nearly every request that starts one.

The #1517 regression test moves onto a shared gate_summarization()
helper with bounded waits and a 30 s deadlock timeout, and a wrong
comment in the message-length test is corrected.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013zM4AyQyFwwUv3RNmMKwgY
Signed-off-by: Zubiao Xiong <[email protected]>
@xiongzubiao
xiongzubiao force-pushed the fix/stm-lifecycle-and-rwlock-reentrancy branch from 4f0b971 to e585c72 Compare September 30, 2026 16:01

This branch has not been deployed

No deployments
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.

[Bug]: Three short-term memory lifecycle defects around cancellation and reset

2 participants