Skip to content

room: every FDE has a standing conversation with the project's agent - #314

Merged
udaychandra merged 7 commits into
mainfrom
agent/sail-project-dm
Oct 15, 2026
Merged

udaychandra merged 7 commits into
mainfrom
agent/sail-project-dm

Conversation

@udaychandra

@udaychandra udaychandra commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Spec: sail-project-dm. Paired with standardapplied/mast#134 (branch agent/sail-project-dm), which places the home room in Mast's sidebar.

What changes

  • agent.member in the definition (agent, model, mode). It is optional, and each field has a default: agent.type, the agent's default model, and full. The member is validated when the definition is read, whether it is declared or comes from the agent.type default. A member the project does not install, an unknown mode, or a model that is not one shell-safe token is refused there, so project apply refuses before provisioning and names the field. A definition that names no agent has no member. Agent.installs() replaces three copies of the "install, else type" rule (apply, provisioner, reviewer roster).
  • HomeRoom.ensure is the last step of ProjectApplyCommand.converge. It runs as the box's FDE (CliOperator) and goes through the API's own door, POST /v1/rooms, which now takes an optional member.
    • The member is admitted exactly as addRoomMember admits one: owner, trusted roster, a valid mode and model, the container Running, and the harness installed. The room is then written with the member already seated, in one revision.
    • A refused member creates no room and leaves nothing to sync, and the next apply creates the room seated. So an empty roster is only ever the owner's choice, and a room that already exists is never touched.
  • The room id is HomeRoom.idOf(project, handle). It is <project>--<handle> when that is a valid room id.
    • Any other valid handle (upper case, '.', '_', or too long) is folded to the room id alphabet, cut to fit, and suffixed with the handle's CRC-32 (8 hex characters). Every FDE gets a home room, and ids stay in the room/spec namespace.
    • An existing room under that id that someone else owns refuses the apply and names the owner. An id a spec already holds refuses with the API's own sentence.
  • sail project apply --json gains home_room: {id, created, engaged} (or {skipped}). GET /v1/projects/{p} gains member and the reader's home_room id, so Mast never derives the id itself.
  • ARCHITECTURE.md gains the home-room contract H1–H6 with its tests. README and CHANGELOG are updated.
  • Separate fix: AgentLogStreamer.pump dropped lines that a tail wrote between an empty ready() and its exit. pumpRelaysWhatTheTailWroteBetweenAnEmptyReadyAndItsExit reproduces it deterministically.

Tests

  • HomeRoomTest runs against the real SailOperations with a scripted container shell. It covers:
    • the first apply, a second apply as a no-op, and an FDE's disengage winning;
    • the codex member with its model, and a later definition leaving the roster alone;
    • a refused member creating no room or revision, then the next apply creating it seated;
    • each FDE owning their own room;
    • a folded handle (Ada.L) getting a room, and the longest project and handle staying valid and distinct;
    • a squatted id, an id taken by a spec, an unreadable room, no handle, no agent, and a box without rooms.
  • HomeRoomIT (real incus) runs the sequence: missing harness and no room, then install and the room created seated, then a no-op apply, then disengage, then an apply that leaves it disengaged.
  • SailYamlTest covers the implicit member refused when not installed, no agent meaning no member, and a member block with nothing to fall back on.
  • ApiRouterTest covers POST /v1/rooms with a member. SailOperationsTest covers member and home_room on the project response.

Departures from the spec

  • The spec-collision refusal uses the API's actual sentence, "Spec 'acme--ada' already owns that conversation id.". The spec quoted a different sentence but said "the same sentence the API uses".
  • Build step 4 says a refused engage leaves the room "created and unengaged, so the next apply engages it". Here a refused member creates no room, and the next apply creates it engaged. The outcome is the same, and it matches scenarios 5 and 6 ("no room is created"). It also means an empty roster never has to be told apart from a disengage.
  • A handle that is not a valid room id is folded and given a digest suffix (HomeRoom.idOf), so the room id is not literally <project>--<handle> for those handles.

sail project apply leaves the box's FDE a home room, <project>--<handle>,
titled with the project's name, assigned to the FDE and engaged with the
project's member through the doors POST /v1/rooms and POST
/v1/rooms/{id}/members take, so every admission check runs as it does there.
A second apply changes nothing; a room the FDE disengaged stays disengaged; a
refused engage keeps the room and the next apply engages it.

agent.member (agent, model, mode) is a new optional key of the definition,
defaulting to agent.type, the agent's default model and full access; a member
the project does not install, an unknown mode or an unsafe model is refused
when the definition is read. GET /v1/projects/{p} names the member and
project apply --json reports home_room.

Also: the run log stream reads a gone tail to the end of its pipe, so the
lines written between an empty ready() and the exit are no longer dropped.

Spec: sail-project-dm.
POST /v1/rooms takes an optional member, admitted exactly as addRoomMember
admits one (owner, trusted roster, mode, model, Running, installed) and seated
in the same write that creates the room. HomeRoom.ensure creates the home room
that way, so a refused member leaves no room and no revision for sync to carry,
the next apply creates it seated, and an empty roster is only ever its owner's
choice: the updated_at == created_at inference is gone.

HomeRoom.idOf keeps <project>--<handle> when that is a room id, and otherwise
folds the handle to the room id alphabet, cuts it to fit and suffixes 8 hex of
its sha256, so every valid handle gets a home room within the spec/room id
namespace. GET /v1/projects/{p} names the reader's home_room id so Mast never
derives it. A room someone else owns under the id refuses the apply naming the
owner.

The agent.type default member is validated when the definition is read, like a
declared one; a definition naming no agent has no member.

Spec: sail-project-dm.
A folded id ending in the handle's CRC-32 shared a namespace with verbatim
ids, so Ada.L and ada-l-2a8d4330 both named acme--ada-l-2a8d4330 and the
second FDE's apply was refused. The folded id is now home-- and the SHA-256
of project and handle: 64 hex characters, longer than any handle, so no
verbatim id can spell it.

Spec: sail-project-dm.
@udaychandra
udaychandra merged commit acb9ea8 into main Oct 15, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant