`routes/planner.rs` has had Opus proposing rosters since the Master Planner
shipped, and none of it ever reached a mission: the proposal lived in React state
and died with the tab. A mission's shape came from a team template instead —
fixed roles, and every claw minted `claude-sonnet-5` from a literal in
`mint_team_from_template`. That literal is why no mission has ever run more than
one provider.
A roster is `(topology_kind, [(role, backend)])`, which is exactly what the
composed executor already consumes: `Roster::graph` builds a `TopologyGraph` with
the backend in `attrs`, and `MicroVmTurnExecutor` reads `attrs["backend"]` per
node. So a verifier on another provider's rootfs stops being a bolt-on and
becomes a graph node — the correlated-failure break the independent judge exists
for, one layer down.
Three verbs, and the split is the point. **suggest** asks the model and persists
the answer, changing nothing. **decide** approves (writes `config.roster` and
switches the mission to the composed engine) or rejects. A proposal is never
applied on arrival: a model sizing a team is a suggestion about how many VMs to
boot, and this codebase treats model output that costs money as evidence for a
decision, not the decision.
Fail-closed at every seam, because each of these otherwise surfaces much later
and much more expensively:
- a backend no ONLINE node can boot is refused when PROPOSED, naming the ones
the fleet actually has. Placement would refuse it too — at launch, after the
roster was approved and someone believed the mission would run. The model is
handed that same list in its prompt, so the usual case never arises.
- an invented `topology_kind` is refused, not defaulted. `parse_topology_kind`
defaults to hub-spoke, which is right for a template we wrote and wrong for a
string a model just produced: running a `pipeline` proposal as a hub-and-spoke
changes what every node sees and nothing would say so.
- the roster is validated BEFORE it is stored, so a stored proposal is always
one that could be approved; and again at approval, against the fleet as it is
then — a node can go offline in between.
- `MAX_MEMBERS = 6`. Each member is a whole VM, not a subagent, and a model
asked to size a team proposes twelve happily.
Two properties live in SQL rather than in the handler: at most one approved
roster per mission (partial unique index — two approved rosters are two answers
to "what shape is this mission", and the executor reads one field), and
decide-once (`WHERE status = 'proposed'`, so a double-clicked approve claims
nothing the second time). Both tested against a real database, including that the
second approval is refused by Postgres rather than merely losing a race.
NEGATIVE CONTROL, run rather than assumed: with the roster preference removed
from `composed_graph`, `an_approved_roster_outranks_the_template` FAILS — 3 nodes
from the template instead of the roster's 2. A stored roster that is silently
ignored at launch is precisely the shape this project keeps paying for.
Not closed: per-role models for CLAWS. `template_roles` has no model column, so a
ZeroClaw team still mints one model for every role. The literal is now a named
constant that says so and points at the roster path, rather than sitting inline
where nobody reads it.
527 tests pass, clippy clean. Migration 0070. Not yet exercised against the
deployed stack — the route has never been called with a live model.
Co-Authored-By: Claude Opus 5 <[email protected]>
52 lines
2.7 KiB
SQL
52 lines
2.7 KiB
SQL
-- A model's proposal for how a mission's team should be shaped, and whether a
|
|
-- human accepted it.
|
|
--
|
|
-- `routes/planner.rs` has had Opus proposing 2-6 members with a model each since
|
|
-- the Master Planner shipped, and none of it has ever reached a mission: the
|
|
-- proposal lived in React state and died with the tab. Missions instead got a
|
|
-- team template picked in the wizard, whose roles are fixed and whose every claw
|
|
-- is minted `claude-sonnet-5` — a hardcode in `mint_team_from_template`, and the
|
|
-- reason no mission has ever run heterogeneous providers.
|
|
--
|
|
-- Persisting the proposal is what makes it reviewable and what makes an approval
|
|
-- an event rather than a click. A proposal is NEVER applied on arrival: a model
|
|
-- sizing a team is a suggestion about how to spend money on VMs, and the row
|
|
-- records who accepted it and when.
|
|
--
|
|
-- `status`:
|
|
-- proposed — the model's answer, applied to nothing.
|
|
-- approved — written onto the mission (`config.roster`); terminal.
|
|
-- rejected — declined; kept, because the ones a human turned down are the
|
|
-- only record of what the planner gets wrong.
|
|
CREATE TABLE IF NOT EXISTS mission_team_proposals (
|
|
id UUID PRIMARY KEY,
|
|
mission_id UUID NOT NULL REFERENCES missions (id) ON DELETE CASCADE,
|
|
workspace_id UUID NOT NULL,
|
|
-- The roster itself: {"topology_kind": "...", "members": [{role, backend,
|
|
-- model, rationale}, ...]}. Stored as the model produced it (after
|
|
-- validation) rather than normalised into columns — the shape is the
|
|
-- planner's contract, and splitting it here would mean migrating this table
|
|
-- every time the planner learns a field.
|
|
roster JSONB NOT NULL,
|
|
-- Which model authored it, so a bad roster can be traced to a model rather
|
|
-- than to "the planner".
|
|
author_model TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'proposed'
|
|
CHECK (status IN ('proposed', 'approved', 'rejected')),
|
|
-- Why the proposal was refused, or why an approval could not be applied.
|
|
note TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
decided_at TIMESTAMPTZ,
|
|
decided_by UUID
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS mission_team_proposals_mission_idx
|
|
ON mission_team_proposals (mission_id, created_at DESC);
|
|
|
|
-- At most one approved roster per mission. Two approved proposals would be two
|
|
-- answers to "what shape is this mission", and the executor reads one field —
|
|
-- so the second would silently win by being written last.
|
|
CREATE UNIQUE INDEX IF NOT EXISTS mission_team_proposals_one_approved
|
|
ON mission_team_proposals (mission_id)
|
|
WHERE status = 'approved';
|