Skip to content

fix(sessions): scope Claude sessions per chat + thread (DM/group/topic) - #6

Merged
paulpwo merged 19 commits into
mainfrom
feat/session-scoping
Apr 13, 2026
Merged

paulpwo merged 19 commits into
mainfrom
feat/session-scoping

Conversation

@paulpwo

@paulpwo paulpwo commented Apr 13, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes the bug where a forum-topic Claude session was leaking into DM conversations (and vice versa) because context.user_data["claude_session_id"] is only scoped per-user — it ignores which chat/thread the user is writing from.

Now every Claude session is keyed by a namespaced scope {chat_id}:{thread_id}:

  • DMs → dm:<user_id> with an isolated workdir /workspace/_dm_<user_id>
  • Groups (no topics) → <chat_id>:main
  • Forum topics → <chat_id>:<thread_id>

Classic DM usage keeps working — no features lost, no migration pain for existing users (legacy rows get is_active=FALSE and users just start a fresh session).

What changed

  • New helper: src/bot/session_scope.py — scope_key, user_data_session_key, is_dm, dm_workdir_for, ensure_dm_workdir
  • DB migration v8: adds chat_id + thread_id to sessions + idx_sessions_scope; deactivates pre-v8 rows
  • Storage: SessionStorage + repos now read/write scope columns; new get_active_session_by_scope
  • Claude layer: session.py + facade.py plumb scope through session resolution
  • Handlers: 51 call sites in command.py, callback.py, message.py, sdd_handler.py + 12 in orchestrator.py replaced to use scoped keys
  • DM workdir: orchestrator provisions /workspace/_dm_<user_id> and assigns it to current_dir when is_dm(update) at 3 sites
  • Tests: tests/bot/test_session_scope.py, tests/unit/test_storage/test_migration_v8.py, new orchestrator tests
  • Docs: docs/session-scoping.md with chat-type matrix

Test plan

  • Migration v8 runs cleanly on prod DB (backup bot.db.pre-v8 taken on EC2)
  • DM conversation starts fresh session (isolated workdir)
  • Group chat without topics keeps one session per chat
  • Forum topic A and topic B have independent sessions
  • Switching between DM ↔ topic does NOT leak session state
  • Existing active sessions at deploy time are deactivated (users see "new session") — expected

🤖 Generated with Claude Code

paulpwo and others added 19 commits April 13, 2026 08:55
…atch 1

Confirms design decision: SessionManager identity stays on session_id (the
globally unique string assigned by Claude). Scope resolution lives at the
handler boundary, which selects the right session_id and hands it to the
facade. ClaudeSession / SessionManager / ClaudeIntegration do not need
chat_id or thread_id fields in batch 1.

See sdd/session-scoping/design for the full rationale.
Add ensure_dm_workdir helper that idempotently creates the per-user DM
working directory and raises DmWorkdirError on failure. Wire it into
every agentic Claude invocation path in MessageOrchestrator
(agentic_text, agentic_document, _handle_agentic_media_message) via a
_ensure_dm_workdir_or_abort helper that replies with an explicit error
and aborts the turn on mkdir failure. No silent fallback to a shared
workdir — per-user isolation is load-bearing.
…on v8

Add smoke tests proving the scope-key helper fixes the bug:

* DM and forum-topic scopes yield distinct user_data session keys for
  the same user
* /new in one scope does not clear another scope's session
* Restart simulation (fresh user_data) recovers the right session per
  scope via an in-memory stand-in for load_session_by_scope
* Legacy pre-v8 rows (chat_id IS NULL) are excluded from scope lookups
* DM workdir helper creates the directory, is idempotent, and raises
  DmWorkdirError when mkdir fails

Add migration v8 tests (tests/unit/test_storage/test_migration_v8.py):
fresh DB gets chat_id/thread_id columns + idx_sessions_scope index,
legacy rows flipped inactive by the v8 UPDATE, and re-initializing an
already-v8 DB is a no-op (migrations gated by schema_version).
Add docs/session-scoping.md explaining:
* (user_id, chat_id, thread_id) scope triple derivation
* DM convention /workspace/_dm_<user_id> with fail-loud mkdir policy
* Migration v8 forward-only + v9 rollback DDL placeholder
* src/bot/session_scope.py as the single entry point

Link from docs/README.md index.
test_agentic_text_calls_claude and test_agentic_voice_calls_claude run
in DM scope (chat_id == user_id == 123), which now triggers the
lazy /workspace/_dm_<user_id> provisioning added in this branch. The
test host has no writable /workspace, so the real mkdir would abort
the turn and Claude would never be invoked.

Monkeypatch ensure_dm_workdir in both tests to a tmp no-op — the
fail-loud behaviour itself is already covered by the
tests/bot/test_session_scope.py suite.
When the scope is a DM, resolve current_dir to dm_workdir_for(user_id)
instead of the shared approved_directory. Without this, Claude kept
running at /workspace root for DMs — ensure_dm_workdir created the
per-user path but nothing routed it to run_command, defeating R2.

Applied in agentic_text, agentic_document, and _handle_agentic_media_message
right after _ensure_dm_workdir_or_abort. Non-DM scopes (groups and
forum topics) keep the existing resolution (explicit current_directory
or settings.approved_directory).
Two new tests cover the R2 binding:

- test_agentic_text_dm_uses_dm_workdir_as_working_directory asserts
  that a DM update causes run_command to receive
  working_directory=/workspace/_dm_<user_id> — not the shared
  approved_directory.
- test_agentic_text_forum_topic_does_not_use_dm_workdir guards the
  non-DM branch: a forum-topic update keeps approved_directory and
  never calls ensure_dm_workdir, so we do not accidentally route
  every scope through the DM path.
Spell out the three chat-type cases (DM, plain group, forum topic) in a
table so readers do not have to cross-reference the helper module and
the orchestrator to understand where Claude runs. Also add an explicit
warning that classic-mode handlers do NOT provision DM workdirs — a
revisit item if classic mode is ever re-enabled.
CI black --check was flagging 5 files. No logic changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a regex-based redactor (src/security/secret_scrubber.py) that
replaces common secret formats — GitHub PATs (classic + fine-grained),
Anthropic / OpenAI keys, AWS access keys, Slack tokens, Google API
keys — with [REDACTED-<KIND>] tags before the text reaches the DB.

Wired into MessageRepository.save_message() so prompt, response, and
error are all scrubbed on write.

Motivated by a 2026-04-13 incident where a typo of `/git set` leaked
a GitHub PAT into messages.prompt as plaintext. Scrubbing is applied
only at persistence/logging boundaries — never before sending text to
Claude, since Claude may legitimately need the literal value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@paulpwo paulpwo self-assigned this Apr 13, 2026
@paulpwo
paulpwo merged commit 3106fe7 into main Apr 13, 2026
2 checks passed
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.

1 participant