feat(maestro): drop retained worktrees and header debug toggle
Removes the retained-worktree store end to end: the daemon's state-dir store, GET/DELETE /api/worktrees, `maestro worktrees list|reclaim`, the TUI overlay behind `w`, and the web `#/worktrees` view and header link. The kill guard stays. A forced kill and a dirty natural exit now leave the worktree on disk untouched instead of parking it in a store, so removing one is an ordinary `git worktree remove`. A pristine tree is still removed on exit. DELETE /api/sessions/:id always answers 204. The header's debug button also goes; Ctrl+Shift+D and `?hud` still toggle the HUD. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
43b90ccf0a
commit
d39fbdbbfc
@@ -12,8 +12,8 @@ use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use maestro_protocol::{
|
||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, KillOutcome, RetainedReason,
|
||||
RetainedWorktree, Session, SessionStatus, WorktreeSpec,
|
||||
ActivityState, CatalogEvent, CreateSessionRequest, ErrorInfo, Session, SessionStatus,
|
||||
WorktreeSpec,
|
||||
};
|
||||
use tokio::sync::{broadcast, Mutex};
|
||||
use uuid::Uuid;
|
||||
@@ -51,11 +51,6 @@ pub struct SessionRecord {
|
||||
/// `base_repo` from the original `WorktreeSpec`. Needed because `git
|
||||
/// worktree remove` is invoked from the base repo.
|
||||
pub worktree_base_repo: Option<PathBuf>,
|
||||
/// Branch the worktree was created for, recorded at spawn time so a
|
||||
/// forced kill (#81) can name it in the retained store without asking
|
||||
/// git. Sessions adopted from a sidecar have no branch on record and
|
||||
/// fall back to a probe.
|
||||
pub worktree_branch: Option<String>,
|
||||
pub bridge: PtyBridge,
|
||||
/// Agent-reported activity (#105) still within its TTL. While in force it
|
||||
/// outranks the activity pass's own inference; when it lapses, inference
|
||||
@@ -88,11 +83,6 @@ pub enum SessionError {
|
||||
/// so it stays the operator's handle on their own unfinished work.
|
||||
#[error("kill refused: the session's worktree holds unsaved work ({0})")]
|
||||
KillGuarded(maestro_protocol::InTheWay),
|
||||
/// A forced kill (#81) records the retained worktree *before* tearing
|
||||
/// anything down; if that write fails nothing was destroyed, and the
|
||||
/// error says so rather than silently dropping unrecorded work.
|
||||
#[error("could not record the retained worktree: {0}")]
|
||||
RetainFailed(String),
|
||||
}
|
||||
|
||||
impl SessionError {
|
||||
@@ -105,7 +95,6 @@ impl SessionError {
|
||||
SessionError::NotFound(_) => "not_found",
|
||||
SessionError::InvalidEnv(_) => "invalid_env",
|
||||
SessionError::KillGuarded(_) => "kill_guarded",
|
||||
SessionError::RetainFailed(_) => "retain_failed",
|
||||
};
|
||||
let unsaved_work = match self {
|
||||
SessionError::KillGuarded(b) => Some(*b),
|
||||
@@ -138,9 +127,6 @@ struct Inner {
|
||||
/// `None` when not wired (tests, or before `with_spawn_history`); a present
|
||||
/// but disabled (`0`-day) history no-ops internally.
|
||||
spawn_history: Option<SpawnHistory>,
|
||||
/// Retained-worktree store (#78). `None` when not wired (tests); a forced
|
||||
/// kill (#81) without one fails rather than destroying unrecorded work.
|
||||
retained: Option<crate::retained_store::RetainedStore>,
|
||||
/// The daemon's own HTTP base URL (#103), exported to spawned sessions as
|
||||
/// `MAESTRO_URL`. `None` when unknown — those sessions simply don't get
|
||||
/// the variable. A mutex because the runner sets it after construction,
|
||||
@@ -187,7 +173,6 @@ impl SessionManager {
|
||||
fifo_dir,
|
||||
replay_bytes,
|
||||
spawn_history: None,
|
||||
retained: None,
|
||||
daemon_url: std::sync::Mutex::new(None),
|
||||
report_ttl: crate::activity::REPORT_TTL,
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
@@ -212,16 +197,6 @@ impl SessionManager {
|
||||
self
|
||||
}
|
||||
|
||||
/// Attach the retained-worktree store (#78). A forced kill (#81) needs it
|
||||
/// to record what it retained; without one, forcing a guarded kill fails
|
||||
/// instead of destroying unrecorded work.
|
||||
pub fn with_retained_store(mut self, store: crate::retained_store::RetainedStore) -> Self {
|
||||
let inner = Arc::get_mut(&mut self.inner)
|
||||
.expect("with_retained_store must be called before the SessionManager is cloned");
|
||||
inner.retained = Some(store);
|
||||
self
|
||||
}
|
||||
|
||||
/// Override the agent-report TTL (#105). Tests pass a short one to watch
|
||||
/// inference resume without waiting out [`crate::activity::REPORT_TTL`].
|
||||
pub fn with_activity_report_ttl(mut self, ttl: Duration) -> Self {
|
||||
@@ -442,7 +417,6 @@ impl SessionManager {
|
||||
fifo_path,
|
||||
worktree_path,
|
||||
worktree_base_repo,
|
||||
worktree_branch: req.worktree.as_ref().map(|w| w.branch.clone()),
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
@@ -494,19 +468,19 @@ impl SessionManager {
|
||||
/// is removed; the error names what blocked it and how much of it there
|
||||
/// is. The session stays as the operator's handle on their own unfinished
|
||||
/// work, so ending a session that still matters becomes a deliberate
|
||||
/// `--force`, which retains rather than destroys (WORKTREES.md).
|
||||
/// `--force`, which leaves the tree alone rather than destroying it
|
||||
/// (WORKTREES.md).
|
||||
///
|
||||
/// With `force` (#81) the refusal — and nothing else — is overridden:
|
||||
/// the session ends and the guarded worktree is retained into the store
|
||||
/// with reason `force-killed`, never removed. The outcome names what was
|
||||
/// kept so a client can say where the work went. A tree whose guard is
|
||||
/// clear never needed the override; force removes it as usual.
|
||||
/// the session ends and the guarded worktree is left on disk, never
|
||||
/// removed. A tree whose guard is clear never needed the override; force
|
||||
/// removes it as usual.
|
||||
///
|
||||
/// This is also how an already-dead session is dismissed. tmux is only
|
||||
/// asked to kill something it still has — calling `kill-session` on a
|
||||
/// session tmux already lost fails, and that failure is not interesting.
|
||||
pub async fn kill(&self, id: Uuid, force: bool) -> Result<KillOutcome, SessionError> {
|
||||
let (worktree, alive, known_branch) = {
|
||||
pub async fn kill(&self, id: Uuid, force: bool) -> Result<(), SessionError> {
|
||||
let (worktree, alive) = {
|
||||
let sessions = self.inner.sessions.lock().await;
|
||||
match sessions.get(&id) {
|
||||
None => return Err(SessionError::NotFound(id)),
|
||||
@@ -516,7 +490,6 @@ impl SessionManager {
|
||||
.clone()
|
||||
.zip(record.worktree_path.clone()),
|
||||
record.session.status == SessionStatus::Running,
|
||||
record.worktree_branch.clone(),
|
||||
),
|
||||
}
|
||||
};
|
||||
@@ -529,35 +502,13 @@ impl SessionManager {
|
||||
return Err(SessionError::KillGuarded(probe.expect("guarded ⇒ probed")));
|
||||
}
|
||||
|
||||
let mut retained = None;
|
||||
if guarded {
|
||||
let entry = record_retention(
|
||||
&self.inner,
|
||||
worktree.as_ref().expect("guarded ⇒ worktree"),
|
||||
known_branch,
|
||||
RetainedReason::ForceKilled,
|
||||
)
|
||||
.await?;
|
||||
retained = Some((entry, probe.expect("guarded ⇒ probed")));
|
||||
}
|
||||
|
||||
let removed = {
|
||||
let mut sessions = self.inner.sessions.lock().await;
|
||||
sessions.remove(&id)
|
||||
};
|
||||
let record = removed.ok_or(SessionError::NotFound(id))?;
|
||||
cleanup_record(&self.inner, id, record, alive, retained.is_some()).await;
|
||||
Ok(KillOutcome {
|
||||
retained: retained.map(|(entry, in_the_way)| RetainedWorktree {
|
||||
id: entry.id,
|
||||
path: entry.path,
|
||||
base_repo: entry.base_repo,
|
||||
branch: entry.branch,
|
||||
reason: entry.reason,
|
||||
retained_at: entry.retained_at,
|
||||
in_the_way,
|
||||
}),
|
||||
})
|
||||
cleanup_record(&self.inner, id, record, alive, guarded).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Spawn a background task that polls tmux every `interval` and reaps any
|
||||
@@ -669,9 +620,6 @@ impl SessionManager {
|
||||
// directory blocks that branch forever (#54 review).
|
||||
worktree_path: session.worktree.as_ref().map(|w| w.path.clone()),
|
||||
worktree_base_repo: session.worktree.as_ref().map(|w| w.base_repo.clone()),
|
||||
// The sidecar carries no branch; a forced kill of a
|
||||
// recovered session probes git for it at retain time.
|
||||
worktree_branch: None,
|
||||
bridge,
|
||||
reported_activity: None,
|
||||
settled: false,
|
||||
@@ -840,32 +788,6 @@ async fn probe_worktree(
|
||||
.map_err(|e| SessionError::Worktree(crate::worktree::WorktreeError::from(e)))
|
||||
}
|
||||
|
||||
/// Record a guarded worktree into the retained store with `reason`, BEFORE
|
||||
/// the caller tears anything down: a failed write then leaves every artifact
|
||||
/// untouched instead of destroying work nothing records anymore. Shared by
|
||||
/// the forced kill (`ForceKilled`, #81) and the natural exit
|
||||
/// (`ExitedWithUnsavedWork`, #82) so the two paths cannot drift apart.
|
||||
async fn record_retention(
|
||||
inner: &Arc<Inner>,
|
||||
(base_repo, path): &(PathBuf, PathBuf),
|
||||
known_branch: Option<String>,
|
||||
reason: RetainedReason,
|
||||
) -> Result<crate::retained_store::RetainedWorktreeRecord, SessionError> {
|
||||
let store = inner
|
||||
.retained
|
||||
.clone()
|
||||
.ok_or_else(|| SessionError::RetainFailed("no retained store is wired".into()))?;
|
||||
// Sessions adopted from a sidecar carry no branch; ask the tree itself.
|
||||
let branch = known_branch
|
||||
.or_else(|| crate::worktree_probe::current_branch(path))
|
||||
.unwrap_or_default();
|
||||
let base_repo = base_repo.clone();
|
||||
let path = path.clone();
|
||||
tmux_blocking(move || store.retain(&path, &base_repo, &branch, reason))
|
||||
.await
|
||||
.map_err(|e| SessionError::RetainFailed(e.to_string()))
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
@@ -1069,13 +991,11 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
||||
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
||||
/// still attach and read the replay buffer — the last thing a crashed agent
|
||||
/// printed. The worktree, though, is settled right away: a pristine tree is
|
||||
/// removed, anything else is retained into the store with reason
|
||||
/// `exited-with-unsaved-work` (#82) — nobody is waiting for an answer here,
|
||||
/// so there is nothing to refuse. Once settled, the session drops its
|
||||
/// worktree reference so a later dismissal neither re-probes nor retains
|
||||
/// twice. When settling is impossible (probe or store failure) nothing is
|
||||
/// destroyed: the tree stays attached to the dead record for a later forced
|
||||
/// dismissal (#81).
|
||||
/// removed (#82) — nobody is waiting for an answer here, so there is nothing
|
||||
/// to refuse. Once settled, the session drops its worktree reference so a
|
||||
/// later dismissal does not re-probe it. A tree holding unsaved work is left
|
||||
/// alone and stays attached to the dead record for a later forced dismissal
|
||||
/// (#81), which leaves it on disk too.
|
||||
///
|
||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
||||
/// adopts only what tmux still holds.
|
||||
@@ -1093,21 +1013,20 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
.worktree_base_repo
|
||||
.clone()
|
||||
.zip(record.worktree_path.clone());
|
||||
let branch = record.worktree_branch.clone();
|
||||
Some((session, worktree, branch))
|
||||
Some((session, worktree))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let Some((mut session, worktree, branch)) = flipped else {
|
||||
let Some((mut session, worktree)) = flipped else {
|
||||
return;
|
||||
};
|
||||
tracing::info!(%id, "session vanished from tmux — marking dead");
|
||||
|
||||
if let Some(wt) = &worktree {
|
||||
if settle_worktree_on_exit(inner, id, wt, branch).await {
|
||||
// The tree is settled (removed or retained); a stale "in the
|
||||
// way" would describe something the session no longer holds.
|
||||
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) {
|
||||
@@ -1115,7 +1034,6 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
record.session.blocked_work = None;
|
||||
record.worktree_path = None;
|
||||
record.worktree_base_repo = None;
|
||||
record.worktree_branch = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1134,10 +1052,8 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||
|
||||
/// Apply the exit rule (#82) to a vanished session's worktree: probe what
|
||||
/// would be lost through the kill's own predicate, then remove a pristine
|
||||
/// tree or retain anything else under `exited-with-unsaved-work` — recorded
|
||||
/// before anything is torn down, exactly as the forced kill does (#81). A
|
||||
/// removal that fails falls back to retaining, since whatever blocked it is
|
||||
/// by definition something worth keeping.
|
||||
/// tree. Anything holding unsaved work is left exactly where it is — the
|
||||
/// same thing a forced kill does with a guarded tree (#81).
|
||||
///
|
||||
/// Returns whether the worktree was fully accounted for. When it wasn't,
|
||||
/// nothing was destroyed and the caller keeps the reference on the record.
|
||||
@@ -1145,7 +1061,6 @@ async fn settle_worktree_on_exit(
|
||||
inner: &Arc<Inner>,
|
||||
id: Uuid,
|
||||
(base_repo, path): &(PathBuf, PathBuf),
|
||||
known_branch: Option<String>,
|
||||
) -> bool {
|
||||
let probe = match probe_worktree(&(base_repo.clone(), path.clone())).await {
|
||||
Ok(probe) => probe,
|
||||
@@ -1159,45 +1074,25 @@ async fn settle_worktree_on_exit(
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if probe.is_clear() {
|
||||
match inner.worktree_mgr.remove(base_repo, path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
||||
return true;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = ?e,
|
||||
"natural exit: removal failed; falling back to retention"
|
||||
);
|
||||
}
|
||||
}
|
||||
if !probe.is_clear() {
|
||||
tracing::info!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
"natural exit: worktree holds unsaved work; leaving it on disk"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
match record_retention(
|
||||
inner,
|
||||
&(base_repo.clone(), path.clone()),
|
||||
known_branch,
|
||||
RetainedReason::ExitedWithUnsavedWork,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(entry) => {
|
||||
tracing::info!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
entry = %entry.id,
|
||||
"natural exit: worktree retained with unsaved work"
|
||||
);
|
||||
match inner.worktree_mgr.remove(base_repo, path) {
|
||||
Ok(()) => {
|
||||
tracing::info!(%id, path = %path.display(), "natural exit: pristine worktree removed");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
tracing::warn!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
error = ?e,
|
||||
"natural exit: could not record the retained worktree; leaving it untouched"
|
||||
"natural exit: removal failed; leaving the worktree on disk"
|
||||
);
|
||||
false
|
||||
}
|
||||
@@ -1214,15 +1109,15 @@ async fn settle_worktree_on_exit(
|
||||
/// resurrect it. `kill_tmux` is `true` for an explicit kill and `false` for the
|
||||
/// liveness reaper (tmux already lost the session).
|
||||
///
|
||||
/// `retain_worktree` is `true` for a forced kill (#81) that retained the
|
||||
/// worktree instead of removing it: the store entry is already written, so
|
||||
/// only the removal is skipped.
|
||||
/// `keep_worktree` is `true` for a forced kill (#81) over a guarded tree: the
|
||||
/// session ends but the worktree is left on disk, so only the removal is
|
||||
/// skipped.
|
||||
async fn cleanup_record(
|
||||
inner: &Arc<Inner>,
|
||||
id: Uuid,
|
||||
record: SessionRecord,
|
||||
kill_tmux: bool,
|
||||
retain_worktree: bool,
|
||||
keep_worktree: bool,
|
||||
) {
|
||||
if kill_tmux {
|
||||
let tmux = inner.tmux.clone();
|
||||
@@ -1233,11 +1128,11 @@ async fn cleanup_record(
|
||||
tracing::warn!(error = ?e, %id, "cleanup: failed to delete sidecar");
|
||||
}
|
||||
if let (Some(base), Some(path)) = (&record.worktree_base_repo, &record.worktree_path) {
|
||||
if retain_worktree {
|
||||
if keep_worktree {
|
||||
tracing::info!(
|
||||
%id,
|
||||
path = %path.display(),
|
||||
"forced kill: worktree retained in the store"
|
||||
"forced kill: worktree left on disk"
|
||||
);
|
||||
} else if let Err(e) = inner.worktree_mgr.remove(base, path) {
|
||||
// The kill already passed the unsaved-work guard (#79), so this
|
||||
|
||||
Reference in New Issue
Block a user