Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7049b5f1c0 | ||
|
|
c277ad630c | ||
|
|
fc9c62d46b | ||
|
|
3fe560d67e |
@@ -374,7 +374,7 @@ impl SessionManager {
|
|||||||
.map(wait_for_event_pump);
|
.map(wait_for_event_pump);
|
||||||
futures_util::future::join_all(drains).await;
|
futures_util::future::join_all(drains).await;
|
||||||
for (id, _, _) in children {
|
for (id, _, _) in children {
|
||||||
mark_session_dead(&self.inner, id).await;
|
mark_session_dead_after_shutdown(&self.inner, id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,7 +1656,7 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mark a session whose tmux session has disappeared as [`SessionStatus::Dead`]
|
/// Mark a session whose backing process has disappeared as [`SessionStatus::Dead`]
|
||||||
/// and settle its worktree per the natural-exit rule (#82).
|
/// and settle its worktree per the natural-exit rule (#82).
|
||||||
///
|
///
|
||||||
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
/// The record, its [`PtyBridge`] and its sidecar all survive, so a client can
|
||||||
@@ -1668,9 +1668,24 @@ async fn run_blocked_work_pass(inner: &Arc<Inner>) {
|
|||||||
/// alone and stays attached to the dead record for a later forced dismissal
|
/// alone and stays attached to the dead record for a later forced dismissal
|
||||||
/// (#81), which leaves it on disk too.
|
/// (#81), which leaves it on disk too.
|
||||||
///
|
///
|
||||||
/// Dead sessions do not survive a daemon restart: [`SessionManager::recover`]
|
/// Terminal sessions that die naturally do not survive a daemon restart:
|
||||||
/// adopts only what tmux still holds.
|
/// [`SessionManager::recover`] adopts only what tmux still holds. Structured
|
||||||
|
/// sessions stopped for daemon shutdown retain their sidecars for ACP recovery.
|
||||||
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
||||||
|
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 flipped = {
|
||||||
let mut sessions = inner.sessions.lock().await;
|
let mut sessions = inner.sessions.lock().await;
|
||||||
match sessions.get_mut(&id) {
|
match sessions.get_mut(&id) {
|
||||||
@@ -1694,17 +1709,19 @@ async fn mark_session_dead(inner: &Arc<Inner>, id: Uuid) {
|
|||||||
};
|
};
|
||||||
tracing::info!(%id, "session process gone — marking dead");
|
tracing::info!(%id, "session process gone — marking dead");
|
||||||
|
|
||||||
if let Some(wt) = &worktree {
|
if settle_worktree {
|
||||||
if settle_worktree_on_exit(inner, id, wt).await {
|
if let Some(wt) = &worktree {
|
||||||
// The tree is settled (removed); a stale "in the way" would
|
if settle_worktree_on_exit(inner, id, wt).await {
|
||||||
// describe something the session no longer holds.
|
// The tree is settled (removed); a stale "in the way" would
|
||||||
session.blocked_work = None;
|
// describe something the session no longer holds.
|
||||||
let mut sessions = inner.sessions.lock().await;
|
session.blocked_work = None;
|
||||||
if let Some(record) = sessions.get_mut(&id) {
|
let mut sessions = inner.sessions.lock().await;
|
||||||
record.session.worktree = None;
|
if let Some(record) = sessions.get_mut(&id) {
|
||||||
record.session.blocked_work = None;
|
record.session.worktree = None;
|
||||||
record.worktree_path = None;
|
record.session.blocked_work = None;
|
||||||
record.worktree_base_repo = None;
|
record.worktree_path = None;
|
||||||
|
record.worktree_base_repo = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use common::{wait_for_async, DaemonFixture};
|
use common::{init_bare_repo, init_repo, push_all_to_origin, wait_for_async, DaemonFixture};
|
||||||
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
use maestro_acp::schema::{ContentBlock, SessionUpdate};
|
||||||
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
|
use maestro_acp::{ADAPTER_BIN, INSTALL_COMMAND};
|
||||||
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
|
use maestro_daemon::sidecar::{EventCursor, SidecarStore};
|
||||||
use maestro_daemon::worktree::WorktreeManager;
|
use maestro_daemon::worktree::WorktreeManager;
|
||||||
use maestro_daemon::SessionManager;
|
use maestro_daemon::SessionManager;
|
||||||
use maestro_protocol::{ActivityState, CreateSessionRequest, SessionStatus, SessionView};
|
use maestro_protocol::{
|
||||||
|
ActivityState, CreateSessionRequest, SessionStatus, SessionView, WorktreeSpec,
|
||||||
|
};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn fake_agent() -> PathBuf {
|
fn fake_agent() -> PathBuf {
|
||||||
@@ -285,6 +287,76 @@ async fn recovery_loads_the_acp_session_without_duplicating_history() {
|
|||||||
revived.kill(session.id, false).await.expect("kill");
|
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]
|
#[tokio::test]
|
||||||
async fn failed_recovery_cleans_its_fifo() {
|
async fn failed_recovery_cleans_its_fifo() {
|
||||||
let fx = DaemonFixture::new();
|
let fx = DaemonFixture::new();
|
||||||
|
|||||||
+4
-2
@@ -208,8 +208,10 @@ 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
|
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
|
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
|
/api/sessions/:id` (`session/cancel`, then `SIGTERM`) and with the daemon
|
||||||
itself. Terminal sessions keep surviving restarts through tmux; structured
|
itself. A daemon restart ends an in-flight structured turn, but preserves its
|
||||||
ones do not yet.
|
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 browser UI
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user