Compare commits

..
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 f801b12e87 fix(maestro): suffix branch name on new-branch worktree collision
git worktree add -b fails outright when the branch already exists, even
if the target directory is free. A random fast-forward handle commonly
collides with a branch an earlier, already-removed worktree left behind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 13:29:11 +01:00
47 changed files with 183 additions and 3773 deletions
Generated
+56 -746
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -10,7 +10,6 @@ repository = "https://git.naps.pt/naps62/maestro"
publish = false
[workspace.dependencies]
agent-client-protocol = "2.1"
axum = { version = "0.7", features = ["ws"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
@@ -18,7 +17,6 @@ crossterm = { version = "0.28", features = ["event-stream"] }
dirs = "5"
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }
libc = "0.2"
maestro-acp = { path = "crates/maestro-acp" }
maestro-config = { path = "crates/maestro-config" }
# `default-features = false` so each consumer opts into `haiku-autoname`
# through its own feature of the same name (#131); a workspace entry that
@@ -40,7 +38,6 @@ tempfile = "3"
thiserror = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync", "time", "net", "io-util", "fs", "signal", "process"] }
tokio-tungstenite = "0.21"
tokio-util = { version = "0.7", features = ["compat"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "maestro-acp"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
description = "ACP client role: one claude-code-acp child per structured session"
[dependencies]
agent-client-protocol.workspace = true
libc.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
-566
View File
@@ -1,566 +0,0 @@
//! ACP client role for the daemon (#134): one `claude-code-acp` child per
//! structured session.
//!
//! The daemon is the client; the adapter is the agent. [`spawn`] starts the
//! adapter as a child process with stdio pipes, in the session's `cwd` with
//! the session's env, runs the `initialize` + `session/new` handshake, and
//! hands back an [`AcpSession`] that owns the connection for the life of the
//! session.
//!
//! **The handshake declares almost nothing.** `initialize` reports
//! `fs.readTextFile`, `fs.writeTextFile` and `terminal` as `false`, so the
//! adapter uses Claude Code's own Read / Write / Bash instead of asking the
//! daemon to act on its behalf — exactly what already happens in the
//! terminal view. `session/request_permission` is the one client method that
//! gets a real implementation, in #139; here it is a stub that declines.
//!
//! Every `session/update` notification the adapter sends lands on an
//! unbounded channel the session owns ([`AcpSession::take_updates`]). Nothing
//! is dropped while nobody reads: #135 takes the receiver and persists each
//! notification to the per-session event log before broadcasting it.
//!
//! A respawn after a daemon restart sets [`SpawnConfig::load_session`] and
//! the handshake runs `session/load` with the stored id instead of
//! `session/new`. The history the agent streams back while that request is
//! in flight is *not* delivered: the daemon's event log already holds it,
//! and replaying it would double every line.
//!
//! The child is a child of the daemon, full stop. It shares the daemon's
//! process group, is marked to receive `SIGTERM` when the daemon dies
//! (`PR_SET_PDEATHSIG` on Linux), and [`AcpSession::terminate`] is what both
//! `DELETE /api/sessions/:id` and daemon shutdown call: `session/cancel`, a
//! short wait, then `SIGTERM`, then `SIGKILL` as the last resort.
#![deny(missing_debug_implementations)]
use std::collections::{HashMap, VecDeque};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use agent_client_protocol::schema::v1::{
CancelNotification, ClientCapabilities, FileSystemCapabilities, Implementation,
InitializeRequest, LoadSessionRequest, NewSessionRequest, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SessionId, SessionNotification,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{ByteStreams, Client};
use tokio::io::AsyncBufReadExt;
use tokio::sync::{mpsc, oneshot, watch};
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
pub use agent_client_protocol::schema::v1 as schema;
/// The adapter binary, resolved on `PATH`.
pub const ADAPTER_BIN: &str = "claude-code-acp";
/// How to install the adapter when it is missing. Named in the error so an
/// operator staring at a failed session knows what to run.
pub const INSTALL_COMMAND: &str =
"npm install -g --prefix ~/.local @zed-industries/claude-code-acp";
/// How long `initialize` + `session/new` may take before the spawn is
/// declared failed. The real adapter boots a node process and the Claude
/// Code SDK behind it; a slow machine takes seconds, never a minute.
pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
/// How long [`AcpSession::terminate`] waits after `session/cancel` before
/// sending `SIGTERM`. Cancel is a courtesy that lets the adapter finish the
/// turn it is on; the signal is what actually ends it.
pub const CANCEL_GRACE: Duration = Duration::from_millis(500);
/// How long [`AcpSession::terminate`] waits after `SIGTERM` before `SIGKILL`.
pub const TERM_GRACE: Duration = Duration::from_secs(2);
/// Bytes of the child's stderr kept for the failure message.
const STDERR_TAIL_BYTES: usize = 4096;
/// What [`spawn`] needs to start one adapter.
#[derive(Debug, Clone)]
pub struct SpawnConfig {
/// Working directory of the child and `cwd` of `session/new`.
pub cwd: PathBuf,
/// Environment laid over the daemon's own for the child. A `PATH` in
/// here is also what the adapter is resolved against, since that is the
/// `PATH` the child would see.
pub env: HashMap<String, String>,
/// Upper bound on the handshake; [`HANDSHAKE_TIMEOUT`] by default.
pub handshake_timeout: Duration,
/// An existing ACP session id to resume with `session/load` (#135)
/// instead of opening a fresh one. The adapter must advertise
/// `loadSession`; the handshake fails otherwise.
pub load_session: Option<String>,
}
impl SpawnConfig {
pub fn new(cwd: impl Into<PathBuf>, env: HashMap<String, String>) -> Self {
Self {
cwd: cwd.into(),
env,
handshake_timeout: HANDSHAKE_TIMEOUT,
load_session: None,
}
}
/// Resume `session_id` with `session/load` instead of `session/new`.
pub fn load_session(mut self, session_id: impl Into<String>) -> Self {
self.load_session = Some(session_id.into());
self
}
}
#[derive(Debug, thiserror::Error)]
pub enum AcpError {
/// The adapter is not on `PATH`. The message names the install command
/// so the failed session is actionable rather than silently dead.
#[error("{ADAPTER_BIN} not found on PATH; install it with: {INSTALL_COMMAND}")]
AdapterMissing,
#[error("failed to start {ADAPTER_BIN}: {0}")]
Spawn(#[source] std::io::Error),
/// The adapter answered the handshake with an error.
#[error("ACP handshake failed: {0}")]
Handshake(String),
/// The adapter went away before the handshake finished. Carries the
/// tail of its stderr, which is where a missing Claude login shows up.
#[error("{ADAPTER_BIN} exited during the ACP handshake ({status}){stderr}")]
ExitedEarly { status: String, stderr: String },
#[error("ACP handshake timed out after {0:?}")]
HandshakeTimeout(Duration),
}
/// Resolve [`ADAPTER_BIN`] against the `PATH` the child would run with: the
/// session env's `PATH` when it sets one, the daemon's own otherwise.
pub fn resolve_adapter(env: &HashMap<String, String>) -> Option<PathBuf> {
let path: OsString = match env.get("PATH") {
Some(p) => OsString::from(p),
None => std::env::var_os("PATH")?,
};
std::env::split_paths(&path)
.filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(ADAPTER_BIN))
.find(|candidate| is_executable(candidate))
}
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
enum Command {
Cancel,
Close,
}
enum ChildCommand {
Signal(libc::c_int),
}
/// Why the handshake did not produce a session id.
enum ReadyError {
/// The adapter answered with an error.
Handshake(String),
/// The transport closed under the request: the child is gone.
Closed,
}
fn ready_error(e: agent_client_protocol::Error) -> ReadyError {
if agent_client_protocol::is_incoming_transport_closed(&e) {
ReadyError::Closed
} else {
ReadyError::Handshake(e.to_string())
}
}
/// A live ACP connection to one adapter child. Dropping it sends `SIGTERM`
/// if the child is still running; [`AcpSession::terminate`] is the polite
/// version.
#[derive(Debug)]
pub struct AcpSession {
session_id: String,
pid: u32,
commands: mpsc::UnboundedSender<Command>,
child_commands: mpsc::UnboundedSender<ChildCommand>,
updates: Mutex<Option<mpsc::UnboundedReceiver<SessionNotification>>>,
exit: watch::Receiver<Option<ExitStatus>>,
}
impl AcpSession {
/// The id `session/new` returned. What a respawn passes to
/// `session/load` (#135).
pub fn session_id(&self) -> &str {
&self.session_id
}
/// The adapter's pid.
pub fn pid(&self) -> u32 {
self.pid
}
/// Whether the child has been reaped.
pub fn has_exited(&self) -> bool {
self.exit.borrow().is_some()
}
/// Wait for the child to exit.
pub async fn wait(&self) -> Option<ExitStatus> {
wait_exit(&self.exit).await
}
/// The stream of raw `session/update` notifications, handed out once.
/// Notifications that arrived before the take are still queued on it.
pub fn take_updates(&self) -> Option<mpsc::UnboundedReceiver<SessionNotification>> {
self.updates.lock().expect("updates mutex").take()
}
/// Send `session/cancel`. Fire-and-forget: a closed connection means the
/// child is already gone, which is the outcome cancel was after.
pub fn cancel(&self) {
let _ = self.commands.send(Command::Cancel);
}
/// End the child: `session/cancel`, wait `cancel_grace`, `SIGTERM`, wait
/// `term_grace`, `SIGKILL`. Returns once the child has been reaped.
pub async fn terminate(&self, cancel_grace: Duration, term_grace: Duration) {
self.cancel();
if tokio::time::timeout(cancel_grace, self.wait())
.await
.is_ok()
{
return;
}
self.signal(libc::SIGTERM);
if tokio::time::timeout(term_grace, self.wait()).await.is_ok() {
return;
}
tracing::warn!(
pid = self.pid,
"{ADAPTER_BIN} ignored SIGTERM; sending SIGKILL"
);
self.signal(libc::SIGKILL);
let _ = self.commands.send(Command::Close);
self.wait().await;
}
fn signal(&self, sig: libc::c_int) {
let _ = self.child_commands.send(ChildCommand::Signal(sig));
}
}
impl Drop for AcpSession {
fn drop(&mut self) {
self.signal(libc::SIGTERM);
let _ = self.commands.send(Command::Close);
}
}
/// Start the adapter for one session and run the handshake.
///
/// Fails without a running child: on any error the process is already
/// signalled, so a failed session never leaves a stray adapter behind.
pub async fn spawn(config: SpawnConfig) -> Result<AcpSession, AcpError> {
let adapter = resolve_adapter(&config.env).ok_or(AcpError::AdapterMissing)?;
let mut cmd = tokio::process::Command::new(&adapter);
cmd.current_dir(&config.cwd)
.envs(&config.env)
// Claude Code refuses to start when it thinks it is nested inside
// another Claude Code session; a daemon launched from one would
// otherwise never get a structured session up.
.env_remove("CLAUDECODE")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
// If the daemon is dropped without a graceful shutdown the runtime
// still tears the task holding this `Child` down, and that kills it.
.kill_on_drop(true);
#[cfg(target_os = "linux")]
{
// Same process group as the daemon (no setpgid), and SIGTERM when the
// daemon dies however it dies: an agent that outlives its daemon is
// exactly the orphan #134 rules out.
// SAFETY: prctl is async-signal-safe and touches no shared state.
unsafe {
cmd.pre_exec(|| {
libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM);
Ok(())
});
}
}
let mut child = cmd.spawn().map_err(AcpError::Spawn)?;
let pid = child.id().expect("child pid is known before wait");
let stdin = child.stdin.take().expect("piped stdin");
let stdout = child.stdout.take().expect("piped stdout");
let stderr = child.stderr.take().expect("piped stderr");
let stderr_tail = Arc::new(Mutex::new(VecDeque::<u8>::new()));
tokio::spawn(drain_stderr(stderr, pid, stderr_tail.clone()));
let (exit_tx, exit_rx) = watch::channel(None);
let (child_commands_tx, child_commands_rx) = mpsc::unbounded_channel();
tokio::spawn(supervise_child(child, pid, child_commands_rx, exit_tx));
let (updates_tx, updates_rx) = mpsc::unbounded_channel();
let (commands_tx, commands_rx) = mpsc::unbounded_channel();
let (ready_tx, ready_rx) = oneshot::channel();
let transport = ByteStreams::new(stdin.compat_write(), stdout.compat());
tokio::spawn(run_connection(
transport,
config.cwd.clone(),
config.load_session.clone(),
updates_tx,
commands_rx,
ready_tx,
));
let outcome = tokio::time::timeout(config.handshake_timeout, ready_rx).await;
let failure = match outcome {
Ok(Ok(Ok(session_id))) => {
return Ok(AcpSession {
session_id,
pid,
commands: commands_tx,
child_commands: child_commands_tx,
updates: Mutex::new(Some(updates_rx)),
exit: exit_rx,
});
}
Ok(Ok(Err(ReadyError::Handshake(message)))) => AcpError::Handshake(message),
Ok(Ok(Err(ReadyError::Closed))) | Ok(Err(_)) => {
// The connection ended before the handshake did: the child went
// away. Reap it so the status is real, then report.
let status = wait_exit(&exit_rx).await;
let stderr = {
let mut tail = stderr_tail.lock().expect("stderr tail");
tail.make_contiguous();
String::from_utf8_lossy(tail.as_slices().0)
.trim()
.to_string()
};
AcpError::ExitedEarly {
status: status.map_or_else(|| "unknown status".into(), |s| s.to_string()),
stderr: if stderr.is_empty() {
String::new()
} else {
format!(": {stderr}")
},
}
}
Err(_) => AcpError::HandshakeTimeout(config.handshake_timeout),
};
// No half-started adapter survives a failed spawn.
let _ = commands_tx.send(Command::Close);
let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGTERM));
if tokio::time::timeout(TERM_GRACE, wait_exit(&exit_rx))
.await
.is_err()
{
let _ = child_commands_tx.send(ChildCommand::Signal(libc::SIGKILL));
wait_exit(&exit_rx).await;
}
Err(failure)
}
async fn wait_exit(exit: &watch::Receiver<Option<ExitStatus>>) -> Option<ExitStatus> {
let mut exit = exit.clone();
let status = exit.wait_for(|s| s.is_some()).await.ok()?;
*status
}
async fn supervise_child(
mut child: tokio::process::Child,
pid: u32,
mut commands: mpsc::UnboundedReceiver<ChildCommand>,
exit_tx: watch::Sender<Option<ExitStatus>>,
) {
loop {
tokio::select! {
status = child.wait() => {
let status = status.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
command = commands.recv() => match command {
Some(ChildCommand::Signal(sig)) => {
// This task alone owns `child`. It cannot reap the child
// between receiving this command and sending the signal,
// so its PID cannot be reused in that window.
unsafe { libc::kill(pid as libc::pid_t, sig) };
}
None => {
// The session vanished without a command receiver. This
// task still owns the unreaped child, so this cannot hit
// a reused PID.
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
let status = child.wait().await.ok();
tracing::debug!(pid, ?status, "{ADAPTER_BIN} exited");
let _ = exit_tx.send(status);
return;
}
},
}
}
}
/// The capabilities the daemon declares: none of the filesystem or terminal
/// surface, so the adapter falls back to Claude Code's own tools.
fn client_capabilities() -> ClientCapabilities {
ClientCapabilities::new()
.fs(FileSystemCapabilities::new()
.read_text_file(false)
.write_text_file(false))
.terminal(false)
}
async fn run_connection<T>(
transport: T,
cwd: PathBuf,
load_session: Option<String>,
updates_tx: mpsc::UnboundedSender<SessionNotification>,
mut commands_rx: mpsc::UnboundedReceiver<Command>,
ready_tx: oneshot::Sender<Result<String, ReadyError>>,
) where
T: agent_client_protocol::ConnectTo<Client> + 'static,
{
// Set while `session/load` is in flight. Notification handlers run in
// the dispatch loop, in arrival order, so everything the agent streams
// before its load response sees the flag up and is dropped: that is the
// history the event log already holds (#135).
let replaying = Arc::new(AtomicBool::new(false));
let replaying_seen = replaying.clone();
let result = Client
.builder()
.name("maestro")
.on_receive_notification(
async move |notification: SessionNotification, _cx| {
if replaying_seen.load(Ordering::SeqCst) {
tracing::trace!("dropping replayed notification during session/load");
return Ok(());
}
// A dropped receiver only means nobody wants updates any more.
let _ = updates_tx.send(notification);
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.on_receive_request(
async move |request: RequestPermissionRequest, responder, _cx| {
// Stub until #139: decline rather than grant something the
// operator never saw.
tracing::warn!(
session = %request.session_id,
"session/request_permission declined: not implemented yet (#139)"
);
responder.respond(RequestPermissionResponse::new(
RequestPermissionOutcome::Cancelled,
))
},
agent_client_protocol::on_receive_request!(),
)
.connect_with(transport, async move |cx| {
let init = InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities())
.client_info(Implementation::new("maestro", env!("CARGO_PKG_VERSION")));
let initialized = match cx.send_request(init).block_task().await {
Ok(r) => r,
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
};
let session_id = match load_session {
Some(existing) => {
if !initialized.agent_capabilities.load_session {
let _ = ready_tx.send(Err(ReadyError::Handshake(format!(
"{ADAPTER_BIN} does not advertise loadSession; cannot resume session {existing}"
))));
return Ok(());
}
replaying.store(true, Ordering::SeqCst);
let (loaded_tx, loaded_rx) = oneshot::channel();
let replaying_done = replaying.clone();
if let Err(e) = cx
.send_request(LoadSessionRequest::new(existing.clone(), cwd))
.on_receiving_result(move |result| {
replaying_done.store(false, Ordering::SeqCst);
async move {
let _ = loaded_tx.send(result);
Ok(())
}
})
{
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
match loaded_rx.await {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
Err(_) => {
let _ = ready_tx.send(Err(ReadyError::Handshake(
"session/load response was not delivered".into(),
)));
return Ok(());
}
}
existing
}
None => match cx
.send_request(NewSessionRequest::new(cwd))
.block_task()
.await
{
Ok(created) => created.session_id.0.to_string(),
Err(e) => {
let _ = ready_tx.send(Err(ready_error(e)));
return Ok(());
}
},
};
let _ = ready_tx.send(Ok(session_id.clone()));
loop {
tokio::select! {
cmd = commands_rx.recv() => match cmd {
Some(Command::Cancel) => {
let _ = cx.send_notification(CancelNotification::new(
SessionId::new(session_id.as_str()),
));
}
Some(Command::Close) | None => break,
},
_ = cx.incoming_closed() => break,
}
}
Ok(())
})
.await;
if let Err(e) = result {
tracing::debug!(error = %e, "ACP connection ended with an error");
}
}
async fn drain_stderr(
stderr: tokio::process::ChildStderr,
pid: u32,
tail: Arc<Mutex<VecDeque<u8>>>,
) {
let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(pid, "{ADAPTER_BIN}: {line}");
let mut tail = tail.lock().expect("stderr tail");
tail.extend(line.as_bytes());
tail.push_back(b'\n');
while tail.len() > STDERR_TAIL_BYTES {
tail.pop_front();
}
tail.make_contiguous();
}
}
-146
View File
@@ -1,146 +0,0 @@
#!/usr/bin/env python3
"""A deterministic stand-in for `claude-code-acp` (#134).
Speaks just enough JSON-RPC over stdio for the daemon's handshake:
answers `initialize` and `session/new`, logs every method it receives, and
exits on SIGTERM. Tests point `PATH` at a directory holding this file under
the adapter's name, so the real adapter (which needs Claude auth and is
slow) never runs in the deterministic suite.
Environment knobs, all optional:
FAKE_ACP_LOG append one line per event to this file:
start pid=<pid> cwd=<cwd>
env <NAME>=<value> (for FAKE_ACP_ECHO_ENV)
caps <json of clientCapabilities>
recv <method>
signal SIGTERM
FAKE_ACP_ECHO_ENV name of one env var to log at start
FAKE_ACP_INIT_FAIL=1 answer `initialize` with a JSON-RPC error
FAKE_ACP_DELAY_SESSION_NEW_MS=n wait n milliseconds before answering
`session/new`
FAKE_ACP_EXIT_AFTER_NEW=1 exit 0 right after answering `session/new`
FAKE_ACP_EXIT_ON_CANCEL=1 exit 0 on `session/cancel` (default: keep running
so the SIGTERM path is exercised)
FAKE_ACP_IGNORE_EOF=1 keep running after stdin closes, until signalled
FAKE_ACP_EMIT_ON_CANCEL=1 emit a final notification before handling cancel
FAKE_ACP_NO_LOAD=1 advertise `loadSession: false`
FAKE_ACP_LOAD_REPLAY=n on `session/load`, replay n `agent_message_chunk`
notifications ("replayed <i>") before answering,
the way a real agent streams history back
FAKE_ACP_LOAD_FAIL=1 answer `session/load` with a JSON-RPC error
Signals: SIGUSR1 emits one `agent_message_chunk` notification ("tick <n>",
counting from 1) and logs `emit tick <n>`, so a test can make the agent
speak on demand (#135).
"""
import json
import os
import signal
import sys
import time
LOG = os.environ.get("FAKE_ACP_LOG")
def log(line):
if not LOG:
return
with open(LOG, "a") as f:
f.write(line + "\n")
f.flush()
def send(obj):
sys.stdout.write(json.dumps(obj) + "\n")
sys.stdout.flush()
def on_sigterm(_signum, _frame):
log("signal SIGTERM")
sys.exit(0)
signal.signal(signal.SIGTERM, on_sigterm)
session_id = f"fake-{os.getpid()}"
ticks = 0
def chunk(text):
return {"jsonrpc": "2.0", "method": "session/update", "params": {
"sessionId": session_id,
"update": {"sessionUpdate": "agent_message_chunk",
"content": {"type": "text", "text": text}},
}}
def on_sigusr1(_signum, _frame):
global ticks
ticks += 1
send(chunk(f"tick {ticks}"))
log(f"emit tick {ticks}")
signal.signal(signal.SIGUSR1, on_sigusr1)
log(f"start pid={os.getpid()} cwd={os.getcwd()}")
echo = os.environ.get("FAKE_ACP_ECHO_ENV")
if echo:
log(f"env {echo}={os.environ.get(echo, '')}")
for raw in sys.stdin:
raw = raw.strip()
if not raw:
continue
msg = json.loads(raw)
method = msg.get("method")
if method is None:
# A response to something we sent; nothing to do.
continue
log(f"recv {method}")
rid = msg.get("id")
params = msg.get("params") or {}
if method == "initialize":
log("caps " + json.dumps(params.get("clientCapabilities", {}), sort_keys=True))
if os.environ.get("FAKE_ACP_INIT_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake init failure"}})
continue
send({"jsonrpc": "2.0", "id": rid, "result": {
"protocolVersion": params.get("protocolVersion", 1),
"agentCapabilities": {
"loadSession": os.environ.get("FAKE_ACP_NO_LOAD") != "1"},
"agentInfo": {"name": "fake-acp-agent", "version": "0.0.0"},
"authMethods": [],
}})
elif method == "session/new":
time.sleep(int(os.environ.get("FAKE_ACP_DELAY_SESSION_NEW_MS", "0")) / 1000)
send({"jsonrpc": "2.0", "id": rid, "result": {"sessionId": session_id}})
send(chunk("hello from fake"))
if os.environ.get("FAKE_ACP_EXIT_AFTER_NEW") == "1":
sys.exit(0)
elif method == "session/load":
session_id = params.get("sessionId")
log(f"recv session/load sessionId={session_id}")
if os.environ.get("FAKE_ACP_LOAD_FAIL") == "1":
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32000, "message": "fake load failure"}})
continue
for i in range(int(os.environ.get("FAKE_ACP_LOAD_REPLAY", "0"))):
send(chunk(f"replayed {i + 1}"))
send({"jsonrpc": "2.0", "id": rid, "result": {}})
send(chunk("hello after load"))
elif method == "session/cancel":
if os.environ.get("FAKE_ACP_EMIT_ON_CANCEL") == "1":
send(chunk("final on cancel"))
if os.environ.get("FAKE_ACP_EXIT_ON_CANCEL") == "1":
sys.exit(0)
elif rid is not None:
send({"jsonrpc": "2.0", "id": rid,
"error": {"code": -32601, "message": f"method not found: {method}"}})
if os.environ.get("FAKE_ACP_IGNORE_EOF") == "1":
while True:
signal.pause()
-394
View File
@@ -1,394 +0,0 @@
//! `maestro-acp` against the fake adapter in `tests/fake_acp_agent.py`, plus
//! one ignored test against the real `claude-code-acp`.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{AcpError, SpawnConfig, ADAPTER_BIN, INSTALL_COMMAND};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fake_acp_agent.py")
}
/// A temp `bin/` holding the fake agent under the adapter's name, plus a
/// log file the agent appends to. `PATH` for the spawn is just that dir.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// The fake's `bin/` first, so it shadows any real adapter, then the
/// process `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
fn config(&self, cwd: &Path) -> SpawnConfig {
let mut config = SpawnConfig::new(cwd, self.env());
config.handshake_timeout = Duration::from_secs(10);
config
}
async fn wait_for_log(&self, needle: &str) {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if self.log().contains(needle) {
return;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!("log never contained {needle:?}:\n{}", self.log());
}
fn pid(&self) -> u32 {
self.log()
.lines()
.find_map(|line| line.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|pid| pid.parse().ok())
.expect("pid in log")
}
async fn wait_for_exit(&self) {
let pid = self.pid();
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} still alive");
}
}
fn pid_alive(pid: u32) -> bool {
Path::new(&format!("/proc/{pid}")).exists()
&& std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
#[tokio::test]
async fn spawn_runs_adapter_in_cwd_with_env_and_restrictive_caps() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
config
.env
.insert("MAESTRO_SESSION_ID".into(), "sess-123".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert!(session.session_id().starts_with("fake-"));
assert!(pid_alive(session.pid()));
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"child cwd missing from log:\n{log}"
);
assert!(log.contains("env MAESTRO_SESSION_ID=sess-123"), "{log}");
assert!(log.contains("recv initialize"), "{log}");
assert!(log.contains("recv session/new"), "{log}");
let caps = log
.lines()
.find_map(|l| l.strip_prefix("caps "))
.expect("caps line");
let caps: serde_json::Value = serde_json::from_str(caps).unwrap();
assert_eq!(caps["fs"]["readTextFile"], false);
assert_eq!(caps["fs"]["writeTextFile"], false);
assert_eq!(caps["terminal"], false);
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn missing_adapter_names_the_install_command() {
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let err = maestro_acp::spawn(SpawnConfig::new(cwd.path(), env))
.await
.expect_err("no adapter on PATH");
assert!(matches!(err, AcpError::AdapterMissing));
let msg = err.to_string();
assert!(msg.contains(ADAPTER_BIN), "{msg}");
assert!(msg.contains(INSTALL_COMMAND), "{msg}");
}
#[tokio::test]
async fn session_updates_reach_the_taken_receiver() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
.await
.expect("spawn");
let mut updates = session.take_updates().expect("first take");
assert!(session.take_updates().is_none(), "handed out once");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(first.session_id.0.as_ref(), session.session_id());
match first.update {
SessionUpdate::AgentMessageChunk(chunk) => match chunk.content {
ContentBlock::Text(t) => assert_eq!(t.text, "hello from fake"),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn terminate_sends_cancel_then_sigterm() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(fake.config(cwd.path()))
.await
.expect("spawn");
let pid = session.pid();
session
.terminate(Duration::from_millis(200), Duration::from_secs(2))
.await;
assert!(session.has_exited());
fake.wait_for_log("signal SIGTERM").await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!pid_alive(pid), "child {pid} still alive");
}
#[tokio::test]
async fn terminate_is_satisfied_by_an_exit_on_cancel() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_ON_CANCEL".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
session
.terminate(Duration::from_secs(2), Duration::from_secs(2))
.await;
assert!(session.has_exited());
let log = fake.log();
assert!(log.contains("recv session/cancel"), "{log}");
assert!(!log.contains("signal SIGTERM"), "cancel was enough:\n{log}");
}
#[tokio::test]
async fn handshake_error_fails_the_spawn_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_INIT_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("init fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake init failure"), "{err}");
// Closing the connection gives the fake EOF on stdin; the SIGTERM that
// follows may find it already gone. Either way it must not survive.
let pid: u32 = fake
.log()
.lines()
.find_map(|l| l.strip_prefix("start pid="))
.and_then(|rest| rest.split(' ').next())
.and_then(|p| p.parse().ok())
.expect("pid in log");
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "child {pid} outlived the failed spawn");
}
#[tokio::test]
async fn early_exit_is_observed_after_a_successful_handshake() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config
.env
.insert("FAKE_ACP_EXIT_AFTER_NEW".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
let status = tokio::time::timeout(Duration::from_secs(5), session.wait())
.await
.expect("exit within 5s")
.expect("status");
assert!(status.success(), "{status}");
assert!(session.has_exited());
}
#[tokio::test]
async fn child_exit_before_handshake_reports_exited_early() {
// A "adapter" that dies before answering anything.
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
let script = bin.join(ADAPTER_BIN);
std::fs::write(&script, "#!/bin/sh\necho 'not logged in' >&2\nexit 3\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let cwd = TempDir::new().unwrap();
let env = HashMap::from([("PATH".to_string(), bin.to_string_lossy().into_owned())]);
let mut config = SpawnConfig::new(cwd.path(), env);
config.handshake_timeout = Duration::from_secs(10);
let err = maestro_acp::spawn(config).await.expect_err("child died");
match &err {
AcpError::ExitedEarly { status, stderr } => {
assert!(status.contains('3'), "{status}");
assert!(stderr.contains("not logged in"), "{stderr}");
}
other => panic!("unexpected {other:?}"),
}
}
#[tokio::test]
async fn drop_terminates_and_reaps_the_child() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path());
config.env.insert("FAKE_ACP_IGNORE_EOF".into(), "1".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
drop(session);
fake.wait_for_log("signal SIGTERM").await;
fake.wait_for_exit().await;
}
fn chunk_text(n: &maestro_acp::schema::SessionNotification) -> String {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => t.text.clone(),
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
#[tokio::test]
async fn load_session_resumes_the_stored_id_and_drops_the_replay() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_REPLAY".into(), "3".into());
let session = maestro_acp::spawn(config).await.expect("spawn");
assert_eq!(session.session_id(), "fake-old");
let log = fake.log();
assert!(
log.contains("recv session/load sessionId=fake-old"),
"{log}"
);
assert!(!log.contains("recv session/new"), "{log}");
// The three replayed chunks never reach the receiver: the event log
// already holds them. The first notification after the response does.
let mut updates = session.take_updates().expect("updates");
let first = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("update within 5s")
.expect("channel open");
assert_eq!(chunk_text(&first), "hello after load");
// Nothing queued behind it either.
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(updates.try_recv().is_err(), "replay leaked through");
session
.terminate(Duration::from_millis(100), Duration::from_secs(2))
.await;
}
#[tokio::test]
async fn load_session_requires_the_load_capability() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
config.env.insert("FAKE_ACP_NO_LOAD".into(), "1".into());
let err = maestro_acp::spawn(config)
.await
.expect_err("no loadSession");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("loadSession"), "{err}");
assert!(!fake.log().contains("recv session/load"), "{}", fake.log());
}
#[tokio::test]
async fn load_session_error_fails_the_spawn() {
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut config = fake.config(cwd.path()).load_session("fake-old");
config.env.insert("FAKE_ACP_LOAD_FAIL".into(), "1".into());
let err = maestro_acp::spawn(config).await.expect_err("load fails");
assert!(matches!(err, AcpError::Handshake(_)), "{err}");
assert!(err.to_string().contains("fake load failure"), "{err}");
}
/// End to end against the installed `claude-code-acp`. Needs Claude auth,
/// takes seconds, so it is opt-in: `cargo test -p maestro-acp -- --ignored`.
#[tokio::test]
#[ignore]
async fn real_adapter_handshake() {
let cwd = TempDir::new().unwrap();
let session = maestro_acp::spawn(SpawnConfig::new(cwd.path(), HashMap::new()))
.await
.expect("real claude-code-acp handshake");
assert!(!session.session_id().is_empty());
assert!(pid_alive(session.pid()));
session
.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
assert!(session.has_exited());
}
-3
View File
@@ -51,7 +51,6 @@ pub async fn run_spawn(client: &DaemonClient, args: SpawnArgs) -> Result<Session
};
let body = CreateSessionRequest {
view: Default::default(),
cwd: args.cwd.clone(),
command: args.command.clone(),
env,
@@ -237,7 +236,6 @@ mod tests {
fn session(cwd: &str, command: &str, id: u128) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(id),
cwd: PathBuf::from(cwd),
command: command.into(),
@@ -254,7 +252,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
@@ -91,7 +91,6 @@ async fn spawn_round_trips_through_the_daemon() {
let created = mock
.client()
.create_session(&maestro_protocol::CreateSessionRequest {
view: Default::default(),
cwd: "/home/mock".into(),
command: "bash".into(),
env: Default::default(),
-2
View File
@@ -28,7 +28,6 @@ dirs.workspace = true
futures-util.workspace = true
include_dir = { workspace = true, optional = true }
libc.workspace = true
maestro-acp.workspace = true
mime_guess = { workspace = true, optional = true }
maestro-config.workspace = true
maestro-hooks = { workspace = true, default-features = false }
@@ -43,7 +42,6 @@ tracing-subscriber.workspace = true
uuid.workspace = true
[dev-dependencies]
libc.workspace = true
reqwest.workspace = true
tokio-tungstenite.workspace = true
urlencoding = "2"
+4 -17
View File
@@ -106,7 +106,7 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
projects.start_background_refresh();
let router = local_api::router(local_api::state_for(
manager.clone(),
manager,
config.hostname.clone(),
config.home_dir.clone(),
config.spawn_presets.clone(),
@@ -118,22 +118,9 @@ async fn run(config_override: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
let bound = listener.local_addr()?;
tracing::info!(addr = %bound, "API listening");
// Ctrl-C at a terminal or SIGTERM from systemd: either one is a
// shutdown, and both must reach the adapter children below (#134). The
// unit's `KillMode=process` signals only the daemon, so nothing else
// would end them.
let shutdown_manager = manager.clone();
let shutdown = async move {
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => tracing::info!("ctrl-c received — shutting down"),
_ = sigterm.recv() => tracing::info!("SIGTERM received — shutting down"),
}
// Axum waits for upgraded WebSockets to drain. Structured children
// must be settled before that wait, while the signal path still owns
// the shutdown order (#179).
shutdown_manager.shutdown().await;
let shutdown = async {
let _ = tokio::signal::ctrl_c().await;
tracing::info!("ctrl-c received — shutting down");
};
axum::serve(listener, router)
-1
View File
@@ -170,7 +170,6 @@ fn session_error_status(code: &str) -> StatusCode {
// The session outlived its pane: nothing to type into, and nothing
// the daemon can do about it.
"not_running" => StatusCode::CONFLICT,
"shutting_down" => StatusCode::SERVICE_UNAVAILABLE,
_ => StatusCode::BAD_GATEWAY,
}
}
+48 -494
View File
@@ -7,22 +7,21 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use maestro_acp::{AcpSession, SpawnConfig};
use maestro_protocol::{
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, NameSource, Session,
SessionStatus, SessionView, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
SessionStatus, WorktreeSpec, NAME_KEY, NAME_SOURCE_KEY,
};
use tokio::sync::{broadcast, watch, Mutex};
use tokio::sync::{broadcast, Mutex};
use uuid::Uuid;
use crate::panes::Panes;
use crate::pty::{pipe_pane_command, PtyBridge};
use crate::sidecar::{EventCursor, EventLog, LoggedEvent, SidecarError, SidecarStore};
use crate::sidecar::{SidecarError, SidecarStore};
use crate::spawn_history::{RecentDir, SpawnHistory, SpawnHistoryRecord};
use crate::tmux::TmuxError;
use crate::viewport::{ViewportId, Viewports};
@@ -88,24 +87,6 @@ pub struct SessionRecord {
/// glow, this holds through focus loss until the session does new work.
/// Daemon-local; never rides the wire.
pub awaiting_suppressed: bool,
/// The `claude-code-acp` child behind a [`SessionView::Structured`]
/// session (#134): its connection, pid and the raw notification stream.
/// `None` for terminal sessions and for a structured session whose
/// adapter never came up. Shared so a kill or shutdown can terminate it
/// outside the sessions lock. Daemon-local; never rides the wire.
pub acp: Option<Arc<AcpSession>>,
/// Open log plus its post-append live sender. Their shared lock is the
/// replay/live seam used by the structured WebSocket in #138.
acp_events: Option<Arc<Mutex<AcpEventStream>>>,
/// Completion signal for the task draining ACP notifications into the
/// event log. Shutdown waits for it before returning.
acp_event_pump: Option<watch::Receiver<bool>>,
}
#[derive(Debug)]
struct AcpEventStream {
log: EventLog,
live: broadcast::Sender<LoggedEvent>,
}
#[derive(Debug, thiserror::Error)]
@@ -127,8 +108,6 @@ pub enum SessionError {
/// a stray keystroke on an attach socket, which is dropped.
#[error("session is no longer running: {0}")]
NotRunning(Uuid),
#[error("daemon is shutting down")]
ShuttingDown,
/// The kill was refused (#79): the session's worktree holds unsaved work.
/// Nothing happened — the session keeps running and the tree is untouched,
/// so it stays the operator's handle on their own unfinished work.
@@ -146,7 +125,6 @@ impl SessionError {
SessionError::NotFound(_) => "not_found",
SessionError::InvalidEnv(_) => "invalid_env",
SessionError::NotRunning(_) => "not_running",
SessionError::ShuttingDown => "shutting_down",
SessionError::KillGuarded(_) => "kill_guarded",
};
let unsaved_work = match self {
@@ -198,12 +176,6 @@ struct Inner {
/// client that reconnects never collides with the registration its
/// previous socket has not finished releasing.
next_viewport: AtomicU64,
/// Once daemon shutdown starts, it owns structured-session finalization.
/// Background passes must not classify those deliberate exits as natural.
shutting_down: AtomicBool,
/// Serializes structured creation with shutdown so every adapter child is
/// either rejected or included in shutdown's final drain.
structured_lifecycle: Mutex<()>,
}
impl SessionManager {
@@ -246,8 +218,6 @@ impl SessionManager {
events,
viewports: Mutex::new(Viewports::new()),
next_viewport: AtomicU64::new(0),
shutting_down: AtomicBool::new(false),
structured_lifecycle: Mutex::new(()),
}),
}
}
@@ -317,83 +287,6 @@ impl SessionManager {
sessions.values().map(|r| r.session.clone()).collect()
}
/// Read replay and acquire its live continuation under the same lock.
/// Events are therefore present exactly once across the returned seam.
pub async fn subscribe_acp_events_with_replay(
&self,
id: Uuid,
cursor: EventCursor,
limit: usize,
) -> Result<Option<(Vec<LoggedEvent>, broadcast::Receiver<LoggedEvent>)>, SessionError> {
let stream = {
let sessions = self.inner.sessions.lock().await;
let Some(record) = sessions.get(&id) else {
return Ok(None);
};
let Some(stream) = &record.acp_events else {
return Ok(None);
};
stream.clone()
};
let stream = stream.lock().await;
let live = stream.live.subscribe();
let replay = self.inner.sidecar.read_events(id, cursor, limit)?;
Ok(Some((replay, live)))
}
/// Read a cursor-bounded slice of the durable structured-session log.
pub fn read_acp_events(
&self,
id: Uuid,
cursor: EventCursor,
limit: usize,
) -> Result<Vec<LoggedEvent>, SessionError> {
self.inner
.sidecar
.read_events(id, cursor, limit)
.map_err(SessionError::Sidecar)
}
/// End every adapter child before the daemon exits (#134): each gets
/// `session/cancel`, then SIGTERM, concurrently. The sessions stay in
/// the map, marked Dead and written through to their sidecars, so what
/// is on disk after the restart is truthful. tmux sessions are left
/// alone — surviving a restart is what they are for.
pub async fn shutdown(&self) {
if self.inner.shutting_down.swap(true, Ordering::AcqRel) {
return;
}
let _lifecycle = self.inner.structured_lifecycle.lock().await;
let children: Vec<(Uuid, Arc<AcpSession>, Option<watch::Receiver<bool>>)> = {
let sessions = self.inner.sessions.lock().await;
sessions
.iter()
.filter_map(|(id, r)| {
(r.session.status == SessionStatus::Running)
.then_some(())
.and_then(|()| r.acp.clone())
.map(|acp| (*id, acp, r.acp_event_pump.clone()))
})
.collect()
};
if children.is_empty() {
return;
}
tracing::info!(count = children.len(), "shutdown: ending adapter children");
let terminations = children
.iter()
.map(|(_, acp, _)| acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE));
futures_util::future::join_all(terminations).await;
let drains = children
.iter()
.filter_map(|(_, _, done)| done.clone())
.map(wait_for_event_pump);
futures_util::future::join_all(drains).await;
for (id, _, _) in children {
mark_session_dead_after_shutdown(&self.inner, id).await;
}
}
/// Subscribe to a session's PTY broadcast. `None` if the session is
/// unknown to this daemon.
pub async fn subscribe_pty(&self, id: Uuid) -> Option<broadcast::Receiver<Vec<u8>>> {
@@ -424,7 +317,7 @@ impl SessionManager {
// replayed from it renders as garbage until something forces a
// repaint. A dead session has no pane left to capture, so it
// keeps the byte tail.
let alive = rec.session.status == SessionStatus::Running && rec.acp.is_none();
let alive = rec.session.status == SessionStatus::Running;
// The capture shells out to tmux while the replay lock is held,
// which parks the reader for the length of one tmux round-trip.
// That only happens on attach, and the alternative is a seam in
@@ -473,17 +366,6 @@ impl SessionManager {
/// Spawn a new session. On success returns the freshly-created `Session`.
pub async fn create(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
if req.view == SessionView::Structured {
let _lifecycle = self.inner.structured_lifecycle.lock().await;
if self.inner.shutting_down.load(Ordering::Acquire) {
return Err(SessionError::ShuttingDown);
}
return self.create_inner(req).await;
}
self.create_inner(req).await
}
async fn create_inner(&self, req: CreateSessionRequest) -> Result<Session, SessionError> {
let (effective_cwd, worktree_path, worktree_base_repo) = match &req.worktree {
Some(spec) => {
let WorktreeSpec {
@@ -545,66 +427,20 @@ impl SessionManager {
{
session_env.insert("MAESTRO_URL".into(), url);
}
// A structured session (#134) gets no tmux pane: the agent is a
// child of the daemon, driven over ACP. An adapter that cannot come
// up still yields a session — Dead, with the reason on it — so the
// operator sees what to fix instead of a silently missing session.
let (acp, acp_failure) = match req.view {
SessionView::Structured => {
match maestro_acp::spawn(SpawnConfig::new(
&effective_cwd,
session_env
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
))
.await
{
Ok(acp) => (Some(Arc::new(acp)), None),
Err(e) => {
tracing::warn!(%id, error = %e, "structured session: adapter failed to start");
(None, Some(e.to_string()))
}
}
}
SessionView::Terminal => {
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
let command = req.command.clone();
let sink = pipe_pane_command(&fifo_path);
let env = session_env.clone();
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &env, &sink))
.await
};
if let Err(e) = spawned {
// `bridge` drops at the end of this block, closing the
// FIFO reader.
let _ = std::fs::remove_file(&fifo_path);
self.rollback_worktree(&worktree_base_repo, &worktree_path);
return Err(SessionError::Tmux(e));
}
(None, None)
}
};
let acp_events = if let Some(acp) = &acp {
match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
Some(Arc::new(Mutex::new(AcpEventStream { log, live: tx })))
}
Err(e) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
let _ = std::fs::remove_file(&fifo_path);
self.rollback_worktree(&worktree_base_repo, &worktree_path);
return Err(SessionError::Sidecar(e));
}
}
} else {
None
let spawned = {
let tmux = self.inner.tmux.clone();
let cwd = effective_cwd.clone();
let command = req.command.clone();
let sink = pipe_pane_command(&fifo_path);
let env = session_env.clone();
tmux_blocking(move || tmux.new_session_piped(id, &cwd, &command, &env, &sink)).await
};
if let Err(e) = spawned {
// `bridge` drops at the end of this block, closing the FIFO reader.
let _ = std::fs::remove_file(&fifo_path);
self.rollback_worktree(&worktree_base_repo, &worktree_path);
return Err(SessionError::Tmux(e));
}
// Capture spawn-history inputs before `req` is partially moved into
// `session`. `history_cwd` is the *chosen* working dir (the request
@@ -627,21 +463,13 @@ impl SessionManager {
base_repo: base_repo.clone(),
},
),
status: if acp_failure.is_some() {
SessionStatus::Dead
} else {
SessionStatus::Running
},
status: SessionStatus::Running,
created_at: Utc::now(),
// A freshly-spawned session is producing its initial output; the
// activity watcher refines `foreground`/`activity` on its next tick.
foreground: None,
activity: if acp_failure.is_some() {
ActivityState::Error
} else {
ActivityState::Active
},
activity_detail: acp_failure,
activity: ActivityState::Active,
activity_detail: None,
last_activity_at: Utc::now(),
activity_since: chrono::Utc::now(),
@@ -650,33 +478,18 @@ impl SessionManager {
// fresh tree has nothing in the way anyway.
blocked_work: None,
last_focused_at: None,
acp_session_id: acp.as_ref().map(|a| a.session_id().to_string()),
view: req.view,
};
if let Err(e) = self.inner.sidecar.write(&session) {
// The process already started; end it so no ghost survives a
// tmux already spawned the session; kill it so no ghost survives a
// failed sidecar write, then unwind the FIFO + worktree.
match &acp {
Some(acp) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await
}
None => {
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
}
let tmux = self.inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
let _ = std::fs::remove_file(&fifo_path);
let _ = self.inner.sidecar.delete(id);
self.rollback_worktree(&worktree_base_repo, &worktree_path);
return Err(SessionError::Sidecar(e));
}
let event_pump = acp
.as_ref()
.zip(acp_events.as_ref())
.map(|(acp, events)| (acp.clone(), events.clone()));
{
let mut sessions = self.inner.sessions.lock().await;
sessions.insert(
@@ -693,18 +506,9 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
acp,
acp_events,
acp_event_pump: None,
},
);
}
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
}
let _ = self.inner.events.send(CatalogEvent::SessionAdded {
session: session.clone(),
});
@@ -771,9 +575,7 @@ impl SessionManager {
.worktree_base_repo
.clone()
.zip(record.worktree_path.clone()),
// A structured session has no pane to kill; its child is
// ended through the ACP handle in `cleanup_record`.
record.session.status == SessionStatus::Running && record.acp.is_none(),
record.session.status == SessionStatus::Running,
),
}
};
@@ -881,10 +683,6 @@ impl SessionManager {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
// Unknown on adoption without a sidecar; matches every
// other field's forward-compat default.
view: maestro_protocol::SessionView::Terminal,
},
};
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
@@ -916,11 +714,6 @@ impl SessionManager {
naming: false,
focused_since: None,
awaiting_suppressed: false,
// A structured session's adapter died with the previous
// daemon; only tmux-backed sessions can be adopted.
acp: None,
acp_events: None,
acp_event_pump: None,
},
);
let _ = self
@@ -929,138 +722,6 @@ impl SessionManager {
.send(CatalogEvent::SessionAdded { session });
adopted += 1;
}
// Structured sessions have no tmux process to discover. Their
// sidecars are the recovery inventory: when an ACP id exists, start a
// fresh adapter process and load that conversation into it.
for mut session in self
.inner
.sidecar
.load_all()?
.into_iter()
.filter(|session| session.view == SessionView::Structured)
{
if self.inner.sessions.lock().await.contains_key(&session.id) {
continue;
}
let id = session.id;
let mut session_env = sanitize_env(&session.env)?;
let fifo_path = self.inner.fifo_dir.join(format!("{id}.fifo"));
let bridge =
PtyBridge::start_with_replay(fifo_path.clone(), self.inner.replay_bytes).await?;
session_env.insert("MAESTRO_SESSION_ID".into(), id.to_string());
if let Some(url) = self
.inner
.daemon_url
.lock()
.expect("daemon_url mutex")
.clone()
{
session_env.insert("MAESTRO_URL".into(), url);
}
let (acp, failure) = match session.acp_session_id.clone() {
Some(acp_session_id) => {
let config = SpawnConfig::new(
&session.cwd,
session_env
.iter()
.map(|(key, value)| (key.clone(), value.clone()))
.collect(),
)
.load_session(acp_session_id);
match maestro_acp::spawn(config).await {
Ok(acp) => (Some(Arc::new(acp)), None),
Err(error) => {
tracing::warn!(%id, %error, "structured session: adapter failed to resume");
(None, Some(error.to_string()))
}
}
}
None => (
None,
Some("structured session has no stored ACP session id".into()),
),
};
session.status = if failure.is_some() {
SessionStatus::Dead
} else {
SessionStatus::Running
};
session.activity = if failure.is_some() {
ActivityState::Error
} else {
ActivityState::Active
};
session.activity_detail = failure;
let acp_events = if let Some(acp) = &acp {
match self.inner.sidecar.open_events(id) {
Ok(log) => {
let (tx, _) = broadcast::channel(EVENTS_CAPACITY);
Some(Arc::new(Mutex::new(AcpEventStream { log, live: tx })))
}
Err(error) => {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
let _ = std::fs::remove_file(&fifo_path);
return Err(SessionError::Sidecar(error));
}
}
} else {
None
};
if let Err(error) = self.inner.sidecar.write(&session) {
if let Some(acp) = &acp {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
let _ = std::fs::remove_file(&fifo_path);
return Err(SessionError::Sidecar(error));
}
let event_pump = acp
.as_ref()
.zip(acp_events.as_ref())
.map(|(acp, events)| (acp.clone(), events.clone()));
self.inner.sessions.lock().await.insert(
id,
SessionRecord {
session: session.clone(),
fifo_path,
worktree_path: session
.worktree
.as_ref()
.map(|worktree| worktree.path.clone()),
worktree_base_repo: session
.worktree
.as_ref()
.map(|worktree| worktree.base_repo.clone()),
bridge,
reported_activity: None,
settled: false,
#[cfg(feature = "haiku-autoname")]
naming: false,
focused_since: None,
awaiting_suppressed: false,
acp,
acp_events,
acp_event_pump: None,
},
);
if let Some((acp, events)) = event_pump {
let done = start_acp_event_pump(&self.inner, id, &acp, events);
if let Some(record) = self.inner.sessions.lock().await.get_mut(&id) {
record.acp_event_pump = Some(done);
}
}
let _ = self
.inner
.events
.send(CatalogEvent::SessionAdded { session });
adopted += 1;
}
Ok(adopted)
}
@@ -1386,55 +1047,6 @@ fn sanitize_env(
Ok(out)
}
fn start_acp_event_pump(
inner: &Arc<Inner>,
id: Uuid,
acp: &Arc<AcpSession>,
events: Arc<Mutex<AcpEventStream>>,
) -> watch::Receiver<bool> {
let (done_tx, done_rx) = watch::channel(false);
let Some(mut updates) = acp.take_updates() else {
tracing::error!(%id, "structured session notification stream was already taken");
let _ = done_tx.send(true);
return done_rx;
};
let weak_inner = Arc::downgrade(inner);
let weak_acp = Arc::downgrade(acp);
tokio::spawn(async move {
while let Some(notification) = updates.recv().await {
let append_error = {
let mut events = events.lock().await;
match events.log.append(&notification) {
Ok(seq) => {
let _ = events.live.send(LoggedEvent { seq, notification });
None
}
Err(error) => Some(error),
}
};
if let Some(error) = append_error {
tracing::error!(%id, %error, "structured event log append failed");
if let Some(acp) = weak_acp.upgrade() {
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
if let Some(inner) = weak_inner.upgrade() {
mark_session_dead(&inner, id).await;
}
break;
}
}
let _ = done_tx.send(true);
});
done_rx
}
async fn wait_for_event_pump(mut done: watch::Receiver<bool>) {
if !*done.borrow() {
let _ = done.wait_for(|finished| *finished).await;
}
}
async fn tmux_blocking<T, F>(f: F) -> T
where
F: FnOnce() -> T + Send + 'static,
@@ -1465,45 +1077,27 @@ async fn probe_worktree(
/// One pass of the liveness watcher: diff the in-memory session set against
/// the tmux-reported one, and reap anything we still track but tmux has lost.
async fn run_liveness_pass(inner: &Arc<Inner>) {
if inner.shutting_down.load(Ordering::Acquire) {
return;
}
// Snapshot the in-memory ids first; if we own no sessions there is
// nothing to reap and no reason to spawn a tmux subprocess (this also
// avoids racing with the very first `new-session` that would otherwise
// be competing to start the tmux server).
// Only sessions we still believe are running can transition; a session
// already marked Dead must not re-emit an update on every 2s tick.
// A structured session (#134) is not in tmux at all: its liveness is its
// adapter child, checked through the ACP handle instead.
let (tmux_backed, acp_backed): (Vec<Uuid>, Vec<(Uuid, Arc<AcpSession>)>) = {
let known: Vec<Uuid> = {
let sessions = inner.sessions.lock().await;
if sessions.is_empty() {
return;
}
let running = sessions
sessions
.iter()
.filter(|(_, r)| r.session.status == SessionStatus::Running);
let mut tmux_backed = Vec::new();
let mut acp_backed = Vec::new();
for (id, record) in running {
match &record.acp {
Some(acp) => acp_backed.push((*id, acp.clone())),
None => tmux_backed.push(*id),
}
}
(tmux_backed, acp_backed)
.filter(|(_, r)| r.session.status == SessionStatus::Running)
.map(|(id, _)| *id)
.collect()
};
for (id, acp) in acp_backed {
if acp.has_exited() {
mark_session_dead(inner, id).await;
}
}
if tmux_backed.is_empty() {
if known.is_empty() {
return;
}
let live_ids = {
let tmux = inner.tmux.clone();
match tmux_blocking(move || tmux.list_sessions()).await {
@@ -1516,7 +1110,7 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
};
let live: HashSet<Uuid> = live_ids.into_iter().collect();
for id in tmux_backed {
for id in known {
if !live.contains(&id) {
mark_session_dead(inner, id).await;
}
@@ -1527,9 +1121,6 @@ async fn run_liveness_pass(inner: &Arc<Inner>) {
/// `SessionUpdated` only when `activity` or `foreground` actually changes — so a
/// steady state produces no diffs. Runs on the existing liveness tick.
async fn run_activity_pass(inner: &Arc<Inner>) {
if inner.shutting_down.load(Ordering::Acquire) {
return;
}
let ids: Vec<Uuid> = {
let sessions = inner.sessions.lock().await;
if sessions.is_empty() {
@@ -1572,9 +1163,6 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
let updated = {
let mut sessions = inner.sessions.lock().await;
if inner.shutting_down.load(Ordering::Acquire) {
return;
}
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
@@ -1649,9 +1237,6 @@ async fn run_activity_pass(inner: &Arc<Inner>) {
/// error must not read as "nothing blocks". Like the activity pass, this only
/// emits `SessionUpdated` when the value actually changed.
async fn run_blocked_work_pass(inner: &Arc<Inner>) {
if inner.shutting_down.load(Ordering::Acquire) {
return;
}
let targets: Vec<(Uuid, (PathBuf, PathBuf))> = {
let sessions = inner.sessions.lock().await;
sessions
@@ -1679,9 +1264,6 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
};
let updated = {
let mut sessions = inner.sessions.lock().await;
if inner.shutting_down.load(Ordering::Acquire) {
return;
}
let Some(rec) = sessions.get_mut(&id) else {
continue;
};
@@ -1698,7 +1280,7 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
}
}
/// Mark a session whose backing process has disappeared as [`SessionStatus::Dead`]
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
/// and settle its worktree per the natural-exit rule (#82).
///
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
@@ -1710,27 +1292,9 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
/// alone and stays attached to the dead record for a later forced dismissal
/// (#81), which leaves it on disk too.
///
/// Terminal sessions that die naturally do not survive a daemon restart:
/// [`SessionManager::recover`] adopts only what tmux still holds. Structured
/// sessions stopped for daemon shutdown retain their sidecars for ACP recovery.
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
/// adopts only what tmux still holds.
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
if inner.shutting_down.load(Ordering::Acquire) {
return;
}
mark_session_dead_with_worktree_settlement(inner, id, true).await;
}
/// A daemon restart terminates ACP children, but it is not a natural adapter
/// exit: the sidecar is the recovery inventory, including its worktree.
async fn mark_session_dead_after_shutdown(inner: &Arc<Inner>, id: Uuid) {
mark_session_dead_with_worktree_settlement(inner, id, false).await;
}
async fn mark_session_dead_with_worktree_settlement(
inner: &Arc<Inner>,
id: Uuid,
settle_worktree: bool,
) {
let flipped = {
let mut sessions = inner.sessions.lock().await;
match sessions.get_mut(&id) {
@@ -1752,21 +1316,19 @@ async fn mark_session_dead_with_worktree_settlement(
let Some((mut session, worktree)) = flipped else {
return;
};
tracing::info!(%id, "session process gone — marking dead");
tracing::info!(%id, "session vanished from tmux — marking dead");
if settle_worktree {
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
record.session.blocked_work = None;
record.worktree_path = None;
record.worktree_base_repo = None;
}
if let Some(wt) = &worktree {
if settle_worktree_on_exit(inner, id, wt).await {
// The tree is settled (removed); a stale "in the way" would
// describe something the session no longer holds.
session.blocked_work = None;
let mut sessions = inner.sessions.lock().await;
if let Some(record) = sessions.get_mut(&id) {
record.session.worktree = None;
record.session.blocked_work = None;
record.worktree_path = None;
record.worktree_base_repo = None;
}
}
}
@@ -1856,14 +1418,6 @@ async fn cleanup_record(
let tmux = inner.tmux.clone();
let _ = tmux_blocking(move || tmux.kill_session(id)).await;
}
if let Some(acp) = &record.acp {
// `session/cancel`, a short wait, then SIGTERM (#134).
acp.terminate(maestro_acp::CANCEL_GRACE, maestro_acp::TERM_GRACE)
.await;
}
if let Some(done) = record.acp_event_pump.clone() {
wait_for_event_pump(done).await;
}
let _ = std::fs::remove_file(&record.fifo_path);
if let Err(e) = inner.sidecar.delete(id) {
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
+6 -169
View File
@@ -5,22 +5,9 @@
//! original `cwd`, command, env, metadata, etc. Files are deleted on
//! `KillSession`. Sessions whose sidecar is missing at recovery time are still
//! advertised but with empty metadata (handled by the caller, not here).
//!
//! A structured session (#135) also owns `<id>.events.jsonl` next to its
//! sidecar: every `session/update` notification its adapter sent, one JSON
//! object per line, appended before it is broadcast. The log is the source of
//! truth for the transcript; `seq` is the line's 1-based position, so a
//! client that holds seq `n` asks for [`EventCursor::Since`]`(n)`. A line
//! that does not parse — including the partial line a hard kill leaves at
//! the end — keeps its seq and is skipped on read. `delete` removes the log
//! with the sidecar; `load_all` never reads one as a sidecar.
use std::collections::VecDeque;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use maestro_acp::schema::SessionNotification;
use maestro_protocol::Session;
use uuid::Uuid;
@@ -51,98 +38,6 @@ impl SidecarStore {
self.dir.join(format!("{id}.json"))
}
/// Where the per-session event log lives (#135).
pub fn events_path_for(&self, id: Uuid) -> PathBuf {
self.dir.join(format!("{id}.events.jsonl"))
}
/// Open the event log for `id` for appending, creating it (and the
/// directory) when missing. One handle per live session: the caller
/// keeps it for the session's lifetime rather than reopening per
/// notification.
///
/// A log whose last line has no newline — what a hard kill mid-write
/// leaves behind — is terminated first, so the next append starts a
/// fresh line instead of gluing onto the fragment. The fragment keeps
/// the seq of its line and reads as malformed.
pub fn open_events(&self, id: Uuid) -> Result<EventLog, SidecarError> {
std::fs::create_dir_all(&self.dir)?;
let mut file = OpenOptions::new()
.create(true)
.read(true)
.append(true)
.open(self.events_path_for(id))?;
let mut lines = 0u64;
let mut last = None;
let mut reader = BufReader::new(&mut file);
let mut buf = [0u8; 64 * 1024];
loop {
let n = reader.read(&mut buf)?;
if n == 0 {
break;
}
lines += buf[..n].iter().filter(|&&b| b == b'\n').count() as u64;
last = Some(buf[n - 1]);
}
if last.is_some_and(|b| b != b'\n') {
file.write_all(b"\n")?;
lines += 1;
}
file.seek(SeekFrom::End(0))?;
Ok(EventLog {
file,
last_seq: lines,
})
}
/// Read events from the log for `id`, at most `limit`, in ascending seq
/// order. A missing log is an empty log. Lines that fail to parse are
/// skipped but still consume a seq, so cursors stay stable.
pub fn read_events(
&self,
id: Uuid,
cursor: EventCursor,
limit: usize,
) -> Result<Vec<LoggedEvent>, SidecarError> {
let file = match File::open(self.events_path_for(id)) {
Ok(f) => f,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
if limit == 0 {
return Ok(Vec::new());
}
let mut out: VecDeque<LoggedEvent> = VecDeque::new();
let mut seq = 0u64;
for line in BufReader::new(file).split(b'\n') {
let line = line?;
seq += 1;
let keep = match cursor {
EventCursor::Since(since) => seq > since,
EventCursor::Before(Some(before)) => seq < before,
EventCursor::Before(None) => true,
};
if !keep {
if matches!(cursor, EventCursor::Before(Some(_))) {
break;
}
continue;
}
let Ok(notification) = serde_json::from_slice::<SessionNotification>(&line) else {
continue;
};
out.push_back(LoggedEvent { seq, notification });
match cursor {
EventCursor::Since(_) if out.len() >= limit => break,
EventCursor::Before(_) if out.len() > limit => {
out.pop_front();
}
_ => {}
}
}
Ok(out.into())
}
/// Atomically write `session` to `<dir>/<id>.json`. Creates the directory
/// if it does not exist.
pub fn write(&self, session: &Session) -> Result<(), SidecarError> {
@@ -164,11 +59,13 @@ impl SidecarStore {
}
}
/// Delete the sidecar and the event log for `id`. No-op for whichever
/// does not exist.
/// Delete the sidecar for `id`. No-op if it does not exist.
pub fn delete(&self, id: Uuid) -> Result<(), SidecarError> {
remove_if_present(&self.events_path_for(id))?;
remove_if_present(&self.path_for(id))
match std::fs::remove_file(self.path_for(id)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Read every sidecar JSON in the directory. Files that fail to parse are
@@ -184,8 +81,6 @@ impl SidecarStore {
for entry in entries {
let entry = entry?;
let path = entry.path();
// `.events.jsonl` logs (#135) sit in the same directory and are
// never sidecars.
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
@@ -200,61 +95,3 @@ impl SidecarStore {
Ok(out)
}
}
fn remove_if_present(path: &Path) -> Result<(), SidecarError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
/// Which slice of a session's event log to read.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EventCursor {
/// Forward: events with `seq` strictly greater than the cursor, the
/// first `limit` of them. `Since(0)` is the whole log from the start.
/// What a client catching up after a reconnect asks for.
Since(u64),
/// Backward: events with `seq` strictly less than the cursor, the last
/// `limit` of them, still returned in ascending order. `None` means the
/// tail of the log, for a first paint. The next page passes the smallest
/// seq it received.
Before(Option<u64>),
}
/// One line of a session's event log.
#[derive(Debug, Clone, PartialEq)]
pub struct LoggedEvent {
/// 1-based line position in the log; the cursor for [`EventCursor`].
pub seq: u64,
pub notification: SessionNotification,
}
/// An open, append-only handle on one session's event log. Obtained from
/// [`SidecarStore::open_events`]; held for the session's lifetime.
#[derive(Debug)]
pub struct EventLog {
file: File,
last_seq: u64,
}
impl EventLog {
/// The seq of the last line, `0` for an empty log.
pub fn last_seq(&self) -> u64 {
self.last_seq
}
/// Append one notification as a line and return its seq. The line is
/// handed to the kernel in a single write before this returns, so a
/// caller that broadcasts afterwards never announces an event the log
/// would not replay — short of the machine losing power.
pub fn append(&mut self, notification: &SessionNotification) -> Result<u64, SidecarError> {
let mut line = serde_json::to_vec(notification)?;
line.push(b'\n');
self.file.write_all(&line)?;
self.file.flush()?;
self.last_seq += 1;
Ok(self.last_seq)
}
}
-606
View File
@@ -1,606 +0,0 @@
//! Structured sessions (#134): a `claude-code-acp` child per session instead
//! of a tmux pane. Runs against the fake adapter in
//! `crates/maestro-acp/tests/fake_acp_agent.py`; nothing here needs Claude.
mod common;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
use maestro_acp::schema::{ContentBlock, SessionUpdate};
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
use maestro_daemon::worktree::WorktreeManager;
use maestro_daemon::SessionManager;
use maestro_protocol::{
ActivityState, CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec,
};
use tempfile::TempDir;
fn fake_agent() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../maestro-acp/tests/fake_acp_agent.py")
}
/// The fake adapter installed as `claude-code-acp` in a temp `bin/`, with
/// the log file it appends to. `env()` is what a spawn request carries.
struct Fake {
dir: TempDir,
}
impl Fake {
fn new() -> Self {
let dir = TempDir::new().unwrap();
let bin = dir.path().join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(fake_agent(), bin.join(ADAPTER_BIN)).unwrap();
Self { dir }
}
fn log_path(&self) -> PathBuf {
self.dir.path().join("agent.log")
}
fn log(&self) -> String {
std::fs::read_to_string(self.log_path()).unwrap_or_default()
}
/// Fake `bin/` first so it shadows any real adapter, then the process
/// `PATH` so the script's `python3` still resolves.
fn env(&self) -> HashMap<String, String> {
let path = format!(
"{}:{}",
self.dir.path().join("bin").display(),
std::env::var("PATH").unwrap_or_default()
);
HashMap::from([
("PATH".to_string(), path),
(
"FAKE_ACP_LOG".to_string(),
self.log_path().to_string_lossy().into_owned(),
),
])
}
/// The pid the fake logged at start, once it has.
fn pid(&self) -> u32 {
self.log()
.lines()
.filter_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
.next_back()
.expect("pid in log")
}
}
fn event_text(event: &maestro_daemon::sidecar::LoggedEvent) -> &str {
match &event.notification.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(text) => &text.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
fn structured(cwd: &Path, env: HashMap<String, String>) -> CreateSessionRequest {
CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.to_path_buf(),
command: "claude".into(),
env,
metadata: HashMap::new(),
worktree: None,
}
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|s| !s.contains(") Z "))
.unwrap_or(false)
}
async fn wait_for_exit(pid: u32) {
let deadline = Instant::now() + Duration::from_secs(5);
while pid_alive(pid) && Instant::now() < deadline {
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!pid_alive(pid), "pid {pid} still alive");
}
#[tokio::test]
async fn structured_spawn_starts_the_adapter_in_cwd_with_session_env() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_ECHO_ENV".into(), "MAESTRO_SESSION_ID".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
assert_eq!(session.view, SessionView::Structured);
assert_eq!(session.status, SessionStatus::Running);
let acp_id = session.acp_session_id.clone().expect("acp session id");
assert!(acp_id.starts_with("fake-"), "{acp_id}");
// The child ran in the session cwd with the session's env — including
// the identity the daemon injects, so the agent knows which session it
// is (#103).
let log = fake.log();
let cwd_canon = cwd.path().canonicalize().unwrap();
assert!(
log.contains(&format!("cwd={}", cwd_canon.display())),
"{log}"
);
assert!(
log.contains(&format!("env MAESTRO_SESSION_ID={}", session.id)),
"{log}"
);
// No tmux pane was created for it: the agent is the daemon's child.
let panes = fx.tmux.tmux.list_sessions().expect("list");
assert!(
!panes.contains(&session.id),
"structured session got a tmux pane"
);
// The sidecar carries the id a respawn will pass to session/load (#135).
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(sidecar.acp_session_id.as_deref(), Some(acp_id.as_str()));
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| !events.is_empty())
})
.await;
// Live delivery happens only after the notification is durable.
let (replay, mut updates) = fx
.manager
.subscribe_acp_events_with_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("replay read")
.expect("event subscription");
assert_eq!(replay.len(), 1);
assert!(updates.try_recv().is_err(), "replay duplicated onto live");
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
let live = tokio::time::timeout(Duration::from_secs(5), updates.recv())
.await
.expect("an update within 5s")
.expect("broadcast open");
assert_eq!(event_text(&live), "tick 1");
let stored = fx
.manager
.read_acp_events(session.id, EventCursor::Since(live.seq - 1), 1)
.expect("read event logged before broadcast");
assert_eq!(stored, vec![live]);
fx.manager.kill(session.id, false).await.expect("kill");
assert!(!fx
.sidecar_dir()
.join(format!("{}.events.jsonl", session.id))
.exists());
}
#[tokio::test]
async fn recovery_loads_the_acp_session_without_duplicating_history() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_LOAD_REPLAY".into(), "2".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
let acp_id = session.acp_session_id.clone().expect("acp id");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
unsafe {
libc::kill(fake.pid() as libc::pid_t, libc::SIGUSR1);
}
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 2)
})
.await;
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running)
);
let log = fake.log();
assert!(
log.contains(&format!("recv session/load sessionId={acp_id}")),
"{log}"
);
wait_for_async(Duration::from_secs(5), || async {
revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 3)
})
.await;
let (replay, mut live) = revived
.subscribe_acp_events_with_replay(session.id, EventCursor::Since(0), 10)
.await
.expect("replay read")
.expect("recovered event subscription");
assert_eq!(replay.len(), 3);
assert!(live.try_recv().is_err(), "replay duplicated onto live");
let new_pid = fake.pid();
assert_ne!(new_pid, old_pid);
unsafe {
libc::kill(new_pid as libc::pid_t, libc::SIGUSR1);
}
let event = tokio::time::timeout(Duration::from_secs(5), live.recv())
.await
.expect("live event after recovery")
.expect("broadcast open");
assert_eq!(event.seq, 4);
assert_eq!(event_text(&event), "tick 1");
let stored = revived
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("recovered log");
assert_eq!(stored.len(), 4, "session/load replay must not be appended");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "tick 1", "hello after load", "tick 1"]
);
revived.kill(session.id, false).await.expect("kill");
}
#[tokio::test]
async fn shutdown_preserves_a_pristine_structured_worktree_for_recovery() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let repo = TempDir::new().unwrap();
init_repo(repo.path());
let origin = TempDir::new().unwrap();
init_bare_repo(origin.path());
let branch = "feature/restart";
let worktree = repo
.path()
.join("worktrees")
.join("feature")
.join("restart");
let session = fx
.manager
.create(CreateSessionRequest {
view: SessionView::Structured,
cwd: repo.path().to_path_buf(),
command: "claude".into(),
env: fake.env(),
metadata: HashMap::new(),
worktree: Some(WorktreeSpec {
base_repo: repo.path().to_path_buf(),
branch: branch.into(),
create_branch: true,
}),
})
.await
.expect("create");
assert_eq!(session.cwd, worktree);
push_all_to_origin(repo.path(), origin.path());
fx.manager.shutdown().await;
assert!(
worktree.is_dir(),
"shutdown must not settle a pristine worktree"
);
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", session.id))).unwrap(),
)
.unwrap();
assert_eq!(
sidecar.worktree.as_ref().map(|wt| &wt.path),
Some(&worktree)
);
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert_eq!(revived.recover().await.expect("recover"), 1);
assert_eq!(
revived.status(session.id).await,
Some(SessionStatus::Running)
);
assert!(fake.log().contains("recv session/load"), "{}", fake.log());
assert!(worktree.is_dir(), "recovery must retain the worktree");
revived.kill(session.id, false).await.expect("dismiss");
assert!(
!worktree.exists(),
"operator dismissal still cleans a pristine tree"
);
}
#[tokio::test]
async fn failed_recovery_cleans_its_fifo() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let old_pid = fake.pid();
fx.manager.shutdown().await;
wait_for_exit(old_pid).await;
let events_path = fx
.sidecar_dir()
.join(format!("{}.events.jsonl", session.id));
std::fs::remove_file(&events_path).unwrap();
std::fs::create_dir(&events_path).unwrap();
let revived = SessionManager::new(
Arc::new(fx.tmux.tmux.clone()),
SidecarStore::new(fx.sidecar_dir()),
WorktreeManager::new(None),
fx.fifo_dir(),
);
assert!(revived.recover().await.is_err());
assert!(!fx.fifo_dir().join(format!("{}.fifo", session.id)).exists());
let failed_pid = fake.pid();
assert_ne!(failed_pid, old_pid);
wait_for_exit(failed_pid).await;
}
#[tokio::test]
async fn missing_adapter_yields_a_failed_session_naming_the_install_command() {
let fx = DaemonFixture::new();
let empty = TempDir::new().unwrap();
let cwd = TempDir::new().unwrap();
let env = HashMap::from([(
"PATH".to_string(),
empty.path().to_string_lossy().into_owned(),
)]);
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("a failed session, not an error");
assert_eq!(session.status, SessionStatus::Dead);
assert_eq!(session.activity, ActivityState::Error);
let detail = session.activity_detail.clone().expect("actionable detail");
assert!(detail.contains(ADAPTER_BIN), "{detail}");
assert!(detail.contains(INSTALL_COMMAND), "{detail}");
assert_eq!(session.acp_session_id, None);
// It is a real session: listed, on disk, and dismissable like any other
// dead one.
assert!(fx.manager.has(session.id).await);
assert!(fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
}
#[tokio::test]
async fn kill_sends_cancel_then_sigterm_and_removes_the_sidecar() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut events = fx.manager.events();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
let pid = fake.pid();
assert!(pid_alive(pid));
fx.manager.kill(session.id, false).await.expect("kill");
wait_for_exit(pid).await;
let log = fake.log();
let cancel = log.find("recv session/cancel").expect("cancel logged");
let term = log.find("signal SIGTERM").expect("SIGTERM logged");
assert!(cancel < term, "cancel must precede SIGTERM:\n{log}");
assert!(!fx.manager.has(session.id).await);
assert!(!fx
.sidecar_dir()
.join(format!("{}.json", session.id))
.exists());
let mut removed = false;
while let Ok(ev) = events.try_recv() {
if matches!(ev, maestro_protocol::CatalogEvent::SessionRemoved { id } if id == session.id) {
removed = true;
}
}
assert!(removed, "SessionRemoved never broadcast");
}
#[tokio::test]
async fn shutdown_reaps_every_adapter_child() {
let fx = DaemonFixture::new();
let cwd = TempDir::new().unwrap();
let a = Fake::new();
let b = Fake::new();
let sa = fx
.manager
.create(structured(cwd.path(), a.env()))
.await
.expect("create a");
let sb = fx
.manager
.create(structured(cwd.path(), b.env()))
.await
.expect("create b");
let (pa, pb) = (a.pid(), b.pid());
assert!(pid_alive(pa) && pid_alive(pb));
fx.manager.shutdown().await;
wait_for_exit(pa).await;
wait_for_exit(pb).await;
assert!(a.log().contains("signal SIGTERM"), "{}", a.log());
assert!(b.log().contains("signal SIGTERM"), "{}", b.log());
// Still listed, now Dead: the sidecar is truthful for whoever reads it
// after the restart (#135).
assert_eq!(fx.manager.status(sa.id).await, Some(SessionStatus::Dead));
assert_eq!(fx.manager.status(sb.id).await, Some(SessionStatus::Dead));
let sidecar: maestro_protocol::Session = serde_json::from_slice(
&std::fs::read(fx.sidecar_dir().join(format!("{}.json", sa.id))).unwrap(),
)
.unwrap();
assert_eq!(sidecar.status, SessionStatus::Dead);
}
#[tokio::test]
async fn shutdown_waits_for_structured_creation_and_rejects_new_creates() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_DELAY_SESSION_NEW_MS".into(), "500".into());
let create_cwd = cwd.path().to_path_buf();
let manager = fx.manager.clone();
let create = tokio::spawn(async move { manager.create(structured(&create_cwd, env)).await });
wait_for_async(Duration::from_secs(5), || async {
fake.log().contains("recv session/new")
})
.await;
let manager = fx.manager.clone();
let shutdown = tokio::spawn(async move { manager.shutdown().await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!shutdown.is_finished(),
"shutdown returned before create finished"
);
let session = create.await.expect("create task").expect("create");
let pid = fake.pid();
shutdown.await.expect("shutdown task");
wait_for_exit(pid).await;
assert!(fake.log().contains("signal SIGTERM"), "{}", fake.log());
assert_eq!(
fx.manager.status(session.id).await,
Some(SessionStatus::Dead)
);
assert!(matches!(
fx.manager.create(structured(cwd.path(), fake.env())).await,
Err(maestro_daemon::SessionError::ShuttingDown)
));
// A repeated signal must not attempt to terminate the child again.
fx.manager.shutdown().await;
assert_eq!(fake.log().matches("signal SIGTERM").count(), 1);
}
#[tokio::test]
async fn shutdown_drains_final_notifications_to_the_log() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let mut env = fake.env();
env.insert("FAKE_ACP_EMIT_ON_CANCEL".into(), "1".into());
let session = fx
.manager
.create(structured(cwd.path(), env))
.await
.expect("create");
wait_for_async(Duration::from_secs(5), || async {
fx.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.is_ok_and(|events| events.len() == 1)
})
.await;
fx.manager.shutdown().await;
let stored = fx
.manager
.read_acp_events(session.id, EventCursor::Since(0), 10)
.expect("event log after shutdown");
assert_eq!(
stored.iter().map(event_text).collect::<Vec<_>>(),
["hello from fake", "final on cancel"]
);
}
#[tokio::test]
async fn adapter_exit_marks_the_session_dead_and_keeps_it_listed() {
let fx = DaemonFixture::new();
let fake = Fake::new();
let cwd = TempDir::new().unwrap();
let session = fx
.manager
.create(structured(cwd.path(), fake.env()))
.await
.expect("create");
fx.manager.start_liveness_watcher(Duration::from_millis(50));
// The agent dies out from under the daemon: the liveness pass, not tmux,
// is what notices for a structured session.
let pid = fake.pid();
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
wait_for_exit(pid).await;
wait_for_async(Duration::from_secs(3), || async {
fx.manager.status(session.id).await == Some(SessionStatus::Dead)
})
.await;
assert!(fx.manager.has(session.id).await);
fx.manager.kill(session.id, false).await.expect("dismiss");
assert!(!fx.manager.has(session.id).await);
}
@@ -42,7 +42,6 @@ async fn fixture(ttl: Duration) -> Fixture {
async fn spawn_sleeping(manager: &SessionManager) -> Uuid {
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
-1
View File
@@ -75,7 +75,6 @@ async fn create_bash_session(h: &Harness) -> Uuid {
let s = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd_path,
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
-1
View File
@@ -50,7 +50,6 @@ async fn spawn(manager: &SessionManager, name: Option<&str>) -> Uuid {
};
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
@@ -19,7 +19,6 @@ use tempfile::TempDir;
fn request(cwd: &std::path::Path, repo: &std::path::Path, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
-10
View File
@@ -174,7 +174,6 @@ async fn list_sessions_starts_empty_and_reflects_creates() {
assert!(empty.is_empty());
let req = CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -391,7 +390,6 @@ async fn patch_session_renames_it() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -434,7 +432,6 @@ async fn post_focus_stamps_last_focused_at() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -468,7 +465,6 @@ async fn delete_owned_session_kills_it() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -490,7 +486,6 @@ async fn delete_owned_session_kills_it() {
async fn spawn_via_manager(h: &Harness) -> Uuid {
h.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -600,7 +595,6 @@ async fn delete_a_session_whose_worktree_holds_unsaved_work_returns_409_with_the
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -653,7 +647,6 @@ async fn delete_with_force_ends_the_session_and_keeps_the_worktree() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 60".into(),
env: HashMap::new(),
@@ -687,7 +680,6 @@ async fn attach_ws_streams_pty_bytes() {
let session = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -756,7 +748,6 @@ async fn events_ws_sends_a_snapshot_then_streams_deltas() {
let created = h
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -801,7 +792,6 @@ async fn every_events_subscriber_gets_its_own_snapshot() {
h.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
-1
View File
@@ -51,7 +51,6 @@ async fn spawn_named(manager: &SessionManager, name: Option<&str>) -> Uuid {
};
manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: "/tmp".into(),
command: "sleep 60".into(),
env: Default::default(),
@@ -64,7 +64,6 @@ impl Fx {
fn request(&self, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: self.tmp.path().to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -22,7 +22,6 @@ async fn create_then_kill_round_trip() {
let session = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -64,7 +63,6 @@ async fn create_appends_to_spawn_history() {
let session = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -94,7 +92,6 @@ async fn create_without_history_logs_nothing() {
let cwd = TempDir::new().unwrap();
fx.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -117,7 +114,6 @@ async fn recover_adopts_existing_tmux_sessions() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -157,7 +153,6 @@ async fn create_captures_initial_output_in_replay_buffer() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// The shell prints the marker before doing anything else and
// then sleeps so the session stays alive. Before NAP-253 this
@@ -205,7 +200,6 @@ async fn attach_replay_is_the_current_screen_not_the_byte_tail() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// Print a line, then switch to the alternate screen the way a
// full-screen program does on startup. Only the second marker is
@@ -264,7 +258,6 @@ async fn attach_replay_carries_the_panes_mouse_modes() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
// Alternate screen, then button-event tracking (?1002) with SGR
// coordinates (?1006) — what tmux and agent TUIs actually request.
@@ -318,7 +311,6 @@ async fn recover_re_pipes_pane_so_output_reaches_subscribers() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -381,7 +373,6 @@ async fn pty_broadcast_delivers_bytes_to_subscribers() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "bash --noprofile --norc".into(),
env: HashMap::new(),
@@ -432,7 +423,6 @@ async fn liveness_watcher_marks_dead_sessions_and_keeps_them_until_dismissed() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -498,7 +488,6 @@ async fn watcher_refreshes_the_blocked_work_indicator() {
fx.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -591,7 +580,6 @@ async fn kill_removes_worktree_even_when_spec_says_keep() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -629,7 +617,6 @@ async fn killing_refuses_a_worktree_with_unsaved_work_and_keeps_the_session() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -693,7 +680,6 @@ async fn killing_refuses_until_the_branch_is_pushed() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -743,7 +729,6 @@ async fn force_killing_a_guarded_worktree_leaves_it_on_disk() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -789,7 +774,6 @@ async fn force_on_a_clear_worktree_removes_it_as_usual() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -830,7 +814,6 @@ async fn killing_an_ignored_only_worktree_succeeds() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -872,7 +855,6 @@ async fn natural_exit_of_a_guarded_worktree_leaves_it_on_disk() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -934,7 +916,6 @@ async fn natural_exit_of_a_pristine_worktree_removes_it() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -995,7 +976,6 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1030,7 +1010,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let first = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: repo.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1048,7 +1027,6 @@ async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
let second = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: inner_worktree.clone(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1087,7 +1065,6 @@ async fn spawned_sessions_export_maestro_session_id_and_url() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1126,7 +1103,6 @@ async fn a_freshly_created_session_has_never_been_focused() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1152,7 +1128,6 @@ async fn set_focused_true_stamps_last_focused_at() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -1201,7 +1176,6 @@ async fn attaching_stamps_last_focused_at() {
let s = fx
.manager
.create(CreateSessionRequest {
view: Default::default(),
cwd: cwd.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
+1 -220
View File
@@ -7,7 +7,7 @@ use std::path::PathBuf;
use chrono::{DateTime, Utc};
use maestro_daemon::sidecar::SidecarStore;
use maestro_protocol::{Session, SessionStatus, SessionView};
use maestro_protocol::{Session, SessionStatus};
use serde_json::Value;
use tempfile::TempDir;
use uuid::Uuid;
@@ -32,8 +32,6 @@ fn sample(id: Uuid) -> Session {
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
view: SessionView::Terminal,
}
}
@@ -105,37 +103,6 @@ fn load_all_returns_empty_when_directory_missing() {
assert!(store.load_all().unwrap().is_empty());
}
#[test]
fn sidecar_missing_view_key_reads_as_terminal() {
// A pre-#133 sidecar on disk has no `view` field at all.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut value = serde_json::to_value(sample(id)).unwrap();
value.as_object_mut().unwrap().remove("view");
std::fs::write(store.path_for(id), serde_json::to_vec(&value).unwrap()).unwrap();
let loaded = store.read(id).expect("read").expect("present");
assert_eq!(loaded.view, SessionView::Terminal);
}
#[test]
fn sidecar_structured_view_round_trips() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut s = sample(id);
s.view = SessionView::Structured;
store.write(&s).expect("write");
let loaded = store.read(id).expect("read");
assert_eq!(
loaded.as_ref().map(|s| s.view),
Some(SessionView::Structured)
);
}
#[test]
fn write_creates_parent_directory() {
let dir = TempDir::new().unwrap();
@@ -146,189 +113,3 @@ fn write_creates_parent_directory() {
assert!(nested.exists());
assert!(store.path_for(s.id).exists());
}
// ---- per-session event log (#135) ----
use maestro_acp::schema::{ContentBlock, SessionNotification, SessionUpdate, TextContent};
use maestro_daemon::sidecar::EventCursor;
fn note(text: &str) -> SessionNotification {
SessionNotification::new(
"acp-1",
SessionUpdate::AgentMessageChunk(maestro_acp::schema::ContentChunk::new(
ContentBlock::Text(TextContent::new(text)),
)),
)
}
fn text_of(n: &SessionNotification) -> &str {
match &n.update {
SessionUpdate::AgentMessageChunk(chunk) => match &chunk.content {
ContentBlock::Text(t) => &t.text,
other => panic!("unexpected content {other:?}"),
},
other => panic!("unexpected update {other:?}"),
}
}
#[test]
fn append_assigns_line_position_as_seq_and_reads_back() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).expect("open");
assert_eq!(log.last_seq(), 0);
assert_eq!(log.append(&note("a")).unwrap(), 1);
assert_eq!(log.append(&note("b")).unwrap(), 2);
assert_eq!(log.append(&note("c")).unwrap(), 3);
assert_eq!(log.last_seq(), 3);
let path = store.events_path_for(id);
assert!(path.ends_with(format!("{id}.events.jsonl")), "{path:?}");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "one object per line:\n{raw}");
for line in raw.lines() {
let v: Value = serde_json::from_str(line).expect("each line is one JSON object");
assert_eq!(v["sessionId"], "acp-1");
}
let all = store.read_events(id, EventCursor::Since(0), 100).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 2, 3]);
assert_eq!(
all.iter()
.map(|e| text_of(&e.notification))
.collect::<Vec<_>>(),
vec!["a", "b", "c"]
);
}
#[test]
fn since_reads_forward_and_before_reads_backward_with_limit() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
for i in 1..=6 {
log.append(&note(&format!("n{i}"))).unwrap();
}
// `since` is exclusive: what a client that already holds seq 2 needs.
let fwd = store.read_events(id, EventCursor::Since(2), 3).unwrap();
assert_eq!(fwd.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4, 5]);
// Past the end: nothing, not an error.
assert!(store
.read_events(id, EventCursor::Since(6), 3)
.unwrap()
.is_empty());
// `before` is exclusive and pages backward: the `limit` events closest
// to the cursor, still in ascending seq order.
let back = store
.read_events(id, EventCursor::Before(Some(5)), 2)
.unwrap();
assert_eq!(back.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![3, 4]);
// No cursor: the tail of the log, for a first paint.
let tail = store.read_events(id, EventCursor::Before(None), 2).unwrap();
assert_eq!(tail.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![5, 6]);
// Paging past the start runs out cleanly.
let start = store
.read_events(id, EventCursor::Before(Some(2)), 10)
.unwrap();
assert_eq!(start.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
assert!(store
.read_events(id, EventCursor::Before(Some(1)), 10)
.unwrap()
.is_empty());
}
#[test]
fn missing_log_reads_as_empty() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
assert!(store
.read_events(id, EventCursor::Since(0), 10)
.unwrap()
.is_empty());
assert!(store
.read_events(id, EventCursor::Before(None), 10)
.unwrap()
.is_empty());
}
#[test]
fn malformed_lines_are_skipped_but_keep_their_seq() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(&note("first")).unwrap();
drop(log);
// Garbage in the middle and a line that is JSON but not a notification.
let path = store.events_path_for(id);
let mut raw = std::fs::read_to_string(&path).unwrap();
raw.push_str("{not json\n");
raw.push_str("{\"unrelated\": true}\n");
std::fs::write(&path, raw).unwrap();
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 3);
assert_eq!(log.append(&note("fourth")).unwrap(), 4);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 4]);
assert_eq!(text_of(&all[1].notification), "fourth");
}
#[test]
fn truncated_last_line_still_opens_and_the_next_append_starts_a_fresh_line() {
// A hard kill mid-write leaves a partial line with no newline.
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
let mut log = store.open_events(id).unwrap();
log.append(&note("whole")).unwrap();
drop(log);
let path = store.events_path_for(id);
let mut raw = std::fs::read(&path).unwrap();
raw.extend_from_slice(br#"{"sessionId":"acp-1","update":{"sessionUpd"#);
std::fs::write(&path, &raw).unwrap();
// Reads: the partial line is skipped, nothing else is lost.
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1]);
// Reopening terminates the partial line so the next append does not
// glue onto it; the partial keeps a seq of its own.
let mut log = store.open_events(id).unwrap();
assert_eq!(log.last_seq(), 2);
assert_eq!(log.append(&note("after")).unwrap(), 3);
let all = store.read_events(id, EventCursor::Since(0), 10).unwrap();
assert_eq!(all.iter().map(|e| e.seq).collect::<Vec<_>>(), vec![1, 3]);
assert_eq!(text_of(&all[1].notification), "after");
let raw = std::fs::read_to_string(&path).unwrap();
assert_eq!(raw.lines().count(), 3, "{raw}");
}
#[test]
fn delete_removes_the_event_log_and_load_all_ignores_it() {
let dir = TempDir::new().unwrap();
let store = SidecarStore::new(dir.path());
let id = Uuid::new_v4();
store.write(&sample(id)).unwrap();
let mut log = store.open_events(id).unwrap();
log.append(&note("x")).unwrap();
drop(log);
assert!(store.events_path_for(id).exists());
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1, "the .jsonl must not be read as a sidecar");
store.delete(id).unwrap();
assert!(!store.path_for(id).exists());
assert!(!store.events_path_for(id).exists());
// Idempotent with the log already gone.
store.delete(id).unwrap();
}
+4 -6
View File
@@ -405,10 +405,8 @@ fn run_git(dir: &std::path::Path, args: &[&str]) {
);
}
/// 49ab7e9: a new-branch add onto a name that already exists suffixes the
/// branch (and so the directory) instead of failing, per docs/WORKTREES.md.
#[test]
fn add_new_suffixes_when_branch_already_exists() {
fn add_fails_when_branch_already_exists() {
let repo_dir = TempDir::new().unwrap();
let repo = repo_dir.path().to_path_buf();
init_repo(&repo);
@@ -425,9 +423,9 @@ fn add_new_suffixes_when_branch_already_exists() {
let wt_root = TempDir::new().unwrap();
let mgr = WorktreeManager::new(Some(wt_root.path().to_path_buf()));
let path = mgr.add_new(&repo, "feat/dup").unwrap();
assert!(path.ends_with("feat/dup-2"), "{}", path.display());
assert!(path.is_dir());
let err = mgr.add_new(&repo, "feat/dup").unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("git exited"), "{msg}");
}
/// The project picker lists linked worktrees as pickable rows, so `base_repo`
-1
View File
@@ -13,7 +13,6 @@ path = "src/lib.rs"
[dependencies]
chrono.workspace = true
futures-util.workspace = true
libc.workspace = true
maestro-protocol = { workspace = true, features = ["client"] }
maestro-testing.workspace = true
serde.workspace = true
-8
View File
@@ -87,14 +87,6 @@ impl Daemon {
}
}
/// Send the daemon the same SIGTERM systemd uses, without waiting for its
/// graceful HTTP shutdown to finish.
pub fn send_sigterm(&self) {
let child = self.child.as_ref().expect("daemon is running");
let result = unsafe { libc::kill(child.id() as libc::pid_t, libc::SIGTERM) };
assert_eq!(result, 0, "send SIGTERM to daemon");
}
/// Re-spawn the daemon on the same port + state. The previous tmux
/// server (under `TMUX_TMPDIR`) is left running so `recover()` can adopt
/// its sessions.
+4 -129
View File
@@ -15,7 +15,7 @@ use std::time::{Duration, Instant};
use futures_util::{SinkExt, StreamExt};
use maestro_e2e::util::wait_for_async;
use maestro_e2e::{Daemon, TestClient};
use maestro_protocol::{CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec};
use maestro_protocol::{CreateSessionRequest, WorktreeSpec};
use tempfile::TempDir;
use tokio_tungstenite::tungstenite::Message as WsMessage;
@@ -27,7 +27,6 @@ async fn bring_up() -> (Daemon, TestClient) {
fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -36,61 +35,6 @@ fn create_req(cwd: &std::path::Path, command: &str) -> CreateSessionRequest {
}
}
struct FakeAcp {
_dir: TempDir,
env: HashMap<String, String>,
}
fn fake_acp(log: &std::path::Path) -> FakeAcp {
let dir = tempfile::tempdir().expect("fake adapter dir");
let adapter = dir.path().join("claude-code-acp");
std::fs::copy(
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../maestro-acp/tests/fake_acp_agent.py"
),
&adapter,
)
.expect("copy fake adapter");
let mut permissions = std::fs::metadata(&adapter)
.expect("adapter metadata")
.permissions();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
permissions.set_mode(0o755);
}
std::fs::set_permissions(&adapter, permissions).expect("make fake adapter executable");
let env = HashMap::from([
(
"PATH".into(),
format!(
"{}:{}",
dir.path().display(),
std::env::var("PATH").unwrap_or_default()
),
),
("FAKE_ACP_LOG".into(), log.to_string_lossy().into_owned()),
]);
FakeAcp { _dir: dir, env }
}
fn fake_acp_pid(log: &std::path::Path) -> Option<u32> {
std::fs::read_to_string(log).ok()?.lines().find_map(|line| {
line.strip_prefix("start pid=")?
.split(' ')
.next()?
.parse()
.ok()
})
}
fn pid_alive(pid: u32) -> bool {
std::fs::read_to_string(format!("/proc/{pid}/stat"))
.map(|stat| !stat.contains(") Z "))
.unwrap_or(false)
}
/// A worktree spec built through its wire shape. The daemon and clients ship
/// together (#87 dropped `cleanup_on_kill` from the struct), so the JSON
/// object carries only the fields the lifecycle actually honours.
@@ -105,7 +49,6 @@ fn worktree_spec(base_repo: &std::path::Path, branch: &str, create_branch: bool)
/// Spawn request carrying a worktree spec against `base`.
fn worktree_req(base: &std::path::Path, branch: &str, create_branch: bool) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: base.to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -200,7 +143,6 @@ async fn kill_removes_clean_worktree() {
init_repo(base.path());
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -254,7 +196,6 @@ async fn kill_refuses_worktree_with_uncommitted_changes() {
.join("unsaved");
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -313,7 +254,6 @@ async fn spawn_on_existing_branch_checks_it_out() {
run_git(&["branch", "feature/existing"], base.path());
let req = CreateSessionRequest {
view: Default::default(),
cwd: base.path().to_path_buf(),
command: "sleep 30".into(),
env: HashMap::new(),
@@ -383,11 +323,8 @@ async fn spawn_new_branch_creates_worktree_and_runs_in_it() {
client.kill_session(s.id).await.expect("kill");
}
/// #66: a second spawn onto an *existing* branch that already has a worktree
/// is rejected with a structured worktree error; the first worktree stays
/// intact. (A second `create_branch: true` spawn is a different case: 49ab7e9
/// suffixes the new branch name instead, see
/// `colliding_branch_name_gets_suffixed_too`.)
/// #66: a second spawn onto a branch that already has a worktree is rejected
/// with a structured worktree error; the first worktree stays intact.
#[tokio::test(flavor = "multi_thread")]
async fn second_spawn_on_same_branch_is_rejected() {
let (_daemon, client) = bring_up().await;
@@ -400,7 +337,7 @@ async fn second_spawn_on_same_branch_is_rejected() {
.await
.expect("first spawn");
let dup = client
.spawn_session(&worktree_req(base.path(), "feature/dup", false))
.spawn_session(&worktree_req(base.path(), "feature/dup", true))
.await;
match dup {
Err(maestro_e2e::ClientError::Api(maestro_protocol::ClientError::Api { info, .. })) => {
@@ -817,7 +754,6 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
let _dirty = client
.spawn_session(&CreateSessionRequest {
view: Default::default(),
cwd: dirty_repo.path().to_path_buf(),
command: "sh -c 'echo unsaved > scratch.txt'".into(),
env: HashMap::new(),
@@ -866,7 +802,6 @@ async fn natural_exit_settles_dirty_and_pristine_worktrees_differently() {
fn create_req_cmd(cwd: &std::path::Path, command: &str, branch: &str) -> CreateSessionRequest {
CreateSessionRequest {
view: Default::default(),
cwd: cwd.to_path_buf(),
command: command.into(),
env: HashMap::new(),
@@ -1050,66 +985,6 @@ async fn daemon_restart_recovers_session() {
client.kill_session(s.id).await.expect("kill");
}
#[tokio::test(flavor = "multi_thread")]
async fn sigterm_stops_structured_child_before_events_ws_drains() {
let (mut daemon, client) = bring_up().await;
let cwd = TempDir::new().unwrap();
let log = cwd.path().join("fake-acp.log");
let fake = fake_acp(&log);
let session = client
.spawn_session(&CreateSessionRequest {
view: SessionView::Structured,
cwd: cwd.path().to_path_buf(),
command: "claude".into(),
env: fake.env.clone(),
metadata: HashMap::new(),
worktree: None,
})
.await
.expect("spawn structured session");
let ws_url = daemon.client().events_url().expect("events URL");
let (_events_ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.expect("events WS handshake");
wait_for_async("fake ACP starts", Duration::from_secs(5), || {
let log = log.clone();
async move { fake_acp_pid(&log).is_some() }
})
.await;
let pid = fake_acp_pid(&log).expect("fake ACP pid");
daemon.send_sigterm();
wait_for_async(
"fake ACP exits after SIGTERM",
Duration::from_secs(5),
|| async { !pid_alive(pid) },
)
.await;
let sidecar_path = daemon
.state_dir()
.join("sessions")
.join(format!("{}.json", session.id));
wait_for_async(
"dead structured session sidecar",
Duration::from_secs(5),
|| {
let sidecar_path = sidecar_path.clone();
async move {
std::fs::read(&sidecar_path)
.ok()
.and_then(|json| {
serde_json::from_slice::<maestro_protocol::Session>(&json).ok()
})
.is_some_and(|saved| saved.status == SessionStatus::Dead)
}
},
)
.await;
daemon.stop();
}
#[tokio::test(flavor = "multi_thread")]
async fn concurrent_spawns_each_get_unique_ids() {
let (_daemon, client) = bring_up().await;
-2
View File
@@ -268,7 +268,6 @@ mod tests {
fn session(cwd: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(1),
cwd: PathBuf::from(cwd),
command: "bash".into(),
@@ -285,7 +284,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
-78
View File
@@ -116,33 +116,6 @@ pub struct Session {
/// sorts a never-focused session after every session that has one.
#[serde(default)]
pub last_focused_at: Option<DateTime<Utc>>,
/// Whether this session's process is a plain terminal (PTY bytes through
/// tmux) or a structured agent driven over ACP rows (#133). Fixed at
/// spawn time and never changes for the life of the session. Defaults to
/// `Terminal` on the wire so every existing client and payload from a
/// daemon that predates the field keeps working untouched.
#[serde(default)]
pub view: SessionView,
/// The ACP session id the `claude-code-acp` adapter handed back from
/// `session/new` (#134). Only a [`SessionView::Structured`] session has
/// one, and only once its handshake succeeded; a later respawn passes it
/// to `session/load` so the conversation continues (#135). Distinct from
/// the hook-reported Claude Code conversation id (#161): that one is what
/// `claude --resume` takes, this one is what the ACP adapter takes, and
/// the two are minted by different processes with different lifetimes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub acp_session_id: Option<String>,
}
/// A session's process shape (#133): a plain terminal, or a structured agent
/// driven over ACP rows. Chosen at spawn and immutable for the session's
/// life. `Terminal` is the default — every sidecar and `CreateSessionRequest`
/// written before this field existed meant a terminal session.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum SessionView {
#[default]
Terminal,
Structured,
}
/// Default for [`Session::last_activity_at`] when an older payload omits it.
@@ -379,11 +352,6 @@ pub struct CreateSessionRequest {
pub metadata: HashMap<String, Value>,
#[serde(default)]
pub worktree: Option<WorktreeSpec>,
/// The process shape to spawn (#133). Defaulted so every existing client
/// and `curl` against the API keeps requesting a `Terminal` session
/// untouched.
#[serde(default)]
pub view: SessionView,
}
/// Result of `GET /api/dirs`: the immediate subdirectories of one directory.
@@ -894,8 +862,6 @@ mod tests {
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
blocked_work: None,
last_focused_at: None,
view: SessionView::Terminal,
acp_session_id: None,
}
}
@@ -1107,7 +1073,6 @@ mod tests {
branch: "feat/foo".into(),
create_branch: true,
}),
view: SessionView::Terminal,
};
assert_eq!(req, round_trip(&req));
}
@@ -1122,49 +1087,6 @@ mod tests {
assert!(req.worktree.is_none());
assert!(req.env.is_empty());
assert!(req.metadata.is_empty());
assert_eq!(req.view, SessionView::Terminal);
}
#[test]
fn session_view_defaults_to_terminal_on_legacy_payload() {
// A pre-#133 daemon or client omits `view`; it must deserialize to
// `Terminal` rather than failing, and `Structured` round-trips.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.view, SessionView::Terminal);
let mut structured = sample_session();
structured.view = SessionView::Structured;
assert_eq!(structured, round_trip(&structured));
}
#[test]
fn acp_session_id_defaults_to_none_and_round_trips() {
// A payload from before #134 carries no `acp_session_id`; it must
// read as `None`, stay off the wire while unset, and round-trip
// once the adapter has handed one back.
let json = serde_json::json!({
"id": Uuid::from_u128(1),
"cwd": "/tmp",
"command": "bash",
"status": "Running",
"created_at": "2026-05-24T12:00:00Z"
});
let s: Session = serde_json::from_value(json).unwrap();
assert_eq!(s.acp_session_id, None);
let serialized = serde_json::to_value(&s).unwrap();
assert!(serialized.get("acp_session_id").is_none());
let mut structured = sample_session();
structured.view = SessionView::Structured;
structured.acp_session_id = Some("sess_01HZX".into());
assert_eq!(structured, round_trip(&structured));
}
/// A client that predates the new/existing discriminator sends no
-2
View File
@@ -31,7 +31,6 @@ use uuid::Uuid;
/// Build a plausible `Session` for a test to hand to [`MockDaemon::add_session`].
pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
Session {
view: Default::default(),
id,
cwd: cwd.into(),
command: command.into(),
@@ -48,7 +47,6 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
-13
View File
@@ -1882,7 +1882,6 @@ mod tests {
sessions: ids
.iter()
.map(|id| Session {
view: Default::default(),
id: *id,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -1899,7 +1898,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
})
.collect(),
});
@@ -2065,7 +2063,6 @@ mod tests {
let mut app = make_app();
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -2082,7 +2079,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}],
});
app.dispatch(Action::SidebarNext);
@@ -2094,7 +2090,6 @@ mod tests {
use chrono::Utc;
use maestro_protocol::{Session, SessionStatus};
Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: cwd.into(),
@@ -2111,7 +2106,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
@@ -2783,7 +2777,6 @@ mod tests {
);
app.dispatch(Action::OpenSpawnModal);
let req = CreateSessionRequest {
view: Default::default(),
cwd: std::path::PathBuf::from("/srv/work"),
command: "bash".into(),
env: Default::default(),
@@ -3202,7 +3195,6 @@ mod tests {
use chrono::Utc;
use maestro_protocol::{CatalogEvent, Session, SessionStatus};
let make = |n: u128, name: &str, focused: Option<chrono::DateTime<Utc>>| Session {
view: Default::default(),
id: Uuid::from_u128(n),
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3222,7 +3214,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: focused,
acp_session_id: None,
};
let older = Utc::now() - chrono::Duration::seconds(60);
app.catalog.apply_event(CatalogEvent::Snapshot {
@@ -3484,7 +3475,6 @@ mod tests {
let sid = Uuid::from_u128(7);
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3502,7 +3492,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}],
});
app.dispatch(Action::KillHighlighted);
@@ -3522,7 +3511,6 @@ mod tests {
};
app.catalog.apply_event(CatalogEvent::Snapshot {
sessions: vec![Session {
view: Default::default(),
id: sid,
command: "bash".into(),
cwd: std::path::PathBuf::from("/"),
@@ -3539,7 +3527,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}],
});
}
-2
View File
@@ -207,7 +207,6 @@ mod tests {
fn session(id: u128, command: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(id),
command: command.into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -224,7 +223,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
-2
View File
@@ -793,7 +793,6 @@ mod tests {
fn session(n: u128, command: &str) -> Session {
Session {
view: Default::default(),
id: Uuid::from_u128(n),
cwd: PathBuf::from("/home/me/dev/api"),
command: command.to_string(),
@@ -810,7 +809,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
-1
View File
@@ -694,7 +694,6 @@ impl DraftPane {
}
let worktree = self.build_worktree()?;
Ok(CreateSessionRequest {
view: Default::default(),
cwd,
command: command.to_string(),
env: Default::default(),
-1
View File
@@ -108,7 +108,6 @@ impl FastForwardDefaults {
return None;
}
Some(CreateSessionRequest {
view: Default::default(),
cwd: project.path.clone(),
command: command.to_string(),
env: Default::default(),
-4
View File
@@ -484,7 +484,6 @@ mod tests {
let mut metadata = HashMap::new();
metadata.insert("name".into(), serde_json::Value::String(name.into()));
Session {
view: Default::default(),
id: Uuid::from_u128(id),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -501,7 +500,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
}
}
@@ -1252,7 +1250,6 @@ mod tests {
#[test]
fn falls_back_to_command_when_name_metadata_is_absent() {
let s = Session {
view: Default::default(),
id: Uuid::from_u128(1),
command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"),
@@ -1269,7 +1266,6 @@ mod tests {
current_cwd: None,
blocked_work: None,
last_focused_at: None,
acp_session_id: None,
};
let sb = Sidebar {
sessions: vec![s],
-25
View File
@@ -188,31 +188,6 @@ An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
## Structured sessions: `claude-code-acp`
A session spawned with `view: Structured` (#134) runs Claude Code as a child
of the daemon over the Agent Client Protocol instead of in a tmux pane. The
daemon resolves the adapter binary, `claude-code-acp`, on its `PATH` at spawn
time. Install it once per host:
```sh
npm install -g --prefix ~/.local @zed-industries/claude-code-acp
```
That puts `claude-code-acp` in `~/.local/bin`, which must be on the daemon's
`PATH` (a systemd user unit does not read your shell profile; set
`Environment=PATH=…` in the unit or install somewhere already on it). The
adapter needs a logged-in Claude Code (`claude /login`).
When the adapter is missing the spawn still creates the session, marked dead
with the install command above as its activity detail, so the sidebar says
what to run. The adapter is a child of the daemon: it ends with a `DELETE
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
itself. A daemon restart ends an in-flight structured turn, but preserves its
worktree, session record, and event-log transcript. The replacement daemon
starts a new adapter, resumes it with `session/load`, and replays the
transcript. Terminal sessions keep surviving restarts through tmux.
## The browser UI
The daemon serves the SPA off its own listener. Open
+3 -4
View File
@@ -307,10 +307,9 @@ lands on the first row it was hiding. A highlight parked on a skipped row —
reachable by clicking a folder header — still steps to its neighbour rather
than jumping to the end of the list.
The web client needs a chord to reach the list at all (leader-`h` — `Ctrl+A h`
by default — and the same chord back): an attached terminal owns every plain
keystroke, and Escape is left to reach the pane's shell/app instead. The TUI
uses `ctrl-w h`.
The web client needs a chord to reach the list at all (`Ctrl+Shift+H`, and the
same chord back): an attached terminal owns every plain keystroke. The TUI uses
`ctrl-w h`.
Everything else is the same key on both clients:
+20 -7
View File
@@ -4,6 +4,8 @@ import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { attachWsUrl } from "./routes.js";
import { FOCUS_SIDEBAR_EVENT } from "./catalog.js";
import { CommandSet, defineCommands } from "./commands.js";
import { escapeHtml } from "./html.js";
import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js";
import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js";
@@ -12,6 +14,15 @@ import { splitCommand, toolIconSvg } from "./toolIcon.js";
import type { ClientControl, Uuid } from "./types.js";
import { WebglPolicy } from "./webglRenderer.js";
// The pane's half of the command registry (#148): plain Escape hands the
// keyboard back to the session list, the pair to the list's Tab.
const paneKeys = new CommandSet(
defineCommands({
"pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)),
}),
{ preventDefault: false },
);
/** One budget for the whole page — see webglRenderer.ts for why contexts are
* rationed rather than handed to every connection. */
const webglPolicy = new WebglPolicy();
@@ -210,6 +221,15 @@ export function createAttachConnection(
}
}
// Plain Escape hands the keyboard back to the session list instead of the
// shell/app. `false` tells xterm to skip its own handling of this event —
// which is also why the set doesn't `preventDefault`: swallowing here is
// xterm's return value, not the browser's.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== "keydown") return true;
return !paneKeys.handle(e);
});
// Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a
// line at a time and stall), so map finger drags ourselves. Two routes:
// • normal buffer → term.scrollLines walks the scrollback.
@@ -450,12 +470,6 @@ export function createAttachConnection(
const resizeDisp = term.onResize(({ cols, rows }) => {
sendControl({ type: "Resize", cols, rows });
});
// xterm has no built-in copy-on-select; mirror the selection to the
// clipboard ourselves (best-effort — insecure contexts lack the API).
const selectionDisp = term.onSelectionChange(() => {
const text = term.getSelection();
if (text) void navigator.clipboard?.writeText(text).catch(() => {});
});
window.addEventListener("resize", onResize);
const visual = window.visualViewport ?? null;
@@ -511,7 +525,6 @@ export function createAttachConnection(
visual?.removeEventListener("resize", onResize);
dataDisp.dispose();
resizeDisp.dispose();
selectionDisp.dispose();
releaseWebgl();
try {
ws.close();
+4
View File
@@ -53,6 +53,10 @@ export function requestOpenCommandBar(): void {
* connection pool. */
export const FOCUS_PANE_EVENT = "catalog-focus-pane";
/** Ask the shell to hand the keyboard back to the session list — what Escape
* does when the pane's terminal is focused. Handled in main.ts. */
export const FOCUS_SIDEBAR_EVENT = "catalog-focus-sidebar";
/** Ask the shell to open the spawn modal seeded from an existing session
* ("Spawn similar"). Detail is the session being cloned. */
export const SPAWN_SIMILAR_EVENT = "catalog-spawn-similar";
+14 -12
View File
@@ -133,28 +133,31 @@ describe("CommandSet", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => {
it("keeps plain keys deaf inside a form field, and chorded ones live", () => {
const plain = vi.fn();
const chorded = vi.fn();
const set = new CommandSet(
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER),
{ leader: DEFAULT_LEADER },
defineCommands({ "spawn.open": plain, "focus.toggle": chorded }),
);
const input = el("<input>");
expect(pressOn(set, input, "n")).toBe(false);
expect(plain).not.toHaveBeenCalled();
// focus.toggle hangs off the leader (#166): a plain, unmodified Escape is
// left for the pane's shell/app instead.
const terminal = el('<textarea class="xterm-helper-textarea"></textarea>');
pressOn(set, terminal, "a", { ctrlKey: true });
expect(pressOn(set, terminal, "h")).toBe(true);
expect(pressOn(set, input, "H", { ctrlKey: true, shiftKey: true })).toBe(true);
expect(chorded).toHaveBeenCalledTimes(1);
});
it("leaves a plain Escape for the pane instead of binding it", () => {
const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }));
it("keeps pane commands live inside the terminal's textarea", () => {
const run = vi.fn();
const set = new CommandSet(defineCommands({ "pane.focusList": run }), {
preventDefault: false,
});
const textarea = el('<textarea class="xterm-helper-textarea"></textarea>');
expect(pressOn(set, textarea, "Escape")).toBe(false);
const e = key("Escape");
Object.defineProperty(e, "target", { value: textarea });
expect(set.handle(e)).toBe(true);
expect(run).toHaveBeenCalledTimes(1);
// xterm is told to swallow by the return value, not by the browser default.
expect(e.defaultPrevented).toBe(false);
});
});
@@ -170,7 +173,6 @@ describe("the leader", () => {
const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s]));
expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]);
expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]);
expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]);
// Commands that do not hang off the leader are untouched.
expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]);
});
+8 -2
View File
@@ -93,8 +93,7 @@ const SPECS: readonly SpecSource[] = [
id: "focus.toggle",
label: "Move the keyboard between the session list and the pane",
group: "Session list",
keys: [`${DEFAULT_LEADER} h`],
leaderKeys: (leader) => [`${leader} h`],
keys: ["ctrl-shift-h"],
scope: "global",
},
{
@@ -146,6 +145,13 @@ const SPECS: readonly SpecSource[] = [
keys: ["tab"],
scope: "list",
},
{
id: "pane.focusList",
label: "Hand the keyboard back to the session list, from the pane",
group: "Session list",
keys: ["esc"],
scope: "pane",
},
{
id: "help.toggle",
label: "Show or hide this help",
-18
View File
@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
import {
emptyLayout,
equalizeRatios,
leaves,
locateSession,
openSessions,
@@ -45,23 +44,6 @@ describe("splitLeaf", () => {
});
});
describe("equalizeRatios", () => {
it("resets nested splits to equal width, not just the newest one", () => {
const tab = tabWithOneLeaf("s1");
const first = leaves(tab.root)[0].id;
splitLeaf(tab, first, "s2", "row", true); // A|B, 0.5/0.5
const second = leaves(tab.root)[1].id;
splitLeaf(tab, second, "s3", "row", true); // A(0.5) | (B|C, 0.5/0.5) -> B,C at 0.25 each
equalizeRatios(tab.root);
const root = tab.root as Extract<typeof tab.root, { kind: "split" }>;
expect(root.ratio).toBeCloseTo(1 / 3);
const inner = root.b as Extract<typeof root.b, { kind: "split" }>;
expect(inner.ratio).toBeCloseTo(1 / 2);
});
});
describe("removeLeaf", () => {
it("collapses the parent split into the surviving sibling", () => {
const tab = tabWithOneLeaf("s1");
-11
View File
@@ -118,17 +118,6 @@ export function removeLeaf(tab: Tab, leafId: string): void {
tab.root = tab.root ? rec(tab.root) : null;
}
/** Reset every split ratio under `node` so all leaves get equal width,
* regardless of split direction or nesting order. Returns the leaf count. */
export function equalizeRatios(node: Node | null): number {
if (!node) return 0;
if (node.kind === "leaf") return 1;
const na = equalizeRatios(node.a);
const nb = equalizeRatios(node.b);
node.ratio = na / (na + nb);
return na + nb;
}
export function addTab(layout: Layout): Tab {
const tab: Tab = {
id: freshId("t"),
+11 -7
View File
@@ -35,6 +35,7 @@ import { loadLayout, saveLayout } from "./persistence.js";
import { createPreviewSchedule } from "./previewSchedule.js";
import {
FOCUS_PANE_EVENT,
FOCUS_SIDEBAR_EVENT,
OPEN_COMMAND_BAR_EVENT,
OPEN_SPAWN_EVENT,
SESSION_SELECT_EVENT,
@@ -439,11 +440,10 @@ const spawnKeys = new CommandSet(
}),
);
// Leader-h moves the keyboard between the session list and the pane, the web
// counterpart of the TUI's `ctrl-w h`. It has to be a chord behind the
// leader: an attached terminal owns every plain keystroke, and Ctrl+Shift+H
// is already claimed by the browser (history), so nothing unmodified — or
// modified with a common browser chord — could get the focus back out of it.
// Ctrl+Shift+H moves the keyboard between the session list and the pane, the
// web counterpart of the TUI's `ctrl-w h`. It has to be a chord: an attached
// terminal owns every plain keystroke, so nothing unmodified could ever get
// the focus back out of it.
//
// This set also owns the leader (`[tui].leader`, `ctrl-a` by default, served
// on `/api/info`): it holds the key as a partial chord and takes select-all off
@@ -528,8 +528,8 @@ window.addEventListener(
);
// Tab in the session list hands the keyboard to the pane; leader-h (focus.toggle,
// above) hands it back the other way.
// Tab in the session list hands the keyboard to the pane; Escape in the pane
// hands it back — the pair the TUI gets from `tab` and `esc`.
document.addEventListener(FOCUS_PANE_EVENT, () => {
// Handing the keyboard to a previewed pane is choosing it: the preview stops
// being provisional rather than snapping back the moment the list blurs.
@@ -537,6 +537,10 @@ document.addEventListener(FOCUS_PANE_EVENT, () => {
if (previewed) focusSession(previewed);
pool.focusTerminal();
});
document.addEventListener(FOCUS_SIDEBAR_EVENT, () => {
drawer.open(); // no-op on desktop; on mobile the list is off-canvas
sidebar.focusList();
});
/** Open/focus a session and reflect it in the shell chrome. Shared by the
* hash router and the sidebar's click-driven path (#133), which can't rely
-4
View File
@@ -10,7 +10,6 @@ import {
activeTab,
addTab,
closeTab,
equalizeRatios,
findLeaf,
freshId,
type Dir,
@@ -458,7 +457,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
dropPreview();
const tab = activeTab(layout);
tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
equalizeRatios(tab.root);
render();
}
@@ -544,7 +542,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
target.sessionId = sessionId;
} else {
tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
equalizeRatios(tab.root);
}
render();
opts.onOpenSessionsChange?.();
@@ -818,7 +815,6 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (source.kind === "pane" && self) removeLeaf(tab, self);
else if (loc) removeLeaf(loc.tab, loc.leaf.id);
tab.focusedLeafId = splitLeaf(tab, targetLeafId, sessionId, dir, after);
equalizeRatios(tab.root);
render();
}
-4
View File
@@ -56,10 +56,6 @@ export interface Session {
* how the command bar's empty query sorts a never-focused session after every
* session that has one (`docs/COMMAND-BAR.md`). */
last_focused_at?: IsoTimestamp | null;
/** The ACP session id the `claude-code-acp` adapter returned from
* `session/new` (#134). Only a `Structured` session whose handshake
* succeeded has one; a respawn passes it to `session/load` (#135). */
acp_session_id?: string | null;
}
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */