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
@@ -19,12 +19,13 @@ from nisse_dev.delegation import (
|
||||
DELEGATION_INTENT,
|
||||
Caps,
|
||||
Delegation,
|
||||
DelegationResult,
|
||||
delegation_reconciler,
|
||||
)
|
||||
from nisse_dev.git import GitRepo
|
||||
from nisse_dev.llm import BudgetExhausted, LLMError, UpstreamUnavailable
|
||||
from nisse_dev.oplog import OpLog
|
||||
from nisse_dev.state import StateStore
|
||||
from nisse_dev.state import StateStore, Transcript
|
||||
from nisse_dev.tools import Workspace
|
||||
|
||||
|
||||
@@ -86,8 +87,34 @@ def ws(repo: GitRepo) -> Workspace:
|
||||
return Workspace(repo.root, timeout_s=10)
|
||||
|
||||
|
||||
ITEM = "t" # the work item these delegations run in
|
||||
|
||||
|
||||
def log(store: StateStore) -> Transcript:
|
||||
return store.transcript(ITEM, "helper")
|
||||
|
||||
|
||||
class Bound:
|
||||
"""A delegation run in the test's work item, as the executor runs it."""
|
||||
|
||||
def __init__(self, delegation: Delegation, store: StateStore) -> None:
|
||||
self.delegation = delegation
|
||||
self.store = store
|
||||
|
||||
def run(self, **kw: Any) -> DelegationResult:
|
||||
return self.delegation.run(log(self.store), **kw)
|
||||
|
||||
def resume(self, intent: Any) -> DelegationResult:
|
||||
return self.delegation.resume(intent)
|
||||
|
||||
def recover(self, notify: Any) -> DelegationResult | None:
|
||||
return self.delegation.recover(notify)
|
||||
|
||||
|
||||
def build(client: Any, store: StateStore, ws: Workspace, repo: GitRepo, **kw: Any):
|
||||
return Delegation(client, store, ws, repo, model="claude-haiku-4-5", **kw)
|
||||
return Bound(
|
||||
Delegation(client, store, ws, repo, model="claude-haiku-4-5", **kw), store
|
||||
)
|
||||
|
||||
|
||||
def test_successful_delegation_returns_a_structured_handback(
|
||||
@@ -169,7 +196,7 @@ def test_stop_discards_the_branch_and_settles_the_intent(
|
||||
# The unrun call still has a result, so the transcript renders.
|
||||
skipped = [
|
||||
e.event
|
||||
for e in store.read_events()
|
||||
for e in log(store).read()
|
||||
if isinstance(e.event, ToolResult) and e.event.result == STOPPED_RESULT
|
||||
]
|
||||
assert len(skipped) == 1
|
||||
@@ -225,7 +252,7 @@ def test_events_are_checkpointed_as_they_arrive(
|
||||
)
|
||||
build(client, store, ws, repo).run(brief="add a", branch="d")
|
||||
|
||||
kinds = [type(e.event).__name__ for e in store.read_events()]
|
||||
kinds = [type(e.event).__name__ for e in log(store).read()]
|
||||
assert kinds == [
|
||||
"DelegationStarted",
|
||||
"ToolCall",
|
||||
@@ -245,7 +272,7 @@ def test_composite_events_record_the_brief_caps_and_outcome(
|
||||
caps = Caps(turns=7, tokens=1234, wall_clock_s=42.0)
|
||||
build(client, store, ws, repo, caps=caps).run(brief="the brief", branch="d")
|
||||
|
||||
events = [e.event for e in store.read_events()]
|
||||
events = [e.event for e in log(store).read()]
|
||||
started = next(e for e in events if isinstance(e, DelegationStarted))
|
||||
completed = next(e for e in events if isinstance(e, DelegationCompleted))
|
||||
assert started.brief == "the brief"
|
||||
@@ -356,6 +383,7 @@ def pending_delegation_intent(store: StateStore, branch: str = "d") -> Any:
|
||||
DELEGATION_INTENT,
|
||||
{"branch": branch, "base": "main", "brief": "finish it"},
|
||||
cause="test",
|
||||
work_item=ITEM, # owned by the work item it ran in
|
||||
)
|
||||
return store.pending_intents()[-1]
|
||||
|
||||
@@ -385,9 +413,9 @@ def test_clean_cut_after_a_tool_result_is_resumable(
|
||||
repo.checkout_new_branch("d")
|
||||
(repo.root / "partial.py").write_text("x")
|
||||
repo.commit_all("partial work")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolCall(call_id="c", tool="write_file", arguments={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolCall(call_id="c", tool="write_file", arguments={}))
|
||||
log(store).append(ToolResult(call_id="c", result="wrote", success=True))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is True
|
||||
@@ -398,8 +426,8 @@ def test_dangling_tool_call_gates(
|
||||
) -> None:
|
||||
# The effect of the call is unknown and not verifiable by inspection.
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is None
|
||||
@@ -408,8 +436,8 @@ def test_dangling_tool_call_gates(
|
||||
def test_missing_branch_means_nothing_started(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolResult(call_id="c", result="ok", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolResult(call_id="c", result="ok", success=True))
|
||||
intent = pending_delegation_intent(store, branch="never-created")
|
||||
|
||||
assert delegation_reconciler(store, repo)(intent) is False
|
||||
@@ -419,8 +447,8 @@ def test_a_finished_delegation_settles_rather_than_resumes(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
@@ -440,8 +468,8 @@ def test_resume_continues_the_same_delegation(
|
||||
# Interrupt after one turn, then resume: no second delegation_started,
|
||||
# because L7 defines a session as the delegation, not the process.
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
intent = pending_delegation_intent(store)
|
||||
|
||||
client = ScriptedClient(
|
||||
@@ -450,7 +478,7 @@ def test_resume_continues_the_same_delegation(
|
||||
result = build(client, store, ws, repo).resume(intent)
|
||||
|
||||
assert result.usable
|
||||
starts = [e for e in store.read_events() if isinstance(e.event, DelegationStarted)]
|
||||
starts = [e for e in log(store).read() if isinstance(e.event, DelegationStarted)]
|
||||
assert len(starts) == 1
|
||||
assert store.pending_intents() == []
|
||||
assert (repo.root / "b.py").exists()
|
||||
@@ -474,8 +502,8 @@ def test_recover_resumes_a_clean_cut_with_a_notice(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolResult(call_id="c", result="wrote a.py", success=True))
|
||||
pending_delegation_intent(store)
|
||||
notices: list[str] = []
|
||||
|
||||
@@ -493,8 +521,8 @@ def test_recover_settles_a_finished_delegation_without_resuming(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(
|
||||
DelegationCompleted(
|
||||
handback="done",
|
||||
artifacts=[],
|
||||
@@ -514,7 +542,7 @@ def test_recover_settles_a_finished_delegation_without_resuming(
|
||||
def test_recover_discards_an_intent_whose_branch_never_existed(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
store.append_event(ToolResult(call_id="c", result="ok", success=True))
|
||||
log(store).append(ToolResult(call_id="c", result="ok", success=True))
|
||||
pending_delegation_intent(store, branch="never-made")
|
||||
|
||||
assert build(ScriptedClient([]), store, ws, repo).recover(lambda _m: None) is None
|
||||
@@ -525,8 +553,8 @@ def test_recover_leaves_an_unclean_cut_for_the_boot_gate(
|
||||
store: StateStore, ws: Workspace, repo: GitRepo
|
||||
) -> None:
|
||||
repo.checkout_new_branch("d")
|
||||
store.append_event(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
store.append_event(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
log(store).append(DelegationStarted(brief="b", role="helper", model="m", caps={}))
|
||||
log(store).append(ToolCall(call_id="c", tool="run_command", arguments={}))
|
||||
pending_delegation_intent(store)
|
||||
|
||||
assert build(ScriptedClient([]), store, ws, repo).recover(lambda _m: None) is None
|
||||
@@ -581,7 +609,7 @@ def test_tokens_spent_are_reported_not_left_at_zero(
|
||||
assert result.tokens == 2000 # two calls, 1000 each
|
||||
|
||||
completed = next(
|
||||
e.event for e in store.read_events() if isinstance(e.event, DelegationCompleted)
|
||||
e.event for e in log(store).read() if isinstance(e.event, DelegationCompleted)
|
||||
)
|
||||
# L9 evaluates cost against outcome, which needs a real number.
|
||||
assert completed.tokens == 2000
|
||||
|
||||
Reference in new issue
Block a user