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
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:
1 parent
3cf4cefacd
commit
83804fe02a
40 files changed
+1026
-363
No files matched your search
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user