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

+31 -20
View File
@@ -33,13 +33,14 @@ from nisse_dev.canonical import (
AssistantText,
DelegationCompleted,
DelegationStarted,
StoredEvent,
ToolCall,
ToolResult,
)
from nisse_dev.git import GitError, GitRepo
from nisse_dev.llm import LLMClient, LLMError, UpstreamUnavailable
from nisse_dev.reconcile import Reconciler
from nisse_dev.state import Intent, StateStore
from nisse_dev.state import Intent, StateStore, Transcript
from nisse_dev.tools import EXECUTOR_TOOLS, MUTATING_TOOLS, Workspace
DELEGATION_INTENT = "delegation"
@@ -184,12 +185,16 @@ class Delegation:
# 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 = "helper") -> DelegationResult:
def run(
self, transcript: Transcript, *, brief: str, branch: str, role: str = "helper"
) -> DelegationResult:
"""Run a delegation in the work item whose transcript this is,
writing as the helper (L6, L7); its intent is owned by that item."""
started_at = self._now()
base_branch = self._git.current_branch()
# Recorded before the branch exists, so a crash anywhere after this
# leaves a durable statement of what was in flight (L8).
intent_id = self._store.begin_intent(
intent_id = transcript.begin_intent(
DELEGATION_INTENT,
{
"branch": branch,
@@ -210,7 +215,7 @@ class Delegation:
self._store.abort_intent(intent_id, cause=f"could not branch: {exc}")
return DelegationResult("failed", f"could not branch for delegation: {exc}")
self._store.append_event(
transcript.append(
DelegationStarted(
brief=brief,
role=role,
@@ -219,9 +224,9 @@ class Delegation:
backing_model=self._backing_model,
)
)
result = self._loop(brief, branch, started_at)
result = self._loop(transcript, brief, branch, started_at)
result.duration_s = self._now() - started_at
self._store.append_event(
transcript.append(
DelegationCompleted(
handback=result.handback.render() if result.handback else result.note,
artifacts=result.artifacts,
@@ -256,6 +261,7 @@ class Delegation:
"""
payload = intent.payload
branch = str(payload["branch"])
transcript = self._store.transcript(intent.work_item, "helper")
started_at = self._now()
try:
self._git.checkout_existing_branch(branch)
@@ -265,9 +271,11 @@ class Delegation:
)
return DelegationResult("failed", f"could not resume on {branch}: {exc}")
result = self._loop(str(payload.get("brief", "")), branch, started_at)
result = self._loop(
transcript, str(payload.get("brief", "")), branch, started_at
)
result.duration_s = self._now() - started_at
self._store.append_event(
transcript.append(
DelegationCompleted(
handback=result.handback.render() if result.handback else result.note,
artifacts=result.artifacts,
@@ -310,7 +318,9 @@ class Delegation:
return self.resume(intent)
return None
def _loop(self, brief: str, branch: str, started_at: float) -> DelegationResult:
def _loop(
self, transcript: Transcript, brief: str, branch: str, started_at: float
) -> DelegationResult:
system = (
f"{SYSTEM_PROMPT}\n\n{self._workspace.describe()}"
f"\n\nYour sub-task:\n{brief}"
@@ -340,7 +350,7 @@ class Delegation:
)
try:
produced = self._client.complete(
self._store.read_events(),
transcript.read(),
tools=DELEGATE_TOOLS,
model=self._model,
system=system,
@@ -380,7 +390,7 @@ class Delegation:
for event in produced:
# Checkpoint as it arrives, never batched at handback (L9):
# this is what makes the delegation resumable after a crash.
self._store.append_event(event)
transcript.append(event)
if isinstance(event, ToolCall):
calls.append(event)
@@ -400,7 +410,7 @@ class Delegation:
for index, call in enumerate(calls):
if self._cancel.requested:
skip_calls(self._store, calls[index:])
skip_calls(transcript, calls[index:])
return DelegationResult(
"stopped",
f"stopped by the operator during turn {turn + 1}",
@@ -415,7 +425,7 @@ class Delegation:
# and the file state are the whole interface back, so
# an unbacked claim would hand the caller a note about
# work that does not exist.
self._store.append_event(
transcript.append(
ToolResult(
call_id=call.call_id,
result=(
@@ -429,7 +439,7 @@ class Delegation:
)
continue
if handback is None:
self._store.append_event(
transcript.append(
ToolResult(
call_id=call.call_id,
result=(
@@ -440,7 +450,7 @@ class Delegation:
)
)
continue
self._store.append_event(
transcript.append(
ToolResult(
call_id=call.call_id,
result="handback accepted; delegation closed",
@@ -463,7 +473,7 @@ class Delegation:
path = call.arguments.get("path")
if isinstance(path, str):
touched.add(path)
self._store.append_event(
transcript.append(
ToolResult(
call_id=call.call_id,
result=outcome.result,
@@ -552,11 +562,12 @@ def delegation_verdict(
branch = intent.payload.get("branch")
if not branch:
return "gate"
if _completed_after_start(store):
# The delegation's own work item: an intent is owned by the item it ran in.
events = store.transcript(intent.work_item, "helper").read()
if _completed_after_start(events):
return "settled" # it finished; the intent merely outlived its commit
if not git_repo.branch_exists(str(branch)):
return "absent" # the crash landed before any work existed
events = store.read_events()
if not events:
return "gate"
last = events[-1].event
@@ -569,9 +580,9 @@ def delegation_verdict(
return "resume"
def _completed_after_start(store: StateStore) -> bool:
def _completed_after_start(events: list[StoredEvent]) -> bool:
"""True when the last delegation event in the log is a completion."""
for stored in reversed(store.read_events()):
for stored in reversed(events):
if isinstance(stored.event, DelegationCompleted):
return True
if isinstance(stored.event, DelegationStarted):