Skip to content

Decision 138: a manager is the worker's router over a fleet facade — instance.role: manager, a registry in cli-config.yaml, members trusted by name, ambiguity refused ​

  • Status: accepted (the owner, PR #436, 2026-09-29)
  • Date: 2026-09-29
  • Work item: issue-374
  • Deciders: MadaraUchiha-314 (the ask), the-loop (design); MadaraUchiha-314 (owner, at the spec gate and the PR)
  • Refines: decision-110 (an instance is a named, scoped CLI config; the manager is a client, not a peer protocol), decision-059 (the plane carries no in-app auth), decision-087 (the stream is SSE)

Context ​

Since issue-322 an operator can run several instances of the-loop, each a named, scoped cli-config.yaml serving one identical /api/v1 at its own address; the dashboard takes one address. Issue-374 asks for a manager: an instance whose config says it manages others, which serves the same API so the dashboard needs no change, whose registry of instances is editable from the dashboard and from a hot-reloaded file through one code path, and a new tab to see and manage each instance.

Decision-110 D8 left the seams — one identical surface per instance, GET /api/v1/instance naming each, control.instance on every record — and said the manager would be a client of N base URLs.

Decision ​

#What was chosenWhy
D1The role is instance.role in cli-config.yaml, and the registry is instance.manager.instances[] {name, url} in the same block.Decision-110 D1: an instance is a running CLI config, so the file that names it names its role. The registry is three keys away from the name it qualifies.
D2A manager is also a worker, and its own first member: role: manager adds the fleet beside the worker's responsibilities and removes none; the manager's own state is served in-process under its own name, never registered as a URL.The owner's call at the spec gate (PR #436): an instance that becomes a manager keeps its worker responsibilities. It also keeps the config small and the fleet flat — a box that both manages and works runs one instance, not two.
D3One router, two facades: routes.build_router(holder, facade=…) and mcp.build_server(…, facade=…); the worker's facade is the_loop.core, the manager's is the_loop.manager.facade with the same function names and signatures."Exactly the same API surface" becomes a property of construction, and the contract parity test proves it for both applications. A second router would drift; a reverse proxy could not aggregate.
D4The registry is a config key written through the config route: POST /instances/register and /unregister — and the CLI's the-loop instances register and unregister, thin clients of them — call core.config.update_config with a sparse patch; the file is the only store.The ask's "both should converge to the same code path": a hand edit and a dashboard edit are indistinguishable in the file, reach the service through the one per-request refresh, and inherit the splice's comment preservation, validation and atomic write.
D5A member is trusted by its name, checked at probe: GET /instance must answer with the registered name and role: worker before anything is served from or sent to it; an unnamed worker cannot be a member.A URL is an address, not an identity. A re-used port, a wrong host or a redirect must not hand another box's sessions to the board or the board's commands to another box.
D6Ambiguity refuses: a key that resolves to two members is 409 naming them, never first-registered-wins, unless instance names one.Issue-322 allows a work item declared on two instances; a stop sent to the first, or to both, is the two-daemons-steering-one-item failure decision-110 D6 avoided.
D7The instance parameter is part of the one contract, optional on every keyed operation; a worker accepts only its own name.A client need not know which role it talks to; the same dashboard bundle drives a worker and a manager, and "manage one instance individually" is one parameter, not a second surface.
D8The stream is a fan-in of member streams with a per-member cursor (name=offset,…), one upstream per live member however many subscribers.Polling members for events would re-create the latency issue-239 removed; a composite cursor keeps lossless resume per member, and the existing broker keeps the subscriber bound.
D9The instances family is served by every role — a worker answers with itself.One renderer and one code path in the dashboard; the tab is not a manager-only feature that a lone operator must be told to ignore.
D10The client is stdlib on threads; no new dependency.the_loop.client is already urllib; a bounded pool and one thread per upstream stream cost nothing the package does not already carry.
D11An unknown role or an unnamed manager refuses to boot, before the bind and the run lock.A mistyped role must neither silently spawn sessions (the worker reading) nor silently manage nothing (the manager reading); the service.cors refusal is the precedent. Every other reader warns and reads worker, so status still answers.

Consequences ​

Good. The dashboard points at one URL and sees the fleet as one instance, each row saying which instance owns it; an operator adds a box from the tab or the file and gets the same result; one instance is managed on its own with one parameter; a member that answers wrongly is never trusted; the surface cannot drift because there is one of it.

Costs, accepted. A manager's box serves two kinds of load; a keyed operation costs a cached probe plus a call; 30 operations gain an optional parameter; a worker describes itself on a route a lone operator will not need; the dashboard's row key becomes instance@ref; whoever reaches the manager reaches every member's config and restart — the reach is the point, and the guide names it.

Alternatives considered ​

AlternativeWhy not
A top-level manager block, or a separate fleet.yamlA second place for what the instance block already qualifies; a second file outside the reload and the config route (D1, D4)
A manager that does no work (a pure aggregator)The owner's decision at the gate: a manager keeps its worker responsibilities (D2); a pure aggregator would also mean a second process on any box that both manages and works
A second router for the managerDrift; the parity test would prove only that the files matched on the day they were written (D3)
A reverse proxy in front of N instancesCannot aggregate a list read or route a keyed one; every path would need a prefix, which is the second surface the ask forbids
An in-memory registry with a register API, persisted separatelyTwo stores to reconcile; the ask's convergence would be a synchronisation problem (D4)
Trust by URL aloneA re-used address or a redirect is trusted; nothing checks that what answers is what was registered (D5)
First-registered-wins on an ambiguous refTwo daemons steering one item, from one click (D6)
Per-instance routes (/api/v1/instances/<name>/sessions/…)A second surface the dashboard would have to learn; instance as a parameter keeps one (D7)
Polling members for eventsThe latency issue-239 removed, times N (D8)
An async HTTP client (httpx, aiohttp)A new dependency for one bounded pool and N long-lived reads (D10)
Unknown role narrows to workerFails open into spawning sessions on a typo (D11)
Members announcing themselves to a manager (discovery)A second trust boundary and a second protocol with no requirement behind it; the operator registers

Released under the MIT License.