CI / checks (push) Skipped
CI / checks (pull_request) Successful in 49s
`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>
84 lines
2.4 KiB
Python
84 lines
2.4 KiB
Python
import io
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.state import StateStore
|
|
from nisse_dev.worker import InlineWorker, SerialWorker
|
|
|
|
|
|
@pytest.fixture
|
|
def worker() -> SerialWorker:
|
|
w = SerialWorker()
|
|
w.start()
|
|
yield w
|
|
w.stop(timeout=5)
|
|
|
|
|
|
def test_jobs_run_in_order_on_the_worker_thread(worker: SerialWorker) -> None:
|
|
seen: list[tuple[int, str]] = []
|
|
for n in range(3):
|
|
worker.submit(lambda n=n: seen.append((n, threading.current_thread().name)))
|
|
worker.call(lambda: None) # drains everything queued before it
|
|
|
|
assert [n for n, _ in seen] == [0, 1, 2]
|
|
assert {name for _, name in seen} == {"nisse-worker"}
|
|
|
|
|
|
def test_call_returns_the_result_and_raises_the_error(worker: SerialWorker) -> None:
|
|
assert worker.call(lambda: 42) == 42
|
|
|
|
def boom() -> None:
|
|
raise ValueError("bad job")
|
|
|
|
with pytest.raises(ValueError, match="bad job"):
|
|
worker.call(boom)
|
|
|
|
|
|
def test_a_failed_job_is_reported_and_the_worker_carries_on() -> None:
|
|
errors: list[BaseException] = []
|
|
worker = SerialWorker(on_error=errors.append)
|
|
worker.start()
|
|
|
|
def boom() -> None:
|
|
raise RuntimeError("lost job")
|
|
|
|
worker.submit(boom)
|
|
assert worker.call(lambda: "still here") == "still here"
|
|
assert [str(e) for e in errors] == ["lost job"]
|
|
worker.stop(timeout=5)
|
|
|
|
|
|
def test_call_from_the_worker_itself_runs_inline(worker: SerialWorker) -> None:
|
|
# Queueing behind itself would deadlock.
|
|
assert worker.call(lambda: worker.call(lambda: "nested")) == "nested"
|
|
|
|
|
|
def test_the_store_is_usable_only_from_the_thread_that_owns_it(
|
|
worker: SerialWorker, tmp_path: Path
|
|
) -> None:
|
|
store = worker.call(
|
|
lambda: StateStore(tmp_path / "state.db", oplog=OpLog("t", io.StringIO()))
|
|
)
|
|
assert worker.call(store.get_state) is None
|
|
# The tripwire the worker design keeps on: any other thread fails loudly.
|
|
with pytest.raises(Exception, match="same thread"):
|
|
store.get_state()
|
|
worker.call(store.close)
|
|
|
|
|
|
def test_inline_worker_defers_a_job_submitted_from_inside_a_job() -> None:
|
|
worker = InlineWorker()
|
|
order: list[str] = []
|
|
|
|
def outer() -> None:
|
|
order.append("outer start")
|
|
worker.submit(lambda: order.append("inner"))
|
|
order.append("outer end")
|
|
|
|
worker.submit(outer)
|
|
assert order == ["outer start", "outer end", "inner"]
|
|
assert not worker.busy
|