Add the operator kill switch, stop (A4)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 49s
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>
This commit is contained in:
1 parent
d2fe3d5642
commit
abb7fa0216
12 files changed
+887
-39
No files matched your search
@@ -28,6 +28,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Literal
|
||||
|
||||
from nisse_dev.cancel import Cancel, skip_calls
|
||||
from nisse_dev.canonical import (
|
||||
AssistantText,
|
||||
DelegationCompleted,
|
||||
@@ -129,7 +130,7 @@ class Handback:
|
||||
|
||||
@dataclass
|
||||
class DelegationResult:
|
||||
status: Literal["handback", "capped", "failed", "interrupted"]
|
||||
status: Literal["handback", "capped", "failed", "interrupted", "stopped"]
|
||||
note: str
|
||||
handback: Handback | None = None
|
||||
branch: str | None = None
|
||||
@@ -150,9 +151,9 @@ class DelegationResult:
|
||||
|
||||
@property
|
||||
def usable(self) -> bool:
|
||||
"""Only a successful handback may be built upon. A cap breach or a
|
||||
failure leaves a half-edited tree, which A13 says must gate rather
|
||||
than be resumed."""
|
||||
"""Only a successful handback may be built upon. A cap breach, a
|
||||
failure or an operator's stop leaves a half-edited tree, which A13
|
||||
says must gate rather than be resumed."""
|
||||
return self.status == "handback" and self.handback is not None
|
||||
|
||||
|
||||
@@ -168,6 +169,7 @@ class Delegation:
|
||||
backing_model: str = "",
|
||||
caps: Caps | None = None,
|
||||
now: Callable[[], float] = time.monotonic,
|
||||
cancel: Cancel | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._store = store
|
||||
@@ -177,6 +179,10 @@ class Delegation:
|
||||
self._backing_model = backing_model
|
||||
self._caps = caps or Caps()
|
||||
self._now = now
|
||||
# A stopped delegation is discarded like a failed one, not preserved
|
||||
# for resuming as an interrupted one is: the operator stopped it for
|
||||
# a reason, and the next step is to talk about that reason.
|
||||
self._cancel = cancel or Cancel()
|
||||
|
||||
def run(
|
||||
self, *, brief: str, branch: str, role: str = "delegate"
|
||||
@@ -316,6 +322,14 @@ class Delegation:
|
||||
spent = 0
|
||||
|
||||
for turn in range(self._caps.turns):
|
||||
if self._cancel.requested:
|
||||
return DelegationResult(
|
||||
"stopped",
|
||||
f"stopped by the operator before turn {turn + 1}",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
elapsed = self._now() - started_at
|
||||
if elapsed >= self._caps.wall_clock_s:
|
||||
return DelegationResult(
|
||||
@@ -386,7 +400,16 @@ class Delegation:
|
||||
tokens=spent,
|
||||
)
|
||||
|
||||
for call in calls:
|
||||
for index, call in enumerate(calls):
|
||||
if self._cancel.requested:
|
||||
skip_calls(self._store, calls[index:])
|
||||
return DelegationResult(
|
||||
"stopped",
|
||||
f"stopped by the operator during turn {turn + 1}",
|
||||
branch=branch,
|
||||
artifacts=sorted(touched),
|
||||
tokens=spent,
|
||||
)
|
||||
if call.tool == "hand_back":
|
||||
handback = _parse_handback(call.arguments)
|
||||
if handback is not None and handback.succeeded and not did_work:
|
||||
|
||||
Reference in new issue
Block a user