Add the operator kill switch, stop (A4) #41

Merged
dongar merged 1 commits from stop-command into main 2026-09-23 22:50:47 +00:00
Collaborator

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

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)
dev-agent added 1 commit 2026-09-23 22:47:30 +00:00
Add the operator kill switch, stop (A4)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 49s
abb7fa0216
`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>
dongar approved these changes 2026-09-23 22:50:39 +00:00
dongar merged commit 4a9354ae3b into main 2026-09-23 22:50:47 +00:00
dongar deleted branch stop-command 2026-09-23 22:50:47 +00:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dev/nisse#41