honker: row 194 design, agent lifecycle and per-agent veto (#1692)

This commit is contained in:
jason.woltje committed 2026-10-20 14:09:26 -05:00
1 parent 53bb594621
commit 8f80c278bf
1 file changed
+363
+363
View File
@@ -0,0 +1,363 @@
# Row 194 design: agent lifecycle from the Console and the CLI, with a per-agent veto
Brief: `docs/plans/2026-10-20_agent-lifecycle.md` (blob a907109409ac). Gitea #1692. MVP T1 item 5 (mos-0974,
mos-0977, DECISIONS Q176). Owner Honker, reviewer Webby, gate owner Sage. Base: refactor at 53bb59462.
Branch `honker/row194`.
Related designs read: row 190 `agents/darkwing/work/queue-190/DESIGN.md` (r2, after mos-0986), row 191
`agents/rocko/work/queue-191/DESIGN-r2.md`. Row 193 is still in design. All line numbers below are at
53bb59462.
## Summary
- One verb family on the CLI, `mosaic business instance <verb> BUSINESS INSTANCE`, and one Console section.
Create and delete change the business file. Launch, terminate and regenerate act on runs.
- Every human verb goes through the bus with the human proof. Launch and regenerate go through the
launcher's one `start()`, so `--no-launch`, the limits, `launch.revoke` and row 191's switch all apply.
- The chief hires (adds and lists an instance) and fires (ends its runs and delists it). It never deletes.
The human's veto is two lists in the business file: `launch.keep` (instances the chief can't touch)
and `launch.noHire` (definitions the chief can't hire). Only a human verb writes them.
- The broker reloads a business from its file after each committed organization write. Create, delete,
hire, fire and veto changes then count without a bus restart. Hand edits still need one.
- No bus schema change and no migration. One business-file schema change (D2.3) and one role file change
(D2.1, Q1) are called out for review.
## What exists today (checked at 53bb59462)
| # | Fact | Where |
|---|---|---|
| E1 | Instances are keys of the business file's `roles`. `launch.instances` lists those the chief may launch. The list must be non-empty | `business.mjs:121-132` |
| E2 | The human adds an instance and changes its definition, vars and listing. No delete. Credentialed definitions are refused | `instance-settings.mjs:1-5, 22-31, 75-156` |
| E3 | The broker copies the businesses once at start and refuses a role it doesn't know | `broker.mjs:208, 446` |
| E4 | The chief launches over `launch.sock`. `fromSession` checks identity, then `check()` (which reloads the file), then `authorizeLaunch`, then `start()` | `launcher.mjs:232-327, 624-668` |
| E5 | The chief's `launch` tool takes an enum of the instances listed when its bundle was built | `harness/src/tools.mjs:116-119` |
| E6 | The chief ends a run with `role.revoke` plus a reason (`role.end`). Ending then launching again is already a replace | `broker.mjs:1601-1628`, `tools.mjs:167` |
| E7 | `mosaic stop RUN` is a same-uid SIGTERM. It has no bus call and no human proof | `cli.mjs:447-454`, `seat/src/session.mjs:249-270` |
| E8 | The Console stops only the assistant (`assistant.stop`) and writes instances through `instances.set` | `serve.mjs:282-289, 315-363, 535` |
| E9 | The launcher already starts runs for the human on its 2 s tick: threads with pending messages, and merge holders | `launcher.mjs:133, 487-597` |
| E10 | `launch.revoke` and `launch.restore` are human-only. A revoked business refuses `role.launch` | `broker.mjs:770-774, 2087-2095` |
| E11 | Only the assistant is END_PROTECTED. Coder, reviewer, pm and cto all declare gitea and vikunja credentials | `vocabulary.mjs:135`, `roles/*.json` |
The chief's veto today comes from the human-only membership in E2. The chief can only launch what the human
listed. It has no way to create an instance, and the human has no way to stop the chief ending a given
instance's runs.
## D1. The verbs
All of them are human verbs. Each is a broker verb behind `#human(s)` and then the D4 hook. Each logs its
actor. The CLI usage joins `INSTANCES_USAGE` (`cli/src/instances.mjs:9`).
| Verb | CLI | Bus verb | Effect |
|---|---|---|---|
| V1 create | `business instance add B I --definition DEF [--vars FILE] [--launch] --revision REV` | `instances.set` op `add` | as today, with no restart (D3) |
| V2 launch | `business instance launch B I [--wait SECONDS]` | `instance.launch` | one run, through the launcher |
| V3 terminate | `business instance terminate B I [--reason TEXT]` | `instance.end` | ends every run of I |
| V4 regenerate | `business instance regenerate B I [--wait SECONDS]` | `instance.regenerate` | V3, then V2 once the run has exited |
| V5 delete | `business instance delete B I --revision REV` | `instances.set` op `delete` | removes I from the business file |
| V6 veto | `business veto B (keep\|unkeep) I` and `business veto B (nohire\|hire) DEF`, each `--revision REV` | `veto.set` | edits `launch.keep` and `launch.noHire` |
`mosaic business instances B` (read) gains, per instance: `running` (run ids from the bus claims), `kept`
and `retired`. The top level gains `noHire`. `mosaic stop RUN` stays as it is. It is the same-uid operator
tool, and V3 is the human verb.
### V2 launch
1. The broker checks that I exists, isn't a thread instance (`not-thread-launch`: threads start on a
message), isn't retired, and that the business isn't revoked (`launch-revoked`). The human's own brake
applies to the human until they restore it. When row 191 is present, a halted instance refuses
`processing-halted` here, so no launch waits for a resume.
2. The broker writes one `action.allowed` event, action `lifecycle.launch`, subject I, actor the human,
body `{ request, via }`, where `request` is a generated id. It also writes a `human.input` event, as
`launch.revoke` does. The answer is `{ request }`.
3. A new launcher pass, `lifecyclePass`, runs on the same tick as `threadPass` and `landPass`. It reads the
open requests through a new host op, `lifecycleRequests`. For each request it runs, under `serialize`,
`check(I, { human: true })` and then `start({ instance: I, launchedBy: b.human })`.
`human: true` lets the human launch an instance that isn't in `launch.instances`. The listing limits
the chief, not the human. `launch.by` launches with `pm: true`, as `--pm` does today. Every other step
of `check()` and `start()` is unchanged.
4. The pass reports the outcome with host op `lifecycleDone`. That op writes `action.allowed`, action
`lifecycle.done`, citing the request and naming the run or the refusal code. A request with a done
event is closed.
5. A request still open 60 s after it was written closes as `request-expired` and never launches. A bus
restart therefore can't start runs a human asked for before it.
6. The CLI polls a read verb, `instance.request`, until the request closes or `--wait` runs out (default
30 s). It prints the run or the refusal. A timeout exits 3 with the request id, and the launch may still
happen within the 60 s window.
The broker never starts a process. The launcher keeps that, as E9 already does for the human's threads.
### V3 terminate
1. The broker revokes every claim I holds (`#claimEnd(s, h, 'revoke', null, reason, s.human)`). A thread
instance has all its thread claims revoked, which is exactly `assistant.stop`. No claim held answers
`not-held`.
2. It writes `lifecycle.end` with the request id and the runs. The launcher pass sends SIGTERM to each
run's runner after checking its start time, as the idle stop does (`launcher.mjs:526`). The existing
exit handler records the end, so the launch journal and `session.ended` are unchanged.
3. Terminate is allowed while row 191's switch is on. It starts nothing.
### V4 regenerate
A fresh session with the same identity and role. It is V3 then V2 under one request. The pass waits for
the old run's exit handler, then runs `check()` and `start()`. The workspace stays, as it does across every
launch today. The session is new because every non-thread launch gets a new run dir (`seat/src/session.mjs:107`, newRun).
A thread instance (the assistant) refuses `regenerate-thread`. Its context lives in the thread's session
directory, and clearing that is T1 item 7 (clear context). This row doesn't take it.
### V5 delete
`instances.set` gains op `delete`. The writer refuses, in this order:
| Refusal | When |
|---|---|
| `organization-definition-refused` | I is END_PROTECTED |
| `organization-instance-referenced` | I is `launch.by` or an arbiter. The message names the reference |
| `organization-instance-kept` | I is in `launch.keep`. The human runs `veto unkeep` first. Delete never clears a veto silently |
| `organization-credentials-out-of-scope` | I holds credentials or a tracker bot. That stays a hand edit until row 195 |
| `instance-running` | the broker sees a claim for I. The human terminates first |
| BusinessError | I is the last name in `launch.instances` (E1). The message says so |
Otherwise the writer removes `roles.I` and the references that exist only for I: its `launch.instances`
entry, its `launch.limits.tokens.instances` entry and its per-instance model entry. It adds I to a new
top-level list, `retired`. The build lists every place `validateBusinessDocument` reads a role instance
name and puts each one in one of the two groups above. A reference the build finds that this table doesn't
name stops the build and goes back to Webby.
What stays:
- Run evidence (`launches/<b>/<run>/`, `launches/<b>.jsonl`) and bus rows are untouched. They are
write-once.
- The workspace `workspaces/<b>/I/` is renamed to `workspaces/<b>/.retired/I-<transaction>/`, mode 0700.
This row deletes no file. A prune with a receipt is a later act.
- `retired` stops a later create from reusing the name. Decisions bind to the role instance and outlive
restarts (`broker.mjs:783-788`). A new `coder` would otherwise inherit an approval raised by the old one.
The tradeoff is a name that can't come back without a hand edit. I chose that over voiding decisions,
which would touch a second store.
### V6 veto
`veto.set` args: `{ revision, op: "keep" | "unkeep" | "nohire" | "hire", name }`. It goes through the
organization writer, so it is logged with actor and diff in `changes.jsonl`. `keep` needs a declared,
non-END_PROTECTED instance. `nohire` needs a definition in `rolesDir`. A no-op answers
`organization-no-change`. It is human-only and isn't in `TESTER_ORGANIZATION_VERBS` (Q3).
## D2. Chief hire and fire
### D2.1 What the chief may do
| Act | Bus verb | Rule |
|---|---|---|
| launch | `launch` (host) | as today: listed instances only |
| end a run | `role.revoke` + reason | as today, and refused `instance-kept` when the target is kept |
| hire | `instance.hire { name, definition, vars? }` | adds and lists a new instance. Refused when the definition is in `noHire`, END_PROTECTED or credentialed, or the name exists or is retired |
| fire | `instance.fire { name, reason }` | ends I's runs and removes I from `launch.instances`. Refused when I is kept, END_PROTECTED, `launch.by` or the last listed instance |
The chief never deletes. A fired instance stays in `roles` and can be listed again by a hire of the same
name (that is a `change` with `launch: true`, so no new name is spent) or by the human. Delete stays a
human act because it retires the name.
Hire and fire need authority the chief doesn't have. That is Q1. The two options:
- O1, recommended. Add `role.hire` and `role.fire` to `ACTIONS` (`vocabulary.mjs:11`) and to
`roles/chief-of-staff.json` withinRole. The broker checks them with `#consumeAuthority`, as it does
`role.end`. This is a role file change (AGENTS.md invariant 7) and widens what a chief can do. It is what
Case 1 asks for ("hire/fire authority for agents as needed; unless user forbids per agent").
- O2. No role file change. The chief raises a gated `role.hire` or `role.fire` decision, and the human
resolves it. This follows the brief's "verbs, not new role authority" literally, but Case 1's authority
then sits with the human.
The brief says this row adds verbs, not authority. Case 1 says the chief hires and fires. Both can't hold,
so the choice is Sage's and Mos's. The rest of this design works under either option. Only the broker's
authority check and the role file differ.
### D2.2 The veto
- `launch.keep`: instance names. The broker refuses every chief act aimed at a kept instance except launch:
`role.end` (E6 shows end then launch is a replace), fire, and a hire that would change it. The chief
can't stop a runaway kept instance. The human and the limits still can. I chose the wider rule because
"can't fire or replace it" is otherwise open to end-then-relaunch.
- `launch.noHire`: definition names. `instance.hire` refuses `definition-not-hireable`.
- Default, per Jason's wording "unless user forbids per agent": everything the structural rules allow is
hireable and nothing is kept. The structural rules are uncredentialed, not END_PROTECTED, and a valid
version 2 role.
### D2.3 Where it lives and how the broker enforces it
The veto lives in the business file, under `launch`, beside the membership it limits:
`launch.keep: [instance]`, `launch.noHire: [definition]`, and top-level `retired: [instance]`. That file is
the human's. The chief reaches it only through `organization.set`, whose keys (`ORGANIZATION_KEYS`,
`broker.mjs:145`) exclude `launch` and `retired`. A test pins that (T13).
`checkLaunch` (`business.mjs:118`) gains the two keys. Each list is distinct. `keep` names declared,
non-protected instances. `noHire` names definitions present in `rolesDir`. `retired` names none of
`roles`. This is the business-file schema change for review. Old files without the keys load unchanged. An
old bus loader rejects a file that has them (`rejectUnknownKeys`), so a rollback past this row needs the
keys removed first. The activation plan has to say so (R4).
The broker enforces from its live copy of the business, which D3 keeps current. The checks sit in the
`role.revoke` reason branch and in the two new chief verbs, before any write.
## D3. Live changes
Today the broker's `#businesses` is fixed at start (E3), and `instances.set` answers `restart: true` for
add and redefine.
1. `Broker.reloadBusiness(id, document)` swaps in one business's validated document. It is synchronous, so
no request sees half of it. It refuses an unknown id and a document whose `human` changed (that is row
193's ground).
2. The runtime's organization callback (`runtime.mjs:121-133`) calls it after every committed write by
`instances.set`, `veto.set`, `instance.hire`, `instance.fire`, `limits.set` and `organization.set`. It
loads from the file through the same loader `bootConfig` uses (`cli/src/config.mjs:88-105`), so the
broker holds exactly what a restart would give it. Then `limits.refresh(id)` runs, as `plugins.set` does
now.
3. Other startup consumers of `businesses` in `startBroker` (`runtime.mjs:75-81`): credential references,
usage, the web services and the tasks adapter's bots. A create or hire adds no credentials, no tracker
bot and no web grant, because credentialed definitions are refused. The build checks each consumer
against that claim. Any change that would need one of them still answers `restart: true` and doesn't
reload.
4. A definition change on a running instance refuses `instance-running`. A live cap must not change
authority under its run. This is new: today the change waits for the restart.
5. The chief's `launch` tool (E5) takes `instance` as a string with the id pattern instead of the start-time
enum. Its description says the host refuses an unlisted instance. `check()` already refuses one
(`launcher.mjs:235`). Without this, the chief can't launch its own hire until its session restarts.
`tools.mjs` may carry hash pins in tests. The build greps for them (R5).
6. Hand edits of the business file still need a restart. Nothing watches the file.
After this, `restart` is false for create, delete, hire, fire and veto changes, and true only under item 3.
## D4. RBAC until row 193
Every human verb above calls `#human(s)` and then one hook, `#mayHuman(s, verb, target)`. Until row 193
lands, the hook passes for the business's one `human`, which is the owner. The verbs are listed once, in
an exported `LIFECYCLE_HUMAN_VERBS` (`instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`,
and `instances.set` ops `add`, `change`, `delete`), so row 193 maps each verb to the human roles that may
use it. Row 193 replaces the hook body and nothing else. The tester gets none of the new write verbs (Q3).
## D5. One stack instance
Row 190 carries the stack instance as the config `MOSAIC_CONFIG` names. These verbs take no instance name.
The CLI reaches the broker socket under that config's `dataRoot`. The broker acts on its own businesses,
launcher and data root. A business id that exists in two stack instances is two separate businesses. A test
runs two scratch roots with the same business id and shows a launch, a terminate and a delete in one leave
the other byte-identical (T11).
## D6. The kill switch
- Launch and regenerate end in `start()`, which row 191 gates (its D3 inventory, `launcher.mjs:329-457`).
The new pass adds no second start path.
- The broker refuses `instance.launch` and `instance.regenerate` with `processing-halted` while the switch
is on (V2 step 1), so no request is waiting when a human resumes.
- `instance.hire` and `instance.fire` are agent work verbs, so row 191's default refuses them while halted.
The build adds them to row 191's work table, not its read list.
- Terminate, create, delete and veto start nothing and stay allowed.
- If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177). The two halted
checks are written against its reader and tested with its fixture.
## D7. The Console
A new Settings section, "Agents", in its own `agents.js` file. Settings itself isn't rewritten. It lists
each instance with definition, listed, kept, retired and running runs. It has these actions:
- Create (definition select, from `definitions`), Delete (with a confirm naming what stays: the evidence
and the workspace under `.retired`).
- Launch, Terminate (with an optional reason), Regenerate (with a confirm).
- Keep and Unkeep per instance, and a No-hire toggle per definition.
The routes are `POST /api/instances` (op `delete` joins `add` and `change`), `POST /api/agent/launch`,
`/api/agent/terminate`, `/api/agent/regenerate` and `POST /api/veto`. Each uses `instanceSend`'s boundary:
signed-in user, exact Origin, same-origin Fetch Metadata, JSON within 16 KiB, strict keys, one bus call
through the human transport, and `outcome-unknown` on an unexpected answer. Launch and regenerate show the
request, then poll `GET /api/agent/request?id=` until it closes.
When `/api/config` says `send` is false, every action button shows as unavailable with the text "Agent
actions are unavailable here. Use mosaic business instance from the instance CLI." This is the live state
until Q175 phase 2. Every write test uses a fixture server and fixture writers, as rows 165 and 191 do. No
route gets a `--send` exception.
## D8. Tests
Every test uses scratch roots under `TMPDIR` and the existing fixtures (`business/tests/helpers.mjs`,
`cli/tests/helpers.mjs`, `webui/tests/tester-fixture.mjs`). None touches `~/.mosaic-dev`,
`~/.config/mosaic-dev`, a live unit or a slot file.
| Test | Shows | File |
|---|---|---|
| T1 | V1 create on a running bus: a launch of the new instance binds with no restart, and the answer says `restart: false` | cli `instances.test.mjs` |
| T2 | V2: a human launch of an unlisted instance runs. A thread instance, a retired one and a revoked business refuse. A request older than 60 s never launches. A refused `check()` closes the request with its code | cli `lifecycle.test.mjs` (new) |
| T3 | V2 with `--no-launch` and with each limit refusal: no process, request closed with the code | same |
| T4 | V3: claims revoked, runner SIGTERMed after the start-time check, one end record. A mismatched start time sends no signal | same |
| T5 | V4: the new run starts only after the old exit, with a new run dir and the same workspace. The assistant refuses `regenerate-thread` | same |
| T6 | V5: each refusal in the V5 table, in order. A clean delete leaves the evidence bytes unchanged, renames the workspace, adds `retired`, and a create of the same name refuses | business `instance-settings.test.mjs` |
| T7 | V6: each op, the no-op refusal, and a `changes.jsonl` entry with actor `human` | business |
| T8 | Chief hire: added and listed, then launched by the same chief session (D3 item 5). `noHire`, credentialed, END_PROTECTED, existing and retired each refuse | bus `chief-of-staff.test.mjs` |
| T9 | Chief fire: runs end, delisted, still in `roles`. Kept, `launch.by` and last-listed refuse | same |
| T10 | Keep: chief `role.end`, fire and hire aimed at a kept instance refuse `instance-kept`. The human's V3 on it works | same |
| T11 | Two scratch stack instances with the same business id: V2, V3 and V5 in A leave B's root and config byte-identical | cli `lifecycle.test.mjs` |
| T12 | Agent, reader and tester sessions refuse every human verb before any effect. The D4 hook is called with the verb | bus `instances-verb.test.mjs` |
| T13 | `organization.set` with `launch`, `retired`, `keep` or `noHire` keys refuses | bus |
| T14 | Live reload: after each write the broker answers from the new document. A definition change on a running instance refuses `instance-running` | bus |
| T15 | Kill switch (with row 191's fixture): launch and regenerate refuse `processing-halted`, hire and fire refuse, terminate works | bus and cli |
| T16 | Console: each route's boundary refusals, a fixture write per action, `outcome-unknown` on a bad shape, unavailable buttons when `send` is false, request polling | webui `agents.test.mjs` (new) |
| T17 | Business file: the new keys validate, each bad entry refuses, and a file without them loads byte for byte as before | business `business.test.mjs` |
### Mutants
Each one must fail at least one test. They run against the candidate, and the file is restored and its
sha256 checked after each one.
| # | Change | Killer |
|---|---|---|
| M1 | `instance.launch` skips `#human` | T12 |
| M2 | the pass calls `check()` without `human: true` | T2 |
| M3 | the pass starts a request older than 60 s | T2 |
| M4 | the revoked-business check is skipped for the human | T2 |
| M5 | terminate signals without the start-time check | T4 |
| M6 | regenerate starts before the old exit | T5 |
| M7 | delete skips the running check | T6 |
| M8 | delete skips `retired` | T6 |
| M9 | delete clears `keep` instead of refusing | T6 |
| M10 | delete removes the workspace instead of renaming it | T6 |
| M11 | the `keep` check is removed from `role.end` | T10 |
| M12 | `noHire` is not read by hire | T8 |
| M13 | fire deletes the instance from `roles` | T9 |
| M14 | `reloadBusiness` isn't called after `instances.set` | T1, T14 |
| M15 | the tool enum is kept | T8 |
| M16 | the halted check is dropped from `instance.launch` | T15 |
| M17 | the Console route skips the Origin check | T16 |
| M18 | `veto.set` joins `TESTER_ORGANIZATION_VERBS` | T12 |
## Scope and boundaries
- In the row: the verbs and passes above in `business` (instance-settings, business validation), `bus`
(broker verbs, reload, runtime callback), `cli` (instances command, launcher pass, host ops), `harness`
(the launch tool's schema, and the hire and fire tools under O1), `webui` (routes and the Agents
section), the tests, and the package READMEs. `docs/TOOLS.md` text goes to Sage.
- Role file change: `roles/chief-of-staff.json` under O1 only. Business file schema: D2.3. Both are named
for review.
- Not in the row: credentialed hires and deletes (row 195), clear context for the assistant (T1 item 7),
human roles (row 193), and any live activation or Console start.
## Risks
- R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11). Neither the human
verb nor the chief can create or delete them until row 195 binds credentials. Until then this row covers
uncredentialed roles only. That is the largest gap against Case 1, and it isn't this row's to close.
- R2. The human proof is path-bound and same-uid (P19 333). Any same-uid process that runs the tree's
`human-cli.mjs` can launch and delete. Row 190b's separate uid and row 193 narrow it. This row adds no
check of its own.
- R3. The 60 s request window means a slow `check()` (checkouts) can still start a run up to that point
after the CLI gave up. The CLI says so on timeout.
- R4. Rollback past this row needs `keep`, `noHire` and `retired` removed from each business file, or the
old loader refuses it. The activation plan states this.
- R5. `tools.mjs`, `serve.mjs` and `broker.mjs` may carry sha256 pins in other packages' tests. The build
greps the tests for every changed path plus a 64-hex value before in-review.
## Questions
- Q1, for Sage and Mos: chief hire and fire authority, O1 (role file change) or O2 (gated decisions)?
D2.1. I recommend O1.
- Q2, for Webby: keep blocks the chief's `role.end` on that instance, not only fire and replace (D2.2). Is
the wider rule right?
- Q3, for Sage: the tester gets no lifecycle write verbs. The Q173 tester could then test the Agents section
only through fixtures. Should launch and terminate be tester verbs on the tester's own scratch business?