Add the composition root and a runnable entry point
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m14s
Nothing had ever assembled the agent. main.py was still the hello-world placeholder from the scaffold, and no code outside the test suite constructed an Orchestrator — which is how two fully built, fully tested components came to be reachable from nothing: the delegation harness, and the Gitea comment poller. Unit tests that construct a component directly cannot catch that; only assembly can. - config.py validates every variable before anything is built and names all of them at once, rather than failing at first use twenty minutes into a milestone on the call that mattered. Whitespace-only counts as missing, since a token of spaces passes a truthiness check and fails at the first request instead - agent.py builds the whole graph and owns startup and shutdown order: reconcile before accepting commands, and on stop, stop watching, then stop talking, then close the store last - The Gitea account is read via whoami rather than configured: it names the identity every permission is evaluated against, and proves the token works before anything depends on it - Poller lifecycle is now a state-machine concern: watch the plan issue while a gate stands, the PR while it is open, and stop on leaving. The orchestrator owns when to watch; an injected factory owns how to build one - The executor reports each turn, because it runs synchronously inside the transport callback: without it the agent is silent for a whole milestone and a working run looks exactly like a hung one - main.py loads .env, builds, and shuts down cleanly on SIGINT/SIGTERM The entry-point tests changed shape: nisse-dev no longer greets, it refuses to start unconfigured and says everything that is missing. Both tests now run with a deliberately empty environment and a .env path that does not exist, because the working directory's .env holds live credentials and a test must never be one cwd away from starting a real agent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6331fcafd9
commit
2fb25b8195
11 files changed
+694
-15
No files matched your search
@@ -164,6 +164,7 @@ class Orchestrator:
|
||||
gitea_operators: Iterable[str] = (),
|
||||
board: "PlanBoard | None" = None,
|
||||
delegate: Any = None,
|
||||
poller_factory: Any = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self._transport = transport
|
||||
@@ -178,6 +179,11 @@ class Orchestrator:
|
||||
self._gitea_operators = set(gitea_operators)
|
||||
self._board = board
|
||||
self._delegate = delegate
|
||||
# Builds a poller bound to a comment handler; the orchestrator
|
||||
# owns *when* to watch, which is a state-machine concern, while
|
||||
# the factory owns how to build one.
|
||||
self._poller_factory = poller_factory
|
||||
self._pollers: dict[str, Any] = {}
|
||||
# Transport and the comment poller deliver on separate threads;
|
||||
# state transitions must not interleave.
|
||||
self._lock = threading.RLock()
|
||||
@@ -465,6 +471,9 @@ class Orchestrator:
|
||||
# durability of the record, not the ability to approve.
|
||||
return
|
||||
board_state["comment_id"] = post.comment_id
|
||||
# Approvals can arrive on the issue as well as over transport (A4),
|
||||
# so watch it for as long as the gate stands.
|
||||
self._watch("plan", board_state["issue"], self.handle_plan_comment)
|
||||
|
||||
def handle_plan_comment(self, comment: Any) -> None:
|
||||
"""A comment on the project issue may satisfy an open plan gate (A4).
|
||||
@@ -586,6 +595,8 @@ class Orchestrator:
|
||||
"url": result.pull_request.url,
|
||||
"milestone": milestone,
|
||||
}
|
||||
self._unwatch("plan") # the gate is behind us; the PR is the thread now
|
||||
self._watch("review", result.pull_request.number, self.handle_review_comment)
|
||||
self._transition(PR_OPEN, cause=f"milestone {milestone} published")
|
||||
self._transport.send_message(
|
||||
f"{result.note} — review it, then say 'next milestone' or "
|
||||
@@ -594,12 +605,14 @@ class Orchestrator:
|
||||
|
||||
def _handle_pr_open(self, text: str) -> None:
|
||||
if text == "next milestone":
|
||||
self._unwatch("review")
|
||||
self._data["milestone"] = int(self._data.get("milestone", 1)) + 1
|
||||
self._data["gate"] = None
|
||||
self._data["pr"] = None
|
||||
self._transition(MILESTONE_PLANNING, cause="operator: next milestone")
|
||||
self._run_planning_turn(f"milestone-{self._data['milestone']}")
|
||||
elif text == "project complete":
|
||||
self.stop_watching()
|
||||
self._data = {**_fresh_data()}
|
||||
self._project = None
|
||||
self._store.oplog.task_id = None
|
||||
@@ -614,6 +627,27 @@ class Orchestrator:
|
||||
"'project complete'"
|
||||
)
|
||||
|
||||
def _watch(self, kind: str, index: int, handler: Any) -> None:
|
||||
"""Start watching one Gitea thread, replacing any previous watch of
|
||||
the same kind. Priming first, so attaching does not replay comments
|
||||
already addressed."""
|
||||
if self._poller_factory is None:
|
||||
return
|
||||
self._unwatch(kind)
|
||||
poller = self._poller_factory(handler)
|
||||
poller.prime(index)
|
||||
poller.start(index)
|
||||
self._pollers[kind] = poller
|
||||
|
||||
def _unwatch(self, kind: str) -> None:
|
||||
poller = self._pollers.pop(kind, None)
|
||||
if poller is not None:
|
||||
poller.stop()
|
||||
|
||||
def stop_watching(self) -> None:
|
||||
for kind in list(self._pollers):
|
||||
self._unwatch(kind)
|
||||
|
||||
def _await_input(self, *, cause: str, prompt: str, pending: dict[str, Any]) -> None:
|
||||
self._data["pending"] = pending
|
||||
self._transition(AWAIT_INPUT, cause=cause)
|
||||
|
||||
Reference in new issue
Block a user