Files

52 KiB

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 a907109409). 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, regenerate and delete are requests the launcher takes once and carries out under serialize. Launches go through the 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. role.hire and role.fire are new hard waits, so Jason resolves each one (Mos, mos-1017). The routed form waits on a PRD 1.7 draft in this packet.
  • A hire allowlist in code: the chief can hire only a definition whose every action is on HIRE_ALLOWED (message, record, review request, assigned-task updates, and two actions that matter after row 199). Launch, end, organization, naming, hire, verdict, merge and every later action stay out.
  • The per-agent forbid is two lists in the business file that only the human writes: launch.keep (instances the chief can't fire, replace or end) and launch.noHire (definitions the chief can't hire).
  • The broker reloads a business from its file after each committed write. Create, delete, hire, fire and veto changes then count without a bus restart. Hand edits still need one, except bus.tester.
  • No bus schema change and no migration. Named for review: two new vocabulary actions (hard waits), the role-file hireable key, "hireable": [] in roles/chief-of-staff.json, and three business-file keys.

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 and cancels I's open requests
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 [--wait SECONDS] instances.set op delete a request. The launcher removes I from the business file and renames its workspace
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.

D1.1 Requests (V2, V4, V5)

The broker never starts a process or touches a workspace. Launch, regenerate and delete are requests the launcher carries out, as E9 already does for the human's threads. A request has three states, open, taken and closed, all recorded as action.allowed events with reserved action names:

Event Written by Body
lifecycle.launch, lifecycle.regenerate, lifecycle.delete the broker, at the verb { request, via }, subject I, actor the human or tester. A human.input event goes with it, as launch.revoke does
lifecycle.taken host op lifecycleTake { request }
lifecycle.done host op lifecycleDone { request, run? , refusal? }
lifecycle.cancelled V3, for each open request of I { request, by }
  1. The launcher's new lifecyclePass runs on the same tick as threadPass and landPass. It lists open requests with host op lifecycleRequests.
  2. For each one, under serialize, it calls lifecycleTake(request). That op runs in one store transaction. It re-checks, in order: the request is still open (not taken, cancelled or closed), it is less than 60 s old (else it closes as request-expired), the business isn't revoked (launch_state, launch-revoked), I isn't retired, row 191's switch is off (processing-halted) for launch and regenerate, and for a tester request bus.tester is true in the file (D4.1). Any failure closes the request with that code. Otherwise it writes lifecycle.taken and answers go.
  3. Only after go does the pass act: check(I, { human: true }) then start() for a launch, the V5 steps for a delete.
  4. The pass then calls lifecycleDone. A taken request never runs again, whatever happens after the take. If lifecycleDone throws, the request stays taken with no done event. The CLI reports that as outcome-unknown with the request id, and the launcher log names the run.
  5. The CLI polls a read verb, instance.request, until the request closes or --wait runs out (default 30 s). A timeout exits 3 with the request id. An untaken request can still be taken within its 60 s.

So the human's launch.revoke stops every untaken request, not only new ones (B4), and a bus restart can't start a request older than 60 s.

V2 launch

  1. At the verb 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 and, with row 191, isn't halted. These repeat at the take (D1.1 item 2).
  2. After the take, check(I, { human: true }) and start({ instance: I, launchedBy }), where launchedBy is the business human or tester. human: true lets the human launch an instance that isn't in launch.instances. The listing limits the chief, not the human. launch.by still goes through the pm: true branch (launcher.mjs:235), as --pm does today. Every other step of check() and start() is unchanged.

V3 terminate

  1. The broker revokes every claim I holds (#claimEnd(s, h, 'revoke', null, reason, actor)). A thread instance has all its thread claims revoked, which is exactly assistant.stop.
  2. In the same transaction it writes lifecycle.cancelled for every open (untaken) request of I, so no launch the human asked for before the terminate starts after it (B5).
  3. It writes lifecycle.end with 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). It also signals any run of I in the launcher's registry that has spawned but not yet bound, which holds no claim yet. The existing exit handler records the end, so the launch journal and session.ended are unchanged.
  4. Nothing held, nothing open and nothing in the registry answers not-held.
  5. Terminate is allowed while row 191's switch is on. It starts nothing.

A taken launch request is past cancelling. Its run reaches the registry under serialize, and the next terminate ends it. The CLI's terminate output says so when a taken request exists.

There is no SIGKILL escalation in this row. A run that ignores SIGTERM keeps running. mosaic stop and the limits are the operator's tools for it (N6).

V4 regenerate

A fresh session with the same identity and role. It is V3 then V2 under one request. The pass takes the request, 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). If the old run hasn't exited by the request's 60 s mark, the request closes as regenerate-timeout, nothing starts, and the old run is still running (N6).

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. At the verb the broker and the writer refuse, in this order:

Refusal When
tester-refused the caller is the tester (B6)
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 the credentials row (row 199)
instance-running the broker sees a claim for I. The human terminates first
instance-starting an open or taken request for I has no done event
BusinessError I is the last name in launch.instances (E1). The message says so

The checks pass with no write, and the broker records a lifecycle.delete request. After the take, the launcher pass, under serialize so no start() of I runs alongside it:

  1. Re-checks the registry for I (instance-running) and re-runs the writer's checks against the file as it stands. The --revision the human gave must still match, else revision-conflict.
  2. Commits the file through the organization writer. It 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 (refuse or remove). A reference the table doesn't name stops the build and goes back to Webby.
  3. Renames workspaces/<b>/I/ to workspaces/<b>/.retired/I-<transaction>/, mode 0700. No workspace is no rename.
  4. Calls lifecycleDone with the transaction.

The file goes first. Once it is committed, check() refuses I as unknown, so no launch can open the old workspace even if step 3 fails. A failed rename closes the request with workspace-rename-failed, the transaction and the path left behind. A second delete of a retired name skips steps 1 and 2 and retries step 3 only. The file and evidence are already right, so this is the whole recovery.

What stays:

  • Run evidence (launches/<b>/<run>/, launches/<b>.jsonl) and bus rows are untouched. They are write-once.
  • The workspace, under .retired. This row deletes no file. A prune with a receipt is a later act.
  • retired stops a later create or hire 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 name that matches the id pattern. A no-op answers organization-no-change. It is human-only and isn't in TESTER_ORGANIZATION_VERBS.

D2. Chief hire and fire

D2.1 Authority (Mos, mos-1015 and mos-1017, through Sage, sage-1362 and sage-1365)

This row adds role.hire and role.fire to ACTIONS (vocabulary.mjs:11). Under PRD REQ-WAIT-3 a new action is a hard wait until a PRD version says it isn't (vocabulary.mjs:52-57), so both ship as hard waits. Jason resolves each one. There is no PRD change and no grant in a role file: role.mjs:42 refuses a withinRole that lists a hard wait, and REQ-WAIT-2 forbids it.

The flow in this row:

  1. The chief raises a role.hire or role.fire decision whose body carries the arguments (business, name, definition, vars, or name and reason). A hard wait goes to the human only. No arbiter resolves it.
  2. Jason resolves it in the Console or the CLI.
  3. The chief calls instance.hire or instance.fire citing the decision. The broker checks it with #consumeAuthority(s, 'role.hire', { decision, target }), as the role.revoke decision branch does, and the arguments must equal the decision body field by field (N1, webby-0010). For hire that is name, definition and vars (vars compared with canonicalJson, absent equal to absent only). For fire it is name and reason. A key in the call that the body lacks, or a body key the call lacks, refuses decision-mismatch. Every check below runs again at this call, so a veto set after the resolve still refuses.

The routed form, where the chief hires and fires without a decision each time, needs a PRD version that moves both actions to ROUTED. My draft is agents/honker/work/queue-194/PRD-1.7-DRAFT.md. It quotes TARGET.md line 66 and carries Mos's bounds. Jason approves it as a prd.approve decision in the Console, and Sage opens that decision. When it lands, a later commit adds the two actions to both ROUTED lists (vocabulary.mjs, broker.mjs:43-69) and to roles/chief-of-staff.json withinRole. The broker then takes the reason form instead of a decision. The verbs and every check below stay the same. This row builds and tests the hard-wait form only. It writes the hireable key and its allowlist now (D2.3), because both forms use them.

D2.2 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 (D2.4)
hire instance.hire { name, definition, vars?, decision } adds and lists an instance. Refusals below
fire instance.fire { name, reason, decision } ends I's runs, cancels I's open requests, and removes I from launch.instances. Refusals below

Only the business's chief, the instance named by launch.by, may call either verb. Any other caller refuses role-not-chief, even with a resolved decision, as #classification already limits role.launch. The broker reads hireable from that chief's own role file. Both act only in the chief's own business. Neither verb takes a business argument. The broker uses the cap's business, and a decision raised in another business refuses decision-mismatch. Row 191's switch refuses both while halted (D6). Every hire and fire writes changes.jsonl through the organization writer with actor the chief's instance and run, and the decision id.

Hire refuses, in order:

  1. definition-not-hireable when the definition holds an action outside HIRE_ALLOWED (D2.3), whatever any list says.
  2. definition-not-hireable when the definition isn't in the chief role file's hireable list, or is in the business's launch.noHire.
  3. organization-definition-refused for END_PROTECTED, organization-credentials-out-of-scope for a credentialed definition (until the credentials row, row 199), invalid-request for version 1.
  4. organization-instance-retired when the name is retired.
  5. When the name exists: a fired instance (in roles, not listed) is listed again only if definition equals its current one and vars is absent. Any other definition or vars refuses organization-instance-exists, and so does a listed name. A re-hire never moves an instance's decisions to a different role (B3).

Hire's vars may hold only the keys the chief can already set on an instance through organization.set (the model key, broker.mjs:1266-1269) and narrowing limits.* keys. Anything else, such as harness or a route, refuses invalid-request. The human sets those (N3).

Fire refuses instance-kept for a kept instance, organization-definition-refused for END_PROTECTED, organization-instance-referenced for launch.by, and a BusinessError for the last listed instance. A fired instance stays in roles. The chief never deletes. Delete stays a human act because it retires the name.

Fire ends the fired instance's processes, not only its claims (F4, Megavolt through sage-1374). Every run gets the shared model token at start: ENV_ALLOW (packages/seat/src/session.mjs:83-94) passes ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN and the other provider variables into the sandbox. A revoke can't take that token back, so a fired agent whose process lives on still holds it.

The delist is a business file commit through the organization writer, and the revoke and events are store rows. No transaction covers both (webby-0011 B2), so fire runs in a fixed order, store side first:

A. One store transaction: steps 1 to 3 below, plus fire.stopped with the decision id. The decision isn't consumed yet. B. The delist through the organization writer (launch.instances loses I, logged in changes.jsonl). C. One store write consuming the decision and recording fire.done.

If B or C fails, fire answers the step's code and the decision stays unconsumed. A second fire citing the same decision runs again. A finds nothing to revoke, cancel or stop, and that isn't a refusal for fire (unlike V3's not-held). B finds I already delisted and counts as done. C consumes. So a fire is safe to repeat until it's consumed. The other order would leave a delisted agent with a live process and its model token when the store step fails, which is the case F4 exists to stop. A halt between A and B doesn't stop B or C, because neither starts anything. A second fire while halted refuses (D6), and the halt's own stop covers the runs.

Steps 1 to 3 are V3's, with one change:

  1. It revokes every claim the instance holds.
  2. It writes lifecycle.cancelled for each of the instance's open requests.
  3. It writes lifecycle.end with the runs and kill: true. The launcher pass stops each runner, spawned but unbound registry runs included, with escalation (sage-1380 item 1):
    • SIGTERM after the identity check.
    • After the grace, SIGKILL to any runner still alive, after the same identity check again, and lifecycle.end.killed with the run and pid.
    • A runner still alive after SIGKILL and the observe wait gets lifecycle.end.alive. The Console's Agents section shows it to the human. A runner whose identity no longer matches is never signalled. Every check, the recheck after the grace included, uses the identity (start ticks plus boot id), never a bare pid, so a reused pid never counts as the fired run (webby-0011 N2).

Fire's answer lists the runs it is stopping. The delist stops any new chief launch (launch takes listed instances only). A human launch request already taken is past cancelling, as in V3. The human made it, the fire answer names it, and the human's terminate ends its run.

The stop is row 191's per-run stop, not a second one, so the two can't fight (sage-1380 item 1). Row 191's design r2 (agents/rocko/work/queue-191/DESIGN-r2.md, "Stop policy and completion") sends SIGTERM, waits 5 s, sends SIGKILL to verified groups and namespaces, and observes for 2 s. Its identity check is start ticks plus boot id, which replaces the start-time check at launcher.mjs:526. Fire calls that routine for one instance's runs and uses its constants, so the grace is row 191's 5 s, not the 10 s in r3. The order:

  • Fire is refused while halted (D6), so a halt sweep never meets a fire that started after it.
  • A halt that comes during a fire's stop finds those runs already stopping. Each run has one stop receipt (row 191's), and the first stop owns it. The halt sweep joins it instead of sending a second SIGTERM or restarting the deadline, and counts the run in its remaining or quiescent totals.
  • The record says which stop owned it: reason fire with the decision, or row 191's kill-switch with its generation.

V3 terminate is unchanged. It still sends SIGTERM only (N6). Only fire escalates, because only a fired agent must lose the token at once. If row 191 hasn't landed when this row builds, the build chains on its candidate (Q177), as D6 already says.

D2.3 The hire allowlist (B2, sage-1365 item 4, webby-0010 B1)

r2 and r3 had a deny list, HIRE_FLOOR. Webby's B1 showed it missed routed actions (git.merge.protected, which roles/merge-gate.json holds with no credentials, decision.resolve.technical, policy.change, prd.approve), and that any action added later would be hireable by default. That isn't fail-closed. r4 replaces it with an allowlist.

A definition is hireable only if every action in its withinRole and crossRole is on HIRE_ALLOWED:

Allowed action Held today by
message.send, record.write every v2 role file
review.request, task.update.assigned coder, cto, pm, reviewer
task.scope.change, git.push.working coder, cto, pm (credentialed, so they matter only after row 199)

Everything else in ACTIONS fails, including the actions of the old floor (role.launch, role.end, role.revoke, organization.change, instance.name, review.verdict, role.hire, role.fire) and those B1 named. An action added to ACTIONS later fails until a reviewed commit adds it here. git.push.remote (row 199, routed to coder by PRD 1.7) isn't on the list. A coder holding it stays unhireable until a reviewed commit adds it, after the credentials bound is lifted.

Against today's role files: chief-of-staff, cto, merge-gate, pm, reviewer and security-reviewer fail. Assistant, researcher, coder and the five C-suite files pass the allowlist. Assistant is END_PROTECTED and coder is credentialed, so D2.2 item 3 refuses them. The C-suite files hold only message.send and record.write, so hiring one gives no authority beyond its name. hireable still has to list it.

The list is a constant in business/src/vocabulary.mjs, HIRE_ALLOWED. It is checked twice:

  • At load. role.mjs validates the chief role file's hireable list: distinct definition names, each present in rolesDir, version 2, and every action on HIRE_ALLOWED. A list naming one that isn't refuses the role file (hireable names <def>, which holds <action>). The check reads only the named file's authority lists, never its hireable, so a list can't recurse. hireable is an optional key on any role file. This is a role schema change, named for review.
  • At hire, in the broker, against the definition as loaded then. A role file edited after load is caught here.

hireable in roles/chief-of-staff.json stays empty in this row ([], Q4, sage-1374). Naming the first role kind is a separate policy commit in roles/, made after Jason resolves the prd.approve decision, as AGENTS.md invariant 7 requires. With an empty list the chief can hire nothing, even with Jason's resolve, until that commit. The row's tests use scratch role files whose hireable names a kind.

D2.4 The per-agent forbid and the veto (sage-1365 item 3)

TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as needed; unless user forbids per agent". The forbid is two lists in the business file that only the human writes:

  • launch.keep: instance names. The chief can't fire, replace or end a kept instance. The broker refuses instance-kept for every chief act aimed at it except launch:

    • instance.fire, even with a resolved role.fire decision. Mos's limit is that Jason's veto applies to every hire and fire, so the human unkeeps before a fire.
    • a hire of that name.
    • role.revoke in the reason branch (broker.mjs:1607-1613). End then launch is a replace (E6).
    • an organization.set write of that instance's model key (B7). A model change is a replace at the next launch.

    The role.revoke decision branch (broker.mjs:1614-1615) still ends a kept run. It ends a run, it doesn't fire or replace, and it cites a decision the human resolved (Webby's Q2 ruling). The human's V3 and the limits also still end a kept run.

  • launch.noHire: definition names the chief can't hire into this business (D2.2 item 2).

Why the chief can't change the forbid:

  • Only veto.set writes the lists. It is human-only (#human(s) then the D4 hook) and refuses the tester.
  • organization.set, the chief's one write verb, reaches only ORGANIZATION_KEYS (broker.mjs:145), which exclude launch and retired.
  • instances.set is human or tester only (broker.mjs:1248-1298).
  • Hire and fire carry no field that writes either list.

T18 tests each of those paths from a chief session and checks the business file bytes are unchanged.

END_PROTECTED instances can't be kept (N7). That is intended: Q169 lets the chief end the assistant's runs, and the assistant's own stop is assistant.stop. Default, per "unless user forbids": nothing is kept and nothing is on noHire.

D2.5 Where it lives and how the broker enforces it

The forbid lives in the business file, under launch, beside the membership it limits: launch.keep: [instance], launch.noHire: [definition], and top-level retired: [instance]. hireable lives in the chief's role file, because it bounds the role, not one business.

checkLaunch (business.mjs:118) gains the two keys. Each list is distinct. keep names declared, non-protected instances. noHire entries need only the id pattern (N2). A stale deny entry is harmless, and a reviewed commit that removes a role file must not stop every business that lists it. 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, in organization.set's model write and in the two 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 in one place, after every verb that commits a write, whatever the verb (N1). Today that is limits.set, instances.set, routes.set and plugins.set, and after this row veto.set, instance.hire, instance.fire and the launcher's delete through the same writer. It loads from the file through the 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. Existing tests that pin restart: true change with it (N4): business/tests/instance-settings.test.mjs, bus/tests/instances-verb.test.mjs, cli/tests/instances.test.mjs:76-94 (the RESTART_NEEDED text and the restart line) and webui/tests/instances.test.mjs:25, 71, 74, 140-145. The build names each changed assertion in its manifest.
  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. The one exception is bus.tester (D4.1), read from the file at each tester call.

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.

D4.1 The tester (Sage, sage-1355)

What the tester may do after this row:

Verb Tester
instance.launch, instance.end yes, new
instances.set ops add, change yes, as today (row 175, broker.mjs:103, serve.mjs:537)
instances.set op delete no, tester-refused in the broker (B6)
instance.regenerate, veto.set no, tester-refused

The tester's cap is bound to one business, and only when that business sets bus.tester to true (broker.mjs:542-546). That stays Jason's switch, and no bus verb writes it. Two checks run on each tester lifecycle call, before any effect, and again at the take (D1.1):

  1. The request's business is the cap's business. Any other refuses tester-refused.
  2. bus.tester is exactly true in the business file as it stands, read through settings.current(id) as harnessOn does (runtime.mjs:136-142), not from the broker's copy (B8). An unreadable file refuses tester-off. So a hand edit turning testing off counts at the next tester call, without a restart.

The request and done events record actor tester. The tester's Q173 tests of the Agents section then cover launch and terminate on a scratch business, and the rest through fixtures.

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), and the take checks again (D1.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. A hire Jason resolved before a halt still refuses while halted, and the chief calls it again after the resume.
  • 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, and flags a run with a lifecycle.end.alive event as "still running after fire" (D2.2). 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.

agents.js joins the static file list (serve.mjs:28) and index.html (N5).

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. launch.by goes through the pm branch (N8). A thread instance, a retired one and a revoked business refuse at the verb. A request older than 60 s never launches. A refused check() closes the request with its code cli lifecycle.test.mjs (new)
T2a Take: a launch.revoke between the request and the pass closes it launch-revoked with no process. A lifecycleDone that throws once leaves the request taken, and the next tick starts nothing. A taken request is never taken twice same
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. An open request is cancelled and never starts. A spawned, unbound run in the registry is signalled same
T5 V4: the new run starts only after the old exit, with a new run dir and the same workspace. A run that ignores SIGTERM closes the request regenerate-timeout with nothing started. The assistant refuses regenerate-thread same
T6 V5: each refusal in the V5 table, in order, including instance-starting with an open request. A clean delete leaves the evidence bytes unchanged, commits the file before the rename, adds retired, and a create or hire of the same name refuses. A failed rename closes workspace-rename-failed, check() refuses the name, and a second delete renames only business instance-settings.test.mjs and cli lifecycle.test.mjs
T7 V6: each op, the no-op refusal, an unknown noHire name accepted, and a changes.jsonl entry with actor human business
T8 Chief hire (hard-wait form): no decision refuses. A decision raised by the chief goes to the human only, no arbiter. After the resolve, the hire adds and lists the instance, logs actor and decision, and the same chief session launches it (D3 item 5). Arguments that differ from the decision body refuse. A non-chief caller with a resolved decision refuses role-not-chief. Outside hireable, in noHire, credentialed, END_PROTECTED, retired, and extra vars each refuse bus chief-of-staff.test.mjs
T8a Re-hire (B3): a fired name with the same definition and no vars is listed again. Another definition or vars refuses organization-instance-exists same
T8b Hire allowlist (D2.3): one case per action in ACTIONS that is neither on HIRE_ALLOWED nor a hard wait (role.mjs already refuses those), looped from the lists, not typed by hand. A fixture definition, clean when the chief's role file loads and listed in hireable, is rewritten to hold that action before the hire. The hire refuses definition-not-hireable. A definition holding one allowed and one refused action refuses. One holding only allowed actions hires. A pin test fixes HIRE_ALLOWED to the six actions same
T8c Allowlist at load: a role file whose hireable names a definition holding each refused action refuses at loadRole, one case per action, looped the same way. The mixed case refuses. A clean list loads. A missing, version 1 or duplicate name refuses business role.test.mjs
T9 Chief fire: runs end, open requests cancelled, delisted, still in roles. Kept (even with a resolved decision), END_PROTECTED, launch.by and last-listed refuse bus chief-of-staff.test.mjs
T9a Fire ends processes (F4, sage-1380 item 1): with a bound run and a spawned but unbound registry run of I, both fixture runners holding a stand-in model variable, fire answers both runs. After the launcher pass, neither runner pid is alive and I holds no claim. A fixture runner that ignores SIGTERM gets SIGKILL after the grace, is gone, and has lifecycle.end.killed with its run and pid. A stand-in whose kill is stubbed to fail gets lifecycle.end.alive. A runner whose identity changed before the SIGTERM, or between SIGTERM and SIGKILL, gets no signal. V3 terminate of a SIGTERM-ignoring runner sends no SIGKILL cli launcher.test.mjs
T9b Fire and row 191's halt: a halt during a fire's stop sends no second SIGTERM, keeps the fire's deadline, writes one stop receipt with reason fire and the decision, and counts the run in its totals. A fire while halted refuses (D6) cli launcher.test.mjs, on row 191's fixture
T9c Fire order (webby-0011 B2): the organization writer stubbed to fail after A commits. Fire answers the writer's code, I's claims are revoked and its runs stopped, I is still listed, and the decision is unconsumed. A second fire citing the same decision delists I and consumes it. A third fire refuses decision-consumed. With C stubbed to fail, the second fire finds I delisted and only consumes bus chief-of-staff.test.mjs
T10 Keep: chief role.revoke with a reason, fire, hire of that name and an organization.set model write aimed at a kept instance each refuse instance-kept. A role.revoke citing a human-resolved decision ends the kept run (Q2). The human's V3 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. A chief of business A can't hire into business B on the same bus cli lifecycle.test.mjs, bus
T12 Agent and reader sessions refuse every human verb before any effect. The tester gets exactly the D4.1 table, including tester-refused for instances.set op delete (B6). The D4 hook is called with the verb bus instances-verb.test.mjs
T12a Tester launch and terminate work on the cap's business with bus.tester true, record actor tester, and refuse tester-refused for a second business on the same bus. A file edit setting bus.tester to false, with no restart, refuses the next call and the take tester-off. An unreadable file refuses tester-off bus instances-verb.test.mjs
T13 organization.set with launch, retired, keep or noHire keys refuses bus
T14 Live reload: after each committed verb, plugins.set and routes.set included, 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 at the verb and at the take, 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, and agents.js served from the static list 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
T18 The forbid can't be changed by the chief (D2.4): from a chief session, veto.set, organization.set with each list key, instances.set, and hire and fire with extra fields all refuse, and the business file bytes are unchanged bus chief-of-staff.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 take accepts a request older than 60 s T2
M4 the take skips the launch_state check T2a
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 the role.revoke reason branch 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
M19 the tester's business scope check is dropped T12a
M20 the tester's bus.tester file read is dropped T12a
M21 hire skips the role file's hireable list T8
M22 the take doesn't write lifecycle.taken, so a request can be taken twice T2a
M23 terminate doesn't cancel open requests T4
M24 delete skips the instance-starting check T6
M25 delete renames before it commits the file T6
M26 the tester's instances.set op delete refusal is dropped T12
M27 the keep check is dropped from organization.set's model write T10
M28 the keep check is added to the role.revoke decision branch T10
M29 a re-hire accepts a different definition T8a
M30 to M36 the broker's hire check accepts one action outside HIRE_ALLOWED, one mutant per action: role.launch, role.end, organization.change, instance.name, review.verdict, git.merge.protected, decision.resolve.technical T8b
M37 to M43 the same seven, accepted by the load check in role.mjs only T8c
M44 fire skips instance-kept when a decision is cited T9
M45 instance.hire skips the launch.by caller check T8
M46 veto.set skips #human T18
M47 the broker's hire check needs some action on HIRE_ALLOWED instead of every action T8b
M48 the same in role.mjs only T8c
M49 fire revokes the claims but writes no lifecycle.end, so no runner is signalled T9a
M50 fire's signal skips spawned but unbound registry runs T9a
M51 fire's stop drops the SIGKILL escalation T9a
M52 SIGKILL is sent without the second identity check T9a
M53 a killed runner gets no lifecycle.end.killed T9a
M54 V3 terminate escalates to SIGKILL as fire does T9a
M55 the halt sweep sends its own SIGTERM and restarts the deadline on a run fire is stopping T9b
M56 fire delists before the store step T9c
M57 fire consumes the decision in step A T9c
M58 a second fire with nothing to revoke refuses not-held T9c

Scope and boundaries

  • In the row: the verbs and passes above in business (vocabulary, role validation, 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), webui (routes and the Agents section), the tests, the package READMEs and the PRD draft in this packet. docs/TOOLS.md text goes to Sage.
  • Named for review:
    • Vocabulary: role.hire and role.fire join ACTIONS as hard waits, under REQ-WAIT-3. The commit message names them as hard waits, as REQ-WAIT-3 requires.
    • Role schema: the optional hireable key, validated in role.mjs (D2.3).
    • Role file: roles/chief-of-staff.json gains "hireable": []. No authority change.
    • Business file schema: launch.keep, launch.noHire, retired (D2.5).
  • Not in the row: the routed form (after PRD 1.7), filling hireable, credentialed hires and deletes (row 199), clear context for the assistant (T1 item 7), human roles (row 193), SIGKILL escalation, and any live activation or Console start.

Risks

  • R1. Case 1's working roles (coder, reviewer, pm, cto) all declare credentials (E11), and pm, cto and reviewer fail the hire allowlist anyway. Neither the human verb nor the chief can create or delete credentialed roles until the credentials row (row 199). 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, and hireable from the role file, or the old loaders refuse them. The activation plan states this.
  • R5. tools.mjs, serve.mjs, broker.mjs, vocabulary.mjs and role.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, without a head.
  • R6. With hireable empty, the chief can hire nothing until the policy commit in roles/ that follows the prd.approve resolve names a kind (Q4). Case 1's "as needed" waits on that commit and on PRD 1.7.
  • R7. Fire's SIGKILL closes the SIGTERM-ignoring case (sage-1380). A runner still alive after SIGKILL (an unkillable process, or a stop whose identity check fails) keeps the shared model token until the human acts on lifecycle.end.alive. Only a per-agent model token (row 199 Q-M4, a later proxy row) would let a fire revoke the token itself.

Questions and rulings

  • Q1, ruled by Mos (mos-1015, mos-1017, through sage-1365): role.hire and role.fire ship as hard waits Jason resolves. The routed form needs PRD 1.7, drafted in PRD-1.7-DRAFT.md. D2.1.
  • Q2, ruled by Webby (webby-0003): keep blocks the chief's role.revoke reason branch, not its decision branch. D2.4.
  • Q3, ruled by Sage (sage-1355): launch and terminate are tester verbs scoped to the tester's own business, behind bus.tester. D4.1.
  • Q4, ruled by Sage (sage-1374): hireable stays empty in this row. Naming the first role kind is a separate policy commit in roles/ after Jason resolves the prd.approve decision. Tests use scratch role files that name a kind. D2.3.

r2 changes against webby-0003

Finding Where
B1 D2.1: hard waits under REQ-WAIT-3, no role grant. The routed form is PRD 1.7 (packet draft)
B2 D2.3: the floor from sage-1365 item 4, checked at load and at hire, T8b, T8c, M30 to M43 (an allowlist since r4)
B3 D2.2 hire item 5: a re-hire keeps the definition, T8a, M29
B4 D1.1: one lifecycleTake transaction re-checks and writes lifecycle.taken, T2a, M4, M22
B5 V3 cancels open requests and signals unbound runs. V5 refuses instance-starting, runs under serialize, commits the file before the rename, and states the recovery. T4, T6, M23 to M25
B6 V5 table and D4.1: the tester's delete refuses, T12, M26
B7 D2.4: keep covers organization.set's model write, T10, M27
B8 D4.1: bus.tester is read from the file at each call and at the take, T12a, M20
Q2 D2.4, T10, M28
N1 D3 item 2: one reload after every committed write
N2 D2.5: noHire takes any id-pattern name
N3 D2.2: hire's vars limited to the model key and narrowing limits.*
N4 D3 item 4 names the tests that change
N5 D7 and T16: agents.js joins serve.mjs:28 and index.html
N6 V3 and V4: no SIGKILL, regenerate-timeout
N7 D2.4: intended, Q169
N8 V2 item 2 and T2

r3 changes against sage-1374, sage-1377 and Megavolt's F4

Item Where
Q4 ruled: hireable stays empty, the first kind is a later policy commit in roles/ D2.3, R6, Questions
instance.name joins the floor (Webby B2, mos-1023 item 6) D2.3 table, T8b, T8c, M47, M48
F4: fire ends the fired agent's processes, since ENV_ALLOW gives every run the shared model token D2.2, D7, T9a, M49 to M51, R7
git.push.remote routed to coder only (mos-1028 item 3, row 199 r3) PRD-1.7-DRAFT.md, REQ-LAND-2 and the REQ-WAIT-3 append

r4 changes against webby-0010, sage-1380 and megavolt-0008

Item Where
webby-0010 B1: the deny-list floor becomes the allowlist HIRE_ALLOWED, so merge-gate, decision.resolve.technical, policy.change, prd.approve and any later action are refused D2.3, Summary, R1, T8b, T8c, M30 to M43, M47, M48, PRD REQ-COS-3 bound
webby-0010 N1: field-by-field compare of the call against the decision body, extra or missing keys refuse D2.1 item 3
webby-0010 N2: the model write is in REQ-COS-3's text PRD-1.7-DRAFT.md
sage-1380 item 1: fire escalates to SIGKILL through row 191's per-run stop, and the order against a halt is fixed D2.2, T9a, T9b, M51 to M55, R7
sage-1380 item 2 and megavolt-0008: REQ-LAND-2 binds one commit oid, and Megavolt's three text changes against row 199 r4 (e1aa6216c) PRD-1.7-DRAFT.md
webby-0011 B1: same as webby-0010 B1, M47 and M48 now allowlist mutants D2.3
webby-0011 B2: fire runs store side first, then the delist, then the consume, safe to repeat D2.2 order A to C, T9c, M56 to M58
webby-0011 N1: the remote-owner host check at each push sent to Megavolt for row 199
webby-0011 N2: identity, not bare pid, at every check D2.2 step 3
Q4: Webby's view matches Sage's ruling (empty) no change