Put work items in the state store, and bind the log to them (M1.1)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s

Development Plan M1.1, as discussed.

- Schema v4: a work_items table (id, scope, title, status, outcome,
  position, data) and an agent row whose active_item points at one
  (NULL: idle). Canonical events and intents carry their work item, and
  events their actor. Older stores are refused with a clear message:
  there was no production data, so no migration (the mechanism stays).
- The log is reached through a Transcript bound to one work item and
  one actor: store.transcript(item, actor) reads only that item and
  writes with both. The planner, executor, delegation and expert desk
  get one per call from the orchestrator instead of holding the store,
  so a component can neither read another task's events (the planner
  read every earlier task's, #78) nor write without attribution.
  store.all_events() is kept for readers that span items (evaluation
  capture, dumps). Intents are begun through the transcript or with
  work_item=, so each belongs to its item.
- The orchestrator opens a work item on 'use', closes it with its
  outcome (completed or abandoned), and carries what the operator said
  while idle into the new item as copies with their origin, so a task
  sent before 'use' is still planned from (D1). control.py keeps its
  state machine through get_state()/transition(), now over the active
  work item, until M2.6 retires it.
- nisse_dev.dump shows work items and each event's item and actor.

Tests: test_work_items.py (scoping, attribution, intent ownership,
restart, outcomes, carried messages, a second task never seeing the
first); the suites updated for the per-call transcript.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-01 23:20:29 +00:00
1 parent 3cf4cefacd
commit 83804fe02a
40 files changed
+1026 -363

No files matched your search

+18 -16
View File
@@ -23,7 +23,7 @@ from nisse_dev.executor import ExecutorOutcome, LLMExecutor
from nisse_dev.llm import RoleMap
from nisse_dev.oplog import OpLog
from nisse_dev.planner import LLMPlanningAgent
from nisse_dev.state import StateStore
from nisse_dev.state import StateStore, Transcript
from nisse_dev.tools import ToolOutcome
from nisse_dev.transport import InMemoryTransport
from nisse_dev.worker import SerialWorker
@@ -36,13 +36,17 @@ ROLES = RoleMap(
)
def log(store: StateStore, actor: str = "executor") -> Transcript:
return store.transcript("t", actor)
def new_store(path: Path) -> StateStore:
return StateStore(path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
def user_messages(store: StateStore) -> list[str]:
return [
e.event.text for e in store.read_events() if isinstance(e.event, UserMessage)
e.event.text for e in store.all_events() if isinstance(e.event, UserMessage)
]
@@ -58,7 +62,7 @@ class OperatorDuringWork:
self.says = says
self.transport: InMemoryTransport | None = None
def run(self, milestone: int) -> ExecutorOutcome:
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
assert self.transport is not None
self.transport.receive("don", self.says)
if self.cancel.requested:
@@ -158,6 +162,7 @@ class StopsBeforePlanning:
def plan(
self,
transcript: object,
gate: str,
project: str,
feedback: str | None = None,
@@ -200,7 +205,7 @@ class BlocksUntilStopped:
self.cancel = cancel
self.running = threading.Event()
def run(self, milestone: int) -> ExecutorOutcome:
def run(self, transcript: object, milestone: int) -> ExecutorOutcome:
self.running.set()
for _ in range(500):
if self.cancel.requested:
@@ -287,10 +292,8 @@ def test_executor_stops_before_a_turn_without_calling_the_model(
cancel = Cancel()
cancel.set()
client = ScriptedClient([])
executor = LLMExecutor(
client, new_store(tmp_path), ROLES, StopsAfterFirstCall(cancel), cancel=cancel
)
outcome = executor.run(1)
executor = LLMExecutor(client, ROLES, StopsAfterFirstCall(cancel), cancel=cancel)
outcome = executor.run(log(new_store(tmp_path)), 1)
assert outcome == ExecutorOutcome("stopped", "before turn 1")
assert client.calls == 0
@@ -305,14 +308,14 @@ def test_executor_stops_between_tool_calls_and_records_the_skipped_ones(
ToolCall(call_id="a", tool="run_command", arguments={"command": "make"}),
ToolCall(call_id="b", tool="write_file", arguments={"path": "x"}),
]
executor = LLMExecutor(ScriptedClient([calls]), store, ROLES, ws, cancel=cancel)
outcome = executor.run(1)
executor = LLMExecutor(ScriptedClient([calls]), ROLES, ws, cancel=cancel)
outcome = executor.run(log(store), 1)
assert outcome == ExecutorOutcome("stopped", "during turn 1")
assert ws.ran == ["run_command"]
results = {
e.event.call_id: e.event.result
for e in store.read_events()
for e in log(store).read()
if isinstance(e.event, ToolResult)
}
# Every call has a result, so the transcript still renders.
@@ -320,7 +323,7 @@ def test_executor_stops_between_tool_calls_and_records_the_skipped_ones(
class StoppedDelegate:
def run(self, *, brief: str, branch: str) -> DelegationResult:
def run(self, transcript: object, *, brief: str, branch: str) -> DelegationResult:
return DelegationResult("stopped", "stopped by the operator during turn 2")
@@ -339,12 +342,11 @@ def test_a_stopped_delegation_stops_the_milestone(tmp_path: Path) -> None:
executor = LLMExecutor(
ScriptedClient(failing),
new_store(tmp_path),
ROLES,
Failing(),
delegate=StoppedDelegate(),
)
outcome = executor.run(1)
outcome = executor.run(log(new_store(tmp_path)), 1)
assert outcome.status == "stopped"
assert "during a delegation" in outcome.note
@@ -353,9 +355,9 @@ def test_planner_does_not_start_a_turn_once_stopped(tmp_path: Path) -> None:
cancel = Cancel()
cancel.set()
client = ScriptedClient([])
planner = LLMPlanningAgent(client, new_store(tmp_path), ROLES, cancel=cancel)
planner = LLMPlanningAgent(client, ROLES, cancel=cancel)
try:
planner.plan("arch", "demo")
planner.plan(log(new_store(tmp_path), "planner"), "arch", "demo")
except PlanningStopped:
pass
else: