Threads, commands and labels on the new loop (M2.3)
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 3m45s

- Commands that name their item ("go #14", "abandon #14", "stop #14")
  apply to it wherever they're said; go, approve and abandon alone at top
  level are asked about (L5). stop is never taken as an answer.
- A plain-language reply to a restatement is read by GoAheadReader, one
  model call that agrees only on a clear yes; questions never reach it. The
  agent says "taking that as a go-ahead on rev-N" first, bound to the
  revision on screen at arrival (L3, L5, D1).
- Labels steer (D1): `bug` goes ahead after tuning.loop.bug_grace_s
  (default 600 s) unless the operator replies; `discuss` isn't offered
  restate until the operator has spoken; `bug`, `discuss` and `design` add
  to the driver's instructions. A restatement asks no questions.

Tests: label and command unit tests, the property walk with labelled
issues, plain yeses, named gos and time passing (grace_only_unanswered,
discuss_talks_first), and e2e tests for a bug proceeding after its grace
period and a plain-language go-ahead.

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-04 16:13:22 +00:00
1 parent a504526da2
commit 4d9c1d7529
13 files changed
+793 -35

No files matched your search

+70 -4
View File
@@ -19,7 +19,7 @@ from test_loop_milestones import DON, Forge, call
from test_loop_milestones import world as world # noqa: PLC0414 -- the fixture
from nisse_dev.cancel import Cancel
from nisse_dev.canonical import Fact, ToolCall
from nisse_dev.canonical import Fact, PlanSubmitted, ToolCall, UserMessage
from nisse_dev.gitea import Comment, Issue
from nisse_dev.loop.agent import LoopAgent
from nisse_dev.loop.driver import Driver
@@ -60,6 +60,8 @@ class PolicyModel:
return [call("ask_human", question=f"question {self.n}?")]
if roll < 0.12:
return [ToolCall(call_id=f"p{self.n}", tool="nonsense", arguments={})]
if "restate" not in offered and "finish_milestone" not in offered:
return [call("ask_human", question=f"talk first {self.n}?")] # discuss
if "finish_milestone" not in offered:
return [call("restate", text=f"restatement {self.n}")]
if "wait_for_review" in offered and self.rng.random() < 0.2:
@@ -89,8 +91,14 @@ class Walk:
self.steps: list[str] = []
self.shown_at_go: list[str] = [] # item:revision on screen at each go
self.next_issue = 15
self.now = 1000.0
self.agent, self.transport = self._agent()
def reads_yes(self, proposal: str, reply: str) -> bool:
"""The scripted go-ahead reader: a plain yes usually reads as one,
and nothing else ever does."""
return reply == "yep, that's right" and self.rng.random() < 0.7
def _agent(self) -> tuple[LoopAgent, InMemoryTransport]:
transport = InMemoryTransport()
cancel = Cancel()
@@ -110,10 +118,16 @@ class Walk:
cancel=cancel,
oplog=OpLog("nisse-dev", _Null()),
milestones=w["milestones"],
reader=self, # type: ignore[arg-type]
bug_grace_s=600,
clock=lambda: self.now,
)
transport.on_message(agent.on_message)
return agent, transport
def agrees(self, proposal: str, reply: str) -> bool:
return self.reads_yes(proposal, reply)
def items(self) -> list[Any]:
return self.world["store"].work_items()
@@ -127,7 +141,7 @@ class Walk:
loop = loop_of(item) if item else None
if item and loop:
weights.update({"go": 3, "answer": 2, "abandon": 1, "go-stale": 1})
weights.update({"first": 1})
weights.update({"first": 1, "plain-yes": 2, "named-go": 1, "wait": 2})
if loop.pr:
weights.update(
{"comment": 4, "merge": 2, "close-pr": 1, "comment-merge": 2}
@@ -139,11 +153,33 @@ class Walk:
elif action == "issue":
# A new issue, sometimes depending on one that is still open.
n, self.next_issue = self.next_issue, self.next_issue + 1
forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", ()))
labels = self.rng.choice([(), (), ("bug",), ("discuss",), ("design",)])
forge.issues.append(Issue(n, f"task {n}", f"do task {n}", "don", labels))
live = [i.number for i in forge.assigned_issues("", "") if i.number != n]
if live and self.rng.random() < 0.5:
forge.deps[n] = [self.rng.choice(live)]
self.agent.intake()
elif action == "plain-yes" and loop:
# Read as a go-ahead or not; either way bound to what's up now.
if item.position == WAITING and loop.wait == GO_AHEAD:
self.shown_at_go.append(f"{item.id}:{loop.rev}")
self.transport.receive(DON, "yep, that's right", thread=loop.thread)
elif action == "named-go" and loop:
if item.position == WAITING and loop.wait == GO_AHEAD:
self.shown_at_go.append(f"{item.id}:{loop.rev}")
self.transport.receive(DON, f"go #{loop.issue}")
elif action == "wait":
# Time passes: bug countdowns may run out.
self.now += self.rng.choice([60, 700])
for other in open_items:
o = loop_of(other)
if (
other.position == WAITING
and o.wait == GO_AHEAD
and 0 < o.grace_until <= self.now
):
self.shown_at_go.append(f"{other.id}:{o.rev}")
self.agent.intake()
elif action == "first" and loop:
self.agent.handle(InboundMessage(DON, f"do #{loop.issue} next"), None)
elif action == "crash":
@@ -213,7 +249,7 @@ class Walk:
and f.event.text.startswith("go-ahead on rev-")
]
assert all(f"{item.id}:{rev}" in self.shown_at_go for rev in gos), (
f"go_binds: {gos}: {where}"
f"go_binds: {item.id} {gos} not in {self.shown_at_go}: {where}"
)
# Blocked comes from external truth (L1): an item whose issue
# had an open dependency from the start never ran before it closed.
@@ -238,6 +274,36 @@ class Walk:
assert not [
i for i in store.pending_intents() if i.work_item == item.id
], f"closed_items_leave_nothing (intent): {where}"
for item in items:
# D1: a bug's grace period runs out only on an unanswered
# restatement: any reply since it went up stops the countdown.
replied = False
for e in store.transcript(item.id, "orchestrator").read():
text = getattr(e.event, "text", "")
if isinstance(e.event, Fact) and text.startswith("restatement rev-"):
replied = False
elif isinstance(e.event, UserMessage) and e.actor == "operator":
replied = replied or e.event.source.startswith("transport:")
elif text.endswith("from the bug grace period"):
assert not replied, (
f"grace_only_unanswered: #{loop_of(item).issue}: {where}"
)
for item in items:
# D1: a `discuss` item talks before it restates.
if "discuss" not in loop_of(item).labels:
continue
events = store.transcript(item.id, "driver").read()
plans = [e.id for e in events if isinstance(e.event, PlanSubmitted)]
spoke = [
e.id
for e in events
if e.actor == "operator"
and isinstance(e.event, UserMessage)
and e.event.source.startswith("transport:")
]
assert not plans or (spoke and spoke[0] < plans[0]), (
f"discuss_talks_first: #{loop_of(item).issue}: {where}"
)
assert not [i for i in items if i.position == RUNNING], (
f"one_active (nothing left running): {where}"
)