Files
nisse/nisse-dev/src/nisse_dev/loop/items.py
T
dongarandClaude Opus 5.5 ea4376eaa1
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 2m10s
CI / e2e (pull_request) Successful in 10m30s
M3.1: action classes and the fast path
Every execution tool call, the driver's and a helper's, gets a verdict
from code before it runs (P3, P4, D6).

- nisse_core.actions, nisse-core's first code: Action and Verdict, and
  reading shell commands statically, segment by segment (cd, wrappers,
  sh -c). What can't be read that way is unknown; a line is as bad as
  its worst command.
- nisse_dev.loop.actions, the rule table: reading, checks and tests are
  harmless; workspace edits, local git and installs on the build VM are
  contained; pushing, other hosts, uploads, publishing, the forge's own
  tools, deleting outside the workspace and wrecking the VM are
  dangerous; changing branches by hand is refused (the harness owns
  branches) with a pointer to the milestone tools.
- A dangerous call isn't run. The item waits on the operator with a
  question rendered from the action, and `allow` lets exactly that
  action run once. A helper can't ask, so it is refused and told to
  hand back. Unknown actions run, logged, until the classifier (M3.2).
- Every verdict is an action_verdict event, which no model sees, and an
  oplog line; `details` shows the counts and every action not simply
  run.
- The property walk gains dangerous_only_when_allowed (checked against
  a mutant that doesn't use the allow up); the slice e2e checks every
  call was judged and none needed an allow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-08 04:32:13 +00:00

125 lines
5.0 KiB
Python

"""A work item's place in the loop (L1), kept in its row in the state store.
The row's ``position`` is the item's state; what the loop needs besides
(the thread its conversation lives in, the revision of what is put up for
agreement, what it is waiting for) is in ``data["loop"]``.
"""
from dataclasses import asdict, dataclass
from typing import Any
from nisse_dev.state import StateStore, WorkItem
READY = "ready"
RUNNING = "running" # a driver run is under way; on boot it means interrupted
WAITING = "waiting"
BLOCKED = "blocked" # waits for its recorded dependencies to close (L1)
CLOSED = "closed"
# What a waiting item waits for.
GO_AHEAD = "go-ahead" # a go on the restatement up (L3, D1)
ANSWER = "answer" # an answer to a question
MERGE = "merge" # the PR's review and merge (D4, D5)
CHAT = "conversation" # a conversation waits for the operator to say more (L5)
PROVISION = "provisioning" # a new project waits for what it needs (D1, D8)
@dataclass(frozen=True)
class Loop:
thread: str = "" # the root of the item's Matrix thread (L5)
issue: int = 0 # its record issue (D1)
wait: str = "" # GO_AHEAD / ANSWER / MERGE, when waiting
rev: int = 0 # the latest proposal put up (L3)
started: bool = False # scope agreed: a go-ahead was given (D1)
note: str = "" # what a waiting item said it is waiting on
prepared: bool = False # its fresh workspace was made (D3)
milestone: int = 0 # the current milestone (D4)
branch: str = "" # the current milestone's branch, while one is open
pr: int = 0 # the current milestone's PR, once published
pr_url: str = ""
comments: int = 0 # the last PR conversation comment taken (L8)
notes: tuple[str, ...] = () # review notes taken (#69)
fixes: int = 0 # review fixes pushed, for follow-up branch names
blocked_by: tuple[str, ...] = () # open dependencies, "owner/repo#N" (L1)
first: int = 0 # the operator's "do #N next": higher runs sooner
labels: tuple[str, ...] = () # the issue's labels at intake: they steer (D1)
grace_until: float = 0.0 # a `bug` restatement proceeds then, unanswered
talked: bool = False # the operator has spoken in the item's thread
# A conversation (`general`, L1): when it last heard or said anything,
# the conversation it continues, the work it turned into, and the work
# proposed for a go-ahead.
idle_since: float = 0.0
follows: str = ""
became: tuple[str, ...] = ()
proposal_project: str = ""
proposal_title: str = ""
proposal_kind: str = "" # "work" on a project, or a new "project"
new_project: str = "" # a new project being provisioned (D1)
design_merged: bool = False # a `design` item's design PR has merged (D1, D2)
helps: int = 0 # delegations so far, for their branch names (L9)
helped_at: int = 0 # the milestone a helper was last called in for, unasked
# A question from the operator is answered without changing the item
# (L5): the wait it interrupted, to go back to.
question: bool = False
back_to: str = ""
back_note: str = ""
# Where the operator last spoke (D5: answer where asked): "" for the
# Matrix thread, or the PR it came from, with the inline location and a
# little of what they said, for quoting.
asked_on_pr: int = 0
asked_where: str = ""
asked_text: str = ""
# A dangerous action put to the operator (P3): what `allow` would let
# run, and what it did let run, once, in the next run (L3: bound to
# exactly that action, by its key).
pending_action: str = ""
allowed: str = ""
def to_data(self) -> dict[str, Any]:
return asdict(self)
def loop_of(item: WorkItem) -> Loop:
data = dict(item.data.get("loop", {}))
data["notes"] = tuple(data.get("notes", ()))
data["blocked_by"] = tuple(data.get("blocked_by", ()))
data["labels"] = tuple(data.get("labels", ()))
data["became"] = tuple(data.get("became", ()))
return Loop(**data)
def save(
store: StateStore, item_id: str, state: str, loop: Loop, *, cause: str
) -> None:
item = store.work_item(item_id)
assert item is not None
store.update_work_item(
item_id,
position=state,
data={**item.data, "loop": loop.to_data()},
cause=cause,
)
def item_for_thread(
store: StateStore, thread: str, status: str | None = "open"
) -> WorkItem | None:
"""The item whose conversation is ``thread``; the latest, when a closed
conversation's thread was picked up again."""
found = [i for i in store.work_items(status) if loop_of(i).thread == thread]
return found[-1] if found else None
def item_for_issue(
store: StateStore, issue: int, project: str | None = None
) -> WorkItem | None:
"""The latest item, open or closed, recorded by this issue (in
``project``, when given): intake takes an issue once, and again only on a
fresh assignment after its item closed (D1)."""
found = [
item
for item in store.work_items()
if loop_of(item).issue == issue and project in (None, item.scope)
]
return found[-1] if found else None