Compare commits
6 Commits
feat/f3-m3
...
feat/f4-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| c6735d9949 | |||
| 2bf66136e4 | |||
| 4434c3c481 | |||
| dd0a0d38c6 | |||
| d46ac40890 | |||
| 8ddd48c843 |
@@ -4,6 +4,23 @@
|
|||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'node:22-alpine'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||||
|
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||||
|
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
||||||
|
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
||||||
|
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||||
|
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||||
|
# the separate `event: tag` entry.)
|
||||||
|
- &image_build_when
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
exclude:
|
||||||
|
- 'packages/mosaic/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- '.woodpecker/**'
|
||||||
|
|
||||||
when:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
@@ -26,6 +43,15 @@ steps:
|
|||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
# Publish only when a publishable package changed (or on a release tag); a
|
||||||
|
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
|
- event: [push, manual]
|
||||||
|
branch: main
|
||||||
|
path:
|
||||||
|
include:
|
||||||
|
- 'packages/**'
|
||||||
environment:
|
environment:
|
||||||
NPM_TOKEN:
|
NPM_TOKEN:
|
||||||
from_secret: gitea_token
|
from_secret: gitea_token
|
||||||
@@ -91,6 +117,7 @@ steps:
|
|||||||
|
|
||||||
build-gateway:
|
build-gateway:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -116,6 +143,7 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -141,6 +169,7 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
|
when: *image_build_when
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -58,3 +58,15 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
||||||
|
|
||||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
||||||
|
|
||||||
|
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
|
||||||
|
|
||||||
|
- Status: MERGED to main. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
|
||||||
|
|
||||||
|
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor
|
||||||
|
|
||||||
|
- Status: MERGED to main. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
|
||||||
|
|
||||||
|
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
||||||
|
|
||||||
|
- Status: Phase 1 done (abstraction + scaffold). Connector interface (send/subscribe/health) + registry + roster connector schema + design doc; tmux default/back-compat; matrix/discord factories are Phase 2. 7 tests green; no fleet.ts changes. Detail: scratchpads/f4-matrix-connector.md.
|
||||||
|
|||||||
92
docs/fleet/f4-matrix-connector.md
Normal file
92
docs/fleet/f4-matrix-connector.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
||||||
|
|
||||||
|
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
||||||
|
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
|
||||||
|
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
|
||||||
|
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
|
||||||
|
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
|
||||||
|
core.
|
||||||
|
|
||||||
|
## The abstraction (Phase 1 — this PR)
|
||||||
|
|
||||||
|
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface OrchestratorConnector {
|
||||||
|
readonly kind: 'tmux' | 'discord' | 'matrix';
|
||||||
|
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
|
||||||
|
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
|
||||||
|
health(): Promise<ConnectorHealth>; // reachable + authenticated
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
|
||||||
|
ack half; `health()` is the liveness half.
|
||||||
|
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
|
||||||
|
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
|
||||||
|
Discord plugin) fit **without an interface change**.
|
||||||
|
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
|
||||||
|
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
|
||||||
|
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
|
||||||
|
|
||||||
|
### Config model
|
||||||
|
|
||||||
|
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
connector:
|
||||||
|
kind: matrix # tmux | discord | matrix
|
||||||
|
matrix:
|
||||||
|
homeserver_url: https://matrix.example.internal
|
||||||
|
user_id: '@mos:example.internal'
|
||||||
|
room_id: '!abc:example.internal'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
|
||||||
|
environment (the gateway env-config pattern that already masks them). The sanitization gate would
|
||||||
|
reject a token committed to a shipped file anyway.
|
||||||
|
|
||||||
|
## Matrix connector (Phase 2)
|
||||||
|
|
||||||
|
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
|
||||||
|
for MVP), so it is **homeserver-agnostic**:
|
||||||
|
|
||||||
|
| Op | Matrix CS-API |
|
||||||
|
| ----------- | ------------------------------------------------------------------------ |
|
||||||
|
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
|
||||||
|
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
|
||||||
|
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
|
||||||
|
| threads | `m.thread` relations ↔ `threadId` |
|
||||||
|
|
||||||
|
## Local homeserver (infra, not connector code)
|
||||||
|
|
||||||
|
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
|
||||||
|
|
||||||
|
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
|
||||||
|
homeserver.
|
||||||
|
- **Alternative: Synapse** (mature, feature-complete) for scale.
|
||||||
|
|
||||||
|
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
|
||||||
|
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
| Phase | Scope | This PR |
|
||||||
|
| ----- | --------------------------------------------------------------------------------------- | ------- |
|
||||||
|
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
|
||||||
|
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
|
||||||
|
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
|
||||||
|
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
|
||||||
|
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
|
||||||
|
|
||||||
|
## Back-compat & boundaries
|
||||||
|
|
||||||
|
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
|
||||||
|
- Fleet core never branches on connector kind; it depends only on the interface.
|
||||||
|
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
|
||||||
|
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
|
||||||
|
independent of the in-flight fleet-config PRs.
|
||||||
@@ -73,6 +73,37 @@ diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a d
|
|||||||
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
||||||
(orchestration model) for the rationale.
|
(orchestration model) for the rationale.
|
||||||
|
|
||||||
|
## Fleet roster — the two-agent floor and the role library
|
||||||
|
|
||||||
|
A fleet is **never a single agent**. The minimum viable fleet is **two**:
|
||||||
|
|
||||||
|
| Role | Mandate | Boundaries |
|
||||||
|
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| **Orchestrator** | The user's **single point of contact**. Owns the general flow, keeps agentic actions on-target, and **adds/removes agents from the fleet at will** to meet goals and user needs. Exactly **one** per fleet (the existing R5 invariant). | Delegates source work; never the sole worker. |
|
||||||
|
| **Enhancer** | The fleet's **continuous-improvement loop**. Monitors fleet activity, analyzes for enhancements/optimizations, builds a **plan of remediation**, and — **with the orchestrator** — upgrades fleet capability: tool creation/repair, skills, harness improvements, and **bug reports filed to Mosaic Stack** for proper remediation. Recommends which agents are needed. | **Does not code, review code, or perform delivery tasks.** Improvement and diagnosis only. |
|
||||||
|
|
||||||
|
> **Why two, not one:** the orchestrator drives delivery; the enhancer makes the fleet
|
||||||
|
> _get better at delivering_ over time. The enhancer is how the fleet self-heals its tools,
|
||||||
|
> skills, and harnesses, and how real defects flow back to Mosaic Stack as bug reports.
|
||||||
|
> Together they are the irreducible core — every other role is added on demand.
|
||||||
|
|
||||||
|
A **general** fleet starts at this floor: the orchestrator (advised by the enhancer)
|
||||||
|
materializes whatever roles prove necessary over the mission's life. Specialized presets
|
||||||
|
(coding, research, etc.) seed additional roles up front, but all reduce to the same two-agent
|
||||||
|
spine plus an on-demand **role library**:
|
||||||
|
|
||||||
|
| Role profile | Purpose |
|
||||||
|
| ------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| **orchestrator** | point of contact, flow control, fleet composition (1 per fleet) |
|
||||||
|
| **enhancer** | fleet monitoring, optimization, tool/skill/harness upgrades, upstream bug reports |
|
||||||
|
| **coder** | implementation (worker; stops at PR-open) |
|
||||||
|
| **code review** | independent code review gate |
|
||||||
|
| **security review** | security/auth/secret review gate |
|
||||||
|
| **research** | investigation, synthesis, options analysis |
|
||||||
|
| **board** | deliberation panel — moonshot, contrarian, technical, business, financial lenses |
|
||||||
|
| **operations** | infra, deploy, health, incident response |
|
||||||
|
| _…extensible_ | new profiles added as missions demand (orchestrator + enhancer decide) |
|
||||||
|
|
||||||
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
||||||
|
|
||||||
Every artifact, starting Phase 2, MUST:
|
Every artifact, starting Phase 2, MUST:
|
||||||
@@ -102,7 +133,7 @@ Every artifact, starting Phase 2, MUST:
|
|||||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
||||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
||||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: orchestrator+reviewer; ephemeral workers per lane) | planned |
|
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
||||||
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning | planned |
|
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning | planned |
|
||||||
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity | planned |
|
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity | planned |
|
||||||
|
|
||||||
@@ -121,6 +152,28 @@ Every artifact, starting Phase 2, MUST:
|
|||||||
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
||||||
which `fleet init` should automate.
|
which `fleet init` should automate.
|
||||||
|
|
||||||
|
## Decisions of record (2026-06-22, with Jason)
|
||||||
|
|
||||||
|
- **Two-agent floor:** every fleet has, at minimum, an **orchestrator** and an **enhancer**.
|
||||||
|
The orchestrator is the user's point of contact and composes the fleet; the enhancer runs the
|
||||||
|
continuous-improvement loop (monitor → analyze → remediate → upgrade tools/skills/harness →
|
||||||
|
file Mosaic Stack bug reports) and **does not code or review**.
|
||||||
|
- **Role library:** orchestrator, enhancer, coder, code review, security review, research,
|
||||||
|
board (moonshot/contrarian/technical/business/financial), operations — extensible; the
|
||||||
|
orchestrator (advised by the enhancer) adds roles as missions demand.
|
||||||
|
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
|
||||||
|
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
|
||||||
|
on Discord** via the Claude Code discord channel plugin (w-jarvis).
|
||||||
|
|
||||||
|
## Future enhancements (north-star, post-MVP — not on the MVP track)
|
||||||
|
|
||||||
|
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly
|
||||||
|
implements the basic Discord functions **and native Discord threads**. Threads let a user
|
||||||
|
separate conversation topics with the orchestrator (the pattern proven by the Hermes agent).
|
||||||
|
A major enhancement over the current third-party channel plugin; **not required for the MVP**,
|
||||||
|
but a committed north-star target. `ASSUMPTION:` ships as a Mosaic-owned plugin so the fleet
|
||||||
|
controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
|
||||||
|
|
||||||
## Assumptions (veto-able)
|
## Assumptions (veto-able)
|
||||||
|
|
||||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
||||||
|
|||||||
19
docs/scratchpads/f4-matrix-connector.md
Normal file
19
docs/scratchpads/f4-matrix-connector.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# F4 — Orchestrator chat connector + Matrix (#616)
|
||||||
|
|
||||||
|
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
|
||||||
|
|
||||||
|
## Phase 1 (this PR) — abstraction + scaffold
|
||||||
|
|
||||||
|
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
|
||||||
|
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
|
||||||
|
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
|
||||||
|
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
|
||||||
|
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
|
||||||
|
|
||||||
|
## Phase 2+ (follow-ups, in the doc)
|
||||||
|
|
||||||
|
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
|
||||||
26
docs/scratchpads/fleet-enhancer-floor.md
Normal file
26
docs/scratchpads/fleet-enhancer-floor.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Fleet enhancer role + two-agent floor (#614)
|
||||||
|
|
||||||
|
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
|
||||||
|
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
|
||||||
|
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
|
||||||
|
unchanged.
|
||||||
|
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
|
||||||
|
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
|
||||||
|
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
|
||||||
|
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
|
||||||
|
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
|
||||||
|
code or review).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
|
||||||
|
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
|
||||||
|
prettier/sanitize clean. TDD on the init guarantee + remove protection.
|
||||||
|
|
||||||
|
## Stacking
|
||||||
|
|
||||||
|
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.
|
||||||
20
docs/scratchpads/fleet-polish-bundle.md
Normal file
20
docs/scratchpads/fleet-polish-bundle.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Fleet-polish bundle — boot-survival symmetry (#611)
|
||||||
|
|
||||||
|
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
|
||||||
|
|
||||||
|
## Three fixes
|
||||||
|
|
||||||
|
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
|
||||||
|
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
|
||||||
|
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
|
||||||
|
(best-effort, gated on !--keep-files).
|
||||||
|
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
|
||||||
|
independent of --start) — symmetry with disable-on-remove.
|
||||||
|
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
|
||||||
|
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
|
||||||
|
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
|
||||||
|
- TDD on the disable bug per contract.
|
||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: coder0
|
- name: coder0
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: implementer
|
class: implementer
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: generalist
|
- name: generalist
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: worker
|
class: worker
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: coder0
|
- name: coder0
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: implementer
|
class: implementer
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: researcher0
|
- name: researcher0
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: researcher
|
class: researcher
|
||||||
|
|||||||
41
packages/mosaic/framework/fleet/roles/enhancer.md
Normal file
41
packages/mosaic/framework/fleet/roles/enhancer.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Enhancer — fleet role definition
|
||||||
|
|
||||||
|
The **enhancer** is one half of the fleet's two-agent floor: every fleet runs, at
|
||||||
|
minimum, an **orchestrator** and an **enhancer**. The orchestrator drives delivery;
|
||||||
|
the enhancer makes the fleet _get better at delivering_ over time.
|
||||||
|
|
||||||
|
It is a **core, always-on** agent (`class: enhancer`, `persistent_persona: true`),
|
||||||
|
not an ephemeral per-lane worker.
|
||||||
|
|
||||||
|
## Mandate
|
||||||
|
|
||||||
|
The enhancer runs the fleet's **continuous-improvement loop**:
|
||||||
|
|
||||||
|
1. **Monitor** fleet activity — agents, heartbeats, sessions, throughput, failures.
|
||||||
|
2. **Analyze** for enhancements and optimizations — friction, gaps, recurring defects,
|
||||||
|
missing or broken tools, skill/harness shortfalls.
|
||||||
|
3. **Plan** a remediation: a concrete improvement with rationale and expected effect.
|
||||||
|
4. **Upgrade fleet capability — with the orchestrator** — tool creation/repair, skills,
|
||||||
|
harness improvements. The orchestrator owns fleet composition; the enhancer advises and
|
||||||
|
implements improvements to the _means of production_, not the product.
|
||||||
|
5. **File upstream bug reports** to Mosaic Stack for real defects, so they flow back to the
|
||||||
|
framework for proper remediation rather than being patched over locally.
|
||||||
|
6. **Recommend which agents are needed** — advise the orchestrator on roles to add/remove as
|
||||||
|
the mission evolves.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- **Does NOT write product/source code.**
|
||||||
|
- **Does NOT review code** (that is the code-review / security-review roles).
|
||||||
|
- **Does NOT perform delivery tasks.**
|
||||||
|
|
||||||
|
Improvement and diagnosis only. When the enhancer finds work that requires coding or review,
|
||||||
|
it files it (bug report / recommendation) and the orchestrator materializes the right worker.
|
||||||
|
|
||||||
|
## Why two, not one
|
||||||
|
|
||||||
|
The orchestrator alone optimizes for _this_ delivery; the enhancer optimizes for _every future_
|
||||||
|
delivery — self-healing the fleet's tools, skills, and harnesses, and routing real defects
|
||||||
|
upstream. Together they are the irreducible core; every other role is added on demand.
|
||||||
|
|
||||||
|
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).
|
||||||
@@ -113,6 +113,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"connector": {
|
||||||
|
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["kind"],
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"enum": ["tmux", "discord", "matrix"]
|
||||||
|
},
|
||||||
|
"matrix": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["homeserver_url", "user_id", "room_id"],
|
||||||
|
"properties": {
|
||||||
|
"homeserver_url": { "type": "string" },
|
||||||
|
"user_id": { "type": "string" },
|
||||||
|
"room_id": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discord": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["channel_id"],
|
||||||
|
"properties": {
|
||||||
|
"channel_id": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import {
|
|||||||
buildEnableLingerCommand,
|
buildEnableLingerCommand,
|
||||||
buildFleetServiceCommand,
|
buildFleetServiceCommand,
|
||||||
buildSystemdEnableCommand,
|
buildSystemdEnableCommand,
|
||||||
|
buildSystemdDisableCommand,
|
||||||
buildSystemdShowCommand,
|
buildSystemdShowCommand,
|
||||||
buildTmuxListPanesCommand,
|
buildTmuxListPanesCommand,
|
||||||
buildTmuxListSessionsCommand,
|
buildTmuxListSessionsCommand,
|
||||||
classifySendResult,
|
classifySendResult,
|
||||||
countOrchestrators,
|
countOrchestrators,
|
||||||
|
countEnhancers,
|
||||||
detectDrift,
|
detectDrift,
|
||||||
enableFleetUnits,
|
enableFleetUnits,
|
||||||
FLEET_PROFILES,
|
FLEET_PROFILES,
|
||||||
@@ -983,6 +985,129 @@ describe('fleet ps — drift detection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
||||||
|
async function rosterHome(agents: string): Promise<string> {
|
||||||
|
const home = await tempDir();
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('buildSystemdDisableCommand returns the systemctl --user disable array', () => {
|
||||||
|
expect(buildSystemdDisableCommand('mosaic-agent@coder0.service')).toEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'disable',
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet remove DISABLES the unit so a removed agent cannot resurrect on boot', async () => {
|
||||||
|
const home = await rosterHome(
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: orchestrator',
|
||||||
|
' runtime: pi',
|
||||||
|
' class: orchestrator',
|
||||||
|
' - name: coder0',
|
||||||
|
' runtime: codex',
|
||||||
|
' class: worker',
|
||||||
|
].join('\n') + '\n',
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
||||||
|
expect(calls).toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'disable',
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
// stop must still happen too
|
||||||
|
expect(calls).toContainEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet add ENABLES the new agent unit for boot-survival', async () => {
|
||||||
|
const home = await rosterHome(
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
try {
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'add',
|
||||||
|
'coder1',
|
||||||
|
'--runtime',
|
||||||
|
'codex',
|
||||||
|
'--class',
|
||||||
|
'worker',
|
||||||
|
'--no-start',
|
||||||
|
]);
|
||||||
|
expect(calls).toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'enable',
|
||||||
|
'mosaic-agent@coder1.service',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet init --write enforces the two-agent floor (1 orchestrator + >=1 enhancer)', async () => {
|
||||||
|
// The general profile must yield exactly one orchestrator AND at least one
|
||||||
|
// enhancer; the guarantee is enforced (not just warned). Happy path writes cleanly.
|
||||||
|
const home = await tempDir();
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, {
|
||||||
|
runner: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||||
|
mosaicHome: home,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'init',
|
||||||
|
'--profile',
|
||||||
|
'general',
|
||||||
|
'--write',
|
||||||
|
]);
|
||||||
|
const written = await readFile(join(home, 'fleet', 'roster.yaml'), 'utf8');
|
||||||
|
const orchestrators = (written.match(/class:\s*orchestrator/g) ?? []).length;
|
||||||
|
const enhancers = (written.match(/class:\s*enhancer/g) ?? []).length;
|
||||||
|
expect(orchestrators).toBe(1);
|
||||||
|
expect(enhancers).toBeGreaterThanOrEqual(1);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fleet install — auto-enable units for boot-survival', () => {
|
describe('fleet install — auto-enable units for boot-survival', () => {
|
||||||
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
|
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
|
||||||
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
|
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
|
||||||
@@ -2188,47 +2313,63 @@ describe('fleet preset rosters', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('general preset: orchestrator + one generalist worker', async () => {
|
it('general preset: orchestrator + enhancer + one generalist worker', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
|
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer', 'generalist']);
|
||||||
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
||||||
|
expect(roster.agents.find((a) => a.name === 'enhancer')?.className).toBe('enhancer');
|
||||||
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
|
it('coding preset: orchestrator + enhancer + coder0 + coder1 + reviewer', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||||
'orchestrator',
|
'orchestrator',
|
||||||
|
'enhancer',
|
||||||
'coder0',
|
'coder0',
|
||||||
'coder1',
|
'coder1',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
|
it('research preset: orchestrator + enhancer + researcher0 + researcher1 + analyst', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||||
'orchestrator',
|
'orchestrator',
|
||||||
|
'enhancer',
|
||||||
'researcher0',
|
'researcher0',
|
||||||
'researcher1',
|
'researcher1',
|
||||||
'analyst',
|
'analyst',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
|
it('hybrid preset: orchestrator + enhancer + coder0 + researcher0 + reviewer', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||||
'orchestrator',
|
'orchestrator',
|
||||||
|
'enhancer',
|
||||||
'coder0',
|
'coder0',
|
||||||
'researcher0',
|
'researcher0',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('every non-minimal preset carries an enhancer (two-agent floor)', async () => {
|
||||||
|
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||||
|
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||||
|
expect(countOrchestrators(roster)).toBe(1);
|
||||||
|
expect(countEnhancers(roster)).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(roster.agents.find((a) => a.className === 'enhancer')?.runtime).toBe('claude');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
||||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||||
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
|
// Core agents (orchestrator + enhancer) run claude; only ephemeral workers are pi.
|
||||||
|
const workers = roster.agents.filter(
|
||||||
|
(a) => a.className !== 'orchestrator' && a.className !== 'enhancer',
|
||||||
|
);
|
||||||
for (const worker of workers) {
|
for (const worker of workers) {
|
||||||
expect(worker.runtime).toBe('pi');
|
expect(worker.runtime).toBe('pi');
|
||||||
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
||||||
@@ -2370,6 +2511,43 @@ describe('fleet add/remove — pure helpers', () => {
|
|||||||
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
|
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('countEnhancers counts enhancer-class agents (two-agent floor)', () => {
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
...baseRoster,
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||||
|
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||||
|
{ name: 'coder0', runtime: 'codex', className: 'worker' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(countEnhancers(roster)).toBe(1);
|
||||||
|
expect(countEnhancers(baseRoster)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeAgentFromRoster throws when removing the sole enhancer (two-agent floor)', () => {
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
...baseRoster,
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||||
|
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(() => removeAgentFromRoster(roster, 'enhancer')).toThrow('sole enhancer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeAgentFromRoster allows removing an enhancer when another remains', () => {
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
...baseRoster,
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||||
|
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||||
|
{ name: 'enhancer2', runtime: 'claude', className: 'enhancer' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const updated = removeAgentFromRoster(roster, 'enhancer');
|
||||||
|
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer2']);
|
||||||
|
});
|
||||||
|
|
||||||
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
|
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
|
||||||
const yaml = serializeRosterToYaml(baseRoster);
|
const yaml = serializeRosterToYaml(baseRoster);
|
||||||
expect(typeof yaml).toBe('string');
|
expect(typeof yaml).toBe('string');
|
||||||
|
|||||||
@@ -227,6 +227,15 @@ export function buildSystemdEnableCommand(unit: string): string[] {
|
|||||||
return ['systemctl', '--user', 'enable', unit];
|
return ['systemctl', '--user', 'enable', unit];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the systemctl --user disable command for a given unit.
|
||||||
|
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
|
||||||
|
* boot pointing at deleted config (boot-survival symmetry with enable-on-add).
|
||||||
|
*/
|
||||||
|
export function buildSystemdDisableCommand(unit: string): string[] {
|
||||||
|
return ['systemctl', '--user', 'disable', unit];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the loginctl enable-linger command for a given user.
|
* Returns the loginctl enable-linger command for a given user.
|
||||||
* Linger allows user systemd services to survive logout.
|
* Linger allows user systemd services to survive logout.
|
||||||
@@ -872,20 +881,33 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
await mkdir(dirname(destination), { recursive: true });
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
await writeFile(destination, content);
|
await writeFile(destination, content);
|
||||||
|
|
||||||
// Validate: exactly one orchestrator required (R5) — friendly summary on success.
|
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
||||||
|
// one enhancer for every profile except the sanctioned no-orchestrator
|
||||||
|
// `minimal` preset. A mismatch means a corrupted/edited preset — fail hard
|
||||||
|
// rather than write a malformed fleet.
|
||||||
const written = await loadFleetRoster(destination);
|
const written = await loadFleetRoster(destination);
|
||||||
const orchCount = countOrchestrators(written);
|
const orchCount = countOrchestrators(written);
|
||||||
if (orchCount !== 1) {
|
const enhancerCount = countEnhancers(written);
|
||||||
process.stderr.write(
|
if (profile === 'minimal') {
|
||||||
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
|
|
||||||
);
|
|
||||||
console.log(
|
console.log(
|
||||||
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
|
`Initialized ${profile} fleet: ${written.agents.length} agent(s) (no orchestrator). Next: mosaic fleet install`,
|
||||||
|
);
|
||||||
|
} else if (orchCount !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Fleet init failed: the "${profile}" roster has ${orchCount} orchestrator agent(s), ` +
|
||||||
|
`expected exactly 1 (R5). The preset may be corrupted — re-install the framework.`,
|
||||||
|
);
|
||||||
|
} else if (enhancerCount < 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Fleet init failed: the "${profile}" roster has no enhancer agent. Every fleet keeps an ` +
|
||||||
|
`orchestrator + enhancer minimum (two-agent floor). The preset may be corrupted — ` +
|
||||||
|
`re-install the framework.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const workerCount = written.agents.length - 1;
|
const workerCount = written.agents.length - 1 - enhancerCount;
|
||||||
console.log(
|
console.log(
|
||||||
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`,
|
`Initialized ${profile} fleet: 1 orchestrator + ${enhancerCount} enhancer(s) + ` +
|
||||||
|
`${workerCount} worker(s). Next: mosaic fleet install`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1218,6 +1240,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
||||||
|
|
||||||
|
// Enable the unit for boot-survival (non-fatal) — symmetry with
|
||||||
|
// disable-on-remove. Independent of --start so a queued agent still
|
||||||
|
// survives a reboot once its unit exists.
|
||||||
|
try {
|
||||||
|
const enableResult = await runner(
|
||||||
|
...splitCommand(buildSystemdEnableCommand(`mosaic-agent@${name}.service`)),
|
||||||
|
);
|
||||||
|
if (enableResult.exitCode !== 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: could not enable mosaic-agent@${name}.service: ${enableResult.stderr || enableResult.stdout || 'non-zero exit'}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: enable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.start !== false) {
|
if (opts.start !== false) {
|
||||||
await runChecked(runner, buildFleetServiceCommand('start', name));
|
await runChecked(runner, buildFleetServiceCommand('start', name));
|
||||||
console.log(`Started mosaic-agent@${name}.service.`);
|
console.log(`Started mosaic-agent@${name}.service.`);
|
||||||
@@ -1254,6 +1294,26 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable the unit (non-fatal) so an enabled instance cannot resurrect on
|
||||||
|
// boot pointing at the now-deleted config — boot-survival symmetry with
|
||||||
|
// enable-on-add. Skipped only when --keep-files keeps the config in place.
|
||||||
|
if (!opts.keepFiles) {
|
||||||
|
try {
|
||||||
|
const disableResult = await runner(
|
||||||
|
...splitCommand(buildSystemdDisableCommand(`mosaic-agent@${name}.service`)),
|
||||||
|
);
|
||||||
|
if (disableResult.exitCode !== 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: could not disable mosaic-agent@${name}.service: ${disableResult.stderr || disableResult.stdout || 'non-zero exit'}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: disable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write updated roster
|
// Write updated roster
|
||||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||||
|
|
||||||
@@ -1894,6 +1954,15 @@ export function countOrchestrators(roster: FleetRoster): number {
|
|||||||
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count enhancer agents in a parsed roster. The two-agent floor (north-star)
|
||||||
|
* requires every non-minimal fleet to carry at least one enhancer alongside the
|
||||||
|
* sole orchestrator.
|
||||||
|
*/
|
||||||
|
export function countEnhancers(roster: FleetRoster): number {
|
||||||
|
return roster.agents.filter((a) => a.className === 'enhancer').length;
|
||||||
|
}
|
||||||
|
|
||||||
/** Valid runtime identifiers for fleet agents. */
|
/** Valid runtime identifiers for fleet agents. */
|
||||||
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
||||||
'pi',
|
'pi',
|
||||||
@@ -1936,6 +2005,15 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
|||||||
`Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`,
|
`Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Two-agent floor: never drop the last enhancer (the continuous-improvement
|
||||||
|
// loop). Symmetric with the sole-orchestrator guard.
|
||||||
|
const remainingEnhancerCount = remaining.filter((a) => a.className === 'enhancer').length;
|
||||||
|
if (remainingEnhancerCount === 0 && agent.className === 'enhancer') {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot remove agent "${name}": it is the sole enhancer. Every fleet keeps at least one ` +
|
||||||
|
`enhancer (two-agent floor). Add another enhancer first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...roster,
|
...roster,
|
||||||
agents: remaining,
|
agents: remaining,
|
||||||
|
|||||||
85
packages/mosaic/src/fleet/connectors/registry.spec.ts
Normal file
85
packages/mosaic/src/fleet/connectors/registry.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
KNOWN_CONNECTOR_KINDS,
|
||||||
|
isKnownConnectorKind,
|
||||||
|
resolveConnectorKind,
|
||||||
|
registerConnector,
|
||||||
|
hasConnector,
|
||||||
|
createConnector,
|
||||||
|
ConnectorNotImplementedError,
|
||||||
|
_resetConnectorRegistry,
|
||||||
|
} from './registry.js';
|
||||||
|
import type { ConnectorConfig, OrchestratorConnector } from './types.js';
|
||||||
|
|
||||||
|
function fakeConnector(kind: 'tmux' | 'discord' | 'matrix'): OrchestratorConnector {
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
send: async () => ({ delivered: true, messageId: 'x' }),
|
||||||
|
subscribe: () => () => {},
|
||||||
|
health: async () => ({ reachable: true, authenticated: true }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('connector registry (F4 Phase 1)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetConnectorRegistry();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows the three peer connector kinds', () => {
|
||||||
|
expect(KNOWN_CONNECTOR_KINDS).toEqual(['tmux', 'discord', 'matrix']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isKnownConnectorKind guards correctly', () => {
|
||||||
|
expect(isKnownConnectorKind('matrix')).toBe(true);
|
||||||
|
expect(isKnownConnectorKind('irc')).toBe(false);
|
||||||
|
expect(isKnownConnectorKind(42)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolveConnectorKind defaults to tmux when config is absent (back-compat)', () => {
|
||||||
|
expect(resolveConnectorKind(undefined)).toBe('tmux');
|
||||||
|
expect(resolveConnectorKind({ kind: 'matrix' })).toBe('matrix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createConnector throws ConnectorNotImplementedError for an unregistered kind', () => {
|
||||||
|
const cfg: ConnectorConfig = { kind: 'matrix' };
|
||||||
|
expect(() => createConnector(cfg)).toThrow(ConnectorNotImplementedError);
|
||||||
|
expect(() => createConnector(cfg)).toThrow(/not implemented yet/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createConnector with no config resolves the default kind (tmux) and reports it unimplemented in Phase 1', () => {
|
||||||
|
try {
|
||||||
|
createConnector();
|
||||||
|
throw new Error('expected throw');
|
||||||
|
} catch (err) {
|
||||||
|
expect(err).toBeInstanceOf(ConnectorNotImplementedError);
|
||||||
|
expect((err as ConnectorNotImplementedError).kind).toBe('tmux');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('register → has → create resolves a registered factory', () => {
|
||||||
|
expect(hasConnector('matrix')).toBe(false);
|
||||||
|
registerConnector('matrix', (cfg) => fakeConnector(cfg.kind));
|
||||||
|
expect(hasConnector('matrix')).toBe(true);
|
||||||
|
|
||||||
|
const connector = createConnector({ kind: 'matrix' });
|
||||||
|
expect(connector.kind).toBe('matrix');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the config through to the factory', () => {
|
||||||
|
let received: ConnectorConfig | null = null;
|
||||||
|
registerConnector('matrix', (cfg) => {
|
||||||
|
received = cfg;
|
||||||
|
return fakeConnector(cfg.kind);
|
||||||
|
});
|
||||||
|
const cfg: ConnectorConfig = {
|
||||||
|
kind: 'matrix',
|
||||||
|
matrix: {
|
||||||
|
homeserverUrl: 'https://matrix.internal',
|
||||||
|
userId: '@mos:internal',
|
||||||
|
roomId: '!room:internal',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
createConnector(cfg);
|
||||||
|
expect(received).toEqual(cfg);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
packages/mosaic/src/fleet/connectors/registry.ts
Normal file
76
packages/mosaic/src/fleet/connectors/registry.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Connector registry (F4 Phase 1).
|
||||||
|
*
|
||||||
|
* A tiny extensible registry so connector implementations (Phase 2: tmux,
|
||||||
|
* Discord, Matrix) register a factory by kind and fleet core resolves one from
|
||||||
|
* roster config without branching on kind. Phase 1 ships the registry + the
|
||||||
|
* config→kind resolution; the connector factories land in Phase 2.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ConnectorConfig,
|
||||||
|
type ConnectorKind,
|
||||||
|
type OrchestratorConnector,
|
||||||
|
DEFAULT_CONNECTOR_KIND,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
/** The set of connector kinds the framework recognizes. */
|
||||||
|
export const KNOWN_CONNECTOR_KINDS: readonly ConnectorKind[] = ['tmux', 'discord', 'matrix'];
|
||||||
|
|
||||||
|
/** Type guard: is `value` a known connector kind? */
|
||||||
|
export function isKnownConnectorKind(value: unknown): value is ConnectorKind {
|
||||||
|
return typeof value === 'string' && (KNOWN_CONNECTOR_KINDS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the connector kind from roster config. Absent config ⇒ the default
|
||||||
|
* (tmux) so existing rosters keep working unchanged (back-compat).
|
||||||
|
*/
|
||||||
|
export function resolveConnectorKind(config?: ConnectorConfig): ConnectorKind {
|
||||||
|
return config?.kind ?? DEFAULT_CONNECTOR_KIND;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A factory builds a live connector from its validated config. */
|
||||||
|
export type ConnectorFactory = (config: ConnectorConfig) => OrchestratorConnector;
|
||||||
|
|
||||||
|
/** Thrown when no factory is registered for a requested kind. */
|
||||||
|
export class ConnectorNotImplementedError extends Error {
|
||||||
|
constructor(public readonly kind: ConnectorKind) {
|
||||||
|
super(
|
||||||
|
`Connector "${kind}" is not implemented yet. ` +
|
||||||
|
`Register a factory via registerConnector('${kind}', …) (F4 Phase 2).`,
|
||||||
|
);
|
||||||
|
this.name = 'ConnectorNotImplementedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new Map<ConnectorKind, ConnectorFactory>();
|
||||||
|
|
||||||
|
/** Register a connector factory for a kind (idempotent — last registration wins). */
|
||||||
|
export function registerConnector(kind: ConnectorKind, factory: ConnectorFactory): void {
|
||||||
|
registry.set(kind, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a factory is registered for `kind`. */
|
||||||
|
export function hasConnector(kind: ConnectorKind): boolean {
|
||||||
|
return registry.has(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a connector from roster config. Throws `ConnectorNotImplementedError`
|
||||||
|
* when no factory is registered for the resolved kind (the Phase-1 default for
|
||||||
|
* every kind until Phase 2 registers them).
|
||||||
|
*/
|
||||||
|
export function createConnector(config?: ConnectorConfig): OrchestratorConnector {
|
||||||
|
const kind = resolveConnectorKind(config);
|
||||||
|
const factory = registry.get(kind);
|
||||||
|
if (!factory) {
|
||||||
|
throw new ConnectorNotImplementedError(kind);
|
||||||
|
}
|
||||||
|
return factory(config ?? { kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test/runtime helper: drop all registrations. */
|
||||||
|
export function _resetConnectorRegistry(): void {
|
||||||
|
registry.clear();
|
||||||
|
}
|
||||||
111
packages/mosaic/src/fleet/connectors/types.ts
Normal file
111
packages/mosaic/src/fleet/connectors/types.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* Orchestrator chat connectors (F4).
|
||||||
|
*
|
||||||
|
* A connector mediates the chat channel between the fleet **orchestrator** and
|
||||||
|
* its human operator. Connectors are PEERS — tmux (default), Discord, Matrix,
|
||||||
|
* and future first-party plugins — selected per fleet, never hardwired. Fleet
|
||||||
|
* core depends only on the small uniform interface below, so a new connector
|
||||||
|
* drops in without touching the fleet.
|
||||||
|
*
|
||||||
|
* The interface is deliberately minimal: send (orchestrator → human),
|
||||||
|
* subscribe (human → orchestrator), health (reachable/authed liveness). Thread
|
||||||
|
* support is optional metadata (`threadId`) so thread-capable connectors
|
||||||
|
* (Matrix rooms/threads, the future Mosaic Discord plugin) fit without an
|
||||||
|
* interface change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The connector kinds shipped/known to the framework. */
|
||||||
|
export type ConnectorKind = 'tmux' | 'discord' | 'matrix';
|
||||||
|
|
||||||
|
/** A message the orchestrator sends out to the human operator. */
|
||||||
|
export interface OutboundMessage {
|
||||||
|
/** Message body (markdown where the connector supports it). */
|
||||||
|
text: string;
|
||||||
|
/** Optional thread/topic id for thread-capable connectors. */
|
||||||
|
threadId?: string;
|
||||||
|
/** Optional attachment references (paths or URLs); connector-dependent. */
|
||||||
|
attachments?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A message received from the human operator. */
|
||||||
|
export interface InboundMessage {
|
||||||
|
/** Message body. */
|
||||||
|
text: string;
|
||||||
|
/** Thread/topic id if the connector carries one. */
|
||||||
|
threadId?: string;
|
||||||
|
/** Opaque sender identifier (connector-scoped). */
|
||||||
|
sender: string;
|
||||||
|
/** ISO-8601 timestamp the connector assigns/observes. */
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a send — the "ack" half of ack/health. */
|
||||||
|
export interface SendResult {
|
||||||
|
/** True when the connector accepted/delivered the message. */
|
||||||
|
delivered: boolean;
|
||||||
|
/** Connector-assigned message id when available. */
|
||||||
|
messageId?: string;
|
||||||
|
/** Reason when `delivered` is false. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liveness of a connector — the "health" half of ack/health. */
|
||||||
|
export interface ConnectorHealth {
|
||||||
|
/** The transport endpoint is reachable. */
|
||||||
|
reachable: boolean;
|
||||||
|
/** Credentials are valid / the connector is authenticated. */
|
||||||
|
authenticated: boolean;
|
||||||
|
/** ISO-8601 of the last successful interaction, if any. */
|
||||||
|
lastSeen?: string;
|
||||||
|
/** Human-readable detail (e.g. failure reason). */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe handle returned by `subscribe`. */
|
||||||
|
export type Unsubscribe = () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The uniform contract every orchestrator chat connector implements. Small by
|
||||||
|
* design — send / subscribe / health — so connectors are interchangeable and
|
||||||
|
* fleet core never branches on connector kind.
|
||||||
|
*/
|
||||||
|
export interface OrchestratorConnector {
|
||||||
|
/** Which kind of connector this is. */
|
||||||
|
readonly kind: ConnectorKind;
|
||||||
|
/** Send a message from the orchestrator to the operator. */
|
||||||
|
send(message: OutboundMessage): Promise<SendResult>;
|
||||||
|
/** Subscribe to inbound operator messages; returns an unsubscribe handle. */
|
||||||
|
subscribe(handler: (message: InboundMessage) => void): Unsubscribe;
|
||||||
|
/** Report connector liveness (reachable + authenticated). */
|
||||||
|
health(): Promise<ConnectorHealth>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connector configuration carried by the roster (the `connector` block).
|
||||||
|
* Secrets (access tokens, bot tokens) are NEVER stored here — they come from
|
||||||
|
* the environment (the gateway env-config pattern). Absent config ⇒ tmux.
|
||||||
|
*/
|
||||||
|
export interface ConnectorConfig {
|
||||||
|
kind: ConnectorKind;
|
||||||
|
/** Matrix connector settings (homeserver + room); token via env. */
|
||||||
|
matrix?: MatrixConnectorConfig;
|
||||||
|
/** Discord connector settings (channel); token via env. */
|
||||||
|
discord?: DiscordConnectorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatrixConnectorConfig {
|
||||||
|
/** Local homeserver base URL, e.g. https://matrix.example.internal */
|
||||||
|
homeserverUrl: string;
|
||||||
|
/** Full Matrix user id of the orchestrator, e.g. @mos:example.internal */
|
||||||
|
userId: string;
|
||||||
|
/** Room id/alias the orchestrator converses in. */
|
||||||
|
roomId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordConnectorConfig {
|
||||||
|
/** Channel id the orchestrator converses in. */
|
||||||
|
channelId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The default connector when a roster declares none (back-compat). */
|
||||||
|
export const DEFAULT_CONNECTOR_KIND: ConnectorKind = 'tmux';
|
||||||
Reference in New Issue
Block a user