diff --git a/agents/honker/work/queue-194/DESIGN.md b/agents/honker/work/queue-194/DESIGN.md new file mode 100644 index 000000000..a5f1975ca --- /dev/null +++ b/agents/honker/work/queue-194/DESIGN.md @@ -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 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///`, `launches/.jsonl`) and bus rows are untouched. They are + write-once. +- The workspace `workspaces//I/` is renamed to `workspaces//.retired/I-/`, 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?