Skip to content

fix: scope model-switch/reset/error process kills to the invoking topic - #166

Merged
PleasePrompto merged 1 commit into
PleasePrompto:mainfrom
ryuhaneul:fix/topic-scoped-kill
Jul 12, 2026
Merged

fix: scope model-switch/reset/error process kills to the invoking topic#166
PleasePrompto merged 1 commit into
PleasePrompto:mainfrom
ryuhaneul:fix/topic-scoped-kill

Conversation

@ryuhaneul

Copy link
Copy Markdown
Contributor

Problem

Six call sites kill every registered CLI process for the whole chat via
ProcessRegistry.kill_all(chat_id):

  • switch_model (model selector) — /model confirmation
  • cmd_reset / cmd_reset_current/new, /reset command-registry handlers
  • session-error preserve, timeout preserve, and recovery-retry flows

In a multi-topic group every topic shares one chat_id, so any of these
events in one topic aborts other topics' in-flight turns and background
task subprocesses
. The killed CLI exits 143 and surfaces as
CLI returned empty output (exit=143); a background task hit this way is
marked failed/cancelled and its (already billed) work is lost.

This also contradicts the contract documented on Orchestrator.abort()
(the /stop handler): a topic-scoped stop deliberately leaves background
tasks and named sessions alone because they have their own management
surfaces (/tasks, /sessions), yet task/named-session subprocesses are
registered under the same chat bucket and get swept by the chat-wide kills.

Reproduce: in a group with topics, start a background task from topic A,
then run /model (pick a different model) in topic B → the task's
subprocess dies mid-run.

Fix

  • Switch the six call sites to kill_by_chat_topic(chat_id, topic_id)
    the primitive /stop already uses. The callers all have the full
    SessionKey in hand; they now use it.
  • Teach kill_by_chat_topic to preserve processes whose lifecycle is
    owned elsewhere (task:, task_result:, ns: labels), matching the
    Orchestrator.abort() contract. Preserved entries stay registered
    so TaskHub.cancel()kill_for_task() can still terminate them.
  • The recovery flow clears the matching topic-level abort flag
    (clear_topic_abort) instead of the chat-level one.
  • kill_all() itself is unchanged: /stop_all and shutdown teardown
    still sweep everything, tasks included.

Behavior notes:

  • Flat chats (topic_id=None: private chats, non-topic groups) behave as
    before, minus the protected labels.
  • /stop in a topic where only protected processes are running now
    reports "nothing to stop" — the task keeps running and is cancellable
    via /tasks.

Tests

  • kill_by_chat_topic preserves protected labels, keeps them registered,
    and kill_for_task still kills them; protected-only topics return 0
    and set no abort flag; cross-topic survival; topic_id=None flat-chat
    behavior; kill_all still sweeps protected labels.
  • Recovery retry asserts clear_topic_abort(chat, topic).
  • /stop zero-kill UX pinned at the Telegram handler.
  • Label prefixes pinned against their producers (task hub, result
    envelope origin, named-session flows).
  • Existing kill_all assertions for the six call sites updated.

13 files changed, +310/−74. Rollback = revert (no config/schema impact).

Note for merging alongside #164: this PR touches model_selector.py
and flows.py in hunks adjacent to #164 (per-session reasoning effort).
Both are independent single-commit PRs off current main; merging them
in either order yields at most trivial context conflicts.

Model switch, the /new and /reset command handlers, and the session
error/timeout/recovery flows killed every registered CLI process for
the chat via kill_all(chat_id). In multi-topic groups this aborted
other topics' in-flight turns and background task subprocesses
(exit=143, "CLI returned empty output"), losing their results.

Switch these call sites to kill_by_chat_topic(chat_id, topic_id) --
the primitive /stop already uses -- and teach kill_by_chat_topic to
preserve processes whose lifecycle is owned elsewhere (task:,
task_result:, ns: labels), matching the contract documented on
Orchestrator.abort() (the /stop handler). Preserved processes stay
registered so TaskHub.cancel() can still terminate them. kill_all()
semantics are unchanged for /stop_all and shutdown teardown.
@ryuhaneul
ryuhaneul marked this pull request as ready for review July 9, 2026 02:21
@PleasePrompto
PleasePrompto merged commit 78a8a01 into PleasePrompto:main Jul 12, 2026
PleasePrompto added a commit that referenced this pull request Jul 12, 2026
Post-merge fixes threading the Slack transport through main's newer
subsystems (#164 per-session effort, #157 append_system_prompt_files,
#166 topic-scoped kills, #137/#138 task changes):

- Delete fork-only .github/workflows/upstream-release-watch.yml
- Remove dead _is_message_addressed() in messenger/slack/bot.py
- Fix trailing comma in config.example.json Slack section (invalid JSON)
- Drop fork-only "ductor-slack" brand strings from en/wizard.toml
- Harden _normalized_transport_list() in __main__.py: honor a non-empty
  transports list whenever the primary is a member (primary-first, others
  preserved) instead of collapsing to [primary] on any ordering mismatch,
  so telegram+matrix multi-transport setups are never silently dropped;
  add regression tests
- named.py create(): keep both reasoning_effort and key params (# noqa:
  PLR0913, matching the codebase convention e.g. tasks/registry.py)
- Update #164 create() test stubs for the new key kwarg
- mypy: guard the optional slack_bolt import behind TYPE_CHECKING/else to
  satisfy strict no-redef
- uv lock: add slack-bolt/slack-sdk (also picks up pre-existing lock drift)
- ruff format integration-touched files

Note: orch._sessions.list_active_for_chat() in slack/bot.py follows the
existing private-access convention (telegram/startup.py does the same);
no public accessor exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PleasePrompto added a commit that referenced this pull request Jul 12, 2026
Reconcile #169 with main's #164 (per-session reasoning effort) and #166
(topic-scoped kills):
- keep #166's kill_by_chat_topic(chat_id, topic_id) instead of #169's kill_all
- keep #164's per-session effort resolution and sync_session_target(reasoning_effort=)
- route the topic branch of switch_model through #169's resolve_session_target

Review fixes for effort persistence, per-provider bucket staleness, and DRY
follow in a subsequent commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PleasePrompto added a commit that referenced this pull request Jul 12, 2026
…olve_session_target

Review fixes on top of the #169 merge:

1. Effort persistence (reconcile with #164): resolve_session_target now takes
   reasoning_effort and writes it onto the created/retargeted session, so a
   topic /model that also picks an effort survives to the next message. The
   topic branch of switch_model passes the resolved effort through.

2. Per-provider staleness: when a session is stale only because the TARGET
   provider bucket hit max_session_messages, reset ONLY that bucket and keep
   the other providers' history instead of wiping the whole shell. Session-wide
   staleness (idle timeout / daily reset) still replaces the full shell.

3. DRY: factor the freshness primitives (_within_message_limit, _time_fresh)
   shared by resolve_session and resolve_session_target; drop the provider-
   mutating _is_fresh_for_target hack.

4. switch_model: extract _persist_switch_target/_SwitchContext to stay within
   complexity limits (no lint suppressions); keep #166's topic-scoped kills.

Tests: update the bucket-wipe assertions to per-bucket reset, add a genuine
idle-stale whole-shell test, add the codex-fresh + claude-count-stale -> /model
claude keeps codex case, and switch the topic mocks to kill_by_chat_topic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants