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

+47 -11
View File
@@ -35,7 +35,7 @@ from nisse_dev.git import GitError
from nisse_dev.gitea import GiteaError
from nisse_dev.llm import RoleMap
from nisse_dev.reconcile import Reconciler, reconcile_on_boot
from nisse_dev.state import StateStore
from nisse_dev.state import StateStore, Transcript
from nisse_dev.transport import InboundMessage, Transport
from nisse_dev.worker import InlineWorker, Worker
@@ -109,7 +109,7 @@ class MilestoneExecutor(Protocol):
lives in nisse_dev.executor (imported there, not here, to keep the
dependency pointing one way)."""
def run(self, milestone: int) -> Any: ...
def run(self, transcript: Transcript, milestone: int) -> Any: ...
class MilestonePublisher(Protocol):
@@ -149,6 +149,7 @@ class PlanBoard(Protocol):
class PlanningAgent(Protocol):
def plan(
self,
transcript: Transcript,
gate: str,
project: str,
feedback: str | None = None,
@@ -161,6 +162,7 @@ class StubAgent:
def plan(
self,
transcript: Transcript,
gate: str,
project: str,
feedback: str | None = None,
@@ -449,8 +451,19 @@ class Orchestrator:
except Exception as exc: # noqa: BLE001 -- nothing left to tell
traceback.print_exception(exc, file=sys.stderr)
def _log(self, actor: str) -> Transcript:
"""The active work item's transcript as one actor writes it; the
agent-level one when no work item is active (L6, L7)."""
item = self._store.active_item()
return self._store.transcript(item.id if item else None, actor)
def _close_item(self, outcome: str, cause: str) -> None:
item = self._store.active_item()
if item is not None and item.status == "open":
self._store.close_work_item(item.id, outcome=outcome, cause=cause)
def _record(self, msg: InboundMessage) -> None:
self._store.append_event(
self._log("operator").append(
UserMessage(
text=(msg.original or msg.text).strip(),
source=f"transport:{msg.sender}",
@@ -495,7 +508,7 @@ class Orchestrator:
# Every operator message enters the canonical transcript, so planning
# turns see feedback and answers as conversation rather than as
# out-of-band parameters (L7).
self._store.append_event(
self._log("operator").append(
UserMessage(
text=(msg.original or msg.text).strip(),
source=f"transport:{msg.sender}",
@@ -571,6 +584,23 @@ class Orchestrator:
}
self._project = project
self._store.oplog.task_id = task_id
# A work item of its own (L1). One left active by an unfinished task
# (reachable only through a lost pending item) is closed first.
replaced = self._store.active_item()
if replaced is not None:
self._close_item("abandoned", f"replaced by use {project}")
self._store.set_active(None, cause=f"replaced by use {project}")
self._store.open_work_item(
task_id,
title=project,
scope=project,
position=IDLE,
data=self._data,
cause=f"operator command: use {project}",
)
self._store.set_active(task_id, cause=f"operator command: use {project}")
# What the operator said before `use` is part of this task (D1).
self._store.carry_idle_messages(task_id)
if self._board is not None:
try:
self._data["board"] = {
@@ -594,7 +624,11 @@ class Orchestrator:
try:
with self._long_work():
action = self._agent.plan(
gate_name, self._project, feedback=feedback, answer=answer
self._log("planner"),
gate_name,
self._project,
feedback=feedback,
answer=answer,
)
except PlanningStopped as exc:
# Answering resumes planning with the answer, so the reason for
@@ -633,10 +667,10 @@ class Orchestrator:
AWAIT_ARCH_SIGNOFF if gate_name == "arch" else AWAIT_MILESTONE_SIGNOFF
)
roles = self._data.get("roles") or {}
self._store.append_event(
self._log("planner").append(
PlanSubmitted(plan=action.content, model=roles.get("planner", "stub"))
)
self._store.append_event(
self._log("orchestrator").append(
GateOpened(gate=gate_name, reason=f"plan submitted (rev-{rev})")
)
self._transition(await_state, cause=f"plan submitted ({gate_name} rev-{rev})")
@@ -747,7 +781,7 @@ class Orchestrator:
return
self._transport.send_message(f"executing milestone {milestone}")
with self._long_work():
outcome = self._executor.run(milestone)
outcome = self._executor.run(self._log("executor"), milestone)
if outcome.status == "stopped":
self._halt_for_stop(
what=f"milestone {milestone}",
@@ -826,7 +860,7 @@ class Orchestrator:
):
return # not taken: a watch attached later still sees it
self._take_comment(board_state, comment)
self._store.append_event(
self._log("operator").append(
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
)
self._apply_gate_reply(
@@ -881,12 +915,12 @@ class Orchestrator:
self._save(f"answering review comment {comment.id}")
# The comment enters the transcript, so the executor sees review
# feedback as conversation rather than as an out-of-band instruction.
self._store.append_event(
self._log("operator").append(
UserMessage(text=comment.body, source=f"gitea:comment:{comment.id}")
)
try:
with self._long_work():
outcome = self._executor.run(milestone)
outcome = self._executor.run(self._log("executor"), milestone)
except Exception:
# Reported to the operator by _settled: answered, if not well.
self._done_reviewing(pr)
@@ -1002,6 +1036,7 @@ class Orchestrator:
pr = self._data.get("pr") or {}
project = self._project
self.stop_watching()
self._close_item("abandoned", f"operator: abandon ({sender})")
self._data = {**_fresh_data()}
self._project = None
self._store.oplog.task_id = None
@@ -1084,6 +1119,7 @@ class Orchestrator:
elif command == "project complete":
board_state = self._data.get("board") or {}
self.stop_watching()
self._close_item("completed", "operator: project complete")
self._data = {**_fresh_data()}
self._project = None
self._store.oplog.task_id = None