Planning turns, milestones and review responses ran on the thread that delivered the message. So the Matrix sync loop read nothing new until the work finished, and a stop would have arrived after the work it was meant to stop.
The state store also had a latent crash. It was opened on the main thread and used from the Matrix and poller threads, which SQLite refuses by default (ProgrammingError: ... same thread). Tests missed it because the in-memory transport runs handlers on the test's own thread.
What changed
A single worker thread (worker.py) runs every handler and owns the store.
The store is opened on the worker. check_same_thread stays on, so it trips if anything touches the store from another thread (it caught one test already).
Matrix messages are handed to the worker.
Pollers hand over and wait, so their cursor never moves past a comment that hasn't been handled.
A message that arrives during long work gets an immediate busy (EXECUTING); say 'stop' to halt reply. It's recorded, not applied later in a state you weren't answering.
Unit tests run through InlineWorker, which queues a job submitted from inside another, as the real worker would.
stop (cancel.py) sets a shared flag straight from the transport's thread.
The executor and delegation loops check it before each model call and between tool calls. The planner checks it before each turn.
Calls left unrun get a result saying so, which keeps the transcript renderable.
A stopped delegation is discarded, like a failed one.
The agent moves to AWAIT_INPUT and asks why it was stopped. Your reply is recorded for the model. A stopped planning turn resumes with your reply as its answer.
stop with nothing running replies "nothing to stop" and changes nothing. At a gate it's no longer treated as rejection feedback.
Tests
test_stop.py:
stop during a milestone, during planning, and during a delegation;
the reason reaching the transcript;
stop when idle or at a gate, and from a non-operator;
the busy reply;
a real-thread test showing status and stop delivered while a milestone runs.
test_worker.py: ordering, errors, nested calls, the same-thread check, and inline deferral.
test_delegation.py: a stopped delegation discards its branch and settles its intent.
scripts/check.sh passes: 333 unit tests, and the integration suite with 17 passed and 5 skipped.
Deferred
Killing a command that's still running on stop. It currently finishes, within its 120 s timeout.
A model call already in flight when stop arrives also finishes first.
Closes #40.
## Why it needed more than a command
Planning turns, milestones and review responses ran on the thread that delivered the message. So the Matrix sync loop read nothing new until the work finished, and a `stop` would have arrived after the work it was meant to stop.
The state store also had a latent crash. It was opened on the main thread and used from the Matrix and poller threads, which SQLite refuses by default (`ProgrammingError: ... same thread`). Tests missed it because the in-memory transport runs handlers on the test's own thread.
## What changed
**A single worker thread** (`worker.py`) runs every handler and owns the store.
- The store is opened on the worker. `check_same_thread` stays on, so it trips if anything touches the store from another thread (it caught one test already).
- Matrix messages are handed to the worker.
- Pollers hand over and wait, so their cursor never moves past a comment that hasn't been handled.
- A message that arrives during long work gets an immediate `busy (EXECUTING); say 'stop' to halt` reply. It's recorded, not applied later in a state you weren't answering.
- Unit tests run through `InlineWorker`, which queues a job submitted from inside another, as the real worker would.
**`stop`** (`cancel.py`) sets a shared flag straight from the transport's thread.
- The executor and delegation loops check it before each model call and between tool calls. The planner checks it before each turn.
- Calls left unrun get a result saying so, which keeps the transcript renderable.
- A stopped delegation is **discarded**, like a failed one.
- The agent moves to `AWAIT_INPUT` and asks why it was stopped. Your reply is recorded for the model. A stopped planning turn resumes with your reply as its answer.
- `stop` with nothing running replies "nothing to stop" and changes nothing. At a gate it's no longer treated as rejection feedback.
## Tests
- `test_stop.py`:
- stop during a milestone, during planning, and during a delegation;
- the reason reaching the transcript;
- `stop` when idle or at a gate, and from a non-operator;
- the busy reply;
- a real-thread test showing `status` and `stop` delivered while a milestone runs.
- `test_worker.py`: ordering, errors, nested calls, the same-thread check, and inline deferral.
- `test_delegation.py`: a stopped delegation discards its branch and settles its intent.
- `scripts/check.sh` passes: 333 unit tests, and the integration suite with 17 passed and 5 skipped.
## Deferred
- Killing a command that's still running on `stop`. It currently finishes, within its 120 s timeout.
- A model call already in flight when `stop` arrives also finishes first.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
`stop` could not have worked by adding the command alone. Planning
turns, milestones and review responses ran synchronously on the thread
that delivered the message, so the Matrix sync loop read nothing until
the work finished. The state store was also opened on the main thread
and used from the transport and poller threads, which SQLite refuses
with its default same-thread check; the in-memory test transport never
showed it.
A single worker thread now runs every handler and owns the store:
- The worker opens the store, so the same-thread check stays on and
fails loudly if anything touches the store from elsewhere
- Matrix messages are handed to the worker; pollers hand over and wait,
so their cursor never passes an unhandled comment
- A message that arrives during long work gets an immediate "busy; say
'stop' to halt" reply and is recorded, not applied later in a state
the operator was not answering
- Tests keep running inline through InlineWorker, which defers a job
submitted from inside another as the real worker would queue it
`stop` sets a shared Cancel flag straight from the transport's thread.
The executor and delegation loops check it before each model call and
between tool calls; the planner checks it before each turn. Calls left
unrun get a result saying so, so the transcript still renders. A
stopped delegation is discarded like a failed one. The orchestrator
moves to AWAIT_INPUT and asks why the work was stopped. The reply is
recorded for the model, and a stopped planning turn resumes with it as
the answer. `stop` with nothing running says so and changes nothing; at
a gate it is no longer taken as rejection feedback.
Closes#40
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #40.
Why it needed more than a command
Planning turns, milestones and review responses ran on the thread that delivered the message. So the Matrix sync loop read nothing new until the work finished, and a
stopwould have arrived after the work it was meant to stop.The state store also had a latent crash. It was opened on the main thread and used from the Matrix and poller threads, which SQLite refuses by default (
ProgrammingError: ... same thread). Tests missed it because the in-memory transport runs handlers on the test's own thread.What changed
A single worker thread (
worker.py) runs every handler and owns the store.check_same_threadstays on, so it trips if anything touches the store from another thread (it caught one test already).busy (EXECUTING); say 'stop' to haltreply. It's recorded, not applied later in a state you weren't answering.InlineWorker, which queues a job submitted from inside another, as the real worker would.stop(cancel.py) sets a shared flag straight from the transport's thread.AWAIT_INPUTand asks why it was stopped. Your reply is recorded for the model. A stopped planning turn resumes with your reply as its answer.stopwith nothing running replies "nothing to stop" and changes nothing. At a gate it's no longer treated as rejection feedback.Tests
test_stop.py:stopwhen idle or at a gate, and from a non-operator;statusandstopdelivered while a milestone runs.test_worker.py: ordering, errors, nested calls, the same-thread check, and inline deferral.test_delegation.py: a stopped delegation discards its branch and settles its intent.scripts/check.shpasses: 333 unit tests, and the integration suite with 17 passed and 5 skipped.Deferred
stop. It currently finishes, within its 120 s timeout.stoparrives also finishes first.🤖 Generated with Claude Code