Compare commits

...
Author SHA1 Message Date
jason.woltje f70b4da50b honker: row 194 S4 harness: the launch tool takes a role instance name with the id pattern instead of the start-time enum, so the chief launches its own hire in the same session and the host checks the listing, the T8 end to end through hire, the human route step, launch and fire, the chief contract names the route step 2026-10-20 17:02:46 -05:00
jason.woltje 49d7ea62a3 honker: row 194 S3b cli: business instance launch, regenerate, terminate and delete with --wait polling instance.request, business veto, the instances read gains running, every lifecycle outcome but done exits 3 with the request id, the CLI end to end through the pass, T2a, T4 unbound and mismatched runs, T6 evidence bytes, T11 two roots and two businesses on one bus 2026-10-20 16:56:26 -05:00
jason.woltje fe2b30d24e honker: row 194 S3a cli: the launcher's lifecycle pass takes, acts on and closes the human's launch, end, regenerate and delete requests (D1.1, T2-T5, V5 rename and retry), human launches of unlisted instances, regenerate-timeout, --no-launch closes requests launches-disabled, the retry answers the failed delete's transaction 2026-10-20 16:47:19 -05:00
jason.woltje 2c09877ec7 honker: row 194 S2e: role.hire and role.fire routed and within-role on the chief under PRD 1.7 REQ-COS-3 (sage-1471, mos-1089), reason form with no decision, the typed hire and fire tools, unhireableAction moved to vocabulary.mjs so the sandboxed runner loads broker.mjs again (S2d correction) 2026-10-20 16:30:09 -05:00
jason.woltje 089b92ca34 honker: row 194 S2d bus: the chief's instance.hire and instance.fire through a resolved decision raised with the call's arguments, the hire checks against the role files (W-N2, M60), fire steps A, B, C with kill: true, W-N1 instance-firing at the verb, the take and role.launch (D2.1-D2.3, T8-T10, T9c, T18) 2026-10-20 15:53:08 -05:00
jason.woltje 03e5bcb3cb honker: row 194 S2c bus: lifecycle requests for launch, regenerate, delete and end, the take, done and delete host ops, terminate revokes and cancels, V5 delete order with instances.check, the retry after workspace-rename-failed, tester launch and end (D1.1, V2-V5, T6 bus part, T12, T12a) 2026-10-20 15:29:07 -05:00
jason.woltje 11cb0423bc honker: row 194 PRD 1.7 draft r2, REQ-CRED-3 states the grant check and leaves the access path and who grants to the #424 design (duckworth-0038 B1, B2), build is row 210 2026-10-20 15:15:33 -05:00
jason.woltje 0bb1a3bd0f honker: row 194 PRD 1.7 draft, REQ-CRED-3 agent host access with sudo through the credential store, Q183 and Q184 sources, who grants left open (sage-1422, mos-1067, Vikunja #424) 2026-10-20 15:10:36 -05:00
jason.woltje 11069c5630 honker: row 194 S2b bus: veto.set human-only through the writer, LIFECYCLE_HUMAN_VERBS and the D4 hook, keep refuses the reason end and the chief's model write, tester delete refused, human delete held until the request (V6, D2.4, D4, T10, T12, T13, T18 part) 2026-10-20 15:08:11 -05:00
jason.woltje 042b22ee69 honker: row 194 S2a bus: live reload after every committed organization write, reloadBusiness, instance-running on a definition change, restart only when boot-fixed references, roots or bots differ (D3, T14, N4) 2026-10-20 15:04:00 -05:00
jason.woltje a8224700af honker: row 194 S1 business: role.hire and role.fire hard waits, HIRE_ALLOWED and hireable, keep/noHire/retired, instance delete, veto, hire and fire writer ops (T6 business part, T7, T8c, T17) 2026-10-20 14:54:14 -05:00
jason.woltje 69544f342b honker: row 194 PRD 1.7 draft, adding to the fixed hire list is a PRD change (mos-1035 item 3, sage-1395) 2026-10-20 14:50:50 -05:00
jason.woltje 8326a2fb5a honker: row 194 PRD 1.7 draft, REQ-LAND-2 host-built fork from row 199 r6 (megavolt-0014, duckworth-0027) 2026-10-20 14:43:50 -05:00
jason.woltje fa9eaa4c3e Merge remote-tracking branch 'origin/refactor' into honker/row194 2026-10-20 14:41:53 -05:00
jason.woltje f3cf4534bb honker: row 194 PRD 1.7 draft, routing commit names the source commit with no decision (duckworth-0022 N9, sage-1387) 2026-10-20 14:40:36 -05:00
jason.woltje 7d07882362 honker: row 194 design r4 (webby-0010/0011: HIRE_ALLOWED allowlist, decision field compare, fire order store-first; sage-1380: fire SIGKILL via row 191 stop; megavolt-0008: REQ-LAND-2 oid binding, row 199 r4) 2026-10-20 14:35:12 -05:00
jason.woltje d0afda0243 honker: row 194 design r3 (sage-1374, sage-1377: Q4 hireable stays empty, instance.name on the hire floor, fire ends runs F4) and PRD 1.7 draft REQ-LAND-2 git.push.remote for coder 2026-10-20 14:31:19 -05:00
jason.woltje c0a35f47a0 honker: row 194 design r2 (webby-0003 B1-B8, Q2, N1-N8; Mos mos-1017 hard waits, hire floor, per-agent forbid) and PRD 1.7 draft 2026-10-20 14:19:49 -05:00
jason.woltje 14a6f5f9d5 honker: row 194 design r1b, sage-1355 rulings (tester verbs, bounded O1, credentials row) 2026-10-20 14:14:01 -05:00
jason.woltje 8f80c278bf honker: row 194 design, agent lifecycle and per-agent veto (#1692) 2026-10-20 14:09:26 -05:00
36 changed files with 4539 additions and 158 deletions
+414
View File
@@ -0,0 +1,414 @@
# Row 194 build notes
Design r4 approved: DESIGN.md sha256 824ef884 at 7d0788236 (Webby webby-0014, review at e1e64ff01 on
webby/reviews; Mos mos-1042; Sage sage-1387 "build now"). Build base: refactor aa1bd209e merged into
honker/row194 at fa9eaa4c3.
## Build notes from the approval (each gets a test and a mutant)
- W-N1 (webby-0014 N1): launch refuses `instance-firing` while I has a `fire.stopped` with no `fire.done`.
Test case in T9c. Mutant M59: the check is dropped.
- W-N2 (webby-0014 N2): the broker's hire-time check requires roleVersion 2, so a definition with no
action lists refuses. Test case in T8b. Mutant M60: the version check is dropped.
- Grace (sage-1387): 5 s, one constant shared with row 191.
## Chain state (2026-10-20T19:45Z)
Rows 190, 191 and 193 have no product commits yet (rocko/row191 and darkwing/row190 sit at da230ca48, the
brief commit). Slices that don't need row 191 build first. S6 waits for row 191's candidate.
## Slices
| Slice | Scope | Tests | State |
|---|---|---|---|
| S1 | business: vocabulary (role.hire, role.fire hard waits, HIRE_ALLOWED), role.mjs `hireable`, business.mjs keep/noHire/retired, instance-settings delete and veto ops, chief-of-staff.json `hireable: []` | T6 (business), T7, T8c, T17, D2.2 writer | done, see below |
| S2 | bus: reloadBusiness and runtime callback, instance.launch/end/regenerate requests, instances.set delete, veto.set, instance.hire/fire, keep checks, D4 hook, tester checks | T8-T10, T9c, T12-T14, T18 | S2a (D3), S2b (veto, hook, keep, T13), S2c (requests, terminate, delete) and S2d (hire, fire, W-N1) and S2e (PRD 1.7 routing, sage-1471) done |
| S3 | cli: launcher lifecyclePass, host ops, `business instance` verbs | T1-T6, T11 | S3a (launcher pass) and S3b (verbs, veto, end to end, T2a, T4, T6, T11) done |
| S4 | harness: launch tool schema, hire and fire tools | T8 (D3 item 5) | done, see S4 notes |
| S5 | webui: Agents section and routes | T16 | |
| S6 | row 191 chain: halted checks, fire SIGKILL through row 191's stop | T9a, T9b, T15 | waits on row 191 |
## Changed assertions (D3 item 4, N4)
Listed here as the build changes them.
- `packages/business/tests/instance-settings.test.mjs:53, :67, :72`: the row 165 read and add views
gain `kept: false` (D1, the read gains `kept` per instance). No other field changed.
- `packages/business/tests/instance-settings.test.mjs:70` (`added.restart` true to false) and `:91`
(`redefined.restart` true to false): the writer no longer asks for a restart. The bus decides,
after its reload (D3 items 2-3). The two test names say so.
- `packages/cli/tests/instances.test.mjs`: `setup` boots the bus from the file through `bootConfig`, as
the unit does, instead of a one-role stub. The add test (T1) now expects the "no restart" line and
binds `sec-1` on the running bus. The `RESTART_NEEDED` text pins moved, unchanged, to a new test that
boots the old stub (`setup(t, { stub: true })`). Its coder has no credential references and the
file's has, so the bus doesn't reload, prints `RESTART_NEEDED` and doesn't bind `sec-1` (D3 item 3).
- `packages/webui/tests/instances.test.mjs:25`: the fake writer's default answer is `restart: false`,
the usual answer now. The real-broker test boots through `bootConfig` and expects `restart: false`
(`:71`) and the log line `done restart=false` (`:74`). The answer-shape rows at `:140-145` keep
`restart: true`. Each row tests another field, or `restart: 'yes'`, so the value is not what they
check.
- `packages/bus/tests/instances-verb.test.mjs` T12 (S2c): the human delete now answers `{request}`
where S2b refused `invalid-request`. The stub writer answers `instances.check` with the revision,
and the calls list gains that one check, actor `jason`. Nothing else in the test changed.
- `packages/bus/tests/tester-writes.test.mjs:19` (row 175 pin): TESTER_VERBS gains `instance.launch`,
`instance.end` and `instance.request` (D4.1). TESTER_ORGANIZATION_VERBS is unchanged.
- `packages/bus/tests/instances-verb.test.mjs:78` keeps `restart: true`, now for a D3 item 3 reason:
its boot copy is a stub without the file's credential references. A comment there says so.
`reload.test.mjs` covers the reload from a file boot.
- `packages/bus/tests/hard-waits.test.mjs:38` (S2d): the every-hard-wait loop gives `role.hire` and
`role.fire` a target and `arguments`, which their raise now requires (D2.1). The class and route it
checks are unchanged.
- S2e (PRD 1.7 REQ-COS-3) takes back the S2d entry above. `role.hire` and `role.fire` left the hard
waits, so `packages/bus/tests/hard-waits.test.mjs` and `packages/business/tests/hard-waits.test.mjs`
drop them from WAITS, and the bus file's every-hard-wait loop is back to its S2c form. The business
file gains a test that both are routed and not in GATED_ONLY.
- `packages/bus/tests/reload.test.mjs` (S2e): its hard-wait example used `role.hire`, now routed. It
uses `spend`. The check is unchanged.
- `packages/business/tests/chief-of-staff.test.mjs` (S2e): the chief's pinned authority and the test
name gain `role.hire` and `role.fire`. `packages/business/tests/role.test.mjs` gains the check that
no other shipped role holds either.
- `packages/harness/tests/tools.test.mjs` and `packages/harness/tests/bundle.test.mjs` (S2e): the
chief's pinned tool lists gain `hire` and `fire`. Every held action needs a typed tool
(`untypedActions`), so the tools come forward from S4.
- `packages/bus/tests/chief-of-staff.test.mjs` (S2e): the S2d decision-form tests are rewritten to the
reason form (below). The S2d raise tests with `arguments` are gone with the key.
- `packages/bus/tests/lifecycle-verbs.test.mjs` V5 retry (S3a): `lifecycleDelete` on a retry now
answers `{transaction, retry: true}` with the failed delete's transaction, where S2c answered
`{transaction: null, retry: true}`. The launcher needs it to name `.retired/I-<transaction>`. The
test checks the value and that the retry still neither checks nor writes the file.
- `packages/harness/tests/tools.test.mjs:48` (S4): the launch tool's `instance` was an enum of the
start-time listing. It is now `{type: "string", description, pattern: ID_PATTERN.source}`, and the
listing moves to the tool description, which the test matches. `:107` (the pm test's chief check)
asserts there is no enum and the description names `chief.launch.instances`.
- `packages/business/tests/chief-of-staff.test.mjs` (S4): the contract test gains a match on the new
sentence that a hire is listed for `launch` at once but runs only after the human sets its route.
## S1 notes
- Tests: `tests/lifecycle-settings.test.mjs` (new) holds T6's business part (V5 order, last listed,
clean delete, retired refusals for add and hire), T7 (V6) and the writer's hire and fire (D2.2).
T8c is in `role.test.mjs`, T17 in `business.test.mjs`.
- `deleteRefusal`'s entry half (`entry.tracker`, `entry.credentials`) follows V5's wording "holds
credentials or a tracker bot". In a file that validates it is the same as the definition check: a
definition with no credential refuses an entry tracker or credentials at load. So no test reaches it
alone, and no mutant is listed for it.
- END_PROTECTED is `assistant` only. The chief's own instance refuses delete and fire as
`organization-instance-referenced` (launch.by), not as protected.
- The writer refuses an `assistant` hire `organization-definition-refused` through `definitionFor`, as
row 165's add does.
- PRD draft: 69544f342 (sha256 c1f52d98…), sage-1395: adding to the fixed list is a PRD change.
## S2a notes (D3 live reload)
- `Broker.reloadBusiness(id, doc)` runs the boot check (`checkBusiness`, moved out of the
constructor unchanged) on a clone, then refuses a changed `human` and swaps the document in.
- The runtime reloads after every committed write, through the chain `bootConfig` uses
(`loadBusiness`, `resolveInstance` with `organization.system`, `busBusiness`). Then `limits.refresh`
runs. `instances.set` answers `restart: !live`. Any other verb name now throws `invalid-request`
where it used to fall through to `plugins.set`.
- D3 item 3 check: a role in the boot copy must keep its credential references and tracker bot. A role
not in the boot copy must have neither. The tracker sync block must match. Otherwise no reload and
`restart: true`. The first version compared one fingerprint over all roles, so every create failed
it. T14's create test caught that.
- Consumers checked against item 3 (`runtime.mjs` startBroker):
- The Credentials store takes the role and sync references and the project roots it protects at
start. The tasks adapter takes the bots at start. All of these are compared.
- Limits read `settings.current` at each ask. The human proof reads `human` from the boot copy,
and `reloadBusiness` refuses a changed human, so the two agree.
- Web grants: the broker reads `roles.<I>.web` from its own document (`broker.mjs:1545`), so a
reload carries the grant. A hireable definition has no `web` tool.
- Usage loads its `usage-source` plug-ins from `b.plugins` at start. That was already so before
this row (`plugins.set` never restarted them). A reload doesn't change it, and it isn't compared.
- Instance-running: `instances.set` op change with a `definition` on a declared instance with a
claim holder (or thread holders for a thread instance) refuses `instance-running` before the
writer. A vars or launch change still passes.
- Tests: `packages/bus/tests/reload.test.mjs` (T14, 6 tests).
## S2b notes (veto.set, the D4 hook, keep checks)
- `LIFECYCLE_HUMAN_VERBS` (exported from `broker.mjs`) has one entry per verb and per `instances.set`
op: `instance.launch`, `instance.end`, `instance.regenerate`, `veto.set`, `instances.set add`,
`instances.set change`, `instances.set delete`. Choice: `#mayHuman(s, verb, target)` refuses
`human-required` for an entry missing from the list. So the list and the calls can't drift apart,
and T12 shows the hook got the exact entry: each listed human call passes. The tradeoff is that a
new human verb has to join the list before it works, which is what row 193 needs anyway.
- `bindHuman` already refuses any human but the owner, so the hook's owner check can't fail on any
path today. It is the body row 193 replaces.
- M46 (`veto.set` skips `#human`) is equivalent while the generic check runs first: no session
without `s.human` gets past `human-required`, and only `bindHuman` sets `s.human`, always with
`via: 'cli'` and `outsideAgent: true`. T18 shows the refusal. `#human` stays as the design asks.
- `instances.set` now checks `op` in the broker (`add`, `change`, `delete`, else `invalid-request`).
The tester's delete refuses `tester-refused` (B6). Until S2c records the delete request, a human
delete refuses `invalid-request` after the hook. Without that, the writer's `set` op `delete` would
commit with no running or starting check and no workspace rename.
- Keep: the `role.revoke` reason branch refuses `instance-kept` for any caller, not only the chief.
Only the chief holds `role.end` today, so this is the same set. A caller with `role.end` added
later is held to the forbid too. The `organization.set` check refuses when any key of
`args.instances` is kept, before the writer and before the grant is spent. The human's `limits.set`
isn't checked: the human owns the forbid.
- T13 needed no code. `organization.set` and `limits.set` take only `ORGANIZATION_KEYS`, and
`limits` and `models` write only `launch.limits` and `launch.models`.
- Tests: `chief-of-staff.test.mjs` T10 (reason end, decision end, model write), T13 and T18's
veto.set and instances.set paths on a stub writer. T18's file-bytes check and its hire and fire
cases come with S2d. `instances-verb.test.mjs` T12 (sessions, tester, hook). `reload.test.mjs`
gains veto.set.
## S2c notes (lifecycle requests, terminate, delete)
- A request is an `action.allowed` event, action `lifecycle.launch`, `.regenerate`, `.delete` or
`.end`, body `{action, request, via, by, ...}`. The human's also gets `human.input {kind: 'admin'}`.
The tester's gets none. Steps are `lifecycle.taken`, `lifecycle.done {request, run?, transaction?,
refusal?}` and `lifecycle.cancelled {request, by}`, with no role or run in the session. No new
table or event kind, so no migration.
- Host ops for the launcher pass (S3), each in LAUNCH_OPS: `lifecycleRequests`, `lifecycleTake`,
`lifecycleDelete`, `lifecycleDone`. Choice: `lifecycleDelete` commits through the bus's own
organization callback, so the D3 reload runs after it as after any other write. The tradeoff is
one more host op instead of the launcher calling the writer.
- The take re-checks: request-expired (60 s, `LIFECYCLE_REQUEST_MS`), then launch-revoked and
instance-retired for launch and regenerate only, then tester-off for a tester request. A refused
take writes `lifecycle.done` with the refusal, so the request closes. `lifecycle.end` never
expires: the runs it names still need the signal.
- instance-starting counts any taken request of I, and any open one still under 60 s, delete
included. An open request past 60 s can only close request-expired, so it doesn't count. The end
request doesn't count.
- Delete order at the verb (V5): keys and identifier, the 64-hex revision, the retired-name retry
gate, then `instances.check` (the writer's plan and validation, no write). A named organization
refusal fails at once. A BusinessError (the last-listed refusal) is held until instance-running
and instance-starting have passed, then answers invalid-request. Then revision-conflict, then the
request with `{revision}`. `lifecycleDelete` checks running and starting again after the take
(other requests only), then calls `instances.set` op delete with actor = the request's `by`.
- Retry: a retired name that isn't in roles deletes again only when its last delete closed
`workspace-rename-failed`. The request carries `retry: true`, and `lifecycleDelete` answers
`{transaction: null, retry: true}` with no check and no write. The launcher (S3) retries the rename.
- `lifecycleDone` carries `transaction`, so the delete's done names the commit the rename used.
- Terminate (V3): every claim holder of I, or every thread holder for a thread instance, is revoked
with the reason (default "ended by the human" or "ended by the tester"). The answer names the held
runs plus the bound runs of I not yet ended, cancels open requests of I and lists taken ones. With
none of these it refuses not-held. The tester ends only its own threads and cancels only its own
requests.
- The tester's business scope needs no check: its cap is bound to one business (M19), so another
business's instance answers unknown-role. `instance.request` needs no tester-file check: a tester
reads only requests it made.
- The tester switch is read through a `testerOn` option. The runtime reads `bus.tester` from the
live file through `settings.current`, so a change applies with no restart. Anything but `true`,
or a read error, is off.
- Refactor: the organization error mapping moved into `#organizationFail(e, verb)`, shared by the
organization verbs and `lifecycleDelete`. Same mapping as before.
- Deferred: the halted checks (S6, row 191) and W-N1 (S2d with fire).
- Tests: `packages/bus/tests/lifecycle-verbs.test.mjs` (12 tests: T12 lifecycle part, T12a, V2-V5,
the take and the runtime delete end to end). Probe mutants on the expiry, starting (verb and
take), the take's expiry and tester checks, the retry gate, the tester's own-request read and
not-held all died. They are not the M-list run.
## S2d notes (the chief's hire and fire, W-N1)
- Still hard waits (PRD 1.6). Under PRD 1.7 they move to ROUTED in S2e, with the reason form.
- The raise: `decision.raise` takes `arguments`, only for `role.hire` (`{name, definition, vars?}`)
or `role.fire` (`{name, reason}`), and requires it for those. `target` must equal
`arguments.name`. The raise event records it, and the human's decision view shows it under
`authorization.arguments`. The shape checks are the call's (`#hireFireArgs`): identifiers, not
`human` or `tester`, vars only `model` and `limits.*`, reason 1-500 characters.
- The call: `instance.hire {name, definition, vars?, decision}` and `instance.fire {name, reason,
decision}`, from a chief session only. Order: tester-refused, read-only and agent-required
(`#agent`), keys, `role-not-chief` (launch.by), not-configured, decision-mismatch for a decision of
another business, `#checkAuthority`, the call's arguments against the raise's (canonical JSON,
decision-mismatch), decision-consumed, instance-kept. The writer gets the chief as actor
(`chief-of-staff.chief`) and the context `{instance, run, decision}`, which changes.jsonl records.
- Hire: `#hireable` loads the definition and the chief's own role file through the new `roleFile`
option (the runtime passes `loadRole(rolesDir, name)`). Version 1, any action off HIRE_ALLOWED
(`unhireableAction`), not on the chief's `hireable` list, on `noHire` or no file all refuse
definition-not-hireable before the writer. Then the writer's hire, then the consume with
`{transaction}`. Without `roleFile` a hire refuses not-configured.
- Fire, before step A: instance-kept, unknown-role, organization-definition-refused (END_PROTECTED),
organization-instance-referenced (launch.by), and invalid-request for the last listed name.
Step A, one transaction: revoke the claims with the decision and reason, cancel open requests,
list taken ones, write `lifecycle.end {via: 'chief', by, runs, reason, kill: true}` when runs exist,
and `fire.stopped {cites, request}`. Step B: the writer's fire (already delisted is done). Step C,
one transaction: the consume with `{reason, transaction}` and `fire.done {cites, transaction}`.
- The fire's own events cite the decision under `cites`, not `decision`, so `#spent` counts only the
consume. A failure at B or C leaves the decision unspent, so the repeat runs.
- Choice: a repeat after a failed B or C asks for the kill again for any run of I still bound. A
revoked run stays bound until the launcher reports it ended, and a second signal to a dead
process does nothing. The tradeoff is a duplicate `lifecycle.end` against a run that may outlive
the first one.
- W-N1 `#firing`: a `fire.stopped` with no `fire.done` for the same `cites`. It refuses
instance-firing at the human and tester verbs (`#launchable`), at the take of a launch or
regenerate (`#takeRefusal`), and at the chief's `role.launch` (`#authorityFor`).
- `#organizationFail` passes the writer's own definition-not-hireable and instance-kept through by
name for hire and fire. They come from the file as it stands, after the broker's checks passed.
- `lifecycleView` shows `kill: true` when the request carries it.
- Deferred: the kill escalation in the launcher (S3, S6).
- Tests: `packages/bus/tests/chief-of-staff.test.mjs`, 16 new (T8 x5, T8a, T8b x2, T9 x2, T9c x2,
T10, T18, the runtime end to end, W-N1 at the take). 39 in the file pass. Probe mutants on 21
lines (the arguments check, each W-N1 site, M60's version check, keep, the other-business check,
last-listed, kill, the chief's list, noHire, role-not-chief, the pre-check consume, both consumes'
events, the raise's argument rules, the revoke, the cancel, the pass-through, the view's kill) all
died. They are not the M-list run.
## S2e notes (PRD 1.7 REQ-COS-3 routing, sage-1471, mos-1089)
- Role-policy change, for Webby to review on its own: `roles/chief-of-staff.json` withinRole gains
`role.hire` and `role.fire`. `packages/business/src/vocabulary.mjs` and `packages/bus/src/broker.mjs`
move both from the hard waits to ROUTED. `roles/chief-of-staff.md` and `roles/pm.md` drop them from
the hard-wait list, which `workflow-roles.test.mjs:113` pins to GATED_ONLY. No other shipped role
gains either action, and `role.test.mjs` pins that.
- The call takes the reason form, as `role.end` does: `instance.hire {name, definition, vars?, reason}`
and `instance.fire {name, reason}`, with no decision. The reason is 1-500 characters for both.
- Choice: the decision form is gone, not kept beside the reason form. A within-role raise closes at
once, so a decision there adds an event and no check. The `decision.raise` `arguments` key from S2d
goes with it. Tradeoff: if a later PRD gates hire again, the raise path comes back as new code.
- Choice: hire takes a reason too. The PRD logs every hire and fire with its reason.
- Order: tester-refused, read-only and agent-required, keys, `role-not-chief` (launch.by),
not-configured, `#checkAuthority` with `{target: name}`, instance-kept. `role-not-chief` comes before
the authority check, so a non-chief role refuses `role-not-chief` even when its file grants the
actions. A chief whose file lacks the grant refuses `decision-required`.
- The writer gets only its own keys. The reason goes in the change context `{instance, run, reason}`,
which changes.jsonl records. The hire's consume detail is `{reason, transaction}`.
- W-N1 `#firing` now goes by order, since the reason form has no decision id: a `fire.stopped` of the
subject with no later `fire.done` (`seq` after it) for the same subject. `fire.stopped` carries
`{action, request}` and `fire.done` `{action, transaction}`.
- Harness: the typed `hire` and `fire` tools (verbs `instance.hire` and `instance.fire`) come forward
from S4, because a held action with no typed tool fails `untypedActions`. Each tool follows its own
grant. `roles/chief-of-staff.md` already names them. The rest of the S4 contract text and the runner
end to end for hire and fire stay in S4.
- Going live is a later re-pin step with Mos (sage-1471). Landing the change doesn't put it on the live
stack.
- Correction to S2d (found in S2e): S2d's broker imported `business/src/role.mjs`, which is not in
`seat/src/sandbox.mjs` RUNNER_FILES. The runner imports broker.mjs, so a session died with
ERR_MODULE_NOT_FOUND: cli `launcher.test.mjs` failed 15 of 29 and seat `sandbox.test.mjs` 4 of 10 at
089b92ca3. A bisect showed S2c passes 29 of 29. I had not run those two files for S2d. Fix:
`unhireableAction` moves into vocabulary.mjs, which is mounted, and role.mjs re-exports it.
RUNNER_FILES is unchanged, so the sandbox boundary does not widen. Rule from here (sage-1497): a
change to broker.mjs imports, or those of any RUNNER_FILES file, runs these four singly: seat
`sandbox.test.mjs`, cli `launcher.test.mjs`, cli `thread-launch.test.mjs` and cli
`git-socket.test.mjs`.
- Mutant (sage-1497): broker.mjs imports `unhireableAction` from role.mjs again. Killed: sandbox.test
fails 4 of 10 (role.mjs outside RUNNER_FILES), launcher.test 15 of 29, thread-launch 7 of 11. git-socket
passes 24 of 24 under it, so it does not catch this one and stays on the list for its own sandbox runs.
- Tests: `packages/bus/tests/chief-of-staff.test.mjs` S2d block rewritten to the reason form (39 pass),
with new tests for the reason bounds, `role-not-chief` with the grant, `decision-required` for a chief
without each grant, W-N1 by order, and the runtime end to end with the reason in the receipt context.
`packages/harness/tests/tools.test.mjs` gains the hire and fire tool shape test, and that no coder,
reviewer or pm gets either tool.
- Probe (not the M-list run): 16 mutants, all killed. ROUTED drops of each action in the broker and in
vocabulary, the role file drops of each, `#firing` without the order and without the subject, the
reason unchecked, the writer given the reason, the context without it, no `#checkAuthority`, no
`role-not-chief`, the consume without the reason, the hire tool's reason optional, and the fire tool
gated on the hire grant. The last one survived at first and a one-grant check in tools.test killed it.
## S3a notes (the launcher's lifecycle pass)
- `packages/cli/src/launcher.mjs` `lifecyclePass` runs on the tick after the thread and land passes.
It lists the open requests (host op `lifecycleRequests`) and carries each under `serialize`: the
take, the act, then `lifecycleDone`. A take that answers no closes nothing more here. A refusal
goes in the launch log as `refused` with the request id. A done that throws leaves the request
taken with no done, which S3b's CLI reports as `outcome-unknown`. The log line names a run that
did start.
- Launch: `check()` and `start()` gain `human`. The human's request may name any declared instance,
as V2 asks. The listing limits the chief. launch.by still takes the `pm` branch (N8). A tester
request launches with `launchedBy: "tester"`.
- End: SIGTERM to each run the verb named and to any other run of the instance in the registry,
each after the start-time check, with a `lifecycle-end` entry. The exit handler writes the one
`end` record. A thread instance's end signals only the runs the verb named, so the tester's end
leaves the human's threads alone. No SIGKILL escalation here: that is row 191's stop (S6).
- Regenerate: the end's signals, then a wait until the old run leaves the registry, then a launch.
The wait runs under `serialize`, so no other start runs meanwhile. Past the request's mark
(`at` + 60 s, `lifecycleRequestMs` for tests) it closes `regenerate-timeout` and starts nothing. A
closing host closes it `host-closing`. Tradeoff: one stuck regenerate holds other starts for up to
60 s. The other choice, a second pass that comes back for it, needed state across ticks for the
same result.
- Delete (V5 steps 1-3): `instance-running` against the registry first, then `lifecycleDelete` (the
writer's commit), then the rename of `workspaces/<b>/I/` to `workspaces/<b>/.retired/I-<tx>/`,
both 0700. The file goes first, so a failed rename can't leave a launchable name on the old
workspace. A failed rename closes `workspace-rename-failed` with the transaction. A later delete of
the name is the retry, and the broker now answers the failed transaction, so the rename target is
the same (changed assertion above). No workspace means nothing to rename.
- `--no-launch` (row 163): the ticker now runs on a no-launch host, but only the lifecycle pass, and
`act` closes each request `launches-disabled` before any signal, start or rename. The thread and
land passes stay off, so `launcher.test.mjs`'s no-launch tests pass unchanged.
- Tests: new `packages/cli/tests/lifecycle.test.mjs`, 9 tests on the in-process host of
thread-launch.test.mjs, with ticks called by the test: T2 (unlisted and launch.by launches, a
request past 60 s, a check refusal with its code in the log), T3 (`--no-launch`, `capacity-full`
with the limit in the log, `instance-running`), T4 (end at the verb, the signal, one end record,
the cancelled request never runs), T5 (a new run on the same workspace, `regenerate-timeout`
with a SIGSTOPped runner, `regenerate-thread` at the verb), V5 (a failed rename then the retry,
modes 0700, no workspace).
- Singles at S3a: seat sandbox 10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24,
bus lifecycle-verbs 12/12, cli instances 6/6, cli lifecycle 9/9, bus process 8/8. broker.mjs
imports did not change, and the four must-run files ran anyway.
## S3b notes (the CLI verbs)
- `packages/cli/src/instances.mjs`: `business instance launch|regenerate BUSINESS I [--wait S]
[--tester]`, `terminate BUSINESS I [--reason TEXT] [--tester]`, `delete BUSINESS I --revision REV
[--wait S]` and `business veto BUSINESS keep|unkeep INSTANCE|nohire|hire DEFINITION --revision
REV`. `--wait` takes 0 to 600 s and defaults to 30. Every usage error exits 4 before any bus call.
add and set refuse `--wait` and `--reason`.
- Launch, regenerate and delete print the request id on stderr, then poll `instance.request` every
200 ms. Closed with a run or a transaction prints it and exits 0. A refusal prints its reason, the
request id and any transaction. A cancel prints who cancelled it. Still open at `--wait` says so and
names the 60 s expiry. Taken with no done is `outcome-unknown` and points at the launch log.
Terminate doesn't wait: it prints the revoked runs, the cancelled requests and any taken request
past cancelling.
- Exit codes: every lifecycle outcome that isn't done (refusal, cancel, still open, outcome unknown)
exits 3, as the design's `--wait` timeout does. Tradeoff: errors.mjs has 1 for an unknown outcome
elsewhere. One code lets a script treat "the request did not do it" as one case, and the message
says which. A refusal at the verb keeps the transport's code (2), as add and set do.
- The bus `instances` read gains `running` per instance (`#running`, from the claims), so the CLI
and S5 can show it without a second verb. An answer with no instances map passes through as
before. `bus instances-verb.test.mjs`'s stub writer answers `{ok: true}` and caught the first
version, which threw. The CLI view marks `kept` and `running r1, r2` and adds `retired:` and
`the chief may not hire:` lines.
- `scripts/mosaic` sends `business veto` to packages/cli with instances and instance.
- Tests: cli `instances.test.mjs` +8: the V6 veto ops and kept-delete refusal, the `running` read,
launch and regenerate printing the run, a closed refusal with its id (exit 3), `--wait 0` still
open and taken `outcome-unknown`, request-cancelled, terminate output and `not-held`, delete to
its transaction and `workspace-rename-failed` naming it, usage errors (exit 4, no call), and the
scripts/mosaic veto route. The existing route test gains the veto assertions. cli
`lifecycle.test.mjs` +5: the CLI end to end through the pass (launch, regenerate, `capacity-full`
with the request id, terminate, delete), T2a (`launch-revoked` at the take with no process, a
done that fails once leaves the request taken and the next pass starts nothing), T4 (a spawned
run the bus never bound is signalled, a mismatched start time is not), T6 (run evidence bytes
unchanged by a delete, the launch log only grows, a create and a launch of the retired name
refuse), T11 (two roots with business `acme`: a launch, a terminate and a delete in one leave the
other's files byte-identical). bus `chief-of-staff.test.mjs` +1: T11's bus half, a chief on a bus
with two businesses hires and fires only in its own.
- Not covered here: T6's `instance-starting` and the V5 refusal order are bus tests from S2c.
`check()` refusing a retired name is shown through the verb and the take, since `check()` reads a
file that no longer lists the name.
- Singles at S3b: cli instances 14/14, cli lifecycle 14/14, bus lifecycle-verbs 12/12, bus
instances-verb 3/3 (after the fix), bus chief-of-staff 40/40, webui instances 5/5, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, usage 2/2, project 4/4, app
30/30, host 42/42, tester-threads 5/5, tester-reads 6/6, stack-copy 12/12, queue dispatch 4/4,
runs task-cli 9/9, webui pinned-copy 10/10, service 31/31, views 3/3. The broker change adds no
import. The four must-run files ran while the `instances` guard was being written, and the guard
touches only the read. No test pins a hash of scripts/mosaic.
## S4 notes (the launch tool takes a hire, D3 item 5)
- `packages/harness/src/tools.mjs`: the launch tool's `instance` is a string with the ID_PATTERN
pattern, not an enum fixed when the bundle was built. The start-time listing stays in the
description. The host checks the listing at each launch (`instance-not-listed`), so the chief
launches its own hire in the same session. The hire and fire tools came forward in S2e.
- The runner does not enforce a tool's schema pattern. A bad name reaches the host and refuses
`instance-not-listed`, so the end-to-end test asserts that. M15 (the enum kept) is killed by the
tools.test shape assertion, not by the end-to-end test.
- `packages/cli/tests/lifecycle.test.mjs` gains the T8 end to end: the chief's session hires
`sec-2` (definition chief-information-security-officer, the shipped definition `hireable` accepts:
message.send and record.write, no credential) with `TOOL hire`, a launch refuses `no-route`, the
human sets the route with `instances.set` op change, the same session launches it (launchedBy
chief), `NOT A NAME` and `reviewer` refuse `instance-not-listed`, then `TOOL fire` ends its run,
leaves it `launched: false` and a later launch refuses `instance-not-listed`.
- Route gap, raised with Sage (honker-0027): harness, model and thinking are agent-layer vars
(`business/src/vars.mjs:142-147`) and hire vars allow only model and limits.* (N3). So a hire runs
only after the human sets harness and thinking. D3 item 5 says the same session launches it, and
it does, after that human step. Letting hire vars carry the route would widen the chief's
authority, a policy change that is not mine to make. As built the human step stays, and
`roles/chief-of-staff.md` says so.
- `roles/chief-of-staff.md` (role policy, named in the manifest): one paragraph on the hire's route
step and on a fired instance staying in the file off the list.
- Singles, each run alone: business chief-of-staff 10/10, spec-review-contracts 3/3, role 19/19,
resolve 10/10, harness bundle 22/22, tools 23/23, runner 34/34, pi-session 8/8, seat sandbox
10/10, cli launcher 29/29, thread-launch 11/11, git-socket 24/24, lifecycle 15/15, webui reads
11/11. No test pins a hash of tools.mjs or roles/chief-of-staff.md. tools.mjs gains an import from
vocabulary.mjs, already in RUNNER_FILES, and the four must-run files passed after it.
+707
View File
@@ -0,0 +1,707 @@
# 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, 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 |
@@ -0,0 +1,158 @@
# PRD 1.7 draft: the chief of staff's hire and fire authority, the coder's push to its fork, and agent
# host access through the credential store
Draft by Honker for row 194 (#1692), under Mos's ruling mos-1017 (through Sage, sage-1365). Not approved.
It changes nothing until Jason approves it as a `prd.approve` decision in the Console. Sage opens that
decision and sends Mos its id. Base: `docs/prd/mosaic-stack.md` version 1.6 at 53bb59462.
## What it changes
Row 194 adds `role.hire` and `role.fire` to the vocabulary, and row 199 adds `git.push.remote`. Under
REQ-WAIT-3 all three are hard waits, so today Jason resolves every hire, every fire and every push to a
remote. This version routes hire and fire for the chief of staff within fixed bounds, so the chief hires
and fires as needed and the user's per-agent forbid still holds. It routes `git.push.remote` for the coder
only (Mos, mos-1028 item 3). It adds agent host access with sudo through the credential store for
agents with a grant (Q183, Q184, Vikunja #424, due 2026-10-27). One `prd.approve` decision covers it all
(Vikunja #420).
When this version lands, a reviewed commit:
1. adds `role.hire`, `role.fire` and `git.push.remote` to `ROUTED` in
`packages/business/src/vocabulary.mjs` and in `packages/bus/src/broker.mjs`.
2. adds `role.hire` and `role.fire` to `roles/chief-of-staff.json` withinRole, and `git.push.remote` to
`roles/coder.json` withinRole. No other role file gets `git.push.remote`.
3. switches the broker's check on `instance.hire` and `instance.fire` from a cited decision to the
`reason` form, as `role.end` works for REQ-ROLE-2, and makes `publish`'s `decision` optional for a
within-role class (row 199 D9 step 1).
4. states, for a `git.push.remote` with no decision, where the source commit comes from: the latest work
commit the bus recorded for the coder's task. The check still matches it against the work ref tip and
the push sends it by oid (row 199, duckworth-0022 N9).
5. adds the host credential grant check and the host access it allows (REQ-CRED-3, Vikunja #424).
Every other check stays as rows 194 and 199 build it. Filling `hireable` stays a separate reviewed role
file commit in `roles/`.
## Text for the PRD
Status line, appended to the current paragraph:
> Version 1.7 routes the chief of staff's hire and fire actions and the coder's push to its own fork,
> and adds agent host access through the credential store, approved by Jason in decision
> <prd.approve decision id>.
Source, a new bullet above "Version 1.6 source":
> - Version 1.7 source, TARGET.md line 66: "Chief of staff agent has hire/fire authority for agents as
> needed; unless user forbids per agent". And Jason through Mos (mos-1067), DECISIONS Q183, in part: "I will
> still want to allow infra agent(s) on my .53 instance to access other servers, etc. However, not all
> agents will need to have that access. I envision a credential store within Mosaic Stack that works
> as some sort of variable for the agent to call. The system will enforce access to the credentials to
> keep the agents sandboxed with proper security. I need to be able to trust my agents with assisting
> me. This will require sudo access to several systems. If we can utilize the credential store in
> Mosaic Stack and allow agents to perform their jobs with NOPASSWD sudo on various machines, I am a
> happy man." DECISIONS Q184, on whether that is in place by 10-27: "Q5: by 10.27"
Under "Added in version 1.2", after REQ-COS-2:
> Added in version 1.7:
>
> - **REQ-COS-3.** The chief of staff hires and fires role instances without a decision each time. Hiring
> adds a role instance to the organization file and lists it for launch. Firing ends the instance's
> runs and removes it from the launch list. It stays in the organization file. Only the user deletes
> an instance. These actions are `role.hire` and `role.fire`. They leave the hard waits and route
> under this requirement, within these bounds:
> - The chief acts only in its own organization.
> - The chief hires only role definitions that need no credentials, until a later version adds
> credentialed roles.
> - The chief hires only the role kinds its role file lists. Changing that list is a reviewed commit.
> - The chief hires only a role whose every action is on a fixed list of plain work actions: sending
> messages, writing records, asking for review, updating its assigned tasks, and the scope and
> working-push actions of an assigned task. A role holding any other action, such as launch, end,
> organization, naming, hire, review verdict or merge authority, or any action added later, is never
> hired. So the chief never hires a chief, never picks its own reviewers or merge gate, and the author
> is never the reviewer. This holds whatever the role file's list says. Adding to that list is a PRD
> change.
> - The chief may set a hired instance's model and narrow its limits, and nothing else about it.
> - The user can forbid per agent. The user marks an instance so the chief can't fire it, end its runs
> or change its model, and marks role kinds the chief can't hire. Only the user changes these marks, through the
> WebUI or the CLI.
> - The kill switch stops every hire and fire while it is on.
> - Every hire and fire is logged with the chief's instance, its run and the reason.
Under "Landing", after REQ-LAND-1:
> Added in version 1.7:
>
> - **REQ-LAND-2.** A coder pushes its own work to its own fork. The action is `git.push.remote`.
> - What it pushes: one commit, by its oid, never a ref name. That commit is the latest work commit the
> bus recorded for the coder's task, and the coder made that push. A decision that authorizes a push
> names that commit, and it doesn't authorize any other.
> - Where it lands: only the branch `refs/heads/<its Gitea user>/<branch>`. The host builds that
> name, the agent never does. A push never forces.
> - Which remote: the agent's own fork of the project's upstream. The business file names the upstream,
> and the host never contacts it. For each push the host builds the fork's address over https from
> the upstream's host, port and repository name and the agent's own Gitea user. The history check,
> the push and the credential match all use that address. So the agent's own user owns the receiving
> repository on every push, and the agent never names it.
> - The host refuses an upstream on a host or port other than the agent's Gitea, and an upstream the
> agent's own user owns. It refuses a fork that is missing, empty or doesn't share the project's
> history. Gitea's push-to-create stays off, so a push never creates a repository.
> - Who may: the action routes under REQ-DEC-2 and is granted within-role to the coder role only. No
> other role file grants it.
> - Before version 1.7 the action was a hard wait under REQ-WAIT-3, so each push needed a decision
> the human resolved, and that decision named the commit.
>
> Every push is logged with the remote, the ref, the commit and, when one was given, the decision.
Under "Credentials", after REQ-CRED-2:
> Added in version 1.7:
>
> - **REQ-CRED-3.** An agent with a grant can run commands with sudo on another host. The agent asks
> for the host credential by name. The stack checks that a grant exists for that agent and that host
> before the credential is used. The agent never sees the key or any other secret value (REQ-CRED-1,
> REQ-VAR-2). An agent without a grant for that host is refused. Not every agent gets a grant. Who
> grants a host credential, and how, is set by the #424 design, which Jason approves.
REQ-WAIT-3, appended:
> `role.hire` and `role.fire` were added as hard waits and route under REQ-COS-3 since version 1.7.
> `git.push.remote` was added as a hard wait and routes under REQ-LAND-2 since version 1.7.
## Notes for the reviewer and for Mos
- REQ-COS-1 (1.2) already says the chief "hires and terminates role instances". REQ-COS-3 gives that
sentence its actions and bounds. It doesn't widen REQ-COS-1.
- The forbid now names what r3 called "replace": ending a kept instance's run with a reason and changing
its model (webby-0010 N2). A user-resolved decision still ends a kept run (row 194 D2.4).
- The fixed list is `HIRE_ALLOWED` in row 194 D2.3, an allowlist since r4 (webby-0010 B1). r3's text
named the refused authorities instead, which let merge-gate and any later action through.
- The kill switch is row 191's. If row 191 hasn't landed when this version is approved, the bound still
holds through row 194's chain on its candidate.
- REQ-LAND-2 follows row 199's design r6 (4d3007ec9 on megavolt/row199, approved by Duckworth in
duckworth-0027: D9 to D11, D9a, D9c, Q-M1 and Q-M2, and the oid binding from mos-1034 item 5) and
Megavolt's wording in megavolt-0005, megavolt-0008 and megavolt-0014, through Sage (sage-1377,
sage-1380, sage-1387). The fork bullet answers duckworth B5 and B6 and webby-0011 N1. Commit-email
checks, the refusal codes and the URL parse rules (D9c) are host checks with tests in row 199, not
bounds, so they stay out of the text (megavolt-0008).
- Once the routing lands and the decision is optional for the within-role class, the commit is the task's
latest recorded push at check time. It is still checked against the work ref tip and pushed by oid, so
the binding holds without a decision (megavolt-0008). Step 4 of "When this version lands" puts that in
the routing commit (duckworth-0022 N9, sage-1387).
- REQ-LAND-2 and REQ-COS-3 meet at the coder. Coder declares Gitea credentials, so REQ-COS-3's
credentials bound keeps the chief from hiring one until a later version lifts it. `git.push.remote` isn't
on REQ-COS-3's fixed list either, so a coder holding it stays unhireable until a PRD version adds it
(row 194 D2.3).
- Adding to the fixed action list is a PRD change (mos-1035 item 3, sage-1395). The role file's
`hireable` list stays a reviewed commit in `roles/`.
- The credentials bound names no row number, so the text doesn't go stale when row 199 lands. A later
version lifts it.
- REQ-CRED-3 states only what Jason said in Q183 and Q184 (sage-1422). It names the outcome and the
check, not how the stack reaches the host. Proxying the ssh and a signing socket are both designs, and
the #424 design picks one (duckworth-0038 B1).
- Open point: Q183 doesn't say who grants host access or how, so the text leaves it open and defines no
mechanism. The #424 design settles it, Jason approves that design, and Duckworth reviews it because it
gives agents sudo on hosts (Q184, Mos's reading, duckworth-0038 B2). The build is row 210 (#1707,
owner Gizmoduck), a new row on top of row 199 (megavolt-0019, sage-1430).
- This draft follows the PRD's own style: plain words, no semicolons in new sentences, and the status
paragraph never edited in place except to append.
+494 -45
View File
@@ -3,7 +3,7 @@ import { isAbsolute } from 'node:path';
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED } from '../../business/src/vocabulary.mjs';
import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, unhireableAction } from '../../business/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -29,6 +29,9 @@ export const ACTIONS = Object.freeze([
'organization.change',
// Row 141 (#1635): routed under PRD 1.5 REQ-MSG-2.
'instance.name',
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'credential.mint',
'spend',
'deploy',
@@ -64,6 +67,9 @@ const ROUTED = new Set([
'policy.change',
'prd.approve',
'decision.resolve.technical',
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
'role.hire',
'role.fire',
]);
// REQ-WAIT-1's hard waits: always gated, and a raise for one goes to the human, cited or not.
export const HARD_WAITS = Object.freeze(ACTIONS.filter((action) => !ROUTED.has(action)));
@@ -97,10 +103,42 @@ export const TASK_VERBS = Object.freeze(ACTIONS.filter((a) => a.startsWith('task
// Row 177 (#1675, Q173): the tester's own threads, owner `tester`, and a stop of their runs.
export const TESTER_VERBS = Object.freeze(['message.send', 'message.receive', 'message.read', 'inbox', 'agents', 'tasks', 'trail', 'decisions', 'messages', 'replies',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop']);
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of the requests they make.
'instance.launch', 'instance.end', 'instance.request']);
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
// Row 194 (#1692, D4): the human lifecycle verbs, one entry per verb and instances.set op. Each calls
// #mayHuman after #human, and the hook refuses a verb missing here. Row 193 maps each entry to the
// human roles that may use it.
export const LIFECYCLE_HUMAN_VERBS = Object.freeze(['instance.launch', 'instance.end', 'instance.regenerate', 'veto.set',
'instances.set add', 'instances.set change', 'instances.set delete']);
// Row 194 (D1.1): the requests the launcher carries out, and the events that move each one. All are
// action.allowed events, which event.emit refuses for any action but routine.
const LIFECYCLE_REQUESTS = ['lifecycle.launch', 'lifecycle.regenerate', 'lifecycle.delete', 'lifecycle.end'];
const LIFECYCLE_STEPS = ['lifecycle.taken', 'lifecycle.done', 'lifecycle.cancelled'];
// A launch, regenerate or delete request older than this never runs. A terminate's signal has no
// limit: the launcher checks each run's start time before it signals.
export const LIFECYCLE_REQUEST_MS = 60_000;
const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
...('revision' in r ? { revision: r.revision, retry: r.retry === true } : {}),
...('runs' in r ? { runs: r.runs } : {}),
...(r.kill === true ? { kill: true } : {}),
done: r.done ? { run: r.done.run ?? null, transaction: r.done.transaction ?? null, refusal: r.done.refusal ?? null } : null,
cancelled: r.cancelled ? { by: r.cancelled.by } : null,
});
const SELF_KINDS = new Set(['task.created', 'task.assigned', 'task.state', 'task.closed', 'task.conflict']);
const POLL_KINDS = new Set([
'task.changed.external',
@@ -145,6 +183,7 @@ const oid = (x) => typeof x === 'string' && /^[0-9a-f]{40}([0-9a-f]{24})?$/.test
export const ORGANIZATION_KEYS = ['revision', 'limits', 'models', 'instances', 'reset', 'assistants'];
// Row 165 (#1662): the keys of the human-only instances.set (business instance-settings.mjs).
const INSTANCE_KEYS = ['revision', 'instance', 'op', 'definition', 'vars', 'launch'];
const VETO_KEYS = ['revision', 'op', 'name'];
// Row 123 (#1617): role.end never reaches the caller's own run. END_PROTECTED (vocabulary.mjs, row
// 155): only the chief of staff, or a decision the human resolved through the CLI, ends or revokes
// an instance of one (#protectedEnd, Q169). A raise to revoke one is a hard wait.
@@ -180,6 +219,36 @@ export const THREAD_PAGE_MAX = 200;
// A project or thread name: what the user calls it, one line.
const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
// The checks a business document passes at boot and at every reload.
function checkBusiness(name, b) {
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
const a = b.roles[role].authority;
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
fail('invalid-arbiter');
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
for (const d of Object.values(b.definitions ?? {})) {
const m = d?.messaging;
if (m === undefined || m === null) continue;
if (
!object(m) ||
!['sendTo', 'receiveFrom'].every(
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
)
)
fail('invalid-messaging');
}
}
export class Broker {
#store;
#businesses;
@@ -199,11 +268,13 @@ export class Broker {
#usage;
#limits;
#harnessOn;
#testerOn;
#roleFile;
// Row 125 (#1619, REQ-LIMIT-4): a run a limit stops, by business:run, until its next receive or state.
#stops = new Map();
#clock = 0;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null }) {
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, usage = null, limits = null, harnessOn = null, testerOn = null, roleFile = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
this.#businesses = structuredClone(businesses);
this.#store = store;
@@ -218,6 +289,15 @@ export class Broker {
// module-shaped name passes and the launcher's own harness-off check is the only one.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
this.#harnessOn = harnessOn ?? (() => true);
// Row 194 (#1692, D4.1, B8): testerOn(business) is true when the organization file as it stands
// sets bus.tester to exactly true; the bus runtime reads it live, so a hand edit counts at the next
// tester lifecycle call and at the take. Without it the broker's own copy decides.
if (testerOn !== null && typeof testerOn !== 'function') throw new TypeError('testerOn must be a function');
this.#testerOn = testerOn ?? ((business) => this.#business(business).vars?.['bus.tester'] === true);
// Row 194 (#1692, D2.3): roleFile(name) loads role definition `name` from the roles directory as it
// stands, for the hire checks. Without it a hire refuses not-configured.
if (roleFile !== null && typeof roleFile !== 'function') throw new TypeError('roleFile must be a function');
this.#roleFile = roleFile;
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
this.#requirements = loadRequirements();
for (const table of [
@@ -240,34 +320,17 @@ export class Broker {
this.#clock = Math.max(this.#clock, ms);
}
}
for (const [name, b] of Object.entries(this.#businesses)) {
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
for (const role of Object.keys(b.roles)) {
if (!id(role) || role === 'human' || role === 'tester') fail('invalid-business');
const a = b.roles[role].authority;
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
fail('invalid-arbiter');
// Row 120 (#1614): a role definition's optional messaging lists. A malformed one refuses at boot.
for (const d of Object.values(b.definitions ?? {})) {
const m = d?.messaging;
if (m === undefined || m === null) continue;
if (
!object(m) ||
!['sendTo', 'receiveFrom'].every(
(k) => Array.isArray(m[k]) && m[k].every((r) => id(r) && r !== 'tester'),
)
)
fail('invalid-messaging');
}
}
for (const [name, b] of Object.entries(this.#businesses)) checkBusiness(name, b);
}
// Row 194 (#1692, D3): swap in one business's document, loaded from the file as a restart would
// load it. It is synchronous, so no request sees half of it. A new human is row 193's ground.
reloadBusiness(name, document) {
if (!Object.hasOwn(this.#businesses, name)) fail('unknown-business');
if (!object(document)) fail('invalid-business');
const next = structuredClone(document);
checkBusiness(name, next);
if (next.human !== this.#businesses[name].human) fail('invalid-business');
this.#businesses[name] = next;
}
#now() {
this.#clock = Math.max(Date.now(), this.#clock + 1);
@@ -304,6 +367,92 @@ export class Broker {
owner,
);
}
// Row 194 (#1692): the runs that hold a claim for an instance, one per thread for a thread instance.
#running(business, instance) {
if (this.#threaded(business, instance)) return this.#threadHolders(business, instance).map((h) => h.holder_run);
const h = this.#holder(business, instance);
return h?.op === 'claim' ? [h.holder_run] : [];
}
// Row 194 (#1692, D1.1): a lifecycle request and how far it got, or null. The request is the
// action.allowed event the verb wrote. Its taken, done and cancelled events name it by id.
#lifecycle(business, request) {
const row = this.#store.get(
"SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?,?)",
business, request, ...LIFECYCLE_REQUESTS);
if (!row) return null;
const steps = this.#store.all(
"SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.request')=? AND json_extract(body,'$.action') IN (?,?,?) ORDER BY seq",
business, request, ...LIFECYCLE_STEPS).map((e) => JSON.parse(e.body));
const step = (action) => steps.find((x) => x.action === action) ?? null;
const done = step('lifecycle.done'), cancelled = step('lifecycle.cancelled'), taken = step('lifecycle.taken') !== null;
return { ...JSON.parse(row.body), at: row.at, instance: row.subject, taken, done, cancelled,
state: done || cancelled ? 'closed' : taken ? 'taken' : 'open' };
}
// The requests not yet closed, oldest first, of one instance or of the whole business.
#openRequests(business, instance = null) {
return this.#store.all(
`SELECT json_extract(r.body,'$.request') request FROM events r WHERE r.business=? AND r.kind='action.allowed'
AND json_extract(r.body,'$.action') IN (?,?,?,?) AND (? IS NULL OR r.subject=?)
AND NOT EXISTS (SELECT 1 FROM events c WHERE c.business=r.business AND c.kind='action.allowed'
AND json_extract(c.body,'$.request')=json_extract(r.body,'$.request') AND json_extract(c.body,'$.action') IN ('lifecycle.done','lifecycle.cancelled'))
ORDER BY r.seq`,
business, ...LIFECYCLE_REQUESTS, instance, instance).map((r) => this.#lifecycle(business, r.request));
}
#expired(r) {
return Math.max(Date.now(), this.#clock) - Date.parse(r.at) >= LIFECYCLE_REQUEST_MS;
}
// V5 instance-starting: a launch, regenerate or delete of I that is taken, or open and still young
// enough to be taken. An open request past its 60 s can only close as request-expired.
#starting(business, instance) {
return this.#openRequests(business, instance).some((r) => r.action !== 'lifecycle.end' && (r.state === 'taken' || !this.#expired(r)));
}
// The verb's request event, with a human.input for the human's own, as launch.revoke writes. A
// tester request records `tester` and has no human input.
#lifecycleRecord(s, action, instance, extra = {}) {
const request = randomUUID();
const eid = this.#event(s, 'action.allowed', { action, request, via: s.tester ? 'tester' : s.via, by: this.#owner(s), ...extra }, instance);
if (!s.tester) this.#event(s, 'human.input', { kind: 'admin', event: eid });
return request;
}
// A step the launcher's host ops write, or a terminate's cancel. No session acts in it.
#lifecycleStep(business, action, body) {
this.#event({ business, role: null, run: null }, 'action.allowed', { action, ...body });
}
// D4.1: a tester lifecycle call, and the take of a tester request, need bus.tester exactly true in
// the file as it stands.
#testerFile(business) {
if (this.#testerOn(business) !== true) fail('tester-off');
}
// V2 and V4 at the verb. The take repeats the revoked and retired checks.
#launchable(s, instance, thread) {
const b = this.#business(s.business);
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
if (this.#threaded(s.business, instance)) fail(thread);
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
if (this.#firing(s.business, instance)) fail('instance-firing');
}
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
#firing(business, instance) {
return !!this.#store.get(
`SELECT 1 FROM events f WHERE f.business=? AND f.kind='action.allowed' AND f.subject=? AND json_extract(f.body,'$.action')='fire.stopped'
AND NOT EXISTS (SELECT 1 FROM events d WHERE d.business=f.business AND d.kind='action.allowed' AND d.subject=f.subject
AND json_extract(d.body,'$.action')='fire.done' AND d.seq>f.seq) LIMIT 1`,
business, instance);
}
// D1.1 item 2: why the take closes a request instead of going, or null. A terminate's signal
// always goes. Its claims were revoked at the verb.
#takeRefusal(business, r) {
if (r.action === 'lifecycle.end') return null;
if (this.#expired(r)) return 'request-expired';
if (r.action !== 'lifecycle.delete') {
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', business)?.state === 'revoked') return 'launch-revoked';
if ((this.#business(business).retired ?? []).includes(r.instance)) return 'instance-retired';
if (this.#firing(business, r.instance)) return 'instance-firing';
}
if (r.by === 'tester' && this.#testerOn(business) !== true) return 'tester-off';
return null;
}
// The claim a run acts under: its thread's for a thread run, its role's otherwise.
#own(s) {
return s.thread ? this.#threadHolder(s.thread) : this.#holder(s.business, s.role);
@@ -376,6 +525,12 @@ export class Broker {
if (s.reader) fail('read-only');
if (!s.human || s.via !== 'cli' || s.outsideAgent !== true) fail('human-required');
}
// Row 194 (#1692, D4): until row 193, a lifecycle verb passes for the business's one human, the
// owner. Row 193 replaces this body and nothing else.
#mayHuman(s, verb, target) {
if (!LIFECYCLE_HUMAN_VERBS.includes(verb) || typeof target !== 'string') fail('human-required');
if (s.human !== this.#business(s.business).human) fail('human-required');
}
#cap(s) {
const cap = randomBytes(32).toString('hex');
this.#sessions.set(cap, Object.freeze(s));
@@ -772,6 +927,7 @@ export class Broker {
this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked'
)
fail('launch-revoked');
if (action === 'role.launch' && this.#firing(s.business, target)) fail('instance-firing');
const cls = this.#classification(s, action, target);
if (cls === 'within-role' && !decision) return { class: cls };
if (!decision) fail('decision-required');
@@ -1069,6 +1225,70 @@ export class Broker {
this.#store.transaction(() => this.#claimEnd(session, h, 'release', null, null, 'land-relaunch'));
return { released: true };
}
// Row 194 (#1692, D1.1): the launcher's lifecycle pass. Trusted launcher API, not socket verbs.
// lifecycleRequests lists the requests not yet closed, oldest first.
lifecycleRequests(business) {
this.#business(identifier(business));
return this.#openRequests(business).map(lifecycleView);
}
// The take, in one transaction: an open request that passes #takeRefusal is taken and answers go,
// one that fails it closes with the code. A taken request is never taken again.
lifecycleTake(record) {
keys(record, ['business', 'request'], ['business', 'request']);
this.#business(identifier(record.business));
identifier(record.request);
return this.#store.transaction(() => {
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
if (r.state !== 'open') return { go: false, refusal: 'request-closed', ...lifecycleView(r) };
const refusal = this.#takeRefusal(record.business, r);
this.#lifecycleStep(record.business, refusal ? 'lifecycle.done' : 'lifecycle.taken', { request: r.request, ...(refusal ? { refusal } : {}) });
return { go: !refusal, refusal, ...lifecycleView(this.#lifecycle(record.business, r.request)) };
});
}
// V5 step 2 for a taken delete: the running and starting checks again, then the writer's delete
// against the file as it stands, with the human's revision. The bus reloads after the commit, as
// after every write. A retry of a retired name commits nothing and goes to the rename, with the
// transaction of the delete whose rename failed (row 194 S3), which names the rename's target.
lifecycleDelete(record) {
keys(record, ['business', 'request'], ['business', 'request']);
this.#business(identifier(record.business));
const r = this.#lifecycle(record.business, identifier(record.request)) ?? fail('unknown-request');
if (r.action !== 'lifecycle.delete' || r.state !== 'taken') fail('request-not-taken');
if (r.retry === true) {
const failed = this.#store.all(
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' AND json_extract(body,'$.request')<>? ORDER BY seq DESC",
record.business, r.instance, r.request).map((x) => this.#lifecycle(record.business, x.request))
.find((x) => x.done?.refusal === 'workspace-rename-failed');
return { transaction: failed?.done?.transaction ?? null, retry: true };
}
if (this.#running(record.business, r.instance).length > 0) fail('instance-running');
if (this.#openRequests(record.business, r.instance).some((o) => o.request !== r.request && o.action !== 'lifecycle.end' &&
(o.state === 'taken' || !this.#expired(o)))) fail('instance-starting');
if (!this.#organization) fail('not-configured');
let out;
try {
out = this.#organization(record.business, 'instances.set', { revision: r.revision, instance: r.instance, op: 'delete' }, r.by);
} catch (e) {
this.#organizationFail(e, 'instances.set');
}
return { transaction: out.transaction, retry: false };
}
// The close of a taken request, with the run it started, the delete's transaction or the code
// that stopped it.
lifecycleDone(record) {
keys(record, ['business', 'request', 'run', 'transaction', 'refusal'], ['business', 'request']);
this.#business(identifier(record.business));
identifier(record.request);
for (const k of ['run', 'transaction']) if (k in record) identifier(record[k]);
if ('refusal' in record && !LIFECYCLE_CODE.test(record.refusal)) fail('invalid-request');
return this.#store.transaction(() => {
const r = this.#lifecycle(record.business, record.request) ?? fail('unknown-request');
if (r.state !== 'taken') fail(r.state === 'open' ? 'request-not-taken' : 'request-closed');
const { business, ...body } = record;
this.#lifecycleStep(business, 'lifecycle.done', body);
return lifecycleView(this.#lifecycle(business, r.request));
});
}
// The merge decisions landHeld would count, each with its raised context and closing event. A
// null role is every non-thread instance's (row 156): raised outside a thread by a role that
// isn't a thread instance.
@@ -1245,7 +1465,9 @@ export class Broker {
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
if (PROJECT_FILE_VERBS.includes(request.verb))
return this.#requestProjectFile(s, { verb: request.verb, args: structuredClone(args) });
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set"].includes(request.verb)) {
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
return this.#chiefLifecycle(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set"].includes(request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -1262,13 +1484,42 @@ export class Broker {
else if (request.verb === "plugins.set") keys(args, ["revision", "plugins"], ["revision", "plugins"]);
// Row 165 (#1662): instances.set is human-only and outside ORGANIZATION_KEYS, so
// organization.set and the chief of staff's tool never reach it (sage-0918).
else if (request.verb === "instances.set") keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
else if (request.verb === "instances.set") {
keys(args, INSTANCE_KEYS, ["revision", "instance", "op"]);
if (!["add", "change", "delete"].includes(args.op)) fail("invalid-request");
// Row 194 (D4.1, B6): the tester never deletes.
if (s.tester && args.op === "delete") fail("tester-refused");
if (!s.tester) {
this.#human(s);
this.#mayHuman(s, `instances.set ${args.op}`, args.instance);
}
// A delete is a launcher request (V5): the checks here write nothing, and the launcher's
// pass commits the file and renames the workspace after the take.
if (args.op === "delete") return this.#deleteRequest(s, args);
// Row 194 (D3 item 4): the write now goes live at once, so a live cap's authority must not
// change under its run.
if (args.op === "change" && "definition" in args && typeof args.instance === "string" &&
Object.hasOwn(this.#business(s.business).roles, args.instance) && this.#running(s.business, args.instance).length > 0)
fail("instance-running");
}
// Row 65 (mos-0840): routes.set, the Console's routing table, is human-only and outside
// ORGANIZATION_KEYS too. Through organization.set the chief of staff changes only the model
// key of an instance, except the assistant, whose whole vars it may set (Q169); that write
// runs the same route checks.
else if (request.verb === "routes.set") keys(args, ["revision", "routes"], ["revision", "routes"]);
else keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (#1692, D2.4, V6): only the human writes launch.keep and launch.noHire. The tester
// is refused by the gate above, as veto.set isn't in TESTER_ORGANIZATION_VERBS.
else if (request.verb === "veto.set") {
keys(args, VETO_KEYS, VETO_KEYS);
this.#human(s);
this.#mayHuman(s, "veto.set", args.name);
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
// the next launch. The human's limits.set still can.
const keep = this.#business(s.business).launch?.keep ?? [];
if (role && object(args.instances) && Object.keys(args.instances).some(i => keep.includes(i))) fail("instance-kept");
}
let actor = s.tester ? "tester" : s.human;
// This branch runs outside the store transaction below, so the grant is checked before the writer
// and consumed only once the writer has committed: a refused change logs action.refused alone,
@@ -1283,20 +1534,14 @@ export class Broker {
? { revision: this.#organization(s.business, "limits", {}, actor).revision }
: this.#organization(s.business, role ? "limits.set" : request.verb, args, actor);
} catch (e) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
// The routing table names a model the table lacks; the older verbs keep invalid-request.
if (request.verb === "routes.set" && e.code === "unknown-model") fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
this.#organizationFail(e, request.verb);
}
if (role && !revisionRead) this.#store.transaction(() => this.#consumeAuthority(s, "organization.change"));
this.#secretCheck(result);
// Row 194 (#1692, D1): the read gains each instance's running runs, from the bus claims. An
// answer with no instances map passes through as it did before.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object")
for (const [name, entry] of Object.entries(result.instances)) entry.running = this.#running(s.business, name);
return result;
}
return this.#store.transaction(() => {
@@ -1316,6 +1561,153 @@ export class Broker {
throw this.#refused(s, e, request);
}
}
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
return this.#organization(s.business, op, written, actor, context);
} catch (e) {
this.#organizationFail(e, verb);
}
};
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
}
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
// The last listed name refuses before step A stops anything, as the writer would at step B.
const listed = b.launch?.instances ?? [];
if (listed.length === 1 && listed[0] === args.name) fail('invalid-request');
// Step A (webby-0011 B2): the store side first, so a delisted agent never keeps a live process.
// V3's revoke, cancel and end, with kill: true for the launcher's escalation.
const stopped = this.#store.transaction(() => {
const held = this.#threaded(s.business, args.name)
? this.#threadHolders(s.business, args.name)
: [this.#holder(s.business, args.name)].filter((h) => h?.op === 'claim');
for (const h of held) this.#claimEnd(s, h, 'revoke', null, args.reason);
const runs = held.map((h) => h.holder_run);
for (const [key, r] of this.#runs)
if (r.business === s.business && r.role === args.name && !this.#ended.has(key) && !runs.includes(r.run)) runs.push(r.run);
const open = this.#openRequests(s.business, args.name).filter((r) => r.action !== 'lifecycle.end');
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: s.role });
// The request the launcher's pass takes to signal the runs.
const request = runs.length === 0 ? null : randomUUID();
if (request)
this.#event(s, 'action.allowed', { action: 'lifecycle.end', request, via: 'chief', by: s.role, runs, reason: args.reason, kill: true }, args.name);
this.#event(s, 'action.allowed', { action: 'fire.stopped', request }, args.name);
return { request, runs, cancelled, taken };
});
// Step B: the delist. An instance already delisted counts as done.
const result = write('instance.fire');
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
if (args.name === 'human' || args.name === 'tester') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
if (e.code === "revision-conflict") fail("revision-conflict");
if (e.code?.startsWith("organization-")) fail(e.code);
if (e.code === "invalid-plugins" || e.code === "unknown-plugin") fail(e.code);
// Row 194: the writer's own copy of the hire and fire checks, against the file as it stands.
if ((verb === "instance.hire" || verb === "instance.fire") && ["definition-not-hireable", "instance-kept"].includes(e.code)) fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
if (["harness-off", "harness-refused", "thinking-unsupported"].includes(e.code)) fail(e.code);
// The routing table names a model the table lacks; the older verbs keep invalid-request.
if (verb === "routes.set" && e.code === "unknown-model") fail(e.code);
if (e.name === "BusinessError" || e.code === "invalid-request" || e.code === "unknown-model") {
fail("invalid-request");
}
throw new BusError("outcome-unknown");
}
// Row 194 (#1692, V5): a delete's checks at the verb, in the design's order, then its request.
// The writer's instances.check runs deleteRefusal then the last-listed check, with no write. Its
// named refusals come first. The last-listed one (a BusinessError) waits until the broker's
// running and starting checks have passed. A retired name is a retry of the workspace rename, and
// only after a rename that failed.
#deleteRequest(s, args) {
keys(args, ["revision", "instance", "op"], ["revision", "instance", "op"]);
identifier(args.instance);
if (typeof args.revision !== "string" || !/^[a-f0-9]{64}$/.test(args.revision)) fail("invalid-request");
const b = this.#business(s.business);
if ((b.retired ?? []).includes(args.instance) && !Object.hasOwn(b.roles, args.instance)) {
const last = this.#store.get(
"SELECT json_extract(body,'$.request') request FROM events WHERE business=? AND kind='action.allowed' AND subject=? AND json_extract(body,'$.action')='lifecycle.delete' ORDER BY seq DESC LIMIT 1",
s.business, args.instance);
if (this.#lifecycle(s.business, last?.request ?? "")?.done?.refusal !== "workspace-rename-failed") fail("organization-instance-retired");
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision, retry: true })) };
}
let lastListed = null, revision = null;
try {
({ revision } = this.#organization(s.business, "instances.check", { instance: args.instance }, s.human));
} catch (e) {
if (e.name !== "BusinessError") this.#organizationFail(e, "instances.set");
lastListed = e;
}
if (this.#running(s.business, args.instance).length > 0) fail("instance-running");
if (this.#starting(s.business, args.instance)) fail("instance-starting");
if (lastListed) fail("invalid-request");
if (revision !== args.revision) fail("revision-conflict");
return { request: this.#store.transaction(() => this.#lifecycleRecord(s, "lifecycle.delete", args.instance, { revision: args.revision })) };
}
// Only the runner's bound session and delivered message identify a turn; no host path crosses the wire.
// Row 156 (#1651, sage-0808): a role run's landing turn is a merge decision id instead. Its begin
// needs the decision held for the run's role; its end needs that begin, since the landing may
@@ -1610,6 +2002,9 @@ export class Broker {
this.#consumeAuthority(s, 'role.end', { target: a.role }, { reason: a.reason });
if (a.role === s.role) fail('role-end-refused');
this.#protectedEnd(s, a.role);
// Row 194 (#1692, D2.4): a kept instance's run ends only by a decision the human resolved,
// the human's own end or the limits. End then launch would be a replace.
if ((this.#business(s.business).launch?.keep ?? []).includes(a.role)) fail('instance-kept');
} else {
identifier(a.decision);
this.#consumeAuthority(s, 'role.revoke', { decision: a.decision, target: a.role });
@@ -2285,6 +2680,60 @@ export class Broker {
this.#claimEnd(s, h, 'revoke', null, s.tester ? 'stopped by the tester' : 'stopped by the human', s.tester ? 'tester' : s.human);
return { stopped: held.map((h) => ({ thread: h.thread, run: h.holder_run })) };
}
// Row 194 (#1692, D1.1, V2, V4): launch and regenerate are requests the launcher's pass takes.
// The tester launches (D4.1) and never regenerates: instance.regenerate isn't in TESTER_VERBS.
case 'instance.launch':
case 'instance.regenerate': {
if (!s.tester) this.#human(s);
keys(a, ['instance'], ['instance']);
identifier(a.instance);
if (s.tester) this.#testerFile(s.business);
else this.#mayHuman(s, verb, a.instance);
this.#launchable(s, a.instance, verb === 'instance.launch' ? 'not-thread-launch' : 'regenerate-thread');
const action = verb === 'instance.launch' ? 'lifecycle.launch' : 'lifecycle.regenerate';
return { request: this.#lifecycleRecord(s, action, a.instance) };
}
// V3: every claim of I is revoked, and every open request of I cancelled, in this transaction.
// lifecycle.end names the runs for the launcher's pass to signal, the bound runs that hold no
// claim included. A tester ends only its own threads of a thread instance, as assistant.stop
// does, and cancels only its own requests.
case 'instance.end': {
if (!s.tester) this.#human(s);
keys(a, ['instance', 'reason'], ['instance']);
identifier(a.instance);
if ('reason' in a) string(a.reason, 500);
if (s.tester) this.#testerFile(s.business);
else this.#mayHuman(s, verb, a.instance);
if (!Object.hasOwn(b.roles, a.instance)) fail('unknown-role');
const owner = s.tester ? 'tester' : null;
const threaded = this.#threaded(s.business, a.instance);
const held = threaded
? this.#threadHolders(s.business, a.instance, owner)
: [this.#holder(s.business, a.instance)].filter((h) => h?.op === 'claim');
const reason = a.reason ?? (s.tester ? 'ended by the tester' : 'ended by the human');
for (const h of held) this.#claimEnd(s, h, 'revoke', null, reason, this.#owner(s));
const runs = held.map((h) => h.holder_run);
for (const [key, r] of this.#runs)
if (r.business === s.business && r.role === a.instance && !this.#ended.has(key) && !runs.includes(r.run) &&
(!owner || !r.thread || this.#store.get('SELECT owner FROM threads WHERE id=?', r.thread)?.owner === owner))
runs.push(r.run);
const open = this.#openRequests(s.business, a.instance).filter((r) => r.action !== 'lifecycle.end' && (!owner || r.by === owner));
const cancelled = open.filter((r) => r.state === 'open').map((r) => r.request);
// A taken request is past cancelling. Its run reaches the registry, and the lifecycle.end
// lets the pass signal it there.
const taken = open.filter((r) => r.state === 'taken').map((r) => r.request);
if (runs.length === 0 && cancelled.length === 0 && taken.length === 0) fail('not-held');
for (const request of cancelled) this.#lifecycleStep(s.business, 'lifecycle.cancelled', { request, by: this.#owner(s) });
return { request: this.#lifecycleRecord(s, 'lifecycle.end', a.instance, { runs, reason }), runs, cancelled, taken };
}
// D1.1 item 5: the read the CLI and the Console poll. A tester reads only its own requests.
case 'instance.request': {
if (!s.tester) this.#human(s);
keys(a, ['request'], ['request']);
const r = this.#lifecycle(s.business, identifier(a.request));
if (!r || (s.tester && r.by !== 'tester')) fail('unknown-request');
return lifecycleView(r);
}
case 'names': {
keys(a, []);
if (!s.human && !s.tester) this.#agent(s);
+9
View File
@@ -20,6 +20,10 @@ const LAUNCH_OPS = new Set([
'mayLaunch',
'landHolders',
'landRelease',
'lifecycleRequests',
'lifecycleTake',
'lifecycleDelete',
'lifecycleDone',
]);
function launchOp(m) {
if (m.op === 'identity') return runtime.identity(m.cap);
@@ -44,6 +48,11 @@ function launchOp(m) {
// role run started before its decision was resolved.
if (m.op === 'landHolders') return runtime.landHolders(m.business);
if (m.op === 'landRelease') return runtime.landRelease(m.record);
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
if (m.op === 'lifecycleRequests') return runtime.lifecycleRequests(m.business);
if (m.op === 'lifecycleTake') return runtime.lifecycleTake(m.record);
if (m.op === 'lifecycleDelete') return runtime.lifecycleDelete(m.record);
if (m.op === 'lifecycleDone') return runtime.lifecycleDone(m.record);
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
// Every LAUNCH_OPS name has its line above. A missing one used to answer as credentialStatus,
// whose [] for an unknown role read as "no holders" for landHolders (row 156 mutant P02).
+72 -6
View File
@@ -11,6 +11,11 @@ import { readSessions } from '../../seat/src/session.mjs';
import { createLoader, hasModule } from '../../plugins/src/loader.mjs';
import { pluginSettings } from '../../business/src/plugin-settings.mjs';
import { instanceSettings } from '../../business/src/instance-settings.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { loadRole } from '../../business/src/role.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { canonicalJson } from '../../business/src/util.mjs';
import { busBusiness } from './business.mjs';
import { webService } from './web.mjs';
import { verifyHuman } from './human.mjs';
import { createUsage } from './usage.mjs';
@@ -103,6 +108,40 @@ export async function startBroker({
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog });
for (const id of Object.keys(businesses)) await limits.refresh(id);
const withTokens = (id, result) => ({ ...result, tokens: limits.report(id) });
// Row 194 (#1692, D3): after every write that commits, the broker takes the business as a restart
// would load it. The credential references, the project roots the Credentials store protects
// and the tracker bots are fixed at start (the Credentials store, the tasks adapter), so a file
// whose references, roots or bots differ from the boot copy isn't reloaded, and the write
// answers restart: true. Create and hire add neither,
// because credentialed definitions are refused.
// A role not in the boot copy matches only with no references and no bot.
const fixedRole = r => canonicalJson({ credentials: r?.credentials ?? {}, tracker: r?.tracker ?? null });
const fixedAtStart = b => ({
roles: Object.fromEntries(Object.entries(b.roles).map(([role, r]) => [role, fixedRole(r)])),
sync: canonicalJson(b.tracker?.sync ?? null),
roots: canonicalJson(Object.values(b.projects ?? {}).map(p => p.root).sort()),
});
const sameFixed = (boot, b) => fixedAtStart(b).sync === boot.sync && fixedAtStart(b).roots === boot.roots
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = busBusiness(b, resolved);
if (!sameFixed(booted.get(id), next)) {
reloadLog({ event: 'reload-needs-restart', business: id });
return false;
}
broker.reloadBusiness(id, next);
return true;
} catch (e) {
reloadLog({ event: 'reload-failed', business: id, message: e.message });
return false;
}
}
const broker = new Broker({
usage,
limits,
@@ -118,19 +157,32 @@ export async function startBroker({
},
// limits and limits.set (organization.set is limits.set) carry each token limit's count; a
// plugins.set reloads the business's limit checks in the background.
organization:settings?(id,verb,args,actor)=>{
organization:settings?(id,verb,args,actor,context)=>{
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='limits.set')return withTokens(id,settings.set(id,args,actor));
if(verb==='plugins')return plugins.read(id);
if(verb==='instances')return instances.read(id);
if(verb==='instances.set')return instances.set(id,args,actor);
// Row 65 (mos-0840): the Console's routing table.
if(verb==='routes')return settings.routes(id);
if(verb==='routes.set')return settings.setRoutes(id,args,actor);
const result=plugins.set(id,args,actor);
// Row 194 (#1692, V5): a delete's checks at the verb, with no write and no reload.
if(verb==='instances.check')return instances.checkDelete(id,args.instance);
let result;
if(verb==='limits.set')result=withTokens(id,settings.set(id,args,actor));
else if(verb==='instances.set')result=instances.set(id,args,actor);
else if(verb==='routes.set')result=settings.setRoutes(id,args,actor);
else if(verb==='plugins.set')result=plugins.set(id,args,actor);
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
limits.refresh(id).catch(e=>limitLog({event:'limit-refresh-failed',business:id,message:e.message}));
return result;
return verb==='instances.set'?{...result,restart:!live}:result;
}:null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
// Row 65: a new launch binds only on a harness its business switches on, read from the file
@@ -143,6 +195,15 @@ export async function startBroker({
return false;
}
},
// Row 194 (#1692, D4.1, B8): bus.tester as the file sets it now; an unreadable file is off.
testerOn: (id) => {
try {
const b = settings ? settings.current(id) : businesses[id];
return b?.vars?.['bus.tester'] === true;
} catch {
return false;
}
},
});
// Process identities must be supplied for every managed run when using the human transport.
const launchRecords = [];
@@ -241,6 +302,11 @@ export async function startBroker({
// Row 156 (#1651): the launcher's landing pass.
landHolders: (business) => broker.landHolders(business),
landRelease: (r) => broker.landRelease(r),
// Row 194 (#1692, D1.1): the launcher's lifecycle pass.
lifecycleRequests: (business) => broker.lifecycleRequests(business),
lifecycleTake: (r) => broker.lifecycleTake(r),
lifecycleDelete: (r) => broker.lifecycleDelete(r),
lifecycleDone: (r) => broker.lifecycleDone(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
launches: bound,
+413 -5
View File
@@ -7,9 +7,13 @@ import { createHash } from 'node:crypto';
import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../business/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
import { busBusiness } from '../src/business.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
// Row 123 (#1617): the chief of staff ends runs without a decision and changes the organization
// file through the shared write path. REQ-ROLE-2 (one holder; revoke), REQ-ROLE-3 (the
@@ -27,7 +31,7 @@ const roles = {
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
};
const demo = () => ({
const demo = (extra = {}) => ({
demo: {
id: 'demo',
human: 'jason',
@@ -38,12 +42,13 @@ const demo = () => ({
// Row 65: runtime launches bind only on a harness the business switches on.
plugins: { harness: ['pi'] },
vars: { 'bus.tester': true },
...extra,
},
});
const PATCH = { revision: 'a'.repeat(64), limits: { total: 3 } };
function setup(t) {
function setup(t, extra = {}, { roleFile = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-'));
const store = new Store(root);
t.after(() => {
@@ -51,13 +56,16 @@ function setup(t) {
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const writer = { error: null };
const writer = { error: null, during: null };
const b = new Broker({
store,
businesses: demo(),
businesses: demo(extra),
roleFile,
organization: (...args) => {
organization.push(args);
if (writer.during) writer.during(args);
if (writer.error) throw writer.error;
if (args[1] === 'instance.hire' || args[1] === 'instance.fire') return { revision: 'b'.repeat(64), transaction: 't-' + organization.length };
// The human's limits read answers more than the revision; organization.revision must not.
return args[1] === 'limits'
? { revision: 'b'.repeat(64), limits: { total: 7 }, models: { m: {} }, tokens: { used: 1 } }
@@ -645,3 +653,403 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
);
assert.throws(() => store.run(REVOKE_NEITHER), /revoke needs a resolved decision or a reason/);
});
// Row 194 (#1692, D2.4): the per-agent forbid. The chief can't end a kept instance's run with a
// reason or change its model. A decision the human resolved, and the human's own write, still can.
const KEPT = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['coder'] } };
test('row 194 T10: the chief\'s reason end of a kept instance refuses instance-kept, holds the claim and spends nothing', (t) => {
const { call, lastClaim, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'role.revoke', { role: 'coder', reason: 'replace it' }), /instance-kept/);
assert.deepEqual(lastClaim('coder'), { op: 'claim', decision: null, reason: null, by: 'coder-run' });
assert.equal(allowed('role.end').length, 0);
// An instance not on the list still ends with a reason.
assert.deepEqual(call('chief', 'role.revoke', { role: 'pm', reason: 'stuck' }), { revoked: true });
});
test('row 194 T10 (Q2): a role.revoke citing a decision the human resolved still ends a kept run', (t) => {
const { call, lastClaim } = setup(t, KEPT);
const d = call('chief', 'decision.raise', {
action: 'role.revoke', target: 'coder', question: 'Stop the coder?',
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
});
call('human', 'decision.resolve', { id: d.id, choice: 'yes' });
assert.deepEqual(call('chief', 'role.revoke', { role: 'coder', decision: d.id }), { revoked: true });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: d.id, reason: null, by: 'chief-run' });
});
test('row 194 T10 (B7): the chief\'s organization.set of a kept instance\'s model refuses before the writer; the human\'s limits.set does not', (t) => {
const { call, organization, allowed } = setup(t, KEPT);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { coder: 'm' } }), /instance-kept/);
assert.throws(() => call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm', coder: 'm' } }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(allowed('organization.change').length, 0);
call('chief', 'organization.set', { revision: PATCH.revision, instances: { pm: 'm' } });
call('human', 'limits.set', { revision: PATCH.revision, instances: { coder: 'm' } });
assert.deepEqual(organization.map(([, verb, args]) => [verb, Object.keys(args.instances)]), [['limits.set', ['pm']], ['limits.set', ['coder']]]);
});
test('row 194 T13: organization.set and limits.set refuse the launch, retired, keep and noHire keys before the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const extra of [{ launch: { keep: [] } }, { retired: [] }, { keep: ['pm'] }, { noHire: ['coder'] }])
for (const [who, verb] of [['chief', 'organization.set'], ['human', 'limits.set']])
assert.throws(() => call(who, verb, { ...PATCH, ...extra }), /invalid-request/, `${who} ${Object.keys(extra)[0]}`);
assert.equal(organization.length, 0);
});
test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse, and nothing reaches the writer', (t) => {
const { call, organization } = setup(t, KEPT);
for (const op of ['keep', 'unkeep', 'nohire', 'hire'])
assert.throws(() => call('chief', 'veto.set', { revision: PATCH.revision, op, name: 'coder' }), /human-required/, op);
for (const op of ['add', 'change', 'delete'])
assert.throws(() => call('chief', 'instances.set', { revision: PATCH.revision, instance: 'coder', op, definition: 'coder' }), /human-required/, op);
assert.equal(organization.length, 0);
// The human's veto reaches the writer with the human as actor.
call('human', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' });
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.error = new Error('disk');
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.error = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = () => store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busBusiness(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
for (const instance of ['coder', 'pm'])
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
'stopped-' + instance, new Date().toISOString(), instance, JSON.stringify({ action: 'fire.stopped', request: null }));
for (const request of [launch, regenerate]) {
const { go, refusal } = b.lifecycleTake({ business: 'demo', request });
assert.deepEqual({ go, refusal }, { go: false, refusal: 'instance-firing' });
}
});
+42 -1
View File
@@ -8,7 +8,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, ORGANIZATION_KEYS } from '../src/broker.mjs';
import { Broker, LIFECYCLE_HUMAN_VERBS, ORGANIZATION_KEYS, TESTER_ORGANIZATION_VERBS } from '../src/broker.mjs';
const chiefAuthority = JSON.parse(readFileSync(fileURLToPath(new URL('../../../roles/chief-of-staff.json', import.meta.url)), 'utf8')).authority;
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
@@ -75,6 +75,8 @@ test('row 165 runtime: an add and a change reach the organization file with the
assert.equal(view.definitions['merge-gate'].accepted, true);
const added = bus.broker.request(human, { verb: 'instances.set', args: { ...ADD, revision: view.revision } });
assert.equal(added.instances['sec-1'].launched, true);
// Row 194 (D3 item 3): this boot copy's coder has no credential references and the file's has, so
// the bus doesn't reload and asks for a restart. reload.test.mjs boots from the file.
assert.equal(added.restart, true);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
const first = log().at(-1);
@@ -94,3 +96,42 @@ test('row 165 runtime: an add and a change reach the organization file with the
assert.equal(readFileSync(file, 'utf8'), before);
assert.equal(log().length, entries, 'a refused write logs nothing');
});
// Row 194 (#1692, D4, D4.1): the human lifecycle verbs so far, veto.set and instances.set's three ops.
test('row 194 T12: agent and reader sessions refuse veto.set; the tester gets add and change but not delete or veto.set; the human passes the hook for each listed verb', (t) => {
const root = mkdtempSync(join(tmpdir(), 'instances-verb-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const calls = [];
const b = new Broker({ store, businesses: biz(), organization: (id, verb, args, actor) => (calls.push([verb, args.op, actor]), verb === 'instances.check' ? { revision: ADD.revision } : { ok: true }) });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const tester = b.bindTester({ business: 'demo' });
const caps = { reader: b.bindReader({ business: 'demo' }) };
for (const role of ['chief', 'pm']) caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi' });
const VETO = { revision: ADD.revision, op: 'keep', name: 'pm' };
for (const [who, cap] of Object.entries(caps)) {
assert.throws(() => b.request(cap, { verb: 'veto.set', args: VETO }), /human-required/, who);
assert.throws(() => b.request(cap, { verb: 'instances.set', args: { ...ADD, op: 'delete' } }), /human-required/, who);
}
assert.throws(() => b.request(tester, { verb: 'veto.set', args: VETO }), /tester-refused/);
assert.throws(() => b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }), /tester-refused/);
assert.equal(TESTER_ORGANIZATION_VERBS.includes('veto.set'), false);
assert.equal(calls.length, 0);
// veto.set takes exactly its three keys, and instances.set only its three ops.
for (const args of [{ ...VETO, actor: 'forged' }, { revision: VETO.revision, op: 'keep' }, { ...VETO, holder: 'mos' }])
assert.throws(() => b.request(human, { verb: 'veto.set', args }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'instances.set', args: { ...ADD, op: 'remove' } }), /invalid-request/);
assert.equal(calls.length, 0);
// The hook refuses a verb missing from LIFECYCLE_HUMAN_VERBS, so each call below passing shows the
// hook got that exact entry.
assert.deepEqual([...LIFECYCLE_HUMAN_VERBS].sort(), ['instance.end', 'instance.launch', 'instance.regenerate', 'instances.set add', 'instances.set change', 'instances.set delete', 'veto.set']);
b.request(human, { verb: 'veto.set', args: VETO });
b.request(human, { verb: 'instances.set', args: ADD });
b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
// S2c: the delete is a request after the writer's check, and writes nothing yet.
assert.ok(b.request(human, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'delete' } }).request);
b.request(tester, { verb: 'instances.set', args: ADD });
b.request(tester, { verb: 'instances.set', args: { revision: ADD.revision, instance: 'pm', op: 'change', vars: {} } });
assert.deepEqual(calls, [['veto.set', 'keep', 'jason'], ['instances.set', 'add', 'jason'], ['instances.set', 'change', 'jason'],
['instances.check', undefined, 'jason'], ['instances.set', 'add', 'tester'], ['instances.set', 'change', 'tester']]);
});
+373
View File
@@ -0,0 +1,373 @@
// Row 194 (#1692, D1.1, V2-V5, D4.1): the broker half of the lifecycle verbs. Launch, regenerate
// and delete are requests the launcher's pass takes through the host ops. Terminate revokes and
// cancels at the verb and names the runs the pass signals. The launcher half is in cli lifecycle.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, LIFECYCLE_REQUEST_MS, TESTER_VERBS } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const R = 'a'.repeat(64);
const doc = (extra = {}) => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
vars: { 'bus.tester': true },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
roles: {
chief: { definition: 'chief-of-staff', authority: shipped('chief-of-staff').authority },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none },
assistant: { definition: 'assistant', ...none },
},
retired: ['old'],
...extra,
});
const OTHER = { id: 'other', human: 'jason', arbiters: { technical: 'only-there', delivery: 'only-there' }, roles: { 'only-there': none } };
function setup(t, { organization = null, testerOn = undefined } = {}) {
const root = mkdtempSync(join(tmpdir(), 'lifecycle-verbs-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const tester = { on: true };
const b = new Broker({ store, businesses: { demo: doc(), other: OTHER }, organization,
testerOn: testerOn === undefined ? () => tester.on : testerOn });
const caps = {
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }),
reader: b.bindReader({ business: 'demo' }),
};
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const ask = (who, verb, args = {}) => code(() => call(who, verb, args));
let runs = 0;
const bind = (role, { claim = true, thread, name = `${role}-${++runs}` } = {}) => {
caps[name] = b.bindLaunch({ business: 'demo', role, run: name, harness: 'pi', ...(thread ? { thread } : {}) });
if (claim) call(name, 'role.claim');
return name;
};
const lifecycle = () => store.get("SELECT count(*) n FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action') LIKE 'lifecycle.%'").n;
const inputs = () => store.get("SELECT count(*) n FROM events WHERE kind='human.input'").n;
const take = (request) => b.lifecycleTake({ business: 'demo', request });
const done = (request, extra = {}) => b.lifecycleDone({ business: 'demo', request, ...extra });
const state = (request) => call('human', 'instance.request', { request });
return { b, store, caps, call, code, ask, bind, lifecycle, inputs, take, done, state, tester };
}
// Moves the clock past a request's 60 s, for the rest of the test.
const later = (t) => { const now = Date.now(); t.mock.method(Date, 'now', () => now + LIFECYCLE_REQUEST_MS + 1000); };
test('row 194 T12: agent and reader sessions refuse the lifecycle verbs before any effect; the tester gets launch, end and the request read, never regenerate', (t) => {
const f = setup(t);
f.bind('chief');
f.bind('pm');
const verbs = [['instance.launch', { instance: 'coder' }], ['instance.regenerate', { instance: 'coder' }],
['instance.end', { instance: 'coder' }], ['instance.request', { request: 'r1' }]];
for (const [verb, args] of verbs) {
assert.equal(f.ask('reader', verb, args), 'read-only', `reader ${verb}`);
for (const who of ['chief-1', 'pm-2']) assert.equal(f.ask(who, verb, args), 'human-required', `${who} ${verb}`);
}
assert.equal(f.ask('tester', 'instance.regenerate', { instance: 'coder' }), 'tester-refused');
assert.equal(f.lifecycle(), 0);
for (const verb of ['instance.launch', 'instance.end', 'instance.request']) assert.ok(TESTER_VERBS.includes(verb), verb);
assert.equal(TESTER_VERBS.includes('instance.regenerate'), false);
// Each human verb passes the D4 hook with its LIFECYCLE_HUMAN_VERBS entry.
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
f.call('human', 'instance.regenerate', { instance: 'pm' });
assert.deepEqual(f.call('human', 'instance.end', { instance: 'coder' }).cancelled, [request]);
});
test('row 194 V2 and V4 at the verb: the request is open, by the human, with a human.input; thread, retired, unknown and revoked refuse with no event', (t) => {
const f = setup(t);
const before = f.inputs();
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
const r = f.state(request);
assert.deepEqual([r.action, r.instance, r.by, r.state, r.done, r.cancelled], ['lifecycle.launch', 'coder', 'jason', 'open', null, null]);
assert.equal(f.inputs(), before + 1);
const raw = JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body);
assert.deepEqual(raw, { action: 'lifecycle.launch', request, via: 'cli', by: 'jason' });
const count = f.lifecycle();
assert.equal(f.ask('human', 'instance.launch', { instance: 'assistant' }), 'not-thread-launch');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'assistant' }), 'regenerate-thread');
assert.equal(f.ask('human', 'instance.launch', { instance: 'old' }), 'instance-retired');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'old' }), 'instance-retired');
assert.equal(f.ask('human', 'instance.launch', { instance: 'ghost' }), 'unknown-role');
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder', extra: 1 }), 'invalid-request');
assert.equal(f.ask('human', 'instance.launch', { instance: '../x' }), 'invalid-request');
f.call('human', 'launch.revoke');
assert.equal(f.ask('human', 'instance.launch', { instance: 'coder' }), 'launch-revoked');
assert.equal(f.ask('human', 'instance.regenerate', { instance: 'coder' }), 'launch-revoked');
assert.equal(f.lifecycle(), count);
assert.equal(f.ask('human', 'instance.request', { request: 'no-such' }), 'unknown-request');
});
test('row 194 D1.1 take: a request is taken once, closes once, and lifecycleRequests lists only what is not closed', (t) => {
const f = setup(t);
const { request } = f.call('human', 'instance.launch', { instance: 'coder' });
const second = f.call('human', 'instance.regenerate', { instance: 'pm' }).request;
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => [r.request, r.state]), [[request, 'open'], [second, 'open']]);
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-not-taken');
const go = f.take(request);
assert.deepEqual([go.go, go.refusal, go.state, go.action, go.instance, go.by], [true, null, 'taken', 'lifecycle.launch', 'coder', 'jason']);
const again = f.take(request);
assert.deepEqual([again.go, again.refusal, again.state], [false, 'request-closed', 'taken']);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 1);
assert.equal(f.code(() => f.done(request, { refusal: 'Not A Code' })), 'invalid-request');
assert.equal(f.code(() => f.done(request, { run: '../x' })), 'invalid-request');
assert.equal(f.code(() => f.done(request, { extra: 1 })), 'invalid-request');
const closed = f.done(request, { run: 'run-x' });
assert.deepEqual([closed.state, closed.done], ['closed', { run: 'run-x', transaction: null, refusal: null }]);
assert.equal(f.code(() => f.done(request, { run: 'run-x' })), 'request-closed');
assert.deepEqual(f.take(request).refusal, 'request-closed');
assert.deepEqual(f.b.lifecycleRequests('demo').map((r) => r.request), [second]);
assert.equal(f.code(() => f.take('no-such')), 'unknown-request');
assert.equal(f.code(() => f.b.lifecycleRequests('ghost')), 'unknown-business');
// A request from one business isn't another's.
assert.equal(f.code(() => f.b.lifecycleTake({ business: 'other', request: second })), 'unknown-request');
});
test('row 194 D1.1 take: a launch.revoke after the request closes it launch-revoked, and a request past 60 s closes request-expired', (t) => {
const f = setup(t);
const revoked = f.call('human', 'instance.launch', { instance: 'coder' }).request;
const regen = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
f.call('human', 'launch.revoke');
for (const request of [revoked, regen]) {
const r = f.take(request);
assert.deepEqual([r.go, r.refusal, r.state, r.done.refusal], [false, 'launch-revoked', 'closed', 'launch-revoked']);
}
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.taken'").n, 0);
f.call('human', 'launch.restore');
const old = f.call('human', 'instance.launch', { instance: 'coder' }).request;
later(t);
const r = f.take(old);
assert.deepEqual([r.go, r.refusal, r.state], [false, 'request-expired', 'closed']);
assert.deepEqual(f.b.lifecycleRequests('demo'), []);
});
test('row 194 T12a: the tester launches and ends on its own business with actor tester; bus.tester off in the file refuses the call and the take tester-off', (t) => {
const f = setup(t);
const inputs = f.inputs();
const { request } = f.call('tester', 'instance.launch', { instance: 'coder' });
const r = f.call('tester', 'instance.request', { request });
assert.deepEqual([r.by, r.state], ['tester', 'open']);
assert.equal(JSON.parse(f.store.get("SELECT body FROM events WHERE json_extract(body,'$.request')=? AND json_extract(body,'$.action')='lifecycle.launch'", request).body).via, 'tester');
assert.equal(f.inputs(), inputs, 'a tester request is not human input');
const human = f.call('human', 'instance.launch', { instance: 'pm' }).request;
// A tester reads only its own requests.
assert.equal(f.ask('tester', 'instance.request', { request: human }), 'unknown-request');
// The cap is bound to demo: an instance only `other` declares is unknown here.
assert.equal(f.ask('tester', 'instance.launch', { instance: 'only-there' }), 'unknown-role');
// bus.tester switched off in the file, with no restart.
f.tester.on = false;
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
assert.equal(f.ask('tester', 'instance.end', { instance: 'coder' }), 'tester-off');
const off = f.take(request);
assert.deepEqual([off.go, off.refusal], [false, 'tester-off']);
assert.equal(f.take(human).go, true, 'the human request is not the tester switch');
// Anything but exactly true is off.
f.tester.on = 'true';
assert.equal(f.ask('tester', 'instance.launch', { instance: 'coder' }), 'tester-off');
f.tester.on = true;
const later2 = f.call('tester', 'instance.launch', { instance: 'coder' }).request;
const ended = f.call('tester', 'instance.end', { instance: 'coder' });
assert.deepEqual(ended.cancelled, [later2]);
assert.equal(f.state(ended.request).by, 'tester');
assert.equal(f.state(later2).cancelled.by, 'tester');
});
test('row 194 V3 terminate: claims revoked, open requests cancelled, bound runs named, taken requests listed; nothing at all answers not-held', (t) => {
const f = setup(t);
const held = f.bind('coder');
const unclaimed = f.bind('coder', { claim: false });
const open = f.call('human', 'instance.launch', { instance: 'coder' }).request;
const taken = f.call('human', 'instance.regenerate', { instance: 'coder' }).request;
f.take(taken);
const other = f.call('human', 'instance.launch', { instance: 'pm' }).request;
const out = f.call('human', 'instance.end', { instance: 'coder', reason: 'stuck on a loop' });
assert.deepEqual([out.runs, out.cancelled, out.taken], [[held, unclaimed], [open], [taken]]);
const claim = f.store.get("SELECT * FROM role_claims WHERE role='coder' ORDER BY seq DESC LIMIT 1");
assert.deepEqual([claim.op, claim.holder_run, claim.by, claim.reason], ['revoke', held, 'jason', 'stuck on a loop']);
assert.deepEqual([f.state(open).state, f.state(open).cancelled], ['closed', { by: 'jason' }]);
assert.equal(f.state(taken).state, 'taken', 'a taken request is past cancelling');
assert.equal(f.state(other).state, 'open', 'another instance keeps its request');
// The end is a request the pass takes, with the runs to signal. It never expires.
const end = f.state(out.request);
assert.deepEqual([end.action, end.runs, end.state], ['lifecycle.end', [held, unclaimed], 'open']);
later(t);
assert.equal(f.take(out.request).go, true);
assert.equal(f.take(open).refusal, 'request-closed');
// The runs stay named while bound and not ended.
f.done(taken, { run: 'coder-new' });
for (const run of [held, unclaimed]) f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
assert.equal(f.ask('human', 'instance.end', { instance: 'coder' }), 'not-held');
assert.equal(f.ask('human', 'instance.end', { instance: 'old' }), 'unknown-role');
assert.equal(f.ask('human', 'instance.end', { instance: 'coder', reason: '' }), 'invalid-request');
});
test('row 194 V3 terminate of a thread instance: the human ends every thread, the tester only its own', (t) => {
const f = setup(t);
const hp = f.call('human', 'project.create', { name: 'human work' }).id;
const tp = f.call('tester', 'project.create', { name: 'test work' }).id;
const H = f.call('human', 'thread.create', { project: hp, name: 'mine' }).id;
const T = f.call('tester', 'thread.create', { project: tp, name: 'probe' }).id;
const h = f.bind('assistant', { thread: H });
const tr = f.bind('assistant', { thread: T });
const tester = f.call('tester', 'instance.end', { instance: 'assistant' });
assert.deepEqual(tester.runs, [tr]);
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'claim');
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', T).op, 'revoke');
const human = f.call('human', 'instance.end', { instance: 'assistant' });
assert.deepEqual(human.runs, [h, tr]);
assert.equal(f.store.get('SELECT op FROM thread_claims WHERE thread=? ORDER BY seq DESC LIMIT 1', H).op, 'revoke');
});
// The writer stand-in: instances.check answers per instance, instances.set commits a transaction.
function writer(answers) {
const calls = [];
const organization = (id, verb, args, actor) => {
calls.push([verb, args, actor]);
if (verb === 'instances.check') {
const a = answers[args.instance] ?? { revision: R };
if (a instanceof Error) throw a;
return a;
}
if (verb === 'instances.set') return { transaction: `tx-${calls.length}`, restart: false };
return { ok: true };
};
return { calls, organization };
}
const refused = (code) => Object.assign(new Error(code), { code });
const lastListed = () => Object.assign(new Error('coder is the last name in launch.instances'), { name: 'BusinessError' });
test('row 194 T6 V5 order at the verb: the writer\'s named refusals, then instance-running, instance-starting, the last-listed refusal and the revision', (t) => {
const w = writer({ chief: refused('organization-instance-referenced'), pm: lastListed(), coder: lastListed() });
const f = setup(t, { organization: w.organization });
const del = (instance, revision = R) => f.ask('human', 'instances.set', { revision, instance, op: 'delete' });
// A named refusal comes before the broker's checks, even with a claim held.
f.bind('chief');
assert.equal(del('chief'), 'organization-instance-referenced');
// The last-listed refusal waits until running and starting have passed.
const run = f.bind('coder');
assert.equal(del('coder'), 'instance-running');
f.call('human', 'instance.end', { instance: 'coder' });
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
const starting = f.call('human', 'instance.launch', { instance: 'coder' }).request;
assert.equal(del('coder'), 'instance-starting');
f.take(starting);
assert.equal(del('coder'), 'instance-starting', 'a taken request with no done is still starting');
f.done(starting, { refusal: 'launch-refused' });
assert.equal(del('coder'), 'invalid-request');
assert.equal(del('pm'), 'invalid-request');
assert.equal(del('assistant', 'b'.repeat(64)), 'revision-conflict');
assert.equal(del('assistant', 'not-a-revision'), 'invalid-request');
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete', definition: 'x' }), 'invalid-request');
assert.ok(w.calls.every(([verb]) => verb === 'instances.check'), 'the checks write nothing');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.action')='lifecycle.delete'").n, 0);
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'assistant', op: 'delete' });
const r = f.state(request);
assert.deepEqual([r.action, r.instance, r.by, r.revision, r.retry, r.state], ['lifecycle.delete', 'assistant', 'jason', R, false, 'open']);
assert.deepEqual(w.calls.at(-1), ['instances.check', { instance: 'assistant' }, 'jason']);
// An open delete is a starting request too: a second delete waits for the first.
assert.equal(del('assistant'), 'instance-starting');
});
test('row 194 V5: an open launch past its 60 s is not starting, as it can only close request-expired', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
f.call('human', 'instance.launch', { instance: 'coder' });
later(t);
assert.ok(f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' }).request);
});
test('row 194 V5 after the take: lifecycleDelete re-checks running and starting, then commits through the writer with the human as actor', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'coder', op: 'delete' });
const del = () => f.b.lifecycleDelete({ business: 'demo', request });
assert.equal(f.code(del), 'request-not-taken');
assert.equal(f.take(request).go, true);
// A launch recorded after the delete's verb and a claim taken since both stop it.
const launch = f.call('human', 'instance.launch', { instance: 'coder' }).request;
assert.equal(f.code(del), 'instance-starting');
f.call('human', 'instance.end', { instance: 'coder' });
const run = f.bind('coder');
assert.equal(f.code(del), 'instance-running');
f.call('human', 'instance.end', { instance: 'coder' });
f.b.endLaunch({ business: 'demo', run, reason: 'signalled' });
assert.equal(f.state(launch).cancelled.by, 'jason');
const out = del();
assert.deepEqual(w.calls.at(-1), ['instances.set', { revision: R, instance: 'coder', op: 'delete' }, 'jason']);
assert.deepEqual(out, { transaction: out.transaction, retry: false });
assert.match(out.transaction, /^tx-/);
assert.equal(f.done(request, { transaction: out.transaction }).done.transaction, out.transaction);
const launchReq = f.call('human', 'instance.launch', { instance: 'pm' }).request;
assert.equal(f.code(() => f.b.lifecycleDelete({ business: 'demo', request: launchReq })), 'request-not-taken');
});
test('row 194 V5 retry: a retired name deletes again only after workspace-rename-failed, and that request commits nothing', (t) => {
const w = writer({});
const f = setup(t, { organization: w.organization });
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'old', op: 'delete' }), 'organization-instance-retired');
const { request } = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' });
f.take(request);
const { transaction } = f.b.lifecycleDelete({ business: 'demo', request });
f.done(request, { transaction, refusal: 'workspace-rename-failed' });
// The bus reloads after the commit, as the runtime does.
const next = doc({ retired: ['old', 'pm'], arbiters: { technical: 'coder', delivery: 'chief' } });
delete next.roles.pm;
f.b.reloadBusiness('demo', next);
const writes = w.calls.length;
const retry = f.call('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }).request;
assert.deepEqual([f.state(retry).retry, f.state(retry).revision], [true, R]);
f.take(retry);
// S3: the retry answers the failed delete's transaction, which names the rename's target.
assert.ok(transaction);
assert.deepEqual(f.b.lifecycleDelete({ business: 'demo', request: retry }), { transaction, retry: true });
assert.equal(w.calls.length, writes, 'a retry neither checks nor writes the file');
f.done(retry);
assert.equal(f.ask('human', 'instances.set', { revision: R, instance: 'pm', op: 'delete' }), 'organization-instance-retired');
});
test('row 194 V5 runtime: a delete request, the take and lifecycleDelete commit the file with retired, log the human and reload so the name no longer binds', async (t) => {
const { startBroker } = await import('../src/runtime.mjs');
const { busBusiness } = await import('../src/business.mjs');
const { loadBusiness } = await import('../../business/src/business.mjs');
const { resolveInstance } = await import('../../business/src/resolve.mjs');
const { businessDoc, rolesCopy, systemFor, writeJson } = await import('../../business/tests/helpers.mjs');
const root = mkdtempSync(join(tmpdir(), 'lifecycle-runtime-'));
const rolesDir = rolesCopy(root);
const file = writeJson(join(root, 'businesses/demo.json'), { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
const b = loadBusiness('demo', { dir: root, rolesDir });
const resolved = {};
for (const i of Object.keys(b.roles)) resolved[i] = resolveInstance({ system: systemFor(root), business: b, instance: i });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busBusiness(b, resolved) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
const revision = () => ask('instances').revision;
ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
const before = readFileSync(file, 'utf8');
// The real writer's refusals reach the verb, and none of them writes.
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'coder', op: 'delete' })), 'organization-credentials-out-of-scope');
assert.equal(code(() => ask('instances.set', { revision: revision(), instance: 'pm', op: 'delete' })), 'organization-instance-referenced');
assert.equal(code(() => ask('instances.set', { revision: 'b'.repeat(64), instance: 'sec-1', op: 'delete' })), 'revision-conflict');
assert.equal(readFileSync(file, 'utf8'), before);
const { request } = ask('instances.set', { revision: revision(), instance: 'sec-1', op: 'delete' });
assert.equal(readFileSync(file, 'utf8'), before, 'the verb only records the request');
assert.deepEqual(bus.lifecycleRequests('demo').map((r) => [r.request, r.action, r.instance]), [[request, 'lifecycle.delete', 'sec-1']]);
assert.equal(bus.lifecycleTake({ business: 'demo', request }).go, true);
const { transaction } = bus.lifecycleDelete({ business: 'demo', request });
const after = JSON.parse(readFileSync(file, 'utf8'));
assert.equal(Object.hasOwn(after.roles, 'sec-1'), false);
assert.deepEqual(after.retired, ['sec-1']);
assert.equal(after.launch.instances.includes('sec-1'), false);
const log = readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
assert.deepEqual([log.actor, log.state, log.transaction], ['jason', 'committed', transaction]);
assert.equal(bus.lifecycleDone({ business: 'demo', request, transaction }).state, 'closed');
assert.equal(code(() => bus.broker.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' })), 'unknown-role');
assert.equal(code(() => ask('instance.launch', { instance: 'sec-1' })), 'instance-retired');
});
+126
View File
@@ -0,0 +1,126 @@
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { busBusiness } from '../src/business.mjs';
import { startBroker } from '../src/runtime.mjs';
import { Store } from '../src/store.mjs';
import { loadBusiness } from '../../business/src/business.mjs';
import { resolveInstance } from '../../business/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../business/tests/helpers.mjs';
// The bus form of the business file, built as cli bootConfig builds it.
function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busBusiness(b, resolved);
}
async function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'reload-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi', 'claude-code'] };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const reloads = [];
const reloadBusiness = bus.broker.reloadBusiness.bind(bus.broker);
bus.broker.reloadBusiness = (id, next) => { reloads.push(structuredClone(next)); reloadBusiness(id, next); };
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const revision = () => ask('instances').revision;
return { root, rolesDir, file, bus, reloads, ask, revision };
}
const code = (fn) => { try { fn(); } catch (e) { return e.code; } return null; };
let runs = 0;
const bind = (bus, role) => bus.broker.bindLaunch({ business: 'demo', role, run: `run-${++runs}`, harness: 'pi' });
test('T14 a create goes live with no restart: the new instance binds at once and the answer says restart false', async (t) => {
const f = await setup(t);
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
assert.equal(added.restart, false);
assert.equal(f.reloads.length, 1);
assert.equal(f.reloads[0].roles['sec-1'].definition, 'security-reviewer');
assert.ok(bind(f.bus, 'sec-1'));
});
test('T14 every committed verb reloads once, limits.set, routes.set, plugins.set and veto.set included, and a refused one does not', async (t) => {
const f = await setup(t);
f.ask('limits.set', { revision: f.revision(), limits: { tokens: { instances: { coder: { limit: 100 } } } } });
assert.equal(f.reloads.length, 1);
assert.equal(f.reloads[0].launch.limits.tokens.instances.coder.limit, 100);
f.ask('routes.set', { revision: f.revision(), routes: { coder: { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'low', fallback: null } } });
assert.equal(f.reloads.length, 2);
assert.equal(f.reloads[1].roles.coder.vars.harness, 'claude-code');
f.ask('plugins.set', { revision: f.revision(), plugins: { harness: ['claude-code', 'pi'] } });
assert.equal(f.reloads.length, 3);
assert.deepEqual(f.reloads[2].plugins.harness, ['claude-code', 'pi']);
f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' });
assert.equal(f.reloads.length, 4);
assert.deepEqual(f.reloads[3].launch.keep, ['coder']);
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'ghost', op: 'change', vars: {} })), 'organization-unknown-instance');
assert.equal(code(() => f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'coder' })), 'organization-no-change');
assert.equal(f.reloads.length, 4);
});
test('T14 a definition change on a running instance refuses instance-running; a vars change still goes live', async (t) => {
const f = await setup(t);
f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
const cap = bind(f.bus, 'sec-1');
f.bus.broker.request(cap, { verb: 'role.claim' });
const before = readFileSync(f.file, 'utf8');
assert.equal(code(() => f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', definition: 'merge-gate' })), 'instance-running');
assert.equal(readFileSync(f.file, 'utf8'), before);
const changed = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'change', vars: { model: 'claude-sonnet-5-5' } });
assert.equal(changed.restart, false);
assert.equal(f.reloads.at(-1).roles['sec-1'].vars.model, 'claude-sonnet-5-5');
});
test('T14 a file whose credential references differ from the boot copy is not reloaded and the write answers restart true', async (t) => {
const f = await setup(t);
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
const moved = join(f.root, 'secrets', 'moved-gitea.token');
writeFileSync(moved, 'placeholder-not-a-token', { mode: 0o600 });
doc.roles.coder.credentials.gitea.file = moved;
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
assert.equal(code(() => bind(f.bus, 'sec-1')), 'unknown-role');
});
test('T14 a file whose project root differs from the boot copy is not reloaded, because the Credentials store protects the boot roots', async (t) => {
const f = await setup(t);
const doc = JSON.parse(readFileSync(f.file, 'utf8'));
const moved = join(f.root, 'project-moved');
mkdirSync(moved);
doc.projects.stack.root = moved;
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const added = f.ask('instances.set', { revision: f.revision(), instance: 'sec-1', op: 'add', definition: 'security-reviewer' });
assert.equal(added.restart, true);
assert.equal(f.reloads.length, 0);
});
test('T14 reloadBusiness refuses an unknown business, a changed human and a document the boot check refuses', (t) => {
const root = mkdtempSync(join(tmpdir(), 'reload-unit-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const role = { authority: { withinRole: [], crossRole: [] } };
const demo = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: role } };
const b = new Broker({ store: new Store(root), businesses: { demo } });
assert.equal(code(() => b.reloadBusiness('other', demo)), 'unknown-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, human: 'mos' })), 'invalid-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: role, tester: role } })), 'invalid-business');
assert.equal(code(() => b.reloadBusiness('demo', { ...demo, roles: { coder: { authority: { withinRole: ['spend'], crossRole: [] } } } })), 'invalid-authority');
assert.equal(code(() => b.reloadBusiness('demo', null)), 'invalid-business');
b.reloadBusiness('demo', { ...demo, roles: { coder: role, 'sec-1': role } });
assert.ok(b.bindLaunch({ business: 'demo', role: 'sec-1', run: 'run-1', harness: 'pi' }));
});
@@ -21,6 +21,8 @@ test('row 175: TESTER_VERBS and the organization branch tester set are pinned',
'usage', 'names', 'limits', 'plugins', 'instances', 'project.list', 'thread.list', 'name.set', 'project.create', 'credentials',
// Row 177 (#1675): the tester's own threads.
'thread.create', 'thread.open', 'thread.send', 'thread.messages', 'assistant.stop',
// Row 194 (#1692, D4.1): launch and terminate, and the read of its own requests.
'instance.launch', 'instance.end', 'instance.request',
]);
assert.deepEqual([...TESTER_ORGANIZATION_VERBS], ['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
assert.ok(Object.isFrozen(TESTER_VERBS) && Object.isFrozen(TESTER_ORGANIZATION_VERBS));
+4 -1
View File
@@ -156,7 +156,10 @@ file through the broker's `organization.set`, limited to the keys the human's
call them (row 132). Under Jason's ruling Q169 (row 155, #1649) the chief of
staff is the one agent that ends the assistant, which ends every assistant
thread run, and it configures the assistant through `assistants` (Assistant
settings, below) and names instances through `set_name`. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
settings, below) and names instances through `set_name`. PRD 1.7 REQ-COS-3
(row 194) grants it `role.hire` and `role.fire` within role: it hires and
fires with a reason and no decision, through the broker's `instance.hire` and
`instance.fire`, which the harness's `hire` and `fire` tools call. It has no credentials and the same read-only tools. It is the only shipped role that holds `role.launch`, so
it is the only role `launch.by` can name. The PM holds no `role.launch`
(REQ-LAUNCH-1); it asks the chief of staff for a session.
+28 -3
View File
@@ -9,6 +9,7 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins } from "../../plugins/src/kinds.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -18,7 +19,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "retired"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -118,7 +119,7 @@ function checkLaunch(launch, roles, definitions, file) {
const where = `${file} launch`;
requireObject(launch, where);
if (Object.hasOwn(launch,"max")) refuse(`${where}.max needs migration: mosaic limits migrate --file FILE --models FILE`);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account"], where);
rejectUnknownKeys(launch, ["by", "instances", "models", "limits", "threadIdleSeconds", "account", "keep", "noHire"], where);
requireId(launch.by, `${where}.by`);
if (!Object.hasOwn(roles, launch.by)) refuse(`${where}.by names ${launch.by}, which the business file doesn't declare`);
const launcher = definitions[roles[launch.by].definition];
@@ -142,7 +143,29 @@ function checkLaunch(launch, roles, definitions, file) {
for(const [instance,role] of Object.entries(roles))if(role.vars.model!==undefined&&!Object.hasOwn(models,role.vars.model))refuse(`${where}: instance ${instance} selects unknown model ${role.vars.model}`);
// No upper bound: limits have no ceiling in code (Q150).
const threadIdleSeconds = launch.threadIdleSeconds === undefined ? THREAD_IDLE_SECONDS : requirePositiveInt(launch.threadIdleSeconds, `${where}.threadIdleSeconds`);
return { by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}) };
// Row 194 (#1692, D2.4, D2.5): the human's per-agent forbid. keep names declared instances the
// chief can't fire, replace or end. noHire names definitions the chief can't hire, by the id
// pattern only, so a stale entry is harmless. Both are optional and only the human writes them.
const keep = launch.keep === undefined ? undefined : requireDistinctList(launch.keep, `${where}.keep`, (name) => {
requireId(name, `${where}.keep entry`);
if (!Object.hasOwn(roles, name)) refuse(`${where}.keep names ${name}, which the business file doesn't declare`);
if (END_PROTECTED.includes(roles[name].definition)) refuse(`${where}.keep names ${name}, whose definition ${roles[name].definition} can't be kept`);
});
const noHire = launch.noHire === undefined ? undefined
: requireDistinctList(launch.noHire, `${where}.noHire`, (name) => requireId(name, `${where}.noHire entry`));
return {
by: launch.by, instances, models, limits, threadIdleSeconds, ...(account ? { account } : {}),
...(keep ? { keep } : {}), ...(noHire ? { noHire } : {}),
};
}
// Row 194 (#1692, V5): instance names a delete retired. None may be declared again, so a later
// create or hire can't reuse a name whose decisions outlive it.
function checkRetired(retired, roles, file) {
return requireDistinctList(retired, `${file} retired`, (name) => {
requireId(name, `${file} retired entry`);
if (Object.hasOwn(roles, name)) refuse(`${file} retired names ${name}, which roles still declares`);
});
}
// Validate a parsed business document read from `file`. `rolesDir` is where
@@ -205,9 +228,11 @@ export function validateBusinessDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}),
});
}
+2 -2
View File
@@ -4,9 +4,9 @@
export { BusinessError } from "./errors.mjs";
export {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
} from "./vocabulary.mjs";
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
export { configDir, businessFilePath, validateBusinessDocument, loadBusiness } from "./business.mjs";
+159 -14
View File
@@ -1,8 +1,9 @@
// Row 165 (#1662, Q171): the human adds a role instance and changes an existing one's definition,
// vars and launch membership. It is human-only: the broker's instances and instances.set verbs
// reach it, and neither is in ORGANIZATION_KEYS, so the chief of staff's tool can't (sage-0918).
// holder, credentials and tracker stay hand edits, and there is no delete. Every write goes
// through the organization writer, which logs the actor and the old and new values.
// holder, credentials and tracker stay hand edits. Row 194 (#1692) adds delete, the human's veto
// lists and the chief's hire and fire. Every write goes through the organization writer, which
// logs the actor and the old and new values.
import { existsSync, readdirSync } from "node:fs";
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { validateBusinessDocument, businessFilePath } from "./business.mjs";
@@ -10,11 +11,59 @@ import { checkRoutes } from "./launch-settings.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { resolveInstance } from "./resolve.mjs";
import { loadRole } from "./role.mjs";
import { refuse } from "./errors.mjs";
import { isPlainObject, canonicalJson } from "./util.mjs";
import { END_PROTECTED, ID_PATTERN } from "./vocabulary.mjs";
const SET_KEYS = ["revision", "instance", "op", "definition", "vars", "launch"];
const VETO_KEYS = ["revision", "op", "name"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const id = value => typeof value === "string" && ID_PATTERN.test(value);
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
// Every limits.* key merges by intersection, so it can only narrow.
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
// Row 194 (#1692, V5): why an instance can't be deleted, checked in the design's order, or null.
// The last-listed check runs when the proposed file is validated (deletedDocument).
export function deleteRefusal(document, rolesDir, instance) {
if (!Object.hasOwn(document.roles, instance)) return { code: "organization-unknown-instance" };
const entry = document.roles[instance];
if (END_PROTECTED.includes(entry.definition)) return { code: "organization-definition-refused" };
const references = [];
if (document.launch?.by === instance) references.push("launch.by");
for (const kind of ["delivery", "technical"]) if (document.arbiters?.[kind] === instance) references.push(`arbiters.${kind}`);
if (references.length > 0) {
return { code: "organization-instance-referenced", message: `${instance} is ${references.join(" and ")}; change that first` };
}
if (document.launch?.keep?.includes(instance)) {
return { code: "organization-instance-kept", message: `${instance} is kept; run veto unkeep first` };
}
const definition = loadRole(rolesDir, entry.definition);
if (definition.credentials.length > 0 || entry.tracker !== undefined || entry.credentials !== undefined) {
return { code: "organization-credentials-out-of-scope", message: `${instance} holds credentials or a tracker bot; deleting it is a hand edit` };
}
return null;
}
// The document with `instance` deleted (V5 step 2). It removes roles.<instance> and the references
// that exist only for it, launch.instances and launch.limits.tokens.instances, and adds the name to
// retired. Every other reference (launch.by, the arbiters, launch.keep) refuses in deleteRefusal.
// A business whose only listed instance is this one refuses with a BusinessError.
export function deletedDocument(document, instance) {
const next = structuredClone(document);
delete next.roles[instance];
if (next.launch) {
if (next.launch.instances.length === 1 && next.launch.instances[0] === instance) {
refuse(`${instance} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
next.launch.instances = next.launch.instances.filter(name => name !== instance);
const tokens = next.launch.limits?.tokens?.instances;
if (tokens && Object.hasOwn(tokens, instance)) delete tokens[instance];
}
next.retired = [...(next.retired ?? []), instance];
return next;
}
// A definition this writer can give an instance: version 2, not END_PROTECTED, and declaring no
// credential. An instance of one that declares credentials needs credentials and a tracker bot,
@@ -64,14 +113,19 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
launched: launch ? launch.instances.includes(name) : false,
launcher: launch ? launch.by === name : false,
protected: END_PROTECTED.includes(entry.definition),
kept: (launch?.keep ?? []).includes(name),
}]));
return { business, revision, launch: launch !== null, instances, definitions: definitions() };
// Row 194 (D1): the human's forbid lists and the retired names. The bus adds running runs.
return {
business, revision, launch: launch !== null, instances, definitions: definitions(),
noHire: [...(launch?.noHire ?? [])], retired: [...(document.retired ?? [])],
};
}
// args: { revision, instance, op: "add" | "change", definition?, vars?, launch?: boolean }.
// args: { revision, instance, op: "add" | "change" | "delete", definition?, vars?, launch?: boolean }.
// add needs definition; change needs at least one of definition, vars and launch. vars replaces
// the instance's vars whole. The answer's restart is true when the running bus must restart
// before the change counts: the broker holds the roles it loaded at start (broker.mjs bindLaunch).
// the instance's vars whole. Row 194 (D3): the writer's restart is false. The bus reloads the
// business after the write and sets restart from that reload (runtime.mjs).
function set(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !SET_KEYS.includes(key))) {
throw new OrganizationError("invalid-request");
@@ -80,7 +134,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
throw new OrganizationError("invalid-request");
}
const { instance, op } = args;
if (!id(instance) || (op !== "add" && op !== "change")) throw new OrganizationError("invalid-request");
if (!id(instance) || !["add", "change", "delete"].includes(op)) throw new OrganizationError("invalid-request");
// The broker refuses a business whose roles name human or tester (broker.mjs, the load check),
// so an instance under either name would stop the bus at its next start (row 165 r2, Dewey F1).
if (instance === "human" || instance === "tester") {
@@ -91,15 +145,18 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
if (Object.hasOwn(args, "launch") && typeof args.launch !== "boolean") throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (op === "delete") {
if (Object.keys(args).some(key => !["revision", "instance", "op"].includes(key))) throw new OrganizationError("invalid-request");
return commit(business, old, planDelete(old, instance), { revision: args.revision, actor });
}
const next = structuredClone(old);
const exists = Object.hasOwn(next.roles, instance);
let redefined = false;
if (op === "add") {
if (!Object.hasOwn(args, "definition")) throw new OrganizationError("invalid-request");
if (exists) throw new OrganizationError("organization-instance-exists");
if ((old.retired ?? []).includes(instance)) throw new OrganizationError("organization-instance-retired");
definitionFor(rolesDir, args.definition);
next.roles[instance] = { definition: args.definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
redefined = true;
} else {
if (!["definition", "vars", "launch"].some(key => Object.hasOwn(args, key))) throw new OrganizationError("invalid-request");
if (!exists) throw new OrganizationError("organization-unknown-instance");
@@ -115,8 +172,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
`${instance} holds ${entry.definition} credentials; moving them to another definition is a hand edit`);
}
entry.definition = args.definition;
redefined = true;
}
}
if (Object.hasOwn(args, "vars")) entry.vars = structuredClone(args.vars);
}
if (Object.hasOwn(args, "launch")) {
@@ -125,6 +181,95 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
if (!args.launch) next.launch.instances = listed.filter(name => name !== instance);
else if (!listed.includes(instance)) listed.push(instance);
}
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 194 (V5): the delete's checks against the file as it stands, and its document. The broker
// runs this at the verb with no write, and the launcher's pass runs set op delete after the take.
function planDelete(document, instance) {
const refusal = deleteRefusal(document, rolesDir, instance);
if (refusal) throw new OrganizationError(refusal.code, refusal.message);
return deletedDocument(document, instance);
}
function checkDelete(business, instance) {
const { document, revision } = writer.read(business);
const next = planDelete(document, instance);
validateBusinessDocument(next, businessFilePath(business, dir), { rolesDir });
return { revision };
}
// Row 194 (V6): args { revision, op: keep | unkeep | nohire | hire, name }. keep needs a declared
// instance that isn't END_PROTECTED. nohire needs only the id pattern. Human-only at the broker.
function veto(business, args, actor) {
if (!isPlainObject(args) || Object.keys(args).some(key => !VETO_KEYS.includes(key))) throw new OrganizationError("invalid-request");
if (!revisionOk(args.revision) || !VETO_OPS.includes(args.op) || !id(args.name)) throw new OrganizationError("invalid-request");
const old = writer.read(business).document;
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const next = structuredClone(old);
const { op, name } = args;
const key = op === "keep" || op === "unkeep" ? "keep" : "noHire";
const list = next.launch[key] ?? [];
if (op === "keep") {
if (!Object.hasOwn(next.roles, name)) throw new OrganizationError("organization-unknown-instance");
if (END_PROTECTED.includes(next.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
}
const add = op === "keep" || op === "nohire";
if (add === list.includes(name)) throw new OrganizationError("organization-no-change");
next.launch[key] = add ? [...list, name] : list.filter(entry => entry !== name);
return commit(business, old, next, { revision: args.revision, actor });
}
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
// (declared, not listed) is listed again only with its own definition and no vars. The writer
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
function hire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name, definition } = args;
if (!id(name) || !id(definition) || name === "human" || name === "tester") throw new OrganizationError("invalid-request");
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
}
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
definitionFor(rolesDir, definition);
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
const next = structuredClone(old);
if (Object.hasOwn(old.roles, name)) {
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
throw new OrganizationError("organization-instance-exists");
}
} else {
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
}
next.launch.instances.push(name);
return commit(business, old, next, { revision, actor, context });
}
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
function fire(business, args, { actor, context }) {
const { document: old, revision } = writer.read(business);
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
const { name } = args;
if (!id(name) || !Object.hasOwn(old.roles, name)) throw new OrganizationError("organization-unknown-instance");
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
if (END_PROTECTED.includes(old.roles[name].definition)) throw new OrganizationError("organization-definition-refused");
if (old.launch.by === name) throw new OrganizationError("organization-instance-referenced", `${name} is launch.by`);
if (!old.launch.instances.includes(name)) return { ...read(business), transaction: null, delisted: false };
if (old.launch.instances.length === 1) {
refuse(`${name} is the last name in launch.instances; a business needs one instance the chief may launch`);
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
return { ...commit(business, old, next, { revision, actor, context }), delisted: true };
}
// The checks every write runs, then the write.
function commit(business, old, next, { revision, actor, context }) {
if (canonicalJson(next) === canonicalJson(old)) throw new OrganizationError("organization-no-change");
// The checks mosaic business validate runs, before the writer publishes anything, then the
@@ -151,9 +296,9 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null })
}
}
}
const receipt = writer.write(business, next, { revision: args.revision, actor });
return { ...read(business), transaction: receipt.transaction, restart: redefined };
const receipt = writer.write(business, next, { revision, actor, context });
return { ...read(business), transaction: receipt.transaction, restart: false };
}
return { writer, read, set };
return { writer, read, set, checkDelete, veto, hire, fire };
}
+4 -2
View File
@@ -174,7 +174,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
}
function write(id, document, { revision, actor }) {
// context, when given, is logged with the change: row 194's hire and fire carry the chief's run
// and the decision there (D2.2).
function write(id, document, { revision, actor, context }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
return withLock(id, ({ home, parent }) => {
@@ -200,7 +202,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
durable(candidate, bytes);
syncDir(tx);
const event = {
transaction, actor, business: id, before, after,
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
+29 -6
View File
@@ -4,11 +4,11 @@
// ceilings. Version 1 files still load: they carry no authority, so every
// vocabulary action is gated for them.
import { lstatSync } from "node:fs";
import { existsSync, lstatSync } from "node:fs";
import { basename, dirname, join } from "node:path";
import { refuse } from "./errors.mjs";
import {
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE, unhireableAction,
} from "./vocabulary.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
@@ -17,7 +17,7 @@ import {
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
@@ -75,6 +75,25 @@ function checkSkills(skills) {
});
}
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
export { unhireableAction };
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
// is validated without its own "hireable" check, so a list can't recurse.
function checkHireable(hireable, file) {
const dir = dirname(file);
return requireDistinctList(hireable, "role hireable", (name) => {
requireId(name, "role hireable entry");
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
});
}
function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
@@ -134,8 +153,9 @@ function checkContract(contract, file) {
// Validate a parsed role document read from `file`. Returns a frozen role:
// { roleVersion, name, title, contract, contractPath, tools, network,
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
export function validateRoleDocument(document, file) {
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
export function validateRoleDocument(document, file, options = {}) {
requireObject(document, "role");
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
@@ -147,7 +167,7 @@ export function validateRoleDocument(document, file) {
const network = document.network === undefined ? "none" : checkNetwork(document.network);
return deepFreeze({
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
});
}
for (const key of V2_REQUIRED) {
@@ -165,6 +185,9 @@ export function validateRoleDocument(document, file) {
credentials: checkCredentials(document.credentials),
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
skills: document.skills === undefined ? [] : checkSkills(document.skills),
hireable: document.hireable === undefined ? []
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
: checkHireable(document.hireable, file),
});
}
+33 -2
View File
@@ -35,6 +35,10 @@ export const ACTIONS = Object.freeze([
// Row 141 (#1635): sets a role instance's name. Routed under PRD 1.5 REQ-MSG-2, which has the chief of
// staff set names once its role file grants that within-role.
"instance.name",
// Row 194 (#1692): the chief of staff hires (adds and lists a role instance) and fires (ends its
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
"role.hire",
"role.fire",
"credential.mint",
"spend",
"deploy",
@@ -54,7 +58,7 @@ export const ACTIONS = Object.freeze([
// action joins this list only by the reviewed commit that names it routed.
// git.merge.protected, policy.change, prd.approve and role.revoke left the
// hard waits in 1.5 and route by REQ-DEC-2. instance.name (row 141) is routed
// under REQ-MSG-2 (1.5).
// under REQ-MSG-2 (1.5). role.hire and role.fire left the hard waits in 1.7 (REQ-COS-3).
export const ROUTED = Object.freeze([
"task.create",
"task.assign",
@@ -78,15 +82,42 @@ export const ROUTED = Object.freeze([
"policy.change",
"prd.approve",
"decision.resolve.technical",
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
"role.hire",
"role.fire",
]);
// The hard waits (PRD 1.5 REQ-WAIT-1): always gated, always the human's,
// cited spec or not. A role file may not list these under withinRole or
// crossRole (REQ-WAIT-2). Today this is spend, data.delete, network.expose,
// account.create, credential.mint, message.external, git.push.protected and
// account.create, credential.mint, message.external, git.push.protected,
// deploy; a test pins the list.
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
export const HIRE_ALLOWED = Object.freeze([
"message.send",
"record.write",
"review.request",
"task.update.assigned",
"task.scope.change",
"git.push.working",
]);
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
// use it. It lives here because the session sandbox mounts this file and not role.mjs
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
export function unhireableAction(role) {
for (const key of ["withinRole", "crossRole"]) {
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
if (outside !== undefined) return outside;
}
return null;
}
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
// accepts for tasks.
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
+33
View File
@@ -234,3 +234,36 @@ test("the shipped example refuses as written and validates once filled in", () =
assert.equal(business.tracker.sync.credentials.vikunja.file, join(root, ".config/mosaic-dev/secrets/mosaic-stack/sync-vikunja.token"));
assert.equal(id, 15);
});
// Row 194 (#1692, D2.5, T17): the per-agent forbid and the retired list.
test("row 194 keep, noHire and retired validate, and a file without them loads as before", () => {
const plain = check();
assert.equal(Object.hasOwn(plain.launch, "keep"), false);
assert.equal(Object.hasOwn(plain.launch, "noHire"), false);
assert.equal(Object.hasOwn(plain, "retired"), false);
const business = check((d) => {
d.launch.keep = ["coder", "chief"];
d.launch.noHire = ["reviewer", "no-such-role"];
d.retired = ["old-coder"];
});
assert.deepEqual(business.launch.keep, ["coder", "chief"]);
// A stale noHire entry is harmless: only the id pattern is checked.
assert.deepEqual(business.launch.noHire, ["reviewer", "no-such-role"]);
assert.deepEqual(business.retired, ["old-coder"]);
assert.ok(Object.isFrozen(business.launch.keep) && Object.isFrozen(business.retired));
assert.deepEqual(check((d) => { d.launch.keep = []; d.launch.noHire = []; d.retired = []; }).launch.keep, []);
});
test("row 194 each bad keep, noHire or retired entry refuses", () => {
refuses(() => check((d) => { d.launch.keep = ["ghost"]; }), /launch\.keep names ghost, which the business file doesn't declare/);
refuses(() => check((d) => { d.launch.keep = ["coder", "coder"]; }), /duplicate entry in .*launch\.keep/);
refuses(() => check((d) => { d.launch.keep = "coder"; }), /launch\.keep must be an array/);
refuses(() => check((d) => { d.roles.assistant = { definition: "assistant" }; d.launch.keep = ["assistant"]; }),
/launch\.keep names assistant, whose definition assistant can't be kept/);
refuses(() => check((d) => { d.launch.noHire = ["Bad Name"]; }), /launch\.noHire entry/);
refuses(() => check((d) => { d.launch.noHire = ["coder", "coder"]; }), /duplicate entry in .*launch\.noHire/);
refuses(() => check((d) => { d.retired = ["coder"]; }), /retired names coder, which roles still declares/);
refuses(() => check((d) => { d.retired = ["x", "x"]; }), /duplicate entry in .*retired/);
refuses(() => check((d) => { d.retired = ["../x"]; }), /retired entry/);
refuses(() => check((d) => { d.retired = {}; }), /retired must be an array/);
});
@@ -14,12 +14,15 @@ import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from ".
// Decision 104's list plus role.launch (decision 139) plus role.end and
// organization.change (row 123, REQ-COS-1) and record.write (row 138), plus instance.name
// (row 141, REQ-MSG-2, sage-0589). A role file that differs from it fails.
// (row 141, REQ-MSG-2, sage-0589), plus role.hire and role.fire (row 194, PRD 1.7 REQ-COS-3,
// sage-1471, mos-1089). A role file that differs from it fails.
const APPROVED = Object.freeze({
tools: ["read", "grep", "find", "ls"],
network: "api-only",
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"]), crossRole: Object.freeze([]) }),
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]),
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
hireable: Object.freeze([]),
});
// The fixture business's chief-of-staff instance, which is also its launcher.
@@ -85,9 +88,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
assert.deepEqual(record.credentials, {});
});
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write and instance.name; every other action is gated", () => {
test("the chief of staff holds role.launch, message.send, role.end, organization.change, record.write, instance.name, role.hire and role.fire; every other action is gated", () => {
const record = resolved(loadFixture());
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"];
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"];
for (const action of ACTIONS) {
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
}
@@ -139,6 +142,8 @@ test("the contract names no duty to talk to the human and the pm contract no lau
assert.doesNotMatch(chief, /main (agent|point of interaction)/i);
assert.doesNotMatch(chief, /\bJason\b/);
assert.match(chief, /never talk to the user/);
// Row 194 (#1692, D3 item 5, N3): a hire launches with no restart once the human sets its route.
assert.match(chief, /A hire is listed for `launch` at once, with no restart, but it\s+runs only after the human sets its route \(harness and thinking\)/);
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
assert.doesNotMatch(pm, /^- Launch /m);
assert.match(pm, /You don't launch sessions/);
@@ -33,6 +33,11 @@ test("REQ-ROLE-2: END_PROTECTED includes the assistant's thread definition", ()
assert.ok(Object.isFrozen(END_PROTECTED));
});
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire left the hard waits and are routed.
test("REQ-COS-3: role.hire and role.fire are routed, not hard waits", () => {
for (const action of ["role.hire", "role.fire"]) assert.ok(ROUTED.includes(action) && !GATED_ONLY.includes(action), action);
});
test("REQ-WAIT-3: every action is routed or a hard wait, never both, and the routed list names only actions", () => {
for (const action of ROUTED) assert.ok(ACTIONS.includes(action), action);
for (const action of ACTIONS) assert.notEqual(ROUTED.includes(action), GATED_ONLY.includes(action), action);
@@ -50,7 +50,7 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
const view = f.settings.read("acme");
assert.match(view.revision, /^[a-f0-9]{64}$/);
assert.equal(view.launch, true);
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false });
assert.deepEqual(view.instances.coder, { definition: "coder", vars: { harness: "pi", "limits.network": "none" }, launched: true, launcher: false, protected: false, kept: false });
assert.equal(view.instances.chief.launcher, true);
assert.equal(view.instances.assistant.protected, true);
assert.deepEqual(view.definitions["security-reviewer"], { services: [], accepted: true });
@@ -60,16 +60,16 @@ test("row 165 read lists each instance and the definitions add accepts", () => {
assert.equal(Object.hasOwn(view.definitions, "conductor-policy"), false);
});
test("row 165 add: two instances join, validate exits 0, the log names the human, and the bus restart is reported", () => {
test("row 165 add: two instances join, validate exits 0, the log names the human, and the writer asks for no restart (row 194, D3)", () => {
const f = fixture();
const view = f.settings.read("acme");
const added = f.settings.set("acme", { revision: view.revision, instance: "sec-1", op: "add", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launch: true }, "jason");
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false });
assert.deepEqual(added.instances["sec-1"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false });
assert.notEqual(added.revision, view.revision);
assert.match(added.transaction, /\S/);
assert.equal(added.restart, true);
assert.equal(added.restart, false);
const gate = f.settings.set("acme", { revision: added.revision, instance: "gate-1", op: "add", definition: "merge-gate" }, "jason");
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false });
assert.deepEqual(gate.instances["gate-1"], { definition: "merge-gate", vars: {}, launched: false, launcher: false, protected: false, kept: false });
const file = JSON.parse(readFileSync(f.file, "utf8"));
assert.deepEqual(file.roles["gate-1"], { definition: "merge-gate" });
assert.deepEqual(file.launch.instances, ["coder", "reviewer", "sec-1"]);
@@ -84,11 +84,11 @@ test("row 165 add: two instances join, validate exits 0, the log names the human
assert.equal(second.actor, "jason");
});
test("row 165 change: definition, vars and launch membership log before and after; only a definition change asks for a restart", () => {
test("row 165 change: definition, vars and launch membership log before and after; none asks for a restart, the bus reloads (row 194, D3)", () => {
const f = fixture(doc => { harnessOn(doc); doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi" } }; });
const redefined = f.settings.set("acme", { revision: f.settings.read("acme").revision, instance: "sec-1", op: "change", definition: "merge-gate" }, "jason");
assert.equal(redefined.instances["sec-1"].definition, "merge-gate");
assert.equal(redefined.restart, true);
assert.equal(redefined.restart, false);
let entry = f.log().at(-1);
assert.ok(entry.changes.some(c => c.path === "roles.sec-1.definition" && c.old === "security-reviewer" && c.new === "merge-gate"));
const revarred = f.settings.set("acme", { revision: redefined.revision, instance: "sec-1", op: "change", vars: { harness: "claude-code", model: "claude-opus-5-5" } }, "jason");
@@ -0,0 +1,228 @@
// Row 194 (#1692): the organization writer's delete (V5), the human's veto lists (V6) and the
// chief's hire and fire (D2.2). The broker's identity, decision and allowlist checks are bus tests.
// A refused write leaves the file's bytes alone.
import test, { after } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { instanceSettings } from "../src/instance-settings.mjs";
import { businessDoc, REPO, rolesCopy, systemConfig, systemFor, tmp, writeJson } from "./helpers.mjs";
const CLI = join(REPO, "packages", "business", "src", "cli.mjs");
const roots = [];
after(() => roots.forEach(root => rmSync(root, { recursive: true, force: true })));
const T0 = "2026-10-18T07:00:00.000Z";
const CONTEXT = Object.freeze({ instance: "chief", run: "run-1", reason: "a security review is due" });
// sec-1 is a listed security-reviewer: no credential, no tracker bot, so the writer may delete it.
function fixture(mutate) {
const root = tmp();
roots.push(root);
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, "acme");
doc.roles["sec-1"] = { definition: "security-reviewer" };
doc.launch.instances.push("sec-1");
mutate?.(doc, root);
const dir = join(root, "config");
const file = writeJson(join(dir, "businesses", "acme.json"), doc);
const config = systemConfig(root);
const settings = instanceSettings({ dir, rolesDir, system: systemFor(root) });
const validate = () => spawnSync(process.execPath, [CLI, "validate", "acme"], {
env: { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: config, MOSAIC_ROLES_DIR: rolesDir }, encoding: "utf8",
});
const log = () => readFileSync(settings.writer.logFile("acme"), "utf8").trim().split("\n").map(line => JSON.parse(line));
const onDisk = () => JSON.parse(readFileSync(file, "utf8"));
const revision = () => settings.read("acme").revision;
return { root, file, settings, validate, log, onDisk, revision };
}
// Each refusal: its code (or the validator's BusinessError), and the file's bytes unchanged.
function refuses(f, act, code) {
const bytes = readFileSync(f.file, "utf8");
assert.throws(act, e => (code === "BusinessError" ? e.name === code : e.code === code), code);
assert.equal(readFileSync(f.file, "utf8"), bytes, code);
}
const del = (f, instance, extra = {}) => () => f.settings.set("acme", { revision: f.revision(), instance, op: "delete", ...extra }, "jason");
const veto = (f, op, name, actor = "human") => f.settings.veto("acme", { revision: f.revision(), op, name }, actor);
const hire = (f, args) => f.settings.hire("acme", args, { actor: "chief", context: CONTEXT });
const fire = (f, name) => f.settings.fire("acme", { name }, { actor: "chief", context: CONTEXT });
test("T6 delete refuses in the design's order and leaves the file's bytes alone", () => {
const f = fixture(doc => {
doc.roles.assistant = { definition: "assistant", holder: "mos" };
doc.roles["sec-3"] = { definition: "security-reviewer" };
doc.launch.keep = ["sec-3", "coder"];
});
refuses(f, del(f, "ghost"), "organization-unknown-instance");
refuses(f, del(f, "assistant"), "organization-definition-refused");
refuses(f, del(f, "chief"), "organization-instance-referenced");
refuses(f, del(f, "pm"), "organization-instance-referenced");
refuses(f, del(f, "cto"), "organization-instance-referenced");
assert.throws(del(f, "cto"), /cto is arbiters\.technical; change that first/);
refuses(f, del(f, "sec-3"), "organization-instance-kept");
// coder declares credentials and is kept: kept is checked first, so the veto is named.
refuses(f, del(f, "coder"), "organization-instance-kept");
refuses(f, del(f, "reviewer"), "organization-credentials-out-of-scope");
refuses(f, del(f, "sec-1", { definition: "security-reviewer" }), "invalid-request");
// checkDelete runs the same checks with no write.
refuses(f, () => f.settings.checkDelete("acme", "sec-3"), "organization-instance-kept");
const bytes = readFileSync(f.file, "utf8");
assert.deepEqual(f.settings.checkDelete("acme", "sec-1"), { revision: f.revision() });
assert.equal(readFileSync(f.file, "utf8"), bytes);
});
test("T6 delete refuses the last listed instance", () => {
const f = fixture(doc => { doc.launch.instances = ["sec-1"]; });
refuses(f, del(f, "sec-1"), "BusinessError");
refuses(f, () => f.settings.checkDelete("acme", "sec-1"), "BusinessError");
});
test("T6 a clean delete removes the entry and its own references, adds retired, and the name can't come back", () => {
const f = fixture(doc => { doc.launch.limits.tokens = { instances: { "sec-1": { limit: 100, since: T0 }, coder: { limit: 50, since: T0 } } }; });
const done = del(f, "sec-1")();
assert.equal(Object.hasOwn(done.instances, "sec-1"), false);
assert.deepEqual(done.retired, ["sec-1"]);
assert.equal(done.restart, false);
const file = f.onDisk();
assert.equal(Object.hasOwn(file.roles, "sec-1"), false);
assert.deepEqual(file.launch.instances, ["coder", "reviewer"]);
assert.deepEqual(file.launch.limits.tokens.instances, { coder: { limit: 50, since: T0 } });
assert.deepEqual(file.retired, ["sec-1"]);
const run = f.validate();
assert.equal(run.status, 0, run.stderr);
const entry = f.log().at(-1);
assert.equal(entry.actor, "jason");
assert.equal(entry.transaction, done.transaction);
assert.ok(entry.changes.some(c => c.path === "roles.sec-1" && c.newPresent === false));
assert.ok(entry.changes.some(c => c.path === "retired" && c.oldPresent === false));
refuses(f, () => f.settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "add", definition: "security-reviewer" }, "jason"),
"organization-instance-retired");
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-retired");
refuses(f, del(f, "sec-1"), "organization-unknown-instance");
});
test("T7 veto: keep, unkeep, nohire and hire each write once, a repeat is no change, and the log names the human", () => {
const f = fixture();
let view = veto(f, "keep", "sec-1");
assert.equal(view.instances["sec-1"].kept, true);
assert.equal(view.restart, false);
assert.deepEqual(f.onDisk().launch.keep, ["sec-1"]);
let entry = f.log().at(-1);
assert.equal(entry.actor, "human");
assert.ok(entry.changes.some(c => c.path === "launch.keep" && c.oldPresent === false && c.new.includes("sec-1")));
refuses(f, () => veto(f, "keep", "sec-1"), "organization-no-change");
view = veto(f, "unkeep", "sec-1");
assert.equal(view.instances["sec-1"].kept, false);
assert.deepEqual(f.onDisk().launch.keep, []);
refuses(f, () => veto(f, "unkeep", "sec-1"), "organization-no-change");
view = veto(f, "nohire", "merge-gate");
assert.deepEqual(view.noHire, ["merge-gate"]);
// A name with no role file is accepted: the list forbids, it doesn't declare.
view = veto(f, "nohire", "no-such-role");
assert.deepEqual(view.noHire, ["merge-gate", "no-such-role"]);
refuses(f, () => veto(f, "nohire", "merge-gate"), "organization-no-change");
view = veto(f, "hire", "merge-gate");
assert.deepEqual(view.noHire, ["no-such-role"]);
refuses(f, () => veto(f, "hire", "merge-gate"), "organization-no-change");
entry = f.log().at(-1);
assert.equal(entry.actor, "human");
assert.ok(entry.changes.some(c => c.path === "launch.noHire"));
assert.equal(f.validate().status, 0);
});
test("T7 veto refuses an unknown or END_PROTECTED keep, a bad request, a stale revision and a business without launch", () => {
const f = fixture(doc => { doc.roles.assistant = { definition: "assistant", holder: "mos" }; });
refuses(f, () => veto(f, "keep", "ghost"), "organization-unknown-instance");
refuses(f, () => veto(f, "keep", "assistant"), "organization-definition-refused");
const revision = f.revision();
for (const bad of [
{ revision, op: "keep" }, { revision, op: "forbid", name: "sec-1" }, { revision, op: "keep", name: "Bad Name" },
{ revision: "stale", op: "keep", name: "sec-1" }, { revision, op: "keep", name: "sec-1", by: "chief" },
]) refuses(f, () => f.settings.veto("acme", bad, "human"), "invalid-request");
refuses(f, () => f.settings.veto("acme", null, "human"), "invalid-request");
veto(f, "keep", "sec-1");
refuses(f, () => f.settings.veto("acme", { revision, op: "nohire", name: "coder" }, "human"), "revision-conflict");
const none = fixture(doc => { delete doc.launch; doc.roles["sec-1"] = { definition: "security-reviewer" }; });
refuses(none, () => veto(none, "nohire", "coder"), "organization-launch-not-configured");
});
test("D2.2 hire adds and lists a new instance, logs the chief's context, and sets only model and limits", () => {
const f = fixture();
const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
assert.deepEqual(done.instances["sec-2"], {
definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false,
});
assert.equal(done.restart, false);
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]);
const entry = f.log().at(-1);
assert.equal(entry.actor, "chief");
assert.deepEqual(entry.context, CONTEXT);
assert.equal(entry.transaction, done.transaction);
assert.equal(f.validate().status, 0);
for (const vars of [{ harness: "pi" }, { model: "claude-sonnet-5-5", thinking: "high" }, []]) {
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars }), "invalid-request");
}
for (const args of [{ name: "Bad Name", definition: "security-reviewer" }, { name: "human", definition: "security-reviewer" },
{ name: "tester", definition: "security-reviewer" }, { name: "sec-3", definition: "Bad Name" }]) {
refuses(f, () => hire(f, args), "invalid-request");
}
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars: { model: "not-in-the-table" } }), "BusinessError");
refuses(f, () => hire(f, { name: "coder-2", definition: "coder" }), "organization-credentials-out-of-scope");
refuses(f, () => hire(f, { name: "asst-2", definition: "assistant" }), "organization-definition-refused");
refuses(f, () => hire(f, { name: "sec-3", definition: "no-such-role" }), "BusinessError");
});
test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fired one only as it was", () => {
const f = fixture(doc => {
doc.launch.noHire = ["merge-gate"];
doc.roles["sec-2"] = { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } };
doc.roles["sec-3"] = { definition: "security-reviewer" };
doc.launch.keep = ["sec-3"];
});
refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable");
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept");
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists");
refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists");
// sec-2 is declared and not listed: fired. Only its own definition with no vars lists it again.
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "definition-not-hireable");
veto(f, "hire", "merge-gate");
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "organization-instance-exists");
refuses(f, () => hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-opus-5-5" } }), "organization-instance-exists");
const done = hire(f, { name: "sec-2", definition: "security-reviewer" });
assert.equal(done.instances["sec-2"].launched, true);
assert.deepEqual(f.onDisk().roles["sec-2"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
const none = fixture(doc => { delete doc.launch; });
refuses(none, () => hire(none, { name: "sec-9", definition: "security-reviewer" }), "organization-launch-not-configured");
});
test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and each refusal holds", () => {
const f = fixture(doc => {
doc.roles.assistant = { definition: "assistant", holder: "mos" };
doc.roles["sec-3"] = { definition: "security-reviewer" };
doc.launch.instances.push("sec-3");
doc.launch.keep = ["sec-3"];
});
const done = fire(f, "sec-1");
assert.equal(done.delisted, true);
assert.equal(done.instances["sec-1"].launched, false);
assert.deepEqual(f.onDisk().roles["sec-1"], { definition: "security-reviewer" });
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-3"]);
const entry = f.log().at(-1);
assert.equal(entry.actor, "chief");
assert.deepEqual(entry.context, CONTEXT);
const bytes = readFileSync(f.file, "utf8");
const again = fire(f, "sec-1");
assert.equal(again.delisted, false);
assert.equal(again.transaction, null);
assert.equal(readFileSync(f.file, "utf8"), bytes);
refuses(f, () => fire(f, "ghost"), "organization-unknown-instance");
refuses(f, () => fire(f, "Bad Name"), "organization-unknown-instance");
refuses(f, () => fire(f, "sec-3"), "instance-kept");
refuses(f, () => fire(f, "assistant"), "organization-definition-refused");
refuses(f, () => fire(f, "chief"), "organization-instance-referenced");
assert.equal(f.validate().status, 0);
const one = fixture(doc => { doc.launch.instances = ["sec-1"]; });
refuses(one, () => fire(one, "sec-1"), "BusinessError");
});
+68 -1
View File
@@ -2,7 +2,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { loadRole, loadRoleFile, validateRoleDocument, GATED_ONLY, BusinessError } from "../src/index.mjs";
import { loadRole, loadRoleFile, validateRoleDocument, ACTIONS, GATED_ONLY, HIRE_ALLOWED, BusinessError } from "../src/index.mjs";
import { REPO_ROLES, tmp, writeJson } from "./helpers.mjs";
function refuses(fn, pattern, exitCode = 2) {
@@ -74,7 +74,13 @@ test("shipped authority follows the note's table", () => {
for (const name of others.map((f) => f.slice(0, -5))) {
assert.ok(!auth(name).withinRole.includes("instance.name"), name);
assert.ok(!auth(name).crossRole.includes("instance.name"), name);
// Row 194 (PRD 1.7 REQ-COS-3, sage-1471): hire and fire are the chief's alone too.
for (const action of ["role.hire", "role.fire"]) {
assert.ok(!auth(name).withinRole.includes(action), `${name} ${action}`);
assert.ok(!auth(name).crossRole.includes(action), `${name} ${action}`);
}
}
assert.ok(auth("chief-of-staff").withinRole.includes("role.hire") && auth("chief-of-staff").withinRole.includes("role.fire"));
assert.ok(auth("cto").withinRole.includes("decision.resolve.technical"));
assert.ok(!auth("pm").withinRole.includes("decision.resolve.technical"));
});
@@ -215,3 +221,64 @@ test("contract: a non-empty regular Markdown file beside the role file", () => {
symlinkSync("r.md", join(dir, "link.md"));
refuses(() => validateRoleDocument(doc, file), /non-empty regular file/);
});
// Row 194 (#1692, D2.3, T8c): the chief's hireable list, checked at load against HIRE_ALLOWED.
// Each refused action is looped from the lists, not typed by hand.
const REFUSED_FOR_HIRE = ACTIONS.filter((action) => !HIRE_ALLOWED.includes(action) && !GATED_ONLY.includes(action));
function hireDir(definitions) {
const dir = tmp();
for (const [name, doc] of Object.entries(definitions)) {
writeFileSync(join(dir, `${name}.md`), "# contract\n");
writeJson(join(dir, `${name}.json`), doc === 1 ? { roleVersion: 1, name, tools: ["read"] } : {
roleVersion: 2, name, title: name, contract: `${name}.md`, tools: ["read"], network: "none",
authority: { withinRole: doc.withinRole ?? ["message.send"], crossRole: doc.crossRole ?? [] }, credentials: [],
...(doc.hireable === undefined ? {} : { hireable: doc.hireable }),
});
}
return dir;
}
test("hireable: HIRE_ALLOWED is exactly the six plain work actions, and every one is routed", () => {
assert.deepEqual([...HIRE_ALLOWED], ["message.send", "record.write", "review.request", "task.update.assigned",
"task.scope.change", "git.push.working"]);
assert.ok(Object.isFrozen(HIRE_ALLOWED));
for (const action of HIRE_ALLOWED) assert.ok(ACTIONS.includes(action) && !GATED_ONLY.includes(action), action);
assert.ok(!HIRE_ALLOWED.includes("git.push.remote"));
assert.ok(REFUSED_FOR_HIRE.length >= 15, "the loop covers the routed actions outside the list");
});
test("hireable: a list naming a definition that holds any refused action refuses at load, one case per action", () => {
for (const action of REFUSED_FOR_HIRE) {
for (const key of ["withinRole", "crossRole"]) {
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
}
}
});
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
const clean = hireDir({
chief: { hireable: ["worker", "helper"] },
worker: { withinRole: [...HIRE_ALLOWED] },
// helper's own list names a definition that doesn't exist. Only the chief's list is checked.
helper: { withinRole: ["record.write"], hireable: ["nobody"] },
});
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
});
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
refuses(() => loadRole(hireDir({ chief: { hireable: "w" } }), "chief"), /role hireable must be an array/);
assert.deepEqual(loadRole(hireDir({ chief: {} }), "chief").hireable, []);
assert.deepEqual(loadRole(REPO_ROLES, "chief-of-staff").hireable, []);
// A version 1 file can't carry the key.
refuses(() => validateRoleDocument({ roleVersion: 1, name: "r", tools: ["read"], hireable: [] }, join(tmp(), "r.json")), /unsupported role key/);
});
+1 -1
View File
@@ -576,7 +576,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 165 (#1662): scripts/mosaic sends only `business instances` and `business instance` here.
// Row 165 (#1662): scripts/mosaic sends only `business instances`, `business instance` and (row 194) `business veto` here.
if (verb === "business") return instancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === "talk") return talk(rest, io, deps);
if (verb === "follow") return talk(rest, io, deps, true);
+133 -6
View File
@@ -3,12 +3,19 @@
// and instances.set verbs, and with --tester the tester does (row 175, #1673, Q173). The broker
// refuses an agent and a reader; the business package's instance writer refuses a stale revision
// and every document `mosaic business validate` would refuse, and logs the change with its actor.
// Row 194 (#1692, D1): `business instance launch|terminate|regenerate|delete` and `business veto`.
// Launch, regenerate and delete are requests the launcher's pass carries out. The command polls
// instance.request until the request closes or --wait runs out (D1.1 item 5).
import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
export const INSTANCES_USAGE = "mosaic business instances BUSINESS [--json] [--tester] | " +
"business instance add BUSINESS INSTANCE --definition DEF [--vars FILE] [--launch] --revision REV [--tester] | " +
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester]";
"business instance set BUSINESS INSTANCE [--definition DEF] [--vars FILE] [--launch | --no-launch] --revision REV [--tester] | " +
"business instance launch|regenerate BUSINESS INSTANCE [--wait SECONDS] [--tester] | " +
"business instance terminate BUSINESS INSTANCE [--reason TEXT] [--tester] | " +
"business instance delete BUSINESS INSTANCE --revision REV [--wait SECONDS] | " +
"business veto BUSINESS keep|unkeep INSTANCE --revision REV | business veto BUSINESS nohire|hire DEFINITION --revision REV";
// The bus answers a refusal with its code alone, so the reason a human reads is here.
export const REASONS = {
@@ -23,8 +30,32 @@ export const REASONS = {
"organization-project-mismatch": "a project file's id differs from its key in the business file; `mosaic business validate` shows it",
"organization-unresolvable-instance": "an instance wouldn't resolve against a project file; `mosaic business validate` shows why",
"revision-conflict": "the business file changed since that revision; read it again with `mosaic business instances`",
// Row 194 (#1692): the lifecycle and veto refusals.
"organization-instance-referenced": "the instance is launch.by or an arbiter; change that reference first",
"organization-instance-kept": "the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first",
"organization-instance-retired": "that name was deleted and stays retired; pick another name",
"instance-running": "the instance has a run; end it with `mosaic business instance terminate` first",
"instance-starting": "a request for the instance has not closed yet; wait for it and try again",
"instance-retired": "that name was deleted and launches no more",
"not-thread-launch": "a thread instance starts on a thread message, not by launch",
"regenerate-thread": "a thread instance isn't regenerated; its context is the thread's",
"not-held": "the instance has no run and no open request",
"launch-revoked": "launches are revoked for the business; `mosaic launches on --business BUSINESS` lifts it",
"launches-disabled": "the bus host was started with --no-launch",
"request-expired": "the launcher didn't take the request within its 60 s",
"regenerate-timeout": "the old run didn't exit within the request's 60 s, so no new run started; it still runs",
"workspace-rename-failed": "the business file is changed and the name retired, but the workspace stayed where it was; " +
"run the same delete again to retry the rename",
"capacity-full": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"limit-reached": "a launch limit is reached; `mosaic limits --business BUSINESS` shows them",
"unknown-role": "the business has no such instance",
};
const LIFECYCLE_OPS = ["launch", "terminate", "regenerate", "delete"];
const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
const WAIT_SECONDS = 30;
const POLL_MS = 200;
export const RESTART_NEEDED = "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it";
const NO_RESTART = "no restart: the launcher reads the change at its next launch check";
@@ -35,7 +66,7 @@ function parse(args) {
if (["--json", "--launch", "--no-launch", "--tester"].includes(arg)) {
if (out.flags.has(arg)) throw new CliError(INSTANCES_USAGE, 4);
out.flags.add(arg);
} else if (["--definition", "--vars", "--revision"].includes(arg)) {
} else if (["--definition", "--vars", "--revision", "--wait", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(INSTANCES_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(INSTANCES_USAGE, 4);
@@ -48,6 +79,7 @@ function parse(args) {
function setArgs(parsed) {
const [, op, , instance] = parsed.positional;
const { values, flags } = parsed;
if (values["--wait"] !== undefined || values["--reason"] !== undefined) throw new CliError(INSTANCES_USAGE, 4);
const launch = flags.has("--launch") ? true : flags.has("--no-launch") ? false : undefined;
if (parsed.positional.length !== 4 || !["add", "set"].includes(op) || !values["--revision"] || flags.has("--json") ||
(flags.has("--launch") && flags.has("--no-launch")) || (op === "add" && (!values["--definition"] || launch === false)) ||
@@ -67,10 +99,89 @@ function setArgs(parsed) {
return args;
}
// Row 194: the lifecycle and veto args, or null when `instance OP` is add or set. Usage errors exit 4.
function lifecycleArgs(parsed) {
const { positional, values, flags } = parsed;
const [sub, op, , name] = positional;
const only = (allowed) => Object.keys(values).every((k) => allowed.includes(k)) && [...flags].every((f) => allowed.includes(f));
let wait = WAIT_SECONDS;
if (values["--wait"] !== undefined) {
if (!/^[0-9]{1,3}$/.test(values["--wait"]) || Number(values["--wait"]) > 600) throw new CliError(INSTANCES_USAGE, 4);
wait = Number(values["--wait"]);
}
if (sub === "veto") {
const [, , vop, target] = positional;
if (positional.length !== 4 || !VETO_OPS.includes(vop) || !values["--revision"] || !only(["--revision"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "veto.set", args: { revision: values["--revision"], op: vop, name: target } };
}
if (!LIFECYCLE_OPS.includes(op)) return null;
if (positional.length !== 4) throw new CliError(INSTANCES_USAGE, 4);
if (op === "terminate") {
if (!only(["--reason", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "instance.end", args: { instance: name, ...(values["--reason"] !== undefined ? { reason: values["--reason"] } : {}) } };
}
if (op === "delete") {
if (!values["--revision"] || !only(["--revision", "--wait"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: "instances.set", args: { revision: values["--revision"], instance: name, op: "delete" }, wait };
}
if (!only(["--wait", "--tester"])) throw new CliError(INSTANCES_USAGE, 4);
return { verb: `instance.${op}`, args: { instance: name }, wait };
}
// D1.1 item 5: polls instance.request until the request closes or `wait` seconds pass. A closed
// request with a refusal or a cancel is exit 3 (refused) with its code. Still open or taken at the
// end is exit 3 with the request id, as the design's timeout is: a taken request with no done is
// outcome-unknown.
async function follow(call, request, wait) {
const end = Date.now() + wait * 1000;
for (;;) {
const r = await call("instance.request", { request });
if (r.state === "closed") {
if (r.cancelled) throw refusal("request-cancelled", `cancelled by ${r.cancelled.by} (request ${request})`);
if (r.done?.refusal) {
const kept = r.done.transaction ? `, transaction ${r.done.transaction}` : "";
throw refusal(r.done.refusal, `${REASONS[r.done.refusal] ?? "the launcher refused the request"} (request ${request}${kept})`);
}
return r;
}
if (Date.now() >= end) {
const error = r.state === "taken"
? new CliError(`outcome-unknown: request ${request} was taken and has not closed after ${wait} s; the launch log names any run it started`, 3)
: new CliError(`request ${request} is still open after ${wait} s; the launcher takes it within its 60 s or closes it request-expired`, 3);
error.code = r.state === "taken" ? "outcome-unknown" : "request-open";
error.final = true;
throw error;
}
await new Promise((resolve) => setTimeout(resolve, Math.min(POLL_MS, Math.max(0, end - Date.now()))));
}
}
// `final`: the catch below passes it on as it is, with its request id.
function refusal(code, why) {
const error = new CliError(`${code}: ${why}`, 3);
error.code = code;
error.final = true;
return error;
}
function lifecycleLine(business, op, instance, r) {
if (op === "delete") return `business ${business}: deleted ${instance}, now retired\ntransaction ${r.done.transaction}\n`;
return `business ${business}: ${op === "launch" ? "launched" : "regenerated"} ${instance}, run ${r.done.run} (request ${r.request})\n`;
}
function terminateLines(business, instance, out) {
const lines = [`business ${business}: ended ${instance} (request ${out.request})`];
lines.push(out.runs.length ? `runs ${out.runs.join(", ")}: claims revoked, the launcher signals each` : "no run held a claim");
if (out.cancelled.length) lines.push(`cancelled ${out.cancelled.join(", ")}`);
if (out.taken.length) lines.push(`taken ${out.taken.join(", ")}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`);
return `${lines.join("\n")}\n`;
}
export function formatInstances(view) {
const lines = [`business ${view.business}, revision ${view.revision}`];
for (const [name, entry] of Object.entries(view.instances)) {
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected"].filter(Boolean);
const marks = [entry.launcher && "launcher", entry.launched && "launched", entry.protected && "protected", entry.kept && "kept",
entry.running?.length && `running ${entry.running.join(", ")}`].filter(Boolean);
const vars = Object.keys(entry.vars).length ? ` vars ${JSON.stringify(entry.vars)}` : "";
lines.push(` ${name}: ${entry.definition}${marks.length ? ` (${marks.join(", ")})` : ""}${vars}`);
}
@@ -79,6 +190,8 @@ export function formatInstances(view) {
const bound = definitions.filter(([, d]) => d.services.length).map(([name, d]) => `${name} (${d.services.join(", ")})`);
lines.push(`definitions add and set accept: ${accepted.join(", ") || "none"}`);
if (bound.length) lines.push(`definitions that declare credentials, a hand edit: ${bound.join(", ")}`);
if (view.retired?.length) lines.push(`retired: ${view.retired.join(", ")}`);
if (view.noHire?.length) lines.push(`the chief may not hire: ${view.noHire.join(", ")}`);
return `${lines.join("\n")}\n`;
}
@@ -86,16 +199,29 @@ export function formatInstances(view) {
export async function instancesCommand(args, io, callFor) {
const parsed = parse(args);
const sub = parsed.positional[0];
const business = parsed.positional[sub === "instances" ? 1 : 2];
if (!["instances", "instance"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
const business = parsed.positional[sub === "instance" ? 2 : 1];
if (!["instances", "instance", "veto"].includes(sub) || !business) throw new CliError(INSTANCES_USAGE, 4);
let request;
if (sub === "instances") {
const lifecycle = sub === "instances" ? null : lifecycleArgs(parsed);
if (lifecycle) {
// veto and the lifecycle verbs, below.
} else if (sub === "instances") {
if (parsed.positional.length !== 2 || Object.keys(parsed.values).length || parsed.flags.has("--launch") || parsed.flags.has("--no-launch")) {
throw new CliError(INSTANCES_USAGE, 4);
}
} else request = setArgs(parsed);
try {
const call = callFor({ positional: parsed.positional, flags: parsed.flags, values: { "--business": business } });
if (lifecycle) {
const out = await call(lifecycle.verb, lifecycle.args);
if (sub === "veto") {
return io.stdout.write(`business ${out.business}: veto ${lifecycle.args.op} ${lifecycle.args.name}\nrevision ${out.revision}\ntransaction ${out.transaction}\n`);
}
const op = parsed.positional[1];
if (op === "terminate") return io.stdout.write(terminateLines(business, lifecycle.args.instance, out));
io.stderr.write(`mosaic: request ${out.request}, waiting up to ${lifecycle.wait} s\n`);
return io.stdout.write(lifecycleLine(business, op, lifecycle.args.instance, await follow(call, out.request, lifecycle.wait)));
}
if (!request) {
const view = await call("instances", {});
return io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(view, null, 2)}\n` : formatInstances(view));
@@ -109,6 +235,7 @@ export async function instancesCommand(args, io, callFor) {
result.restart ? RESTART_NEEDED : NO_RESTART,
].join("\n") + "\n");
} catch (error) {
if (error.final) throw error;
const code = error.code;
if (Object.hasOwn(REASONS, code)) {
const refused = new CliError(`${code}: ${REASONS[code]}`, error.exitCode ?? 2);
+167 -13
View File
@@ -95,10 +95,30 @@
//
// Nothing here is persisted. After a restart recover() ends the earlier
// host's runs, so the first tick starts each holder.
//
// Row 194 (#1692, D1.1): the lifecycle pass, on the same tick, carries out the
// human's and the tester's requests the broker recorded (launch, end,
// regenerate, delete). The broker never starts a process or touches a
// workspace. Under serialize, each open request is taken (host op
// lifecycleTake, which re-checks it and closes it with a code when it may not
// run), then acted on, then closed with lifecycleDone. A taken request never
// runs again, whatever happens after the take. A done that throws leaves it
// taken with no done, which the CLI reports as outcome-unknown.
// - launch: check() and start() with `human`, so an unlisted instance runs.
// - end: SIGTERM to each named run's runner after the start-time check, and
// to any other run of the instance in the registry. The exit handler
// records the end. No SIGKILL here (row 191, S6).
// - regenerate: the end's signals, then a new run once the old one has
// left the registry. Not gone by the request's 60 s mark closes it
// regenerate-timeout and starts nothing.
// - delete: instance-running against the registry, then lifecycleDelete
// (the writer's commit), then the rename of workspaces/<b>/I/ to
// workspaces/<b>/.retired/I-<transaction>/. A failed rename closes it
// workspace-rename-failed with the transaction; a later delete retries it.
import { createServer } from "node:net";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
import { limitHit } from "../../business/src/launch.mjs";
import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
@@ -133,6 +153,8 @@ const LINE_MAX = 4096;
const THREAD_TICK_MS = 2000;
// The broker's thread instance definition (packages/bus/src/broker.mjs).
const THREAD_DEFINITION = "assistant";
// Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then.
const LIFECYCLE_REQUEST_MS = 60000;
// A refusal the host decided; `code` goes back to the caller and to the broker.
class Refusal extends Error {
@@ -154,13 +176,14 @@ function call(name, what, fn) {
// host: startHost()'s handle. tracker: true when the broker has task verbs for
// the business. launches: false for `bus start --no-launch` (row 163, the D2
// rehearsal): start() refuses launches-disabled, listen() starts no ticker, a
// rehearsal): start() refuses launches-disabled, the ticker runs only the
// lifecycle pass (row 194), which closes each request launches-disabled, a
// session's launch request is refused, recover() kills and ends nothing, and
// sessions.json stays as it was.
// makeLoader (the plug-in loader's factory, which tests point at a
// scratch tree), bwrap, readOnly, sessionDefaults, harnessVersion,
// stopTimeoutMs, threadTickMs and git (createGitSocket options) exist for
// the tests; the command line never sets them.
// stopTimeoutMs, threadTickMs, lifecycleRequestMs and git (createGitSocket
// options) exist for the tests; the command line never sets them.
export function createLauncher({
host,
system,
@@ -174,6 +197,7 @@ export function createLauncher({
harnessVersion = undefined,
stopTimeoutMs = STOP_TIMEOUT_MS,
threadTickMs = THREAD_TICK_MS,
lifecycleRequestMs = LIFECYCLE_REQUEST_MS,
git = {},
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
}) {
@@ -209,6 +233,7 @@ export function createLauncher({
const refusedLand = new Map();
const refusedRelease = new Map();
let refusedHolders = null;
let refusedRequests = null;
const sockets = new Set();
const gitSocket = createGitSocket({ host, business: b, dataRoot, env, log, ...git });
@@ -228,11 +253,13 @@ export function createLauncher({
// Steps 2 to 9. Returns what the launch needs. Only `--pm` may name
// launch.by itself. A thread instance launches only for a thread, and
// nothing else launches for one.
async function check(instance, { pm = false, thread = null } = {}) {
// nothing else launches for one. Row 194 (#1692, V2): `human` is the
// human's launch request, which may name any declared instance. The listing
// limits the chief, not the human. launch.by still takes the `pm` branch.
async function check(instance, { pm = false, thread = null, human = false } = {}) {
try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance))) throw new Refusal("instance-not-listed");
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
@@ -327,7 +354,7 @@ export function createLauncher({
}
// Bundle, spawn, bind, register, hand over the capability.
async function start({ instance, launchedBy, resolved, model, account = null, thread = null }) {
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, human = false }) {
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch");
// A thread's store is checked first, so a bad id or an unsafe directory
@@ -360,7 +387,7 @@ export function createLauncher({
else if (c.outcome === "fetched") record({ event: "checkout-fetched", instance, project: c.project, reason: c.reason });
}
let harness, route, routeReason;
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread}));
({resolved,model,account,harness,route,routeReason}=await check(instance,{pm:instance===b.launch.by,thread,human}));
const { run, runDir } = newRun(dataRoot, b.id);
const sessionDir = store ? store.session : join(runDir, "session");
if (!store) mkdirSync(sessionDir, { mode: 0o700 });
@@ -489,8 +516,12 @@ export function createLauncher({
if (ticking || closed) return;
ticking = true;
try {
await threadPass();
await landPass();
// A --no-launch host runs only the lifecycle pass, which closes each request launches-disabled.
if (launches) {
await threadPass();
await landPass();
}
await lifecyclePass();
} finally {
ticking = false;
}
@@ -610,6 +641,129 @@ export function createLauncher({
}
}
// Row 194 (#1692, D1.1): the human's and the tester's lifecycle requests, oldest first.
async function lifecyclePass() {
let requests;
try {
requests = await host.op({ op: "lifecycleRequests", business: b.id });
if (!Array.isArray(requests)) throw new Refusal("malformed-answer");
refusedRequests = null;
} catch (e) {
const code = e.code ?? e.message;
if (refusedRequests !== code) log(`lifecycleRequests refused: ${code}`);
refusedRequests = code;
return;
}
for (const r of requests) {
if (closed) return;
// A taken request is past this pass: it ran, or its done failed.
if (r.state !== "open") continue;
await serialize(() => carry(r));
}
}
// One request: the take, the act, the done. Runs under serialize, so no start() runs alongside it.
async function carry(r) {
if (closed) return;
let take;
try {
take = await host.op({ op: "lifecycleTake", record: { business: b.id, request: r.request } });
} catch (e) {
log(`request ${r.request} (${r.instance}): lifecycleTake refused: ${e.code ?? e.message}`);
return;
}
if (take?.go !== true) {
if (take?.refusal && take.refusal !== "request-closed") record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: take.refusal });
return;
}
let done;
try {
done = await act(take);
} catch (e) {
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
}
const { why, limit, ...closing } = done;
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
try {
await host.op({ op: "lifecycleDone", record: { business: b.id, request: r.request, ...closing } });
} catch (e) {
log(`request ${r.request} (${r.instance}): lifecycleDone refused: ${e.code ?? e.message}${closing.run ? `; run ${closing.run} started` : ""}`);
}
}
// What the request did: { run } for a start, { transaction } for a delete, {} for an end, or { refusal }.
async function act(r) {
// Row 163: a --no-launch host starts, signals and renames nothing.
if (!launches) return { refusal: "launches-disabled" };
const launchedBy = r.by === "tester" ? "tester" : b.human;
if (r.action === "lifecycle.launch") return launch(r.instance, launchedBy);
if (r.action === "lifecycle.end") {
terminate(r.instance, r.runs ?? []);
return {};
}
if (r.action === "lifecycle.regenerate") {
terminate(r.instance, []);
const deadline = Date.parse(r.at) + lifecycleRequestMs;
while (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) {
if (closed) return { refusal: "host-closing" };
if (Date.now() >= deadline) return { refusal: "regenerate-timeout" };
await new Promise((resolve) => setTimeout(resolve, 50));
}
return launch(r.instance, launchedBy);
}
if (r.action === "lifecycle.delete") return remove(r);
return { refusal: "invalid-request" };
}
async function launch(instance, launchedBy) {
const pm = instance === b.launch?.by;
const { run } = await start({ instance, launchedBy, human: true, ...(await check(instance, { pm, human: true })) });
return { run };
}
// SIGTERM, after the start-time check, to the named runs and to every other run of the instance
// the registry holds. A thread instance's end names only the runs it revoked (the tester's own).
function terminate(instance, runs) {
const threaded = b.roles[instance]?.definition === THREAD_DEFINITION;
for (const s of registry.sessions) {
if (s.business !== b.id || !(runs.includes(s.run) || (!threaded && s.instance === instance))) continue;
stopping.add(s.run);
record({ event: "lifecycle-end", instance: s.instance, run: s.run });
try {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGTERM");
} catch {}
}
}
// V5 steps 1 to 3. The file goes first: once committed, check() refuses the name, so no launch
// opens the old workspace even if the rename fails.
async function remove(r) {
if (registry.sessions.some((s) => s.business === b.id && s.instance === r.instance)) return { refusal: "instance-running" };
let out;
try {
out = await host.op({ op: "lifecycleDelete", record: { business: b.id, request: r.request } });
} catch (e) {
return { refusal: e.code ?? "outcome-unknown" };
}
const transaction = out?.transaction ?? null;
if (transaction === null) return { refusal: "workspace-rename-failed" };
const from = workspaceDir(dataRoot, b.id, r.instance);
if (!existsSync(from)) return { transaction };
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
return { transaction };
}
// endLaunch's answer ({ released, limit? }), or null when it refused.
async function endRun(run, reason, exitCode) {
try {
@@ -763,7 +917,7 @@ export function createLauncher({
});
chmodSync(path, 0o600);
await gitSocket.listen();
if (b.launch && launches) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
if (b.launch) ticker = setInterval(() => tick().catch((e) => log(`thread tick: ${e.message}`)), threadTickMs);
}
// SIGTERM every runner, wait, then end what's left. Runs before the host
+187 -10
View File
@@ -1,6 +1,9 @@
// Row 165 (#1662, Q171, REQ-ROLE-3): `mosaic business instances` and `mosaic business instance
// add|set` reach the broker's human-only verbs, which write through the business package's
// instance writer. The bus here is a real startBroker over a temporary organization directory.
// Row 194 (#1692, D3): it boots from the file through bootConfig, as the unit does, so a create goes
// live with no restart. A stub boot whose credential references differ from the file still answers
// restart needed.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
@@ -11,20 +14,26 @@ import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
import { main, run } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { RESTART_NEEDED } from "../src/instances.mjs";
import { fixture, io, tmp } from "./helpers.mjs";
const BUSINESS_CLI = fileURLToPath(new URL("../../business/src/cli.mjs", import.meta.url));
const MOSAIC = fileURLToPath(new URL("../../../scripts/mosaic", import.meta.url));
async function setup(t) {
// hook(verb, args, bus), when given, runs before each call reaches the broker (row 194: a test moves a
// request on as the launcher's pass would).
async function setup(t, { stub = false, hook = null } = {}) {
const root = tmp(t, "mosaic-cli-instances-");
const f = fixture(root, "acme");
const dir = join(root, "config");
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: "acme", env: f.env });
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
businesses: stub
? { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } }
: boot.businesses,
organization: stub ? { dir, rolesDir: f.roles, system: systemFor(root) } : boot.organization,
});
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
@@ -33,6 +42,7 @@ async function setup(t) {
system: { dataRoot: f.dataRoot },
transport: (business) => async (verb, args = {}) => {
calls.push({ business, verb, args });
await hook?.(verb, args, bus);
try {
return structuredClone(bus.broker.request(human, { verb, args }));
} catch (e) {
@@ -54,10 +64,10 @@ async function setup(t) {
return { code, out: x.out.text, err: x.err.text };
};
const revision = async () => JSON.parse((await mosaic(["instances", "acme", "--json"])).out).revision;
return { root, f, file, log, validate, mosaic, revision, calls };
return { root, f, file, log, validate, mosaic, revision, calls, bus };
}
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, and the log names the human", async (t) => {
test("row 165 CLI: instance add puts an instance in the file, validate exits 0, the log names the human, and the instance binds with no restart (row 194, T1)", async (t) => {
const s = await setup(t);
const view = await s.mosaic(["instances", "acme"]);
assert.equal(view.code, 0, view.err);
@@ -71,11 +81,10 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
const entry = s.log().at(-1);
const now = await s.revision();
assert.deepEqual(added.out.split("\n"), [
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`, RESTART_NEEDED, "",
"business acme: added sec-1 (security-reviewer, launched)", `revision ${now}`, `transaction ${entry.transaction}`,
"no restart: the launcher reads the change at its next launch check", "",
]);
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
assert.ok(s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), "the running bus binds the new instance");
const doc = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual(doc.roles["sec-1"], { definition: "security-reviewer" });
assert.ok(doc.launch.instances.includes("sec-1"));
@@ -84,6 +93,17 @@ test("row 165 CLI: instance add puts an instance in the file, validate exits 0,
assert.equal(checked.status, 0, checked.stderr);
});
test("row 194 CLI: a bus whose boot credential references differ from the file answers restart needed, and doesn't bind the instance (D3 item 3)", async (t) => {
const s = await setup(t, { stub: true });
const added = await s.mosaic(["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--launch", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
assert.equal(added.out.split("\n").at(-2), RESTART_NEEDED);
assert.equal(RESTART_NEEDED, "restart needed: the running bus keeps the roles it loaded at start; this command does not restart it");
assert.doesNotMatch(RESTART_NEEDED, /Mos|Jason/, "product text names no seat (sage-0931)");
assert.doesNotMatch(added.out, /--restart|run `mosaic bus/, "the CLI names the restart and doesn't offer to run it");
assert.throws(() => s.bus.broker.bindLaunch({ business: "acme", role: "sec-1", run: "run-1", harness: "pi" }), { code: "unknown-role" });
});
test("row 165 CLI: instance set passes the vars file and --no-launch, and a vars-only change needs no restart", async (t) => {
const s = await setup(t);
const vars = writeJson(join(s.root, "vars.json"), { harness: "pi", model: "claude-sonnet-5-5" });
@@ -143,12 +163,169 @@ test("row 165 CLI: usage errors exit 4 before any bus call, and an agent session
await assert.rejects(main(["business", "validate", "acme"], io(s.f.env), {}), /mosaic business instances/);
});
test("row 165: scripts/mosaic sends business instances and instance to packages/cli, and validate still to packages/business", (t) => {
test("row 165: scripts/mosaic sends business instances, instance and (row 194) veto to packages/cli, and validate still to packages/business", (t) => {
const root = tmp(t, "mosaic-cli-route-");
const env = { PATH: process.env.PATH, HOME: join(root, "home"), MOSAIC_CONFIG: join(root, "none.json") };
const routed = spawnSync(MOSAIC, ["business", "instance"], { env, encoding: "utf8" });
assert.equal(routed.status, 4);
assert.match(routed.stderr, /mosaic business instances BUSINESS/);
// Row 194: veto goes to packages/cli too.
const veto = spawnSync(MOSAIC, ["business", "veto"], { env, encoding: "utf8" });
assert.equal(veto.status, 4);
assert.match(veto.stderr, /\| business veto BUSINESS keep\|unkeep INSTANCE/);
const business = spawnSync(MOSAIC, ["business", "validate"], { env, encoding: "utf8" });
assert.doesNotMatch(business.stderr, /mosaic business instances BUSINESS/);
});
test("row 194 CLI V6: veto keep, unkeep, nohire and hire write through veto.set, the read shows kept and noHire, and a no-op refuses", async (t) => {
const s = await setup(t);
const kept = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(kept.code, 0, kept.err);
assert.deepEqual(s.calls.at(-1), { business: "acme", verb: "veto.set", args: { revision: s.calls.at(-1).args.revision, op: "keep", name: "coder" } });
const entry = s.log().at(-1);
assert.deepEqual(kept.out.split("\n"), ["business acme: veto keep coder", `revision ${await s.revision()}`, `transaction ${entry.transaction}`, ""]);
assert.equal(entry.actor, "jason");
const nohire = await s.mosaic(["veto", "acme", "nohire", "merge-gate", "--revision", await s.revision()]);
assert.equal(nohire.code, 0, nohire.err);
const view = await s.mosaic(["instances", "acme"]);
assert.match(view.out, /^ {2}coder: coder \(launched, kept\)/m);
assert.match(view.out, /^the chief may not hire: merge-gate$/m);
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.kept, json.noHire, json.retired], [true, ["merge-gate"], []]);
// A kept instance refuses delete at the verb.
const del = await s.mosaic(["instance", "delete", "acme", "coder", "--revision", await s.revision()]);
assert.equal(del.code, 2);
assert.match(del.err, /^mosaic: organization-instance-kept: the instance is kept; run `mosaic business veto BUSINESS unkeep INSTANCE` first$/m);
const again = await s.mosaic(["veto", "acme", "keep", "coder", "--revision", await s.revision()]);
assert.equal(again.code, 2);
assert.match(again.err, /^mosaic: organization-no-change: /);
for (const [op, name] of [["unkeep", "coder"], ["hire", "merge-gate"]]) {
const r = await s.mosaic(["veto", "acme", op, name, "--revision", await s.revision()]);
assert.equal(r.code, 0, r.err);
}
const after = JSON.parse(readFileSync(s.file, "utf8"));
assert.deepEqual([after.launch.keep, after.launch.noHire], [[], []]);
});
test("row 194 CLI: the read lists each instance's running run from the bus claims", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const json = JSON.parse((await s.mosaic(["instances", "acme", "--json"])).out);
assert.deepEqual([json.instances.coder.running, json.instances.reviewer.running], [["run-c1"], []]);
assert.match((await s.mosaic(["instances", "acme"])).out, /^ {2}coder: coder \(launched, running run-c1\)/m);
});
// The launcher's pass, played by the test: on the first poll the request is taken, then closed with `done`.
const closing = (done) => (verb, args, bus) => {
if (verb !== "instance.request") return;
if (bus.lifecycleTake({ business: "acme", request: args.request }).go && done) bus.lifecycleDone({ business: "acme", request: args.request, ...done(bus, args.request) });
};
test("row 194 CLI D1.1 item 5: launch and regenerate poll instance.request and print the run; a done with a refusal exits 3 with its code and the request id", async (t) => {
let done = () => ({ run: "run-new" });
const s = await setup(t, { hook: closing((bus, request) => done(bus, request)) });
const launched = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.deepEqual(s.calls.find((c) => c.verb === "instance.launch").args, { instance: "reviewer" });
assert.equal(launched.out, `business acme: launched reviewer, run run-new (request ${request})\n`);
assert.match(launched.err, new RegExp(`^mosaic: request ${request}, waiting up to 30 s$`, "m"));
const regen = await s.mosaic(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
assert.match(regen.out, /^business acme: regenerated reviewer, run run-new \(request [^)]+\)$/m);
done = () => ({ refusal: "capacity-full" });
const full = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(full.code, 3);
const id = s.calls.filter((c) => c.verb === "instance.request").at(-1).args.request;
assert.match(full.err, new RegExp(`^mosaic: capacity-full: a launch limit is reached; .* \\(request ${id}\\)$`, "m"));
done = () => ({ refusal: "some-new-code" });
const other = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(other.code, 3);
assert.match(other.err, /^mosaic: some-new-code: the launcher refused the request \(request [^)]+\)$/m);
});
test("row 194 CLI D1.1 item 5: an open request at --wait exits 3 with its id, and a taken one with no done is outcome-unknown", async (t) => {
const s = await setup(t);
const open = await s.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "0"]);
assert.equal(open.code, 3);
const request = s.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(open.err, new RegExp(`^mosaic: request ${request} is still open after 0 s; the launcher takes it within its 60 s or closes it request-expired$`, "m"));
const t2 = await setup(t, { hook: closing(null) });
const taken = await t2.mosaic(["instance", "launch", "acme", "reviewer", "--wait", "1"]);
assert.equal(taken.code, 3);
const id = t2.calls.find((c) => c.verb === "instance.request").args.request;
assert.match(taken.err, new RegExp(`^mosaic: outcome-unknown: request ${id} was taken and has not closed after 1 s; the launch log names any run it started$`, "m"));
assert.ok(t2.calls.filter((c) => c.verb === "instance.request").length > 1, "it polled until --wait ran out");
});
test("row 194 CLI: a request cancelled by a terminate exits 3 request-cancelled", async (t) => {
const s = await setup(t, { hook: (verb, args, bus) => {
if (verb === "instance.request") bus.broker.request(bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true }), { verb: "instance.end", args: { instance: "reviewer" } });
} });
const r = await s.mosaic(["instance", "launch", "acme", "reviewer"]);
assert.equal(r.code, 3);
assert.match(r.err, /^mosaic: request-cancelled: cancelled by jason \(request [^)]+\)$/m);
});
test("row 194 CLI V3: terminate prints the runs, the cancelled and the taken requests, and nothing held refuses not-held", async (t) => {
const s = await setup(t);
const cap = s.bus.broker.bindLaunch({ business: "acme", role: "coder", run: "run-c1", harness: "pi" });
s.bus.broker.request(cap, { verb: "role.claim", args: {} });
const human = s.bus.broker.bindHuman({ business: "acme", human: "jason", via: "cli", outsideAgent: true });
const open = s.bus.broker.request(human, { verb: "instance.launch", args: { instance: "coder" } }).request;
const taken = s.bus.broker.request(human, { verb: "instance.regenerate", args: { instance: "coder" } }).request;
s.bus.lifecycleTake({ business: "acme", request: taken });
const r = await s.mosaic(["instance", "terminate", "acme", "coder", "--reason", "stuck"]);
assert.equal(r.code, 0, r.err);
assert.deepEqual(s.calls.at(-1).args, { instance: "coder", reason: "stuck" });
const lines = r.out.split("\n");
assert.match(lines[0], /^business acme: ended coder \(request [^)]+\)$/);
assert.deepEqual(lines.slice(1), [
"runs run-c1: claims revoked, the launcher signals each", `cancelled ${open}`,
`taken ${taken}: past cancelling; a run it starts is ended by the launcher's pass, or by a second terminate`, "",
]);
const none = await s.mosaic(["instance", "terminate", "acme", "reviewer"]);
assert.equal(none.code, 2);
assert.match(none.err, /^mosaic: not-held: the instance has no run and no open request$/m);
});
test("row 194 CLI V5: delete polls to the transaction and the read lists the name retired; workspace-rename-failed names the transaction", async (t) => {
let rename = null;
const s = await setup(t, { hook: closing((bus, request) => ({ transaction: bus.lifecycleDelete({ business: "acme", request }).transaction, ...(rename ? { refusal: rename } : {}) })) });
for (const name of ["sec-1", "sec-2"]) {
const added = await s.mosaic(["instance", "add", "acme", name, "--definition", "security-reviewer", "--revision", await s.revision()]);
assert.equal(added.code, 0, added.err);
}
const del = await s.mosaic(["instance", "delete", "acme", "sec-1", "--revision", await s.revision()]);
assert.equal(del.code, 0, del.err);
const entry = s.log().at(-1);
assert.equal(del.out, `business acme: deleted sec-1, now retired\ntransaction ${entry.transaction}\n`);
assert.deepEqual(s.calls.find((c) => c.verb === "instances.set" && c.args.op === "delete").args, { revision: s.calls.find((c) => c.args.op === "delete").args.revision, instance: "sec-1", op: "delete" });
assert.match((await s.mosaic(["instances", "acme"])).out, /^retired: sec-1$/m);
rename = "workspace-rename-failed";
const failed = await s.mosaic(["instance", "delete", "acme", "sec-2", "--revision", await s.revision()]);
assert.equal(failed.code, 3);
assert.match(failed.err, new RegExp(`^mosaic: workspace-rename-failed: .*run the same delete again to retry the rename \\(request [^,]+, transaction ${s.log().at(-1).transaction}\\)$`, "m"));
});
test("row 194 CLI: lifecycle and veto usage errors exit 4 before any bus call", async (t) => {
const s = await setup(t);
const rev = "a".repeat(64);
for (const args of [
["instance", "launch", "acme"], ["instance", "launch", "acme", "coder", "extra"],
["instance", "launch", "acme", "coder", "--wait", "x"], ["instance", "launch", "acme", "coder", "--wait", "601"],
["instance", "launch", "acme", "coder", "--revision", rev], ["instance", "launch", "acme", "coder", "--reason", "x"],
["instance", "terminate", "acme", "coder", "--wait", "5"], ["instance", "terminate", "acme", "coder", "--revision", rev],
["instance", "delete", "acme", "coder"], ["instance", "delete", "acme", "coder", "--revision", rev, "--tester"],
["instance", "delete", "acme", "coder", "--revision", rev, "--reason", "x"],
["instance", "add", "acme", "sec-1", "--definition", "security-reviewer", "--revision", rev, "--wait", "5"],
["veto", "acme", "keep", "coder"], ["veto", "acme", "drop", "coder", "--revision", rev],
["veto", "acme", "keep", "--revision", rev], ["veto", "acme", "keep", "coder", "--revision", rev, "--tester"],
["veto", "acme", "keep", "coder", "--revision", rev, "--json"], ["veto"],
]) {
const r = await s.mosaic(args);
assert.equal(r.code, 4, args.join(" "));
}
assert.equal(s.calls.length, 0);
});
+549
View File
@@ -0,0 +1,549 @@
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
// the launcher's lifecycle pass takes each one, acts on it and closes it. Real runners in the bwrap
// sandbox on the fake adapter.
//
// The host is in process, as in thread-launch.test.mjs: the real host proves a human CLI process
// for every human verb, which a test run under an agent can't. The tick interval is long and each
// test calls tick() itself, so a request waits until the test says.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { launchDoc } from "../../business/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { launchLogFile, readSessions, SESSION_CREDENTIALS, workspaceDir } from "../../seat/src/session.mjs";
import { run } from "../src/cli.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, io, tmp } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
async function until(fn, ms = 15000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitIn(cwd, ...args) {
const r = spawnSync("git", ["-c", "user.name=test", "-c", "[email protected]", "-C", cwd, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
}
// chief opus, coder and reviewer sonnet, at most one sonnet run. Only coder is listed, so reviewer
// is an instance the chief may not launch and the human may. sec-1 holds no credential, so the
// organization writer lets the human delete it. assistant is the thread instance.
function prepare(t, edit = (doc) => doc) {
const root = tmp(t, "mosaic-life-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
doc.roles.reviewer.vars = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
doc.roles["sec-1"] = { definition: "security-reviewer", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.roles.assistant = { definition: "assistant", vars: { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" } };
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 1 });
return edit(doc);
});
for (const k of SESSION_CREDENTIALS.flat()) delete f.env[k];
const project = f.doc.projects.stack.root;
gitIn(project, "init", "-q", "-b", "main");
writeFileSync(join(project, "file.txt"), "one\n");
gitIn(project, "add", "file.txt");
gitIn(project, "commit", "-q", "-m", "one");
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
async function start(t, { f, adapter }, options = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId: "acme", env: f.env });
assert.equal(trackers, undefined);
const runtime = await startBroker(boot);
// The real host makes bus-host/ (0700). A --no-launch launcher's read-only registry doesn't.
mkdirSync(join(f.dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
const ops = {
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
// A host op named here throws once, as a bus that refused it would.
const faults = new Set();
const host = {
business: "acme",
path: runtime.path,
dataRoot: f.dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
if (faults.delete(m.op)) throw new BusError("storage-refused");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [FAKE],
threadTickMs: 3600000,
stopTimeoutMs: 1000,
log: (l) => logs.push(l),
...options,
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: "acme", human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const state = (request) => call("instance.request", { request });
// One pass, then the request's state.
const carry = async (request) => {
await launcher.tick();
return state(request);
};
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
const claimed = (run) => until(() => {
const file = join(f.dataRoot, "launches", "acme", run, "runner.log");
return existsSync(file) && readFileSync(file, "utf8").includes("claimed by run");
});
// The CLI through the in-process human. Each poll of instance.request runs one pass first, so a
// --wait sees the request closed as the launcher's ticker would close it.
const cli = async (args) => {
const x = io(f.env);
const deps = {
system: { dataRoot: f.dataRoot },
transport: () => async (verb, a = {}) => {
if (verb === "instance.request") await launcher.tick();
try {
return structuredClone(call(verb, a));
} catch (e) {
if (!(e instanceof BusError)) throw e;
throw Object.assign(new Error(e.code), { code: e.code });
}
},
};
const code = await run(["business", ...args], x, deps);
return { code, out: x.out.text, err: x.err.text };
};
return { runtime, launcher, call, state, carry, running, claimed, logs, close, faults, cli };
}
const launchLog = (f) =>
existsSync(launchLogFile(f.dataRoot, "acme"))
? readFileSync(launchLogFile(f.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l))
: [];
// Stops a runner from outside its namespace, so it can't act on SIGTERM, and kills it at the end.
function stop(t, s) {
process.kill(s.runnerPid, "SIGSTOP");
t.after(() => {
if (startTimeOf(s.runnerPid) === s.runnerStartTime) process.kill(s.runnerPid, "SIGKILL");
});
}
test("row 194 T2: the human's launch runs an unlisted instance and launch.by through the pm branch, and closes with the run", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const { request } = s.call("instance.launch", { instance: "reviewer" });
assert.equal(s.state(request).state, "open");
const done = await s.carry(request);
assert.equal(done.state, "closed");
assert.equal(done.done.refusal, null);
assert.match(done.done.run, /^r[0-9a-f]{12}$/);
const [r] = s.running("reviewer");
assert.equal(r.run, done.done.run);
const session = readSessions(p.f.dataRoot).find((x) => x.run === r.run);
assert.equal(session.launchedBy, "jason");
await s.claimed(r.run);
// launch.by is not on launch.instances. The human's request takes the pm branch, as --pm does.
const chief = s.call("instance.launch", { instance: "chief" }).request;
const c = await s.carry(chief);
assert.equal(c.done.refusal, null);
assert.equal(s.running("chief")[0].run, c.done.run);
// A request is taken once: the next pass starts nothing more.
await s.launcher.tick();
assert.equal(s.running("reviewer").length, 1);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 2);
});
test("row 194 T2: a request past its 60 s never launches, and a refused check closes the request with its code", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const old = s.call("instance.launch", { instance: "coder" }).request;
const now = Date.now();
t.mock.method(Date, "now", () => now + 61000);
const expired = await s.carry(old);
assert.deepEqual([expired.state, expired.done.refusal, expired.done.run], ["closed", "request-expired", null]);
t.mock.restoreAll();
assert.equal(s.running("coder").length, 0);
// A check refusal: the organization file now names a harness that isn't switched on.
const file = join(p.root, "config", "businesses", "acme.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
doc.roles.coder.vars.harness = "claude-code";
doc.plugins.harness = ["pi"];
writeFileSync(file, JSON.stringify(doc, null, 2));
const off = s.call("instance.launch", { instance: "coder" }).request;
const refused = await s.carry(off);
assert.deepEqual([refused.state, refused.done.refusal, refused.done.run], ["closed", "harness-off", null]);
assert.equal(s.running("coder").length, 0);
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === off);
assert.deepEqual([entry.instance, entry.code, entry.by], ["coder", "harness-off", "jason"]);
});
test("row 194 T3: a --no-launch host closes each request launches-disabled and starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p, { launches: false });
const launch = s.call("instance.launch", { instance: "coder" }).request;
const regen = s.call("instance.regenerate", { instance: "reviewer" }).request;
await s.launcher.tick();
for (const request of [launch, regen]) assert.deepEqual([s.state(request).state, s.state(request).done.refusal], ["closed", "launches-disabled"]);
assert.equal(s.launcher.registry.sessions.length, 0);
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
assert.deepEqual(launchLog(p.f).filter((e) => e.event === "launch"), []);
});
test("row 194 T3: a limit refusal closes the request with its code and the limit in the launch log", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const first = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
assert.equal(first.done.refusal, null);
// One sonnet run at a time: reviewer runs sonnet too.
const full = s.call("instance.launch", { instance: "reviewer" }).request;
const r = await s.carry(full);
assert.deepEqual([r.state, r.done.refusal, r.done.run], ["closed", "capacity-full", null]);
assert.equal(s.running("reviewer").length, 0);
const entry = launchLog(p.f).find((e) => e.event === "refused" && e.request === full);
assert.equal(entry.code, "capacity-full");
assert.ok(entry.limit, JSON.stringify(entry));
// The instance already running refuses a second launch of itself.
const again = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
assert.equal(again.done.refusal, "instance-running");
});
test("row 194 T4: terminate revokes and cancels at the verb, then the pass signals the run, which ends once", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const launched = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
const run = launched.done.run;
await s.claimed(run);
const open = s.call("instance.launch", { instance: "coder" }).request;
const out = s.call("instance.end", { instance: "coder", reason: "stuck" });
assert.deepEqual([out.runs, out.cancelled], [[run], [open]]);
assert.equal(s.state(open).state, "closed");
const end = await s.carry(out.request);
assert.deepEqual([end.state, end.done], ["closed", { run: null, transaction: null, refusal: null }]);
await until(() => s.running("coder").length === 0);
const log = launchLog(p.f);
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === run).length, 1);
await until(() => launchLog(p.f).some((e) => e.event === "end" && e.run === run));
assert.equal(launchLog(p.f).filter((e) => e.event === "end" && e.run === run).length, 1);
// The cancelled request never ran.
await s.launcher.tick();
assert.equal(s.running("coder").length, 0);
});
test("row 194 T5: regenerate ends the run in the registry and starts a new run on the same workspace", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
await s.claimed(first);
const marker = join(workspaceDir(p.f.dataRoot, "acme", "coder"), "kept.txt");
writeFileSync(marker, "kept\n");
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
assert.equal(regen.done.refusal, null);
const second = regen.done.run;
assert.notEqual(second, first);
assert.deepEqual(s.running("coder").map((x) => x.run), [second]);
assert.ok(existsSync(join(p.f.dataRoot, "launches", "acme", second)));
assert.equal(readFileSync(marker, "utf8"), "kept\n");
const log = launchLog(p.f);
assert.equal(log.filter((e) => e.event === "lifecycle-end" && e.run === first).length, 1);
assert.equal(log.filter((e) => e.event === "end" && e.run === first).length, 1);
// A thread instance is never regenerated: the verb refuses before any request.
assert.throws(() => s.call("instance.regenerate", { instance: "assistant" }), (e) => e.code === "regenerate-thread");
});
test("row 194 T5: a run that outlasts the request's mark closes the regenerate regenerate-timeout and starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p, { lifecycleRequestMs: 1500 });
const first = (await s.carry(s.call("instance.launch", { instance: "coder" }).request)).done.run;
await s.claimed(first);
stop(t, s.running("coder")[0]);
const regen = await s.carry(s.call("instance.regenerate", { instance: "coder" }).request);
assert.deepEqual([regen.state, regen.done.refusal, regen.done.run], ["closed", "regenerate-timeout", null]);
assert.deepEqual(s.running("coder").map((x) => x.run), [first]);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
});
test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<transaction> at 0700, and a retry renames after workspace-rename-failed", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const ws = workspaceDir(p.f.dataRoot, "acme", "sec-1");
mkdirSync(ws, { recursive: true, mode: 0o700 });
writeFileSync(join(ws, "notes.txt"), "mine\n");
// A file where .retired goes makes the rename fail after the commit.
const retired = join(workspaceDir(p.f.dataRoot, "acme", ".retired"));
writeFileSync(retired, "in the way\n");
const revision = () => s.call("instances").revision;
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
const failed = await s.carry(del);
assert.equal(failed.done.refusal, "workspace-rename-failed");
const transaction = failed.done.transaction;
assert.ok(transaction);
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
assert.equal(Object.hasOwn(file.roles, "sec-1"), false, "the commit stands");
assert.ok(file.retired.includes("sec-1"));
assert.equal(readFileSync(join(ws, "notes.txt"), "utf8"), "mine\n", "the workspace stays where it was");
// A retired name launches no more, so nothing opens the old workspace before the retry.
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
rmSync(retired);
const retry = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
assert.equal(s.state(retry).retry, true);
const ok = await s.carry(retry);
assert.deepEqual(ok.done, { run: null, transaction, refusal: null });
const to = join(retired, `sec-1-${transaction}`);
assert.equal(existsSync(ws), false);
assert.equal(readFileSync(join(to, "notes.txt"), "utf8"), "mine\n");
assert.equal(statSync(retired).mode & 0o777, 0o700);
assert.equal(statSync(to).mode & 0o777, 0o700);
const entry = launchLog(p.f).find((e) => e.event === "workspace-retired");
assert.deepEqual([entry.instance, entry.request, entry.transaction, entry.path], ["sec-1", retry, transaction, to]);
});
test("row 194 V5: a delete with no workspace commits and closes with the transaction", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const del = s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request;
const r = await s.carry(del);
assert.equal(r.done.refusal, null);
assert.ok(r.done.transaction);
assert.equal(existsSync(join(workspaceDir(p.f.dataRoot, "acme", ".retired"))), false);
});
// Every regular file under root, by path, as a sha256. Sockets and other special files are left out.
function snapshot(root) {
const out = {};
const walk = (dir) => {
for (const name of readdirSync(dir).sort()) {
const path = join(dir, name);
const st = lstatSync(path);
if (st.isDirectory()) walk(path);
else if (st.isFile()) out[path.slice(root.length)] = createHash("sha256").update(readFileSync(path)).digest("hex");
}
};
walk(root);
return out;
}
test("row 194 D1.1 item 5: the CLI's launch, regenerate, terminate and delete end to end through the pass, and a closed refusal exits 3 with the request id", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const launched = await s.cli(["instance", "launch", "acme", "reviewer"]);
assert.equal(launched.code, 0, launched.err);
const [r] = s.running("reviewer");
assert.match(launched.out, new RegExp(`^business acme: launched reviewer, run ${r.run} \\(request [^)]+\\)\n$`));
await s.claimed(r.run);
const regen = await s.cli(["instance", "regenerate", "acme", "reviewer"]);
assert.equal(regen.code, 0, regen.err);
const [r2] = s.running("reviewer");
assert.notEqual(r2.run, r.run);
assert.match(regen.out, new RegExp(`^business acme: regenerated reviewer, run ${r2.run} \\(request [^)]+\\)\n$`));
await s.claimed(r2.run);
// One sonnet run at a time: the launcher closes coder's request capacity-full.
const full = await s.cli(["instance", "launch", "acme", "coder"]);
assert.equal(full.code, 3);
const refused = launchLog(p.f).find((e) => e.event === "refused" && e.instance === "coder");
assert.match(full.err, new RegExp(`^mosaic: capacity-full: .* \\(request ${refused.request}\\)$`, "m"));
assert.equal(s.running("coder").length, 0);
const ended = await s.cli(["instance", "terminate", "acme", "reviewer", "--reason", "done for today"]);
assert.equal(ended.code, 0, ended.err);
assert.match(ended.out, new RegExp(`^runs ${r2.run}: claims revoked, the launcher signals each$`, "m"));
await s.launcher.tick();
await until(() => s.running("reviewer").length === 0);
const revision = JSON.parse((await s.cli(["instances", "acme", "--json"])).out).revision;
const del = await s.cli(["instance", "delete", "acme", "sec-1", "--revision", revision]);
assert.equal(del.code, 0, del.err);
assert.match(del.out, /^business acme: deleted sec-1, now retired\ntransaction [^\n]+\n$/);
assert.match((await s.cli(["instances", "acme"])).out, /^retired: sec-1$/m);
});
test("row 194 T2a: a launch.revoke between the request and the pass closes it launch-revoked with no process; a done that fails once leaves the request taken and the next pass starts nothing", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const request = s.call("instance.launch", { instance: "coder" }).request;
s.call("launch.revoke");
const revoked = await s.carry(request);
assert.deepEqual([revoked.state, revoked.done.refusal, revoked.done.run], ["closed", "launch-revoked", null]);
assert.equal(s.launcher.registry.sessions.length, 0);
assert.equal(existsSync(join(p.f.dataRoot, "launches", "acme")), false);
s.call("launch.restore");
const held = s.call("instance.launch", { instance: "coder" }).request;
s.faults.add("lifecycleDone");
const taken = await s.carry(held);
assert.deepEqual([taken.state, taken.done], ["taken", null]);
const [r] = s.running("coder");
assert.ok(s.logs.some((l) => l === `request ${held} (coder): lifecycleDone refused: storage-refused; run ${r.run} started`), s.logs.join("\n"));
await s.launcher.tick();
assert.equal(s.state(held).state, "taken");
assert.equal(s.running("coder").length, 1);
assert.equal(launchLog(p.f).filter((e) => e.event === "launch").length, 1);
});
test("row 194 T4: terminate signals a spawned run in the registry that the bus never bound, and not a run whose start time differs", { timeout: 30000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const spawned = (instance) => {
const child = spawn("sleep", ["60"], { stdio: "ignore" });
t.after(() => child.kill("SIGKILL"));
const exited = new Promise((resolve) => child.on("exit", (code, signal) => resolve(signal)));
return { child, exited, instance };
};
const a = spawned("coder");
const b = spawned("reviewer");
await until(() => startTimeOf(a.child.pid) !== null && startTimeOf(b.child.pid) !== null);
// Neither holds a claim. An open request each lets the end through, and the end cancels it.
for (const [x, start] of [[a, startTimeOf(a.child.pid)], [b, startTimeOf(b.child.pid) + 1]])
s.launcher.registry.add({ business: "acme", instance: x.instance, run: `run-${x.instance}`, pid: x.child.pid, startTime: start, runnerPid: x.child.pid, runnerStartTime: start });
t.after(() => ["coder", "reviewer"].forEach((i) => s.launcher.registry.remove("acme", `run-${i}`)));
for (const x of [a, b]) {
s.call("instance.launch", { instance: x.instance });
const out = s.call("instance.end", { instance: x.instance });
assert.deepEqual([out.runs, out.cancelled.length], [[], 1]);
await s.carry(out.request);
}
assert.equal(await a.exited, "SIGTERM");
await new Promise((r) => setTimeout(r, 300));
assert.equal(b.child.exitCode, null);
assert.equal(b.child.signalCode, null);
const ends = launchLog(p.f).filter((e) => e.event === "lifecycle-end").map((e) => e.run);
assert.deepEqual(ends, ["run-coder", "run-reviewer"]);
});
test("row 194 T6: a delete leaves the run evidence bytes unchanged, and the retired name refuses a create and a launch", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const run = (await s.carry(s.call("instance.launch", { instance: "sec-1" }).request)).done.run;
await s.claimed(run);
await s.carry(s.call("instance.end", { instance: "sec-1" }).request);
await until(() => s.running("sec-1").length === 0 && launchLog(p.f).some((e) => e.event === "end" && e.run === run));
const runDir = join(p.f.dataRoot, "launches", "acme", run);
const before = snapshot(runDir);
const log = readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8");
assert.ok(Object.keys(before).length > 0);
const del = await s.carry(s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "delete" }).request);
assert.equal(del.done.refusal, null);
assert.deepEqual(snapshot(runDir), before);
assert.ok(readFileSync(launchLogFile(p.f.dataRoot, "acme"), "utf8").startsWith(log), "the launch log only grows");
assert.throws(
() => s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-1", op: "add", definition: "security-reviewer" }),
(e) => e.code === "organization-instance-retired",
);
assert.throws(() => s.call("instance.launch", { instance: "sec-1" }), (e) => e.code === "instance-retired");
});
test("row 194 T11: two stack roots with the same business id; a launch, a terminate and a delete in one leave the other byte-identical", { skip, timeout: 90000 }, async (t) => {
const pa = prepare(t);
const pb = prepare(t);
const a = await start(t, pa);
const b = await start(t, pb);
b.call("instances");
const before = snapshot(pb.root);
const run = (await a.carry(a.call("instance.launch", { instance: "coder" }).request)).done.run;
await a.claimed(run);
await a.carry(a.call("instance.end", { instance: "coder" }).request);
await until(() => a.running("coder").length === 0);
const del = await a.carry(a.call("instances.set", { revision: a.call("instances").revision, instance: "sec-1", op: "delete" }).request);
assert.equal(del.done.refusal, null);
await b.launcher.tick();
assert.deepEqual(snapshot(pb.root), before);
assert.equal(b.launcher.registry.sessions.length, 0);
assert.equal(Object.hasOwn(b.call("instances").instances, "sec-1"), true);
});
// D3 item 5 and T8: the chief's session hires through its hire tool, and the same session launches
// the new name with no restart. Its launch tool takes a name, so a name absent from the start-time
// listing gets through to the host, which checks the file.
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
// only message.send and record.write and no credential.
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
const role = JSON.parse(readFileSync(roleFile, "utf8"));
role.hireable = ["chief-information-security-officer"];
writeFileSync(roleFile, JSON.stringify(role, null, 2));
const s = await start(t, p);
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
await s.claimed(chief);
const ask = async (name, args) => {
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
};
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
assert.ok(hired.transaction, JSON.stringify(hired));
const listed = s.call("instances").instances["sec-2"];
assert.equal(listed.launched, true);
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
// human sets, so until then the launch refuses no-route.
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
assert.match(launched.run, /^r[0-9a-f]{12}$/);
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
await s.claimed(launched.run);
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
assert.ok(fired.transaction, JSON.stringify(fired));
await until(() => s.running("sec-2").length === 0, 30000);
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
assert.equal(s.call("instances").instances["sec-2"].launched, false);
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
});
+21 -3
View File
@@ -11,7 +11,7 @@
// name against this list and forwards the call with its own capability.
import { connect } from "node:net";
import { ACTIONS, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION } from "../../business/src/vocabulary.mjs";
const str = (description, extra = {}) => ({ type: "string", description, ...extra });
const obj = (properties, required = []) => ({ type: "object", properties, required, additionalProperties: false });
@@ -113,9 +113,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
decision: COMMON.decision,
}, ["task_ref", "roles"]));
}
// Row 194 (#1692, D3 item 5): the instance is a name, not an enum fixed when the bundle was built,
// so the chief launches its own hire without a restart. The host checks the listing at each launch.
if (resolved.launch) {
add("launch", "launch", "role.launch", `Launch a managed session for one of these role instances: ${resolved.launch.instances.join(", ")}. The host refuses an instance that is already running, a full model family, or a launch while launches are switched off.`, obj({
instance: str("Role instance to launch", { enum: [...resolved.launch.instances] }),
add("launch", "launch", "role.launch", `Launch a managed session for a role instance the organization lists for you to launch. When this session started they were: ${resolved.launch.instances.join(", ")}. An instance you hired since is listed too. The host refuses an instance that isn't listed, one that is already running, a full model family, or a launch while launches are switched off.`, obj({
instance: str("Role instance to launch", { pattern: ID_PATTERN.source }),
}, ["instance"]));
}
if (tracker) {
@@ -170,6 +172,22 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["role", "reason"]));
}
// Row 194 (#1692, PRD 1.7 REQ-COS-3): the chief of staff's hire and fire, routed within-role. The
// broker checks the definition against the role files and the veto on every call.
if (held.has("role.hire")) {
add("hire", "instance.hire", "role.hire", "Hire a new role instance: add it to the organization file under a new name, with a definition your role file lists as hireable. A reason is required, 1 to 500 characters, and goes into the log. vars may set the model key and limits.* keys only. The broker refuses a definition that isn't hireable with definition-not-hireable. Launch the instance afterwards with launch.", obj({
name: str("The new instance's name"),
definition: str("The role definition it runs"),
vars: { type: "object", description: "The instance's vars: model and limits.* keys only" },
reason: str("Why you hire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["name", "definition", "reason"]));
}
if (held.has("role.fire")) {
add("fire", "instance.fire", "role.fire", "Fire a role instance: stop its run, then remove it from the organization file. A reason is required, 1 to 500 characters, and goes into the log. The broker refuses an instance the human has kept with instance-kept. It also refuses your own instance.", obj({
name: str("Role instance to fire"),
reason: str("Why you fire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
}, ["name", "reason"]));
}
if (held.has("organization.change")) {
// Row 134 (#1669): organization_revision is the read change_organization's revision comes from.
// Row 155 (#1649, Q169): assistants configures the assistant.
+5 -4
View File
@@ -92,22 +92,23 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
const b = build(t, r);
const prompt = readFileSync(b.files.prompt, "utf8");
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
assert.match(prompt, /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.match(prompt, /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.ok(b.manifest.typed.includes("launch"));
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
});
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1): the 3d re-pin window checks this manifest: both tools typed.
// Row 141 (#1635, REQ-MSG-2) granted instance.name, and row 155 (#1649) gives it set_name, so nothing
// the chief holds is untyped and the prompt has no untyped line.
// the chief holds is untyped and the prompt has no untyped line. Row 194 (#1692, PRD 1.7 REQ-COS-3)
// granted role.hire and role.fire, with hire and fire.
test("a chief-of-staff bundle lists end_run, change_organization and set_name as typed and nothing untyped, for either harness", (t) => {
for (const harness of ["pi", "claude-code"]) {
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
const b = build(t, r);
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.untyped, [], harness);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
}
});
+35 -5
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { callTool, ORGANIZATION_KEYS, REPLY_CAP, replyText, typedTools, untypedActions } from "../src/tools.mjs";
import { ORGANIZATION_KEYS as BROKER_ORGANIZATION_KEYS, TASK_PAGE, TASK_PAGE_MAX } from "../../bus/src/broker.mjs";
import { SETTINGS_KEYS } from "../../business/src/launch-settings.mjs";
import { ID_PATTERN } from "../../business/src/vocabulary.mjs";
import { fakeToolSocket, resolvedFor, scratch } from "./helpers.mjs";
const names = (tools) => tools.map((t) => t.name);
@@ -37,19 +38,47 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
});
// Row 119 (#1613): the chief of staff holds launch and message.send; row 123 adds role.end and organization.change, which row 132 gives tools.
test("the chief of staff gets launch, send_message, end_run, change_organization and set_name, and no task verbs", (t) => {
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
assert.deepEqual(launch.parameters.properties.instance.enum, ["coder", "reviewer"]);
// Row 194 (#1692, D3 item 5): a name with the id pattern, not an enum fixed at bundle time, so a hire
// launches with no restart. The start-time listing stays in the description, and the host checks it.
assert.deepEqual(launch.parameters.properties.instance, { type: "string", description: "Role instance to launch", pattern: ID_PATTERN.source });
assert.match(launch.description, /When this session started they were: coder, reviewer\. An instance you hired since is listed too\. The host refuses an instance that isn't listed,/);
// Row 132 (#1628): role.end and organization.change have their tools. Row 141 (#1635) granted
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped.
// instance.name, and row 155 (#1649) gives it set_name, so no held action is untyped. Row 194 (#1692,
// PRD 1.7 REQ-COS-3) granted role.hire and role.fire, with hire and fire.
assert.deepEqual(untypedActions(chief, tools), []);
});
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
// and only a holder of the grant gets them.
test("hire and fire send instance.hire and instance.fire under role.hire and role.fire, only for a holder of the grant", (t) => {
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
const hire = tools.find((x) => x.name === "hire");
const fire = tools.find((x) => x.name === "fire");
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["instance.hire", "role.hire", "instance.fire", "role.fire"]);
assert.deepEqual(Object.keys(hire.parameters.properties), ["name", "definition", "vars", "reason"]);
assert.deepEqual(hire.parameters.required, ["name", "definition", "reason"]);
assert.deepEqual(Object.keys(fire.parameters.properties), ["name", "reason"]);
assert.deepEqual(fire.parameters.required, ["name", "reason"]);
for (const tool of [hire, fire]) {
assert.equal(tool.parameters.additionalProperties, false);
assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]);
}
for (const instance of ["coder", "reviewer", "pm"])
for (const name of ["hire", "fire"]) assert.ok(!names(typedTools({ resolved: resolvedFor(t, instance), tracker: true })).includes(name), `${instance} ${name}`);
// Each tool follows its own grant.
const only = (grant) => resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { ...d.roles.chief.vars, "limits.authority": ["message.send", grant] }));
const HIRE_FIRE = (n) => n === "hire" || n === "fire";
assert.deepEqual(names(typedTools({ resolved: only("role.hire") })).filter(HIRE_FIRE), ["hire"]);
assert.deepEqual(names(typedTools({ resolved: only("role.fire") })).filter(HIRE_FIRE), ["fire"]);
});
// Row 140 (#1634, REQ-COS-1): the pm asks for staff with request_staff, which spends message.send; the
// chief of staff and every other role has no such tool, and the chief's launch offer is unchanged.
test("request_staff is the pm's alone: staff.request over message.send, roles and counts bounded", (t) => {
@@ -75,7 +104,8 @@ test("request_staff is the pm's alone: staff.request over message.send, roles an
assert.ok(!names(tools).includes("launch"));
const chief = resolvedFor(t, "chief");
const launch = typedTools({ resolved: chief, tracker: true }).find((x) => x.name === "launch");
assert.deepEqual(launch.parameters.properties.instance.enum, chief.launch.instances);
assert.equal(launch.parameters.properties.instance.enum, undefined);
assert.ok(launch.description.includes(`they were: ${chief.launch.instances.join(", ")}.`));
});
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): each tool maps to the broker verb and action row 123 added.
+9 -11
View File
@@ -1,7 +1,8 @@
// Row 165 (#1662, Q171, REQ-ROLE-3): the Console's role instance read and write. The write has the
// Settings boundary and makes one human-transport call, `instances.set`; the broker's and the
// writer's own checks are in the bus and business tests. The first test runs a real broker over a
// temporary organization directory, so the add reaches the file, the log and `validate`.
// temporary organization directory, so the add reaches the file, the log and `validate`. Row 194
// (#1692, D3): that broker boots from the file through bootConfig, so the add goes live with no restart.
import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
@@ -13,8 +14,8 @@ import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { systemFor } from '../../business/tests/helpers.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
const BUSINESS_CLI = fileURLToPath(new URL('../../business/src/cli.mjs', import.meta.url));
const preview = { host: 'console.example', header: 'x-user', user: 'jason' };
@@ -22,7 +23,7 @@ const headers = { host: 'console.example', 'x-user': 'jason', origin: 'https://c
const REV = 'a'.repeat(64);
const view = (extra = {}) => ({ business: 'acme', revision: 'b'.repeat(64), launch: true, instances: { coder: { definition: 'coder', vars: {}, launched: true, launcher: false, protected: false } }, definitions: {}, ...extra });
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: true }), bus = null } = {}) {
async function setup(t, { on = true, writer = on, write = async args => ({ ...view(), instances: { [args.instance]: { definition: 'merge-gate', vars: {}, launched: false, launcher: false, protected: false } }, transaction: 't-1', restart: false }), bus = null } = {}) {
const calls = [], lines = [];
const server = await startServer({
port: 0, preview, bus, log: line => lines.push(line), send: on ? async () => ({}) : null,
@@ -39,15 +40,12 @@ const send = (s, path, data, hs = headers, method = 'POST') => new Promise((reso
req.end(data === undefined ? undefined : typeof data === 'string' ? data : JSON.stringify(data));
});
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0 and the log names the human', async t => {
test('row 165 Console: the signed-in human adds an instance through a real broker; validate exits 0, the log names the human and no restart is needed (row 194)', async t => {
const root = tmp(t, 'webui-instances-');
const f = fixture(root, 'acme');
const dir = join(root, 'config');
const host = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: 'acme', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir, rolesDir: f.roles, system: systemFor(root) },
});
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const host = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => host.close());
const human = host.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
// The human transport's shape: an answer, or a BusReadError with the broker's code.
@@ -68,10 +66,10 @@ test('row 165 Console: the signed-in human adds an instance through a real broke
const added = await send(s, '/api/instances', { revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true });
assert.equal(added.status, 200, JSON.stringify(added.body));
assert.equal(added.body.restart, true);
assert.equal(added.body.restart, false);
assert.equal(added.body.instances['sec-1'].launched, true);
assert.deepEqual(s.calls, [{ revision: current.revision, instance: 'sec-1', op: 'add', definition: 'security-reviewer', launch: true }]);
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=true$/);
assert.match(s.lines.at(-1), / instance user="jason" instance="sec-1" done restart=false$/);
const entry = log().at(-1);
assert.deepEqual([entry.actor, entry.state, entry.transaction], ['jason', 'committed', added.body.transaction]);
assert.deepEqual(JSON.parse(readFileSync(file, 'utf8')).roles['sec-1'], { definition: 'security-reviewer' });
+3 -2
View File
@@ -6,8 +6,9 @@
"tools": ["read", "grep", "find", "ls"],
"network": "api-only",
"authority": {
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name"],
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire"],
"crossRole": []
},
"credentials": []
"credentials": [],
"hireable": []
}
+8 -2
View File
@@ -31,8 +31,14 @@ product's code, you don't review it and you don't create tasks yourself.
holds them and what they may do. To terminate a role instance, raise a
`role.revoke` decision, naming the instance and saying why. Change the
launch limits, the model table and the model each instance runs with
the `change_organization` tool. To hire an instance or change what one
may do, raise a `policy.change` decision.
the `change_organization` tool. Hire an instance with `hire` and fire
one with `fire`, each with a reason; you can hire only a definition
your role file lists as hireable, and never an instance the human
kept. A hire is listed for `launch` at once, with no restart, but it
runs only after the human sets its route (harness and thinking); until
then `launch` refuses it `no-route`. A fired instance stays in the file
off the list, so `launch` refuses it. To change what an instance may
do, raise a `policy.change` decision.
- You and the human are the ones who configure and stop the assistant.
Change its vars, its tracker or its definition with
`change_organization` and `assistants`; its holder and credentials
+2 -2
View File
@@ -27,9 +27,9 @@ if [ "${1:-}" = queue ]; then
fi
if [ "${1:-}" = business ]; then
# Row 165 (#1662): the human's role instance commands need the human proof,
# so they run in packages/cli. validate and resolve stay in packages/business.
# so they run in packages/cli. validate and resolve stay in packages/business. Row 194 adds veto.
case "${2:-}" in
instances|instance) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
instances|instance|veto) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
shift
exec node "$REPO/packages/business/src/cli.mjs" "$@"