The subtree search returned early whenever it found *any* group member,
so an in-tree stage that was busy or sleeping on a timer masked a
reparented member genuinely parked on the tty.
Checking every member instead would have meant a /proc scan per session
per tick whenever nothing was parked — the common case. ProcGroups
snapshots /proc once per activity pass and indexes by process group, so
the cost is one walk however many sessions the daemon owns, and the
subtree walk disappears: the index is a superset of it.
Also fixes the background-helper test, which had stopped discriminating.
It polled until awaiting_input was true and caught a moment before the
helper spawned, so it passed against a deepest-descendant walk too. It now
waits for the helper to exist first. Both regression tests were re-checked
by patching each old implementation back in.
Reported in review on #51.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A member of the foreground group can sit outside the pane's subtree: a
stage that forks a terminal-reading child and exits leaves that child
reparented to init while it keeps the foreground pgid. The subtree search
finds nothing and the session reads as idle.
An empty subtree result now falls back to scanning /proc for the group.
The scan only runs when the cheap path found nothing, so the common case
still costs a handful of reads.
Tested at the level it can honestly be tested: scan_for_pgid against this
process's own group, and against a group that cannot exist. Reproducing
the reparenting scenario end to end is not practical — setpgid across
sessions is EPERM, and from a shell the window closes as soon as the shell
reclaims the terminal.
Reported in review on #51.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tpgid names a process *group*, not a process. Its leader can exit while
another member still holds the terminal — a foreground pipeline whose
first stage finished — and then /proc/<tpgid> does not exist, so reading
it directly reported the session idle while it sat waiting on the user.
awaiting_input now searches the pane's subtree for live processes whose
pgrp matches, and asks whether any is parked on the tty. Members stay in
that subtree because their parent shell is alive, so they never reparent.
Reported in review on #51. The regression test needs an interactive shell
— job control is what puts a pipeline in its own process group — and waits
for the leader to actually be gone before asserting, otherwise it passes
against the old lookup while the shell is still foreground.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
awaiting_input walked `children` to the deepest descendant, guessing that
the newest child is the foreground one. An agent that starts a background
helper and then blocks on read landed on the helper, so a session genuinely
waiting on the user was reported as idle.
tpgid in /proc/<pid>/stat is the kernel's own answer — the foreground
process group of the controlling terminal — and the group leader's pid is
the group id. One field read replaces a 64-deep walk.
Adds tests/activity.rs: the first end-to-end coverage of this pipeline,
which had none. The background-helper case fails against the old walk and
passes against tpgid.
Also makes the attach handler subscribe before upgrading. Checking
existence and then subscribing inside the callback left a window for the
liveness watcher to reap in between, which attach::serve compensated for
with a clean close; acquiring the subscription up front means the 404 and
the subscription are one step and the window does not exist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SPA attach had no reconnect at all. Any dropped frame closed the socket,
`onClosed` fired, and the pane was destroyed permanently — the TUI
reconnects forever on the same endpoint. Session death also reached the UI
on two uncoordinated channels: the row from the events socket, the pane
from `ws.onclose`, with nothing reconciling them, so either could happen
without the other.
The catalog is now the authority. `shouldRetryClose` decides from session
status: Running means the link dropped, so retry with 500ms-to-10s
backoff; Dead means the PTY stream was sealed and nothing more is coming;
absent means it is gone. Application closes (4000+, a relayed daemon
rejection) and clean closes are never retried. The shell also closes any
pane whose session has left the catalog. The policy lives in reconnect.ts
so it is tested without a DOM.
kill_session checked `has()` before `kill()`, which only opened a window
for the liveness watcher to reap in between — turning a 404 into a 502.
`kill` reports the miss itself.
with_spawn_history logged and carried on when `Arc::get_mut` failed,
producing a daemon whose recent-dirs picker is permanently empty with
nothing to show for it. It panics now: that is a construction-order
mistake, at startup, in code that runs once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maestro-cli/src/config.rs was two pass-throughs, a newtype error, a
two-field projection of MaestroConfigFile, and one four-line or_else with
a single caller. `resolve_daemon_url` moves onto MaestroConfigFile, next
to the schema it reads, and the module goes; its tests move with it.
`[web].dev_proxy_target` is deleted end to end — schema, env var,
normalisation, three tests, README line, and the startup warning that
admitted the server ignores it.
MaestroConfigFile::save had no callers outside its own tests. Deleting it
also removes every skip_serializing_if, the five is_empty impls, and the
Serialize derives that existed only to make its output tidy.
BroadcastTuning was a struct that was never instantiated: all three uses
were `::DEFAULT.field` in const position and there is no [broadcast]
section. Now two plain consts.
$MAESTRO_CONFIG was honoured twice, by resolve_config_path and by clap's
`env =`. Every binary already gets it through clap, so resolve_config_path
and CONFIG_PATH_ENV go and the TUI falls back to default_config_path like
everything else.
Also collapses three layers of 1:1 forwarding: the umbrella's cli_dispatch
shim, and four near-identical 8-line functions in cli/dispatch.rs that
differed only in which run_* they called.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each was a typed field that type-checked on both sides and carried
nothing across.
SessionStatus::Dead was never constructed by the daemon, yet the TUI, CLI
and SPA all render it and DESIGN.md specifies the exited badge. Sessions
were removed on death, never marked. They are now marked Dead and kept
until the operator dismisses them, with the PtyBridge sealed rather than
dropped: attached clients still get a Close frame (NAP-226), and the
replay buffer survives so a later attach can read what a crashed agent
printed. Previously that output was destroyed. Dead sessions are
read-only and do not survive a daemon restart.
CreateSessionRequest.env was accepted, stored, echoed back and dropped —
new_session_piped had no env parameter. It now reaches tmux as sorted
`-e KEY=VAL` flags. A key containing `=` or NUL is rejected, since tmux
would parse it as a different assignment than the caller wrote.
PROTOCOL_VERSION was written by the daemon and read by nobody. Adds
`maestro_protocol::is_compatible`, compared on the major component only;
`maestro list` shows the daemon version and the TUI warns on mismatch.
Also stops mapping every non-404 session error to 502: an invalid env key
is a client mistake and now answers 400.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A background task, a second broadcast channel and a whole module existed
to remap SessionEvent onto CatalogEvent one variant at a time.
SessionManager now emits CatalogEvent directly.
Deletes local_events.rs, the publisher task, the LocalEventBus channel,
enum SessionEvent, LocalApiState::events, and the duplicate capacity
constant — events_capacity was declared twice for one logical stream.
Lag semantics improve. There were two lag points: the publisher falling
behind the manager, which broadcast one Lagged to every client, and each
WS subscriber falling behind the bus. Only the per-subscriber one is left,
which pump_events already handled — a slow client now makes only itself
resync.
Adds the first daemon-level tests for WS /api/events: the Snapshot-then-
deltas contract, and that a late subscriber gets a Snapshot of current
state. Both were described only in a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SessionManager held a concrete Tmux, so its decisions — spawn rollback
ordering, liveness diffing, activity change-detection — could only be
reached by forking a real tmux server. Inner now holds an Arc<dyn Panes>;
Tmux is the production adapter, FakePanes the one tests drive.
Arc<dyn Panes> rather than a type parameter: a generic would spread
through LocalApiState, attach and cli, and one vtable hop is free next to
a fork+exec.
Adds tests/session_decisions.rs — six cases, no subprocess, 0.24s.
Replaces the chmod-0o500 rollback test, which detected root and returned
early, so on CI it asserted nothing; the failure is now forced by a
regular file sitting where the sidecar directory's parent must be, which
no uid can work around. It also asserts the rollback *order*, not just
the end state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Killing the last session shuts the server down, and tmux >= 3.7 reports
that to the still-connected client with a phrasing `is_no_server_error`
did not match. `list_sessions` then returned Err, and `run_liveness_pass`
logs a warning and returns — so the pass reaped nothing.
Silent by construction: nothing asserted the phrasing set, and the only
symptom was an integration test that looked flaky. Both now covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CLI, TUI and end-to-end harness each carried their own client: three
reqwest builders with three timeout policies, three copies of the
success-or-body check, and route strings at every call site. Two of them
discarded the daemon's structured error and reported only a status code.
Deletes `maestro-cli/src/http.rs`, both `ws_base` copies in the TUI, the
CLI's partial `DaemonInfo` (it dropped `version` and `presets`), and the
e2e harness's `check_ok`. `maestro-web` gains a protocol dependency and
stops building the error envelope with a format string and reading it back
as untyped JSON; the daemon and the proxy now register routes from the same
constants.
`maestro_web::router` returns `Result` — an unusable daemon URL fails at
startup instead of at first connect.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
load() and load_or_default() differed only in whether a missing file was
an error. Nothing wants the error: every field has a default. Keeps
load(), drops the MissingFile variant, whose message pointed at the
`maestro daemon configure` subcommand the single-host refactor deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewrites CLAUDE.md as the current-architecture reference, drops the
registry from INSTALL/PRODUCT/DESIGN and the web README, deletes the
registry Dockerfile, and marks the dated design specs superseded where
they describe the registry, JWT auth, or cross-host attach.
The installer loses its configure step — every config field now has a
default, so a daemon starts with no config file at all.
Maestro is now single-host: one daemon owns the machine's sessions and
clients talk to it directly. Removes the registry crate, the daemon's
registry client/broker/catalog mirror, JWT attach auth, cookie auth, and
the perimeter (T2) transport config.
The daemon API is now unauthenticated on its bind address, same trust
model as tmux. Session and CreateSessionRequest lose daemon_id;
RegistryEvent becomes CatalogEvent with no daemon variants; attach moves
to WS /api/attach/:id with no token.
Cross-host (T2-brokered) attach fails when no shared perimeter is
configured: the daemon rejects the upgrade with a structured 502
(no_perimeter_candidate / no_local_perimeter / perimeter_mismatch /
peer_unreachable), but maestro-web's WS proxy had already upgraded the
browser socket and merely dropped it on the upstream failure — the
attach pane flashed and vanished with no reason shown.
- maestro-web proxy: on a failed upstream handshake, relay the daemon's
error message to the browser as an application close frame (4502 +
reason) instead of a bare drop; parse the structured JSON error body.
- web attach.ts: on a 4000+ close with a reason, show it in the pane
header and terminal body and keep the pane up, rather than auto-closing.
- daemon: WARN at startup when no perimeter_id/private_addr is configured,
since cross-host attach is then unavailable.
- docs/INSTALL.md: document the required matching perimeter_id + per-host
private_addr for cross-host attach.
Closes#39
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the freeform `metadata.label` session grouping with nesting derived
from each session's working directory. The daemon's activity watcher already
polls each tmux pane every tick, so it now also captures
`#{pane_current_path}` and surfaces it as a new `Session.current_cwd`
(serde-default, back-compat); `cwd` stays the spawn dir for worktree
inference, spawn-similar, and recents.
Web catalog builds the tree from `current_cwd ?? cwd`, collapses the home
dir to `~`, and folds single-child folder chains so `~/git/maestro` reads as
one node. CLI swaps its LABEL column for an abbreviated DIR; the spawn forms
(web + TUI) and `--label` flag are removed.
The pane format stays space-delimited (parser splits the first three tokens
and keeps the remainder as the path) so a cwd containing spaces survives.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Presets spawn bare commands (`claude`, `opencode`); tmux runs them via
`$SHELL -c` non-interactively, so `~/.zshrc` — where the user's PATH
additions (`~/.local/bin`, `~/.opencode/bin`, …) live — is never sourced.
Under the stripped systemd-user PATH `maestrod` runs with, those binaries
aren't found → "command not found". `zsh` worked only because it's in the
system PATH.
Wrap each session command in `exec $SHELL -lic '<cmd>'` so an interactive
login shell sources the user's rc and restores their PATH before exec'ing
the real process. `exec` keeps the process tree flat so pane-command
inference is unaffected. Falls back to the bare command when $SHELL is unset.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an orthogonal activity dimension to sessions, inferred entirely from
structured tmux/`/proc` state — no PTY byte-stream parsing, no work on the
attach hot path.
Protocol: `ActivityState` enum (Active/Idle/AwaitingInput) + three additive,
serde-defaulted `Session` fields (`foreground`, `activity`, `last_activity_at`)
so the snapshot/diff flow stays forward-compatible with older daemons.
Detection (piggybacks the existing ~2s liveness tick, no new timer):
- foreground command via `tmux list-panes -F '#{pane_pid} #{pane_current_command}
#{pane_tty}'`;
- quiescence from a per-`PtyBridge` last-read `Instant`, bumped in `run_reader`;
- AwaitingInput via best-effort `/proc` walk to the deepest descendant: state
`S` + a non-timer `wchan` + an stdio fd on the pane tty. (`/proc/pid/syscall`
is ptrace-gated and unreadable for own sessions, so `wchan` distinguishes a
blocking read from a plain `sleep`.)
- combine: recent output → Active; else input-blocked → AwaitingInput; else
Idle. Emit `SessionUpdated` only on (activity, foreground) change — no diff
storms at steady state.
`maestro list` gains an ACTIVITY column and shows the live foreground. Pure
combine/parse logic is unit-tested; an e2e asserts `cat` → AwaitingInput and
`sleep` → Idle with correct foreground against real tmux.
Closes#18Closes#8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose the daemon-side spawn data (#24 recents, #25 gitroot, #22 presets) to
the web, mirroring the NAP-329 ListDir broker plumbing end to end so it works
across federated hosts.
Wire (maestro-protocol): `RecentDir`/`RecentDirs`/`GitRoot` payloads;
`DaemonInbound::RecentDirs`/`GitRoot` requests + `ForwardRecentDirs`/
`ForwardGitRoot` relay pairs + their `*Result` replies on both enums. All
forward-compatible via the existing `RpcResult` + `#[serde(other)] Unknown`.
Relay (broker + registry + registry_client): home daemon → registry → owning
daemon → back, with the same fail-fast-while-disconnected + RAII pending-entry
cleanup discipline as ListDir. The owning daemon answers RecentDirs from its
spawn history and GitRoot by resolving the path against its home dir
(shared `dirlist::resolve`) then walking up via `gitroot::find_root`.
Daemon HTTP (maestro-daemon):
- GET /api/daemons/:id/recent-dirs → RecentDirs
- GET /api/daemons/:id/gitroot?path= → GitRoot
- Presets ride GET /api/web/info (the documented DaemonInfo sibling) so the
form needs no extra round-trip; resolved once at startup from [spawn].presets
(a bad preset list now refuses startup).
Tests: protocol round-trip + forward-compat for the new variants; broker
forward round-trips + fail-fast; HTTP handler tests (self recents/gitroot,
presets advertisement, peer 503 without a registry link).
Closes#26
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `spawn_history` daemon module backing the spawn form's "recent dirs"
picker. Sidecars are deleted on kill, so they can't be the source — use an
append-only JSONL log at `<state_dir>/spawn-history.jsonl`, one record per
successful spawn (cwd, command, name, worktree_branch, at).
- Write: `SessionManager::create` appends after a successful spawn,
best-effort — a log-write failure only warns, never fails the spawn.
- Read: `recent_dirs()` folds newest-first, dedups by cwd, drops entries past
the recency cap, caps the result count.
- Prune: opportunistic `compact()` from `append` once the file passes a size
threshold; atomic tmp+rename rewrite.
- Config: `[daemon].spawn_history_days` (default 30, `0` disables). History is
wired into the manager at daemon startup.
Time-dependent logic is factored into `*_as_of(now)` helpers for
deterministic unit tests. Adds module unit tests (append/dedup/recency
prune/compaction/disabled/malformed/cap), a config resolution test, and
real-tmux integration tests proving create→append→read end to end.
Closes#24
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a pure `gitroot::find_root` resolver that walks up from a path to the
nearest ancestor holding a `.git` entry (directory or linked-worktree file)
and returns that repo toplevel, else None. Mirrors the `dirlist` module
shape: filesystem-only, no `git` subprocess, unit-tested in isolation.
Drives the spawn form's worktree section (#21): infer `base_repo` from the
chosen working dir so a worktree spawn needs only a branch. HTTP/wire
exposure lands in #26.
Closes#25
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The peer (T2) attach path had a per-pair access-control layer
(`[daemon.policy].allow_inbound`): a target-side allow-list of daemon ids
permitted to broker-attach, enforced inside JWT verification, hot-reloadable via
SIGHUP / `POST /api/policy/reload`, with a `maestro policy` CLI. On a flat-trust
perimeter (the operator's VPN/firewall is the boundary for which hosts connect)
that granularity is unused, and deny-by-default made it config friction —
cross-daemon attach silently failed until every peer was listed.
Remove the allow-list; keep the registry-minted pair-attach JWT and ALL its
binding checks (iss=registry, aud=target, sid=session, exp). The firewall gates
host->port reachability; it does not stop a reachable host from attaching to a
session it wasn't handed or replaying a token elsewhere — that's the JWT's job
and it stays. Only the `sub in allow_inbound` check is dropped.
Removed: DaemonPolicySection / [daemon.policy].allow_inbound (config + daemon),
policy.rs (SIGHUP listener + GET/POST /api/policy), the `maestro policy` CLI,
PairAttachVerifier's allow-list field/reload/snapshot + JwtError::NotAllowed,
and the e2e broker_rejects_when_target_doesnt_allow_inbound test. The NAP-265
pre-upgrade-502 path stays covered by broker_attach_returns_502_on_perimeter_mismatch.
Also dropped the unit's ExecReload=kill -HUP: with no SIGHUP handler left,
default SIGHUP disposition is terminate, so wiring `systemctl reload` to it would
kill the daemon. Re-introducing a per-pair gate later is a verify-time change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A registry-driven CreateSession/KillSession was reported to the registry twice:
the RPC reply (applied eagerly in create_session/kill_session) AND the async
SessionAdded/SessionRemoved the daemon emits on its own SessionManager stream.
Both landed, so the registry's catalog write had to be idempotent to absorb the
duplicate — masking bugs where the async event never arrives and setting a trap
for any future non-idempotent event (counters, partial updates).
Drop the duplicate at the source: the daemon parks the session id of an
RPC-initiated create/kill in a per-connection RpcCovered set and swallows the
matching async event (consumed exactly once) before it reaches the registry.
Spontaneous changes — sessions created via the daemon's local HTTP API, or
reaped on their own — are not parked and still forward, since the registry has
no other way to learn about them. A lag resync clears the set (the snapshot is
full truth). The registry's eager apply is now the authoritative single write,
not a race-bandaid; idempotency is no longer a correctness invariant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
D: the four independent `= 256` broadcast-channel constants (session events,
local event bus, PTY fan-out, registry events) now derive from one shared
`maestro_config::BroadcastTuning::DEFAULT`, so the coincidence is explicit and
there's a home for per-deployment tuning later.
E: `kill()` and `reap_dead_session()` hand-rolled the same FIFO/sidecar/
worktree/broadcast teardown, differing only in whether tmux still had a session
to kill. Extracted into `cleanup_record(inner, id, record, kill_tmux)`. kill's
sidecar-delete is now best-effort (the session is already out of the
authoritative map), matching the reaper.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add `parse_bind_addr`, `default_state_dir(subdir)`, and an `InvalidBindAddr`
variant to `maestro-config`, and route the daemon, registry, and web config
loaders through them. Each crate's hand-rolled `InvalidBindAddr` variant and
duplicated XDG state-dir resolution are gone; their loaders now `?` the shared
helper, which converts into their existing transparent `Inner` variant.
Also adds the shared `BroadcastTuning` struct (used by the next commit).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`cargo fmt --all -- --check` was already failing on `main` (CI run 149) due to
unformatted code in broker.rs and http.rs; e2e.rs carried the same drift. This
applies rustfmt to those three so the branch passes the fmt gate. No behavior
change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two web spawn QoL improvements plus the wire support they need:
- Command field now prefills `claude` instead of an empty box.
- Working directory prefills the selected daemon's home dir, updating on
daemon change unless the user has typed their own value.
The daemon resolves its `$HOME` via `dirs::home_dir()` at startup and
advertises it end-to-end: a new `home_dir` field on `DaemonOutbound::Hello`
flows through the registry's `register` into `DaemonInfo` (served on
`/api/daemons` and `/api/events`). The field is `#[serde(default)]`, so older
daemons that omit it deserialize to an empty string — backward compatible, and
the UI simply skips prefill when it's empty.
Hierarchical `/`-separated labels in the web sidebar already worked
(web/src/catalog.ts); no change there.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 501 path is gone; update the stale "rejects cross-daemon create" / "broker
is attach-only" module docs to describe the brokered create/kill RPCs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The home daemon's `POST /api/sessions` (peer `daemon_id`) and
`DELETE /api/sessions/:id` (peer-owned) now broker the op to the owning daemon
over the registry control link rather than returning 501/404. This fixes
cross-daemon spawn/kill for *all* local clients — notably maestro-web, which
talks only to the local daemon and had no registry fallback.
- broker: `BrokerHandle::{forward_create,forward_kill}` + completion handlers,
mirroring `resolve_attach` (fail-fast while disconnected, RAII pending
cleanup, RESOLVE_TIMEOUT).
- registry_client: route `Forward{Create,Kill}Result` back to the broker.
- local_api: create forwards on peer `daemon_id`; kill looks up the owner in
the catalog mirror and forwards (only a truly-unknown session is 404).
`broker_error_response` maps BrokerError → HTTP (503/504/502/404/...).
- cli: thread the broker into `LocalApiState`.
Tests: broker round-trip / fail-fast / registry-err propagation; updated the
local_api integration test (peer create now 503s without a registry link
instead of 501, proving it brokers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every `SessionManager` tmux call shelled out via blocking
`std::process::Command::output()` on the tokio worker — including `send_keys`
on the per-keystroke hot path. Under concurrent typing the small worker pool
stalls on `tmux` fork+waits (tens of ms on a loaded host), starving HTTP
handlers, the registry WS, the liveness watcher, and PTY readers.
Wrap each call site (create/kill/recover/liveness/send_keys/resize/pipe_pane)
in `tokio::task::spawn_blocking` via a small `tmux_blocking` helper, so the
blocking fork+wait runs on the blocking pool and never parks an async worker.
The `Tmux` API stays synchronous (lower blast radius than an async migration;
its tests are unchanged). Full daemon suite + send_keys/broker e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`(perimeter_id, private_addr)` was carried as two `Option<String>`s with an
unwritten "both Some or both None" invariant, enforced ad-hoc at every reader
and silently violable (`with_perimeter(Some, None)`).
Collapse it to `Option<PerimeterCandidate>` (reusing the existing protocol
type, so it flows unconverted from Heartbeat → registry → AttachCandidates):
- protocol: `Heartbeat { perimeter: Option<PerimeterCandidate> }` (empty still
serializes to the legacy `{"type":"Heartbeat"}` shape).
- registry: `DaemonEntry.perimeter`, `update_perimeter`, `perimeter_for` lose
the defensive AND-check; `build_attach_candidates` uses it directly.
- daemon: `RegistryClient.with_perimeter(Option<PerimeterCandidate>)`; the
both-or-neither rule now lives once, in `DaemonConfig::perimeter()`, which
warns and drops a half-configured pair instead of advertising it.
Unblocked by NAP-270's coverage; broker T2 + perimeter-mismatch e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The asymmetric-VLAN security boundary and the broker's RPC error branches had
no tests, so a regression that leaked a JWT across perimeters or dialed across
them would ship undetected. Adds the required coverage:
registry (state.rs unit):
- perimeter_for_requires_both_id_and_addr
- resolve_attach_refuses_self_target
- resolve_attach_returns_unknown_session_for_orphan_id
daemon broker (broker.rs unit):
- broker_resolve_attach_propagates_registry_err
- broker_resolve_attach_times_out_when_no_reply (paused-clock, instant)
daemon e2e:
- broker_attach_returns_502_on_perimeter_mismatch (two daemons, different
perimeters → structured 502, never dials across)
Enables tokio `test-util` in maestro-daemon dev-deps for the paused-clock test.
Unblocks NAP-272.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`broker_attach_ws` upgraded the client WS first and only then dialed the peer
inside `pump_broker_ws`; a dial failure (peer down, perimeter rejection, peer
verifier refusing the pair JWT — all reported pre-upgrade) just logged and
closed the socket, indistinguishable from a clean session end.
Move the `connect_async` ahead of `on_upgrade`: on failure return a structured
`502 peer_unreachable` (matching the other pre-upgrade error paths); on success
hand the connected upstream to `pump_broker_ws`.
Updated the allow_inbound-reject e2e to assert the handshake now fails with 502
instead of an opaque post-upgrade close.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`BrokerHandle::resolve_attach` queued ResolveAttach frames with
`out_tx.send().await`. While the registry was disconnected nothing drained the
bounded channel, so after 64 queued frames every caller blocked on the send —
HTTP `/api/attach/:id` requests for peer sessions hung instead of erroring.
Track link state with an `AtomicBool` the registry-client flips on connect /
disconnect: `resolve_attach` returns `NoControlLink` immediately while down, and
uses a non-blocking `try_send` so a back-pressured-but-connected channel can't
block either.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cookie and pair-attach JWTs are signed with the same shared secret. Neither
verifier bound the token's purpose, so a token minted for one could in principle
validate as the other.
- Add a canonical `REGISTRY_ISSUER` to maestro-protocol; registry re-exports it.
- Auth cookies now carry `iss = maestro-registry` + a dedicated `aud =
maestro-cookie`; `verify_cookie` binds both, so a pair-attach JWT (daemon-id
audience) is rejected.
- `PairAttachVerifier::verify` now binds `iss = REGISTRY_ISSUER`, rejecting any
token not minted by the registry.
Tests: pair-verifier rejects a foreign issuer; verify_cookie rejects a
pair-attach JWT. Existing cookie + broker e2e flows unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
create() ran worktree-add → tmux spawn → sidecar write with no unwinding. A
failure in a later step leaked earlier ones — worst case a sidecar write
failure (disk full / EIO / permission) left a ghost `maestro-<uuid>` tmux
session running with no in-memory record, silently adopted with empty metadata
on the next daemon restart.
Each step now unwinds the ones before it: bridge/tmux failures remove the
worktree (+ FIFO), and a sidecar-write failure kills the just-spawned tmux
session before removing the FIFO + worktree.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`BrokerHandle::resolve_attach` (daemon) and `Registry::create_session` /
`kill_session` (registry) insert a pending oneshot into a map, then await
the reply. If the awaiting future is cancelled — e.g. an axum handler
dropped because the browser closed the tab or aborted the XHR — the entry
was never removed, leaking one map slot per cancelled request and growing
unboundedly under UI-driven retry traffic.
Introduce a small RAII `PendingGuard` that removes the entry on drop, so
cleanup happens on every exit path including cancellation. Because a sync
`Drop` can't lock a tokio mutex, move the pending maps to
`std::sync::Mutex` (only ever locked for a tiny insert/remove, never held
across an await): broker `pending`, and the registry's `pending_create` /
`pending_kill` lifted out of the async-locked `State`. Removal is
idempotent, so the happy path (reply already taken by `complete_*`) and
the guard's drop don't conflict; `await_rpc` no longer needs its manual
timeout cleanup.
Tested: cancelling an in-flight `resolve_attach` (broker) and
`kill_session` (registry) leaves the pending map empty.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`SessionManager::recover()` re-inserted each sidecar's `Session` verbatim,
keeping the persisted `daemon_id`. If the operator renamed the daemon between
starts, recovered sessions were re-advertised under the old id and became
unroutable. Overwrite `daemon_id` with the daemon's current id on recover,
warning when the sidecar's id differs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`dispatch_text` (registry) and `handle_inbound` (daemon) treated any
frame that didn't deserialize into a known `DaemonOutbound`/`DaemonInbound`
variant as fatal: the registry returned `false` (breaking the handler
loop and deregistering the daemon, wiping its catalog), and the daemon
propagated the serde error out of the read loop. A newer peer emitting a
not-yet-merged variant would therefore get stuck in a tight
disconnect/reconnect loop.
- Add a `#[serde(other)] Unknown` catch-all to both enums so unknown
`type` tags decode instead of erroring.
- Log-and-skip the `Unknown` variant on both sides.
- Also log-and-skip genuinely malformed frames rather than tearing down
the connection, so one bad frame can't wipe a daemon's catalog.
Tested: unknown/malformed frames keep the connection alive (registry
handler), and unknown `type` decodes to `Unknown` (protocol round-trip).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The daemon→registry uplink could leave a session in the registry's catalog
after the daemon had already dropped it from its authoritative map, so
`maestro list` / the web UI kept showing it while DELETE returned 404
("no such session on this daemon"). It only healed on the next reconnect.
Two defects in `registry_client::connect_once`, both fixed here:
A. Subscribe-after-snapshot race: the uplink took `manager.snapshot()` and
then subscribed to `manager.events()`. A reap/kill firing in that window
was in neither — the registry kept the dead session. Now subscribe before
snapshotting, matching `local_api::pump_events` (which documents the same
ordering rule for the browser/CLI event WS).
B. Lagged events were silently dropped (`continue`). If the 256-deep event
broadcast overflowed, a dropped `SessionRemoved` was never relayed and the
registry stayed stale until reconnect. The events arm is now extracted into
`forward_session_event`, which on `Lagged` re-sends a full `SessionsSnapshot`
— the registry applies it by replacing the daemon's set, healing immediately.
Adds deterministic unit tests for the lag-resync, the Removed mapping, and the
closed-stream break. Existing integration tests still pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Installer: one `cargo install maestro` instead of two; ExecStart becomes
`~/.cargo/bin/maestro daemon`; user unit renamed `maestrod.service` →
`maestro.service`; configure step is `maestro daemon configure`.
- E2E harness: both `Daemon` and `Registry` subprocess wrappers shell out to
the unified `maestro` binary with the appropriate subcommand.
- State dir: `~/.local/state/maestrod` → `~/.local/state/maestro`.
- Docs: CLAUDE.md repo layout, design spec repo layout + "Daemon (`maestro daemon`)"
heading, INSTALL.md (full rewrite of CLI references + new thin-builds section).
New `crates/maestro/` umbrella binary hosts the CLI, daemon, and registry
behind Cargo features `cli`, `daemon`, `registry` (default = all three).
Library crates keep their structure; per-binary `main.rs` files are removed
and their dispatch logic is exposed as public functions:
- `maestro_cli::dispatch(command, config, registry_url)`
- `maestro_daemon::cli::dispatch(config, cmd)` (with public `DaemonCmd`/`ConfigureArgs`)
- `maestro_registry::run()`
Subcommand surface:
- `maestro list / spawn / attach / kill / set-registry / policy` (unchanged)
- `maestro daemon [run|configure]` (was `maestrod`)
- `maestro registry` (was `maestro-registry`)
Hard cutover — old binary names are not preserved.
Verified: default-feature build, plus `--no-default-features --features {cli|daemon|registry}`
each build clean with `-D warnings`.
`PairAttachVerifier` now stores `allow_inbound` behind `Arc<RwLock<_>>` so
clones share the same set. Two triggers re-read the unified config file
and swap it atomically:
- SIGHUP — `systemctl --user reload maestrod`, or any `kill -HUP`. The
installer's user unit now sets `ExecReload=/bin/kill -HUP $MAINPID`.
- `POST /api/policy/reload` — used by `maestro policy reload`. The CLI
reads `[daemon].bind_addr` from the unified config (default 7070) and
POSTs to `http://127.0.0.1:<port>`. No PID lookup required.
Also adds `maestro policy show [--json]` and `GET /api/policy` for
"did my edit get picked up?" debugging. A broken mid-edit config logs
an error and leaves the previously-effective list in place rather than
locking everyone out.
In-flight attaches keep their existing connection — revocation requires
killing the session, as spec'd.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>