Agents run as containers on one stable agent host that keeps their
state and secrets and never runs a command a model chose. Model-chosen
commands run on disposable tool VMs over SSH: a build VM per project
for nisse-dev, its own tool VM for nisse-ops. Secrets are a set per
agent, handed out per operation by the harness and redacted from tool
output. MCP servers stay on the agent host as the one exception.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A model no longer reads another role's turns as its own (#78). The log
stays one record per work item; what a role is shown is projected from
it by Transcript.view() (views.py), which the planner, executor and
helper now build their context from:
- own events unchanged;
- operator messages labelled with their channel, e.g.
[operator, Matrix] or [operator, comment on PR #165];
- orchestrator facts (a new `fact` event) as [orchestrator] statements;
- the executor sees a helper's handback as [helper report], not its turns;
- a helper sees the approved plan and the executor's milestone work as
one-line narration;
- the planner sees no other role's work at all.
The orchestrator now writes facts at the points the models must know
about: work item opened, plan approved or rejected, milestone started,
finished or stopped, published (with PR number and URL) or not, review
comment answered, next milestone, task completed or abandoned.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
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
Architecture L9's role names, for the two roles with a direct
counterpart: the delegation role is now 'helper' and the ask_expert role
'expert', in RoleMap, configuration (roles.helper, roles.expert),
recorded events and tests. 'planner' and 'executor' stay distinct for
now; they become 'driver' when the new loop merges them (Development
Plan M2.3).
Configurations must rename their keys: nisse-dev.example.yaml and
tests/integration/nisse-test.yaml are updated here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Development Plan M0.2.
- scripts/bringup.sh: start [REF] sets the previous run aside under
/root/nisse-e2e/runs/, exports the ref under test and starts the agent
as the transient systemd unit nisse-bringup, so it outlives the shell
that started it; stop (SIGTERM, clean shutdown), status, logs, and
collect (the journal log and a readable transcript).
- nisse_dev.dump (python -m nisse_dev.dump STATE_DB [--full]): a readable
dump of a state store's state, pending intents and canonical log. Opens
the store read-only, so it is safe against a live agent.
- The agent runs as root on this development VM, as it will on its own
VM. Architecture D3/D6/D7 corrected to match the operator's intent:
installing packages and toolchains on its own VM is class 2, recorded
so the environment can be rebuilt (it was class 3 with provisioning an
open question); egress names OS package mirrors. The plan's M0.2, M4.1,
M4.2 and bring-up guards follow.
Acceptance: a smoke run started, answered status in the test room,
stopped cleanly, and collect produced the log and transcript.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Development Plan M0.1. Every section reference in code, tests, specs/,
the example config, the tuning guide, and the CLAUDE.md and README files
now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old
spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps
it). The plan's old-to-new table goes with it.
Mostly mechanical, with a per-site choice where one old section split:
A4 became L2 where it meant stop/abandon/the loop and L3 where it meant
approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2
became D4 for outbound Gitea and publishing and D5 for review intake;
'A3 step N' became the matching step of L8's boot sequence. Comments
describing behaviour the architecture drops (planning always escalating,
thresholds tuned at an old build step) now describe the current code and
point at the Development Plan.
docs/Architecture.md gains one line the rewrite missed: intake outages
delay but never fail work (old B2), now in L5, where the poller and
Matrix code cite it.
Comments and docs only; no behaviour change.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
docs/Development-Plan.md: where the code stands against each section of
docs/Architecture.md, the environment today, how we work (plan-first per
milestone, tests, the Quint model, a bring-up run on this box ending
each phase), and seven phases of milestones from housekeeping to
nisse-ops, each marked pre-approved or needs discussion. Every open
issue is mapped to a milestone or marked superseded; an old-to-new
section table serves the reference update in M0.1.
docs/Architecture.md: status now agreed; D7 regains the browser tool
for the internal test network, left out of the rewrite by mistake.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A full rewrite of the architecture, following the outline agreed with the
operator: a threat model of mistakes, injection and crashes rather than a
malicious model; actions classed by their effects; hard walls, a
deterministic fast path and an action classifier; a conversational loop
driven by a local model with frontier helpers; approvals as scoped
permissions with pre-approved milestones and a trust dial; context and
memory, including agent memory; documents as nisse-dev's workflow.
Added beside docs/Architecture-Build-Spec.md, which stays until this is
agreed; the Development Plan and reference updates come after. New
section letters (P, L, D, O, X, Q) avoid clashing with the old A-H
references in the code meanwhile.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Fixes counterexamples 6 and 7 from specs/orchestrator.qnt, the last two.
6. A crash while the executor answered a review comment lost it: its
cursor is saved before the work (at most once), and a restart in
PR_OPEN resumed quietly. Now pr.reviewing marks the comment until
the executor returns (every way it returns, but not a crash), and
boot resumes the watch just before it, with a notice. The mark stays
until it is answered, so a second crash cannot lose it either.
7. 'project complete' left the plan issue open. It now closes it with a
note, after IDLE is saved, as abandon does (shared helper).
The model marks and redelivers, and closes the issue; scenarios 6 and 7
now expect their properties to hold. A mutant without redelivery fails
scenario 6. All eight properties held together over 300,000 traces.
No xfail reproductions remain.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Fixes counterexample 5 from specs/orchestrator.qnt. A crash after the
PR was opened but before PR_OPEN was recorded left the agent in
EXECUTING; the restart question's 'continue' and 'start' then published
again, Gitea refused a second PR, and there was no way back to PR_OPEN.
On the live run the executor model happened to notice the existing
work; recovery must not depend on that.
- Boot (A3 step 4): in EXECUTING, if Gitea shows the milestone's PR
open, adopt it, watch it from the start, and resume in PR_OPEN with a
notice. Gitea unreachable or the PR closed: the restart question, as
before.
- MilestonePublisher.publish is idempotent: after pushing, an open PR
for the branch is adopted (and so updated) rather than a second one
opened. This also covers a PR opened whose response was lost.
- The model adopts too, gains the lost-response outcome, and now counts
PRs opened per milestone (one_pr_per_milestone was a flag the fixed
model could never set). A mutant without adoption fails the new
scenario; the properties held over 200,000 traces each.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Fixes counterexamples 1-4 from specs/orchestrator.qnt: a repeated
'approve', 'approve rev-1' meant for the architecture, approving on both
channels, and 'approve' plus an afterthought all landed on the next
plan, one the operator had not seen.
- Revisions are numbered across the work item (A4 already required
monotonic tags; the code numbered per gate), so rev-N names one plan.
- Each reply is stamped on arrival with the revision that was up: in
Matrix the one whose approval request had been sent (a plain value
the transport thread reads, restored on boot), in Gitea the latest
plan comment older than the reply (board.posts).
- Gate.evaluate applies a reply that names no revision only if its stamp
is the current revision. Otherwise an approval is not applied and
feedback rejects nothing (it is still in the transcript), and the
agent says which revision is up. 'approve rev-N' binds by its number.
- Spec A4 states both rules. The model applies them; scenarios 1-4 now
expect the properties to hold, and both held over 200,000 random
traces and Apalache's exhaustive check to depth 8.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
specs/orchestrator.qnt models control.py: states, gate, pending
questions, comment cursors, publish intents and the worker queue, with
operator messages, Gitea comments, work outcomes (exceptions included)
and a crash at any point interleaving freely. Eight properties.
Seven counterexamples, each written out as a trace in
specs/orchestrator_scenarios.qnt and reproduced against the real code in
nisse-dev/tests/test_quint_counterexamples.py (xfail, strict, until
fixed):
1-4 an approval or feedback lands on a plan the operator had not seen:
a double 'approve', 'approve rev-1' meant for the architecture,
approving on both channels, and 'approve' plus an afterthought;
5 a crash after the PR opens but before PR_OPEN publishes again and
cannot get back to PR_OPEN;
6 a crash while answering a review comment loses it silently;
7 'project complete' leaves the plan issue open.
no_stranding, await_input_has_a_question and execution_needs_approval
held over 200,000 random traces of 40 steps, and under Apalache's
exhaustive check to depth 8, which does find counterexample 1 at that
depth. No behaviour changes here.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A phone keyboard's "Approve" did not match the approval pattern, so it
counted as rejection feedback and threw the plan away; "Stop" did not
reach the cancel flag; other capitalised commands got a hint instead of
acting.
gates.command_form() is how a message reads as a command: case,
surrounding whitespace, inner runs of whitespace and a trailing '.' or
'!' do not matter. The approval gate and every command match in the
orchestrator (stop, status, abandon, use, start milestone, next
milestone, project complete, the restart choices) use it. Only matching
changes: the project name in 'use <name>' keeps its case, and feedback
and answers are recorded and passed on as written. A stale revision is
still refused, and approval still needs the whole message to be the
command.
Closes#64.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
A4 already names abandon as one of the ways out of a halt (continue,
re-plan, or abandon) and 0.2 closes a work item as completed or
abandoned, but no command did it: from a gate or a question the only way
to drop a task was to edit the state database.
- 'abandon' ends the task from any waiting state and returns to IDLE.
The move is made durable first; then the plan issue and any open
milestone PR are closed with a note (branches kept), and any
unresolved intent is aborted so boot reconciliation does not gate on
work dropped on purpose. A Gitea failure is reported, not fatal.
- Like 'status' it is a command in every state, never read as an answer
or feedback. While work runs, 'stop' comes first, as before.
- The restart / stranded question and the PR_OPEN hints mention it.
- Spec: B1 gains the abandon edge and B2 the command.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
- Record a comment cursor per thread (board.last_comment, the field that
already existed unused; pr.last_comment), advanced durably when a
comment is taken up. CommentPoller.resume_after() skips comments at or
below it, so re-attaching needs no request and cannot replay.
- Boot re-attaches the plan watch at an open gate and the review watch in
PR_OPEN from their cursors, catching up on comments posted while the
agent was down (#59). Data saved without a cursor skips what is there,
so an old approval cannot approve the current revision.
- A restart in PR_OPEN resumes with a notice instead of the restart
question (#58). A stored restart question for PR_OPEN returns to it on
either reply instead of re-planning the milestone.
- A stopped or failed review response gets its own pending kind; the
reply returns to PR_OPEN and resumes the watch from the cursor, so
comments that arrived meanwhile are delivered. It used to return to
milestone sign-off, where starting the milestone would open a second PR.
- Comments ignored because of the state leave the cursor alone, so they
are delivered once the agent is back where they apply.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
The test checks that a request reaches the executor model through
LiteLLM and text comes back. It also asserted the model obeyed "Reply
with exactly one word: pong", which Haiku once answered with "ping" and
failed CI on #66. Assert non-empty text instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
It drove the orchestrator until EXECUTING and accepted EXECUTING as the
end state, which with no publisher configured was the stranding this
branch removes: the milestone now finishes in PR_OPEN. Return once
'start milestone 1' is sent, since the milestone runs inside it, and
expect PR_OPEN or AWAIT_INPUT.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
The first live run found four dead ends one at a time: states where no
operator message could move the agent. Most had one shape: work ended,
by an exception or by a path that never transitioned, and left the agent
in PLANNING_* or EXECUTING, where every message is answered "busy".
The worker only prints an exception, so that would keep happening.
- Every job the orchestrator runs for an operator message or a Gitea
comment now goes through _settled(). If the job leaves a working state
behind, whatever the reason, the agent moves to AWAIT_INPUT with the
continue / re-plan choice a restart offers, and says why. An exception
in a waiting state is reported and the state kept, since its commands
still lead on.
- A sign-off state whose gate record is missing asks to plan again
instead of crashing on every reply.
- The stub configurations (no executor, no publisher) complete the
milestone and wait in PR_OPEN instead of resting in EXECUTING.
test_no_stranding.py drives a whole two-milestone project with a fault
injected at each planner, executor and publisher call, and asserts after
every message that the agent is not in a working state and that the
project still reaches 'project complete'.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
On the first live run the executor committed its milestone itself, as
its plan asked. The publisher only published a commit it made, found a
clean tree, reported "no changes to publish", and left the agent in
EXECUTING, where every message is answered "busy".
- publish(): with nothing left to commit, publish anyway when the branch
has commits origin/<base> lacks; report no changes only when it has
none.
- push_followup(): same for review fixes, comparing HEAD with what the
remote branch holds (ls-remote; pushes go by URL, so there is no
tracking ref).
- Nothing to publish now gates to AWAIT_INPUT, and the reply returns to
the still-approved milestone plan.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
Clients put the addressee's name in front of a message that mentions
it ("Nisse Dev Testing: re-plan"), and the transport passed that text
on whole, so no command matched: the live run's re-plan was asked again,
and an addressed 'approve' would have counted as rejection feedback.
- A leading mention of the agent (an HTML pill, its user ID, or its
display name followed by ':') is removed before commands are matched.
The display name is learnt from the agent's own member event.
- A message whose leading mention is someone else is skipped: in a
shared room it is addressed to another agent or person.
- Only the start counts; a mention later in the text is left alone.
- The transcript records the message as sent (InboundMessage.original).
The sender is unaffected: it still comes from the event's homeserver-set
sender field, never from the text.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A restart in AWAIT_INPUT replaced the saved pending item with a generic
"continue or re-plan?" whose answer nothing handled: the reply cleared
it and left the agent in AWAIT_INPUT with nothing to resume, where 'use'
was refused too. The first live run hit this after a planning failure
and could not be moved by any message.
- Waiting on the operator is a committed state (A3 step 8): on boot, ask
the saved question again instead of replacing it. _await_input keeps
the prompt with the pending item for this.
- The restart question (A3 step 7) records the state it interrupted, and
'continue' / 're-plan' now do what they offer: re-run the interrupted
planning turn, or return an interrupted milestone to its approved plan.
Any other reply asks again rather than being swallowed.
- With nothing pending, 'use <project>' starts over, as the reply
already advised.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It led the executor benchmark (PR #55): 37/40 over eight tasks and 17/20
on the harder four, ahead of claude-haiku-4-5's 15/20. local-fast, the
previous example, scored 0/8 on the harder four. Model selection is on
the dev executor alone for now; the shared-host question (spec Part H)
stays open.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Five runs per task at 320 W with max_turns 40, after the fixes in
#50-#52, for qwen3.8-27b, qwen3.6-35b-a3b, gemma-4-26b-a4b,
devstral-small-2-24b and mimo-v2.6-9b; the harder four also for
claude-haiku-4-5 as the ceiling, and two calibration runs each for
local-fast as the floor. Runs failed by the proxy's rate limiter were
set aside, not stored.
Earlier results move to bench/results/archive/, which the report does
not read: they were made at other power caps, at max_turns 24, or on
the RTX 4070, and are kept as the record.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host-sharing item now records the operator's preference: one local
model for every local role, with agents tagging in and out of the host
as the fallback if no single model fits. A new item records vision as a
requirement (OpenSCAD renders, later video files) and what the text-only
agent needs before any vision work.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>