crates/signaltty-server/src/store.rs

//! In-memory authoritative store. Uses std RwLock so both tokio tasks
//! and PTY pump threads can share it. Hold locks briefly, never across await.

use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, RwLock};

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use signaltty_core::model::{
    AgentKind, Disposition, DispositionOutcome, Notification, Pane, Tab, Task, TaskPr, TaskResult,
    Workspace,
};
use signaltty_core::state::{Attention, Lifecycle, TaskState};
use signaltty_proto::{code, event};

use crate::audit::{AuditLog, EventSequence};

pub const MAX_NOTIFICATIONS: usize = 200;
pub const MAX_EVENTS: usize = 1024;

#[derive(Debug, Clone)]
pub struct StoredEvent {
    pub seq: u64,
    pub name: String,
    pub payload: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WaitBaseline {
    pub pane_id: String,
    pub process_instance: String,
    pub agent_session_id: Option<String>,
    pub session_generation: u64,
    pub lifecycle_seq: u64,
    pub attention_seq: u64,
}

impl WaitBaseline {
    pub fn threshold(&self, outcome: &str) -> u64 {
        if Attention::parse(outcome).is_some() || matches!(outcome, "seen" | "attention_cleared") {
            self.attention_seq
        } else {
            self.lifecycle_seq
        }
    }
}

struct PaneProgress {
    process_instance: String,
    session_generation: u64,
    lifecycle_seq: u64,
    attention_seq: u64,
    transitions: HashMap<&'static str, u64>,
    /// A hook reached this process: screen rules stay silent (ADR-0006).
    hooked: bool,
    /// The current `input_required` was raised by a screen rule.
    screen_attention: bool,
    /// The program this process runs (spec 033): spawn argv, then procscan.
    process_name: String,
}

pub struct Store {
    pub workspaces: HashMap<String, Workspace>,
    pub tabs: HashMap<String, Tab>,
    pub panes: HashMap<String, Pane>,
    pub tasks: HashMap<String, Task>,
    pub notifications: VecDeque<Notification>,
    pub events: VecDeque<StoredEvent>,
    pub seq: u64,
    pub started_at: DateTime<Utc>,
    audit: Option<AuditLog>,
    sequence: Option<EventSequence>,
    publisher: Option<tokio::sync::broadcast::Sender<StoredEvent>>,
    ring_retained_after: u64,
    progress: HashMap<String, PaneProgress>,
    pub task_reservations: usize,
}

/// `status_reason.reason` of a task whose prompt was written but never
/// confirmed by agent activity.
pub const SUBMIT_UNCONFIRMED: &str = "submit_unconfirmed";

/// `done`/`failed` outcome the user already read: nothing left to look at and
/// no open decision (docs/03 "turn finished, user reviewed"). Trailing exit
/// signals must leave such a pane quiet.
pub(crate) fn is_reviewed_terminal(pane: &Pane) -> bool {
    matches!(pane.lifecycle, Lifecycle::Done | Lifecycle::Failed)
        && pane.attention == Attention::None
        && pane.pending_decision.is_none()
}

impl Store {
    pub fn new() -> Store {
        Store {
            workspaces: HashMap::new(),
            tabs: HashMap::new(),
            panes: HashMap::new(),
            tasks: HashMap::new(),
            notifications: VecDeque::new(),
            events: VecDeque::new(),
            seq: 0,
            started_at: Utc::now(),
            audit: None,
            sequence: None,
            publisher: None,
            ring_retained_after: 0,
            progress: HashMap::new(),
            task_reservations: 0,
        }
    }

    pub fn configure_events(
        &mut self,
        log: AuditLog,
        sequence: EventSequence,
        publisher: tokio::sync::broadcast::Sender<StoredEvent>,
    ) {
        self.seq = sequence.head();
        self.ring_retained_after = self.seq;
        self.sequence = Some(sequence);
        self.audit = Some(log);
        self.publisher = Some(publisher);
        for id in self.panes.keys().cloned().collect::<Vec<_>>() {
            self.begin_process(&id);
        }
    }

    fn begin_process(&mut self, pane_id: &str) {
        self.progress.insert(
            pane_id.into(),
            PaneProgress {
                process_instance: signaltty_core::ids::new_pane_id(),
                session_generation: 0,
                lifecycle_seq: self.seq,
                attention_seq: self.seq,
                transitions: HashMap::from([
                    (self.panes[pane_id].lifecycle.as_str(), self.seq),
                    (self.panes[pane_id].attention.as_str(), self.seq),
                ]),
                hooked: false,
                screen_attention: false,
                process_name: signaltty_agent::process::process_name(&self.panes[pane_id].argv),
            },
        );
    }

    pub fn publish_pane(&mut self, pane: &Pane, resumed: bool) -> StoredEvent {
        let mut payload = json!({"pane":pane});
        if resumed {
            payload["resumed"] = json!(true);
        }
        let event = self.emit(signaltty_proto::event::PANE_CREATED, payload);
        self.begin_process(&pane.id);
        event
    }

    pub fn remove_pane(&mut self, pane_id: &str) -> Option<Pane> {
        let pane = self.panes.remove(pane_id)?;
        self.progress.remove(pane_id);
        self.emit(
            signaltty_proto::event::PANE_CLOSED,
            json!({"pane_id":pane_id}),
        );
        // Closing the worker pane fails its non-terminal task exactly once:
        // `task_fail` is a no-op on terminal tasks, so whichever of
        // `on_exit`/`remove_pane` runs first wins and the second is silent.
        // (Discard/cancel transition first, so their closes are no-ops here.)
        let task_id = self
            .tasks
            .values()
            .find(|t| t.pane_id.as_deref() == Some(pane_id) && !t.state.is_terminal())
            .map(|t| t.id.clone());
        if let Some(tid) = task_id {
            self.task_fail(&tid, Some(json!({"reason": "pane_closed"})));
        }
        Some(pane)
    }

    pub fn set_exited(&mut self, pane_id: &str, code: Option<i32>) {
        let Some(pane) = self.panes.get_mut(pane_id) else {
            return;
        };
        // Idempotent: the PTY reader's late on_exit after a server-side close
        // must not re-raise attention the closer already cleared.
        if !matches!(pane.live, signaltty_core::model::LiveState::Live) {
            return;
        }
        let reviewed = is_reviewed_terminal(pane);
        pane.live = signaltty_core::model::LiveState::Exited { code };
        pane.restore_state = signaltty_core::model::RestoreState::Exited;
        pane.last_activity_at = Utc::now();
        // Capture before emit: done/failed must persist after PTY exit
        // (product + docs/03,/07). Generic/None one-shots map exit code →
        // done/failed so `wait --until done` does not hang when the child
        // finishes before the waiter arms. Known agents keep Exited unless
        // hooks already recorded a terminal outcome.
        let next_lifecycle = match pane.lifecycle {
            Lifecycle::Done | Lifecycle::Failed => pane.lifecycle,
            _ if matches!(pane.agent.kind, AgentKind::Generic | AgentKind::None) => match code {
                Some(0) => Lifecycle::Done,
                Some(_) => Lifecycle::Failed,
                None => Lifecycle::Exited,
            },
            _ => Lifecycle::Exited,
        };
        let event = self.emit(
            signaltty_proto::event::PANE_EXITED,
            json!({"pane_id":pane_id,"code":code}),
        );
        if let Some(progress) = self.progress.get_mut(pane_id) {
            progress.lifecycle_seq = event.seq;
            progress.transitions.insert("exited", event.seq);
        }
        self.set_lifecycle(pane_id, next_lifecycle);
        // An exited pane can no longer take input or grant permission.
        if matches!(
            self.panes.get(pane_id).map(|p| p.attention),
            Some(Attention::InputRequired | Attention::PermissionRequired)
        ) {
            self.clear_attention(pane_id, "pane_exited");
        }
        // Exiting after the outcome was read is not news (the process was
        // expected to end); first completions and working exits still alert.
        if !reviewed {
            self.raise_attention(pane_id, Attention::Unread);
        }
        self.clear_decision(pane_id, "pane_exited");

        let task_id = self
            .tasks
            .values()
            .find(|t| t.pane_id.as_deref() == Some(pane_id) && !t.state.is_terminal())
            .map(|t| t.id.clone());
        if let Some(tid) = task_id {
            self.task_fail(
                &tid,
                Some(json!({"reason": "pane_exited", "exit_code": code})),
            );
        }
    }

    pub fn matching_transition(&self, pane_id: &str, outcome: &str) -> Option<u64> {
        let key = if matches!(outcome, "seen" | "attention_cleared") {
            "none"
        } else {
            outcome
        };
        self.progress.get(pane_id)?.transitions.get(key).copied()
    }

    pub fn wait_baseline(&self, pane_id: &str) -> Option<WaitBaseline> {
        let pane = self.panes.get(pane_id)?;
        let progress = self.progress.get(pane_id)?;
        Some(WaitBaseline {
            pane_id: pane_id.into(),
            process_instance: progress.process_instance.clone(),
            agent_session_id: pane.agent.agent_session_id.clone(),
            session_generation: progress.session_generation,
            lifecycle_seq: progress.lifecycle_seq,
            attention_seq: progress.attention_seq,
        })
    }

    pub fn set_agent_session(&mut self, pane_id: &str, session_id: String) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        if pane.agent.agent_session_id.as_ref() == Some(&session_id) {
            return None;
        }
        let replacement = pane.agent.agent_session_id.is_some();
        pane.agent.agent_session_id = Some(session_id);
        if replacement {
            if let Some(progress) = self.progress.get_mut(pane_id) {
                progress.session_generation += 1;
            }
        }
        let pane = pane.clone();
        Some(self.emit(signaltty_proto::event::PANE_UPDATED, json!({"pane":pane})))
    }

    /// `task_ids` scopes `task.*` events to those tasks; the filter runs before
    /// the cap and the `returned` count so unrelated traffic cannot truncate.
    pub fn replay(
        &self,
        after: u64,
        patterns: &[String],
        task_ids: Option<&[String]>,
    ) -> (Value, Vec<StoredEvent>) {
        let history = self.audit.as_ref().map(AuditLog::history);
        let retained_after = history
            .as_ref()
            .filter(|h| h.available)
            .map(|h| h.retained_after.min(self.ring_retained_after))
            .unwrap_or(self.ring_retained_after);
        let disk_proves = history
            .as_ref()
            .map(|h| h.available && after >= h.retained_after)
            .unwrap_or(false);
        let ring_proves = after >= self.ring_retained_after;
        let mut status = if after > self.seq {
            "cursor_ahead"
        } else if disk_proves || ring_proves {
            "complete"
        } else if history.as_ref().map(|h| h.available).unwrap_or(false) {
            "history_lost"
        } else {
            "unavailable"
        };
        let mut events = Vec::new();
        if status == "complete" {
            let backfill = if disk_proves {
                history.unwrap().events
            } else {
                Vec::new()
            };
            events = crate::audit::merge_replay(backfill, self.events_since(after))
                .into_iter()
                .filter(|e| {
                    e.seq > after
                        && e.seq <= self.seq
                        && patterns
                            .iter()
                            .any(|g| signaltty_proto::glob_matches(g, &e.name))
                        && task_ids.is_none_or(|tids| {
                            !e.name.starts_with("task.")
                                || e.payload
                                    .get("task_id")
                                    .and_then(|v| v.as_str())
                                    .is_some_and(|tid| tids.iter().any(|t| t == tid))
                        })
                })
                .collect();
            if events.len() > crate::audit::REPLAY_CAP {
                status = "truncated";
                events.clear();
            }
        }
        let mut coverage = json!({"status":status, "requested_after":after, "retained_after":retained_after, "through":self.seq, "returned":events.len()});
        if status != "complete" {
            coverage["recovery"] = json!("snapshot_then_resubscribe");
        }
        (coverage, events)
    }

    /// Record and publish under the assigning Store lock.
    pub fn emit(&mut self, name: &str, payload: Value) -> StoredEvent {
        self.next_seq();
        let ev = StoredEvent {
            seq: self.seq,
            name: name.to_string(),
            payload,
        };
        self.events.push_back(ev.clone());
        while self.events.len() > MAX_EVENTS {
            if let Some(dropped) = self.events.pop_front() {
                self.ring_retained_after = dropped.seq;
            }
        }
        // Sync before publication so replay can prove state-event coverage.
        if let Some(audit) = &self.audit {
            journal_or_exit(audit.append(ev.seq, &ev.name, &ev.payload));
        }
        if let Some(publisher) = &self.publisher {
            let _ = publisher.send(ev.clone());
        }
        ev
    }

    pub fn push_notification(&mut self, n: Notification) {
        self.notifications.push_back(n);
        while self.notifications.len() > MAX_NOTIFICATIONS {
            self.notifications.pop_front();
        }
    }

    /// Bump the seq counter without recording in the replay ring.
    /// Used for high-volume `pty.data`, which live viewers get and
    /// reattach covers via snapshot.
    pub fn next_seq(&mut self) -> u64 {
        self.seq = match &mut self.sequence {
            Some(sequence) => journal_or_exit(sequence.allocate()),
            None => self.seq.checked_add(1).expect("event sequence exhausted"),
        };
        self.seq
    }

    pub fn live_panes(&self) -> usize {
        use signaltty_core::model::LiveState;
        self.panes
            .values()
            .filter(|p| matches!(p.live, LiveState::Live))
            .count()
    }

    pub fn events_since(&self, from_seq: u64) -> Vec<StoredEvent> {
        self.events
            .iter()
            .filter(|e| e.seq > from_seq)
            .cloned()
            .collect()
    }

    /// Next pane needing a human: severity desc, then most recent activity.
    pub fn next_unread(&self) -> Option<String> {
        let mut panes: Vec<&Pane> = self
            .panes
            .values()
            .filter(|p| p.attention.needs_human())
            .collect();
        panes.sort_by(|a, b| {
            b.attention
                .severity()
                .cmp(&a.attention.severity())
                .then(b.last_activity_at.cmp(&a.last_activity_at))
        });
        panes.first().map(|p| p.id.clone())
    }
}

fn journal_or_exit<T>(result: std::io::Result<T>) -> T {
    result.unwrap_or_else(|error| {
        tracing::error!("event journal failed; refusing further event issuance: {error}");
        std::process::exit(1)
    })
}

impl Default for Store {
    fn default() -> Self {
        Self::new()
    }
}

pub type SharedStore = Arc<RwLock<Store>>;

pub struct TaskReservation {
    store: SharedStore,
    active: bool,
}

impl TaskReservation {
    pub fn commit(mut self, task: Task) -> StoredEvent {
        let mut s = self.store.write().unwrap();
        s.task_reservations = s.task_reservations.saturating_sub(1);
        let ev = s.task_create(task);
        self.active = false;
        ev
    }
}

impl Drop for TaskReservation {
    fn drop(&mut self) {
        if self.active {
            if let Ok(mut s) = self.store.write() {
                s.task_reservations = s.task_reservations.saturating_sub(1);
            }
        }
    }
}

impl Store {
    /// Set lifecycle and publish its paired event only when changed.
    /// Callers hold the lock, so one event costs one lock cycle and the
    /// state change can never part from its emit.
    pub fn set_lifecycle(&mut self, pane_id: &str, next: Lifecycle) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        let prev = pane.lifecycle;
        if prev == next {
            return None;
        }
        let now = Utc::now();
        pane.set_lifecycle(next, now);
        pane.last_activity_at = now;
        let ev = self.emit(
            next.event_name(),
            serde_json::json!({"pane_id": pane_id, "lifecycle": next.as_str(), "prev": prev.as_str()}),
        );
        if let Some(progress) = self.progress.get_mut(pane_id) {
            progress.lifecycle_seq = ev.seq;
            progress.transitions.insert(next.as_str(), ev.seq);
        }
        // A worker that starts after its submit went unconfirmed (late Enter,
        // slow hooks) took the prompt after all: back to working. A turn that
        // already ended refreshes the evidence to turn_ended_without_report.
        if matches!(next, Lifecycle::Working | Lifecycle::Done) {
            let unconfirmed = self
                .tasks
                .values()
                .find(|t| {
                    t.pane_id.as_deref() == Some(pane_id)
                        && t.state == TaskState::InputRequired
                        && t.status_reason.as_ref().and_then(|r| r.get("reason"))
                            == Some(&json!(SUBMIT_UNCONFIRMED))
                })
                .map(|t| t.id.clone());
            if let Some(tid) = unconfirmed {
                self.task_resume_working(&tid);
            }
        }
        Some(ev)
    }

    /// The program a live pane runs, for screen-rule scoping (no event).
    pub fn process_name(&self, pane_id: &str) -> String {
        self.progress
            .get(pane_id)
            .map(|p| p.process_name.clone())
            .unwrap_or_default()
    }

    pub fn set_process_name(&mut self, pane_id: &str, name: String) {
        if let Some(progress) = self.progress.get_mut(pane_id) {
            progress.process_name = name;
        }
    }

    /// The first hook takes the pane over from screen rules and withdraws
    /// the `input_required` they raised; other attention stays.
    pub fn mark_hooked(&mut self, pane_id: &str) -> Option<StoredEvent> {
        let progress = self.progress.get_mut(pane_id)?;
        progress.hooked = true;
        if !std::mem::take(&mut progress.screen_attention)
            || self.panes.get(pane_id)?.attention != Attention::InputRequired
        {
            return None;
        }
        self.clear_attention(pane_id, "hook")
    }

    pub fn is_hooked(&self, pane_id: &str) -> bool {
        self.progress.get(pane_id).is_some_and(|p| p.hooked)
    }

    /// Screen-rule classification (docs/07): only for a live pane whose
    /// current process has seen no hook. `idle` closes a `working` turn as
    /// `done`, settles a `blocked` pane as `idle`, and is otherwise a no-op.
    /// Leaving `blocked` withdraws its `input_required`.
    pub fn apply_screen_state(
        &mut self,
        pane_id: &str,
        state: signaltty_agent::screen::ScreenState,
    ) -> Vec<StoredEvent> {
        use signaltty_agent::screen::ScreenState;
        let Some(pane) = self.panes.get(pane_id) else {
            return Vec::new();
        };
        if !matches!(pane.live, signaltty_core::model::LiveState::Live) || self.is_hooked(pane_id) {
            return Vec::new();
        }
        let (lifecycle, attention) = match (state, pane.lifecycle) {
            (ScreenState::Hold, _) => return Vec::new(),
            (ScreenState::Working, _) => (Lifecycle::Working, None),
            (ScreenState::Blocked, _) => (Lifecycle::Blocked, Some(Attention::InputRequired)),
            (ScreenState::Idle, Lifecycle::Working) => (Lifecycle::Done, Some(Attention::Unread)),
            (ScreenState::Idle, Lifecycle::Blocked) => (Lifecycle::Idle, None),
            (ScreenState::Idle, _) => return Vec::new(),
        };
        // Leaving `blocked` withdraws the input_required raised for it, so a
        // later `done` can still raise `unread`; other attention stays.
        let withdraw = pane.lifecycle == Lifecycle::Blocked
            && lifecycle != Lifecycle::Blocked
            && pane.attention == Attention::InputRequired;
        let Some(ev) = self.set_lifecycle(pane_id, lifecycle) else {
            return Vec::new();
        };
        let mut events = vec![ev];
        if withdraw {
            events.extend(self.clear_attention(pane_id, "screen"));
        }
        let raised = attention.and_then(|a| self.raise_attention(pane_id, a));
        if let Some(progress) = self.progress.get_mut(pane_id) {
            if withdraw || raised.is_some() {
                progress.screen_attention = attention == Some(Attention::InputRequired);
            }
        }
        events.extend(raised);
        events
    }

    /// Raise attention and publish its paired event only when more severe.
    pub fn raise_attention(&mut self, pane_id: &str, next: Attention) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        let prev = pane.attention;
        let raised = prev.raise(next);
        if raised == prev {
            return None;
        }
        let now = Utc::now();
        pane.attention = raised;
        pane.attention_since = Some(now);
        pane.last_activity_at = now;
        let name = if prev == Attention::None {
            signaltty_proto::event::ATTENTION_CREATED
        } else {
            signaltty_proto::event::ATTENTION_UPDATED
        };
        let ev = self.emit(
            name,
            serde_json::json!({"pane_id": pane_id, "attention": raised.as_str(), "prev": prev.as_str()}),
        );
        if let Some(progress) = self.progress.get_mut(pane_id) {
            progress.attention_seq = ev.seq;
            progress.transitions.insert(raised.as_str(), ev.seq);
        }
        Some(ev)
    }

    /// Set (or supersede) the pane's pending decision. A supersede folds
    /// into a single `decision.created` carrying the previous id — one
    /// emit per transition, never a cleared+created pair.
    pub fn set_decision(
        &mut self,
        pane_id: &str,
        decision: signaltty_core::model::Decision,
    ) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        let prev = pane.pending_decision.replace(decision.clone());
        pane.last_activity_at = chrono::Utc::now();
        let ev = self.emit(
            signaltty_proto::event::DECISION_CREATED,
            serde_json::json!({
                "pane_id": pane_id, "decision": decision,
                "prev": prev.map(|d| d.id),
            }),
        );

        let task_id = self
            .tasks
            .values()
            .find(|t| {
                t.pane_id.as_deref() == Some(pane_id)
                    && matches!(t.state, TaskState::Working | TaskState::InputRequired)
            })
            .map(|t| t.id.clone());
        if let Some(tid) = task_id {
            self.task_input_required_on_decision(&tid, &decision.id);
        }

        Some(ev)
    }

    /// Consume the pending decision on answer. `None` when absent or the
    /// id is stale (superseded / already answered): the caller reports
    /// `{answered: false}`, never an error. Consuming does not resume the
    /// pane or task: call `resume_after_answer` once delivery succeeded.
    pub fn answer_decision(
        &mut self,
        pane_id: &str,
        decision_id: &str,
        option_id: &str,
    ) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        match &pane.pending_decision {
            Some(d) if d.id == decision_id => {}
            _ => return None,
        }
        pane.pending_decision = None;
        pane.last_activity_at = chrono::Utc::now();
        let ev = self.emit(
            signaltty_proto::event::DECISION_ANSWERED,
            serde_json::json!({
                "pane_id": pane_id,
                "decision_id": decision_id,
                "option_id": option_id,
            }),
        );

        Some(ev)
    }

    /// The consumed answer demonstrably reached the agent (PTY write or
    /// native reply succeeded): resume the interrupted task and the blocked
    /// pane. Later tool hooks never clear Blocked, so delivery is the one
    /// place a blocked pane resumes. Any other lifecycle is left alone, and a
    /// newer decision that arrived during delivery keeps its own gate.
    pub fn resume_after_answer(&mut self, pane_id: &str) -> Option<StoredEvent> {
        let pane = self.panes.get(pane_id)?;
        if pane.pending_decision.is_some() {
            return None;
        }
        let blocked = pane.lifecycle == Lifecycle::Blocked;

        let task_id = self
            .tasks
            .values()
            .find(|t| t.pane_id.as_deref() == Some(pane_id) && t.state == TaskState::InputRequired)
            .map(|t| t.id.clone());
        let mut ev = task_id.and_then(|tid| self.task_resume_working(&tid));

        if blocked {
            ev = self.set_lifecycle(pane_id, Lifecycle::Working).or(ev);
        }
        ev
    }

    /// Drop the pending decision without answering. `None` when absent.
    ///
    /// Never resumes a worker task: only an explicit answer (via
    /// `answer_decision` + `resume_after_answer`) or an accepted follow-up submit moves an
    /// `input_required` task back to `working`. Timeout, disconnect
    /// (`native_cancelled`) and turn-end (`moved_on`) drops park the task
    /// in `input_required` so the stall is visible instead of silently
    /// resuming unanswered work.
    pub fn clear_decision(&mut self, pane_id: &str, reason: &str) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        let dropped = pane.pending_decision.take()?;
        pane.last_activity_at = chrono::Utc::now();
        let ev = self.emit(
            signaltty_proto::event::DECISION_CLEARED,
            serde_json::json!({
                "pane_id": pane_id,
                "decision_id": dropped.id,
                "reason": reason,
            }),
        );
        Some(ev)
    }

    /// Acknowledge reading without resolving an unanswered decision.
    pub fn mark_seen(&mut self, pane_id: &str, reason: &str) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        pane.last_seen_at = Some(chrono::Utc::now());
        if pane.pending_decision.is_some() {
            return None;
        }
        self.clear_attention(pane_id, reason)
    }

    /// Clear attention explicitly (user replied / focused via hook). Returns
    /// the event to broadcast, if anything changed.
    pub fn clear_attention(&mut self, pane_id: &str, reason: &str) -> Option<StoredEvent> {
        let pane = self.panes.get_mut(pane_id)?;
        if pane.attention == Attention::None {
            return None;
        }
        pane.mark_seen(chrono::Utc::now());
        let ev = self.emit(
            signaltty_proto::event::ATTENTION_CLEARED,
            serde_json::json!({"pane_id": pane_id, "reason": reason}),
        );
        if let Some(progress) = self.progress.get_mut(pane_id) {
            progress.attention_seq = ev.seq;
            progress.transitions.insert("none", ev.seq);
        }
        Some(ev)
    }

    pub fn reserve_task_slot(
        store: &SharedStore,
        max: usize,
    ) -> Result<TaskReservation, (usize, usize)> {
        let mut s = store.write().unwrap();
        let active =
            s.tasks.values().filter(|t| !t.state.is_terminal()).count() + s.task_reservations;
        if active >= max {
            return Err((active, max));
        }
        s.task_reservations += 1;
        Ok(TaskReservation {
            store: Arc::clone(store),
            active: true,
        })
    }

    pub fn task_create(&mut self, task: Task) -> StoredEvent {
        let payload = json!({
            "task_id": task.id,
            "context_id": task.context_id,
            "task": task,
        });
        self.tasks.insert(task.id.clone(), task);
        self.emit(event::TASK_CREATED, payload)
    }

    /// Record the worker pane of a task created before its pane spawned.
    pub fn task_attach_pane(&mut self, task_id: &str, pane_id: &str) -> Option<Task> {
        let task = self.tasks.get_mut(task_id)?;
        task.pane_id = Some(pane_id.to_string());
        task.updated_at = Utc::now();
        let task = task.clone();
        self.emit_task_updated(&task, None);
        Some(task)
    }

    fn emit_task_updated(&mut self, task: &Task, prev_state: Option<TaskState>) -> StoredEvent {
        let mut payload = json!({
            "task_id": task.id,
            "context_id": task.context_id,
            "task": task,
        });
        if let Some(prev) = prev_state {
            payload["prev_state"] = json!(prev.as_str());
        }
        self.emit(event::TASK_UPDATED, payload)
    }

    pub fn task_background_ready(&mut self, task_id: &str) -> Option<StoredEvent> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id)?;
            if task.state != TaskState::Pending {
                return None;
            }
            let prev_state = task.state;
            let now = Utc::now();
            task.transition_to(TaskState::Working, now).ok()?;
            // A Stop that landed before the submit committed leaves the pane
            // lifecycle done with no report: park in input_required here, in
            // the same write, instead of a working state nobody will end.
            // Idle is not turn end.
            if let Some(evidence) = Self::decision_evidence(&self.panes, task) {
                task.transition_to(TaskState::InputRequired, now).ok()?;
                task.status_reason = Some(evidence);
            } else if let Some(evidence) = Self::turn_end_evidence(&self.panes, task) {
                task.state = TaskState::InputRequired;
                task.status_reason = Some(evidence);
                task.updated_at = now;
            }
            (task.clone(), prev_state)
        };
        let ev = self.emit_task_updated(&task_clone, Some(prev_state));
        Some(ev)
    }

    fn decision_evidence(panes: &HashMap<String, Pane>, task: &Task) -> Option<Value> {
        let decision = task
            .pane_id
            .as_deref()
            .and_then(|pid| panes.get(pid))?
            .pending_decision
            .as_ref()?;
        Some(json!({"reason": "decision_required", "decision_id": decision.id}))
    }

    /// Evidence for a turn that ended with no report: the worker pane
    /// lifecycle is already `done` while the task holds no result.
    /// `None` when the pane is still live-turning, idle, or reported.
    fn turn_end_evidence(panes: &HashMap<String, Pane>, task: &Task) -> Option<Value> {
        if task.result.is_some() {
            return None;
        }
        let pane = task.pane_id.as_deref().and_then(|pid| panes.get(pid))?;
        if pane.lifecycle != Lifecycle::Done {
            return None;
        }
        Some(json!({
            "reason": "turn_ended_without_report",
            "last_message": pane.last_message,
        }))
    }

    pub fn task_report(
        &mut self,
        task_id: &str,
        result: TaskResult,
    ) -> Result<(StoredEvent, StoredEvent), (String, String)> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id).ok_or_else(|| {
                (
                    code::NO_SUCH_TASK.to_string(),
                    format!("no such task '{task_id}'"),
                )
            })?;
            let prev_state = task.state;
            let now = Utc::now();
            task.apply_report(result.clone(), now).map_err(|e| {
                (
                    code::BAD_PARAMS.to_string(),
                    format!("failed to apply task report: {e}"),
                )
            })?;
            (task.clone(), prev_state)
        };
        let result_ev = self.emit(
            event::TASK_RESULT,
            json!({
                "task_id": task_clone.id,
                "context_id": task_clone.context_id,
                "result": result,
            }),
        );
        let update_ev = self.emit_task_updated(&task_clone, Some(prev_state));
        Ok((result_ev, update_ev))
    }

    pub fn task_cancel(&mut self, task_id: &str) -> Result<StoredEvent, (String, String)> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id).ok_or_else(|| {
                (
                    code::NO_SUCH_TASK.to_string(),
                    format!("no such task '{task_id}'"),
                )
            })?;
            if task.state.is_terminal() {
                return Err((
                    code::BAD_PARAMS.to_string(),
                    format!("task is already terminal ({})", task.state.as_str()),
                ));
            }
            let prev_state = task.state;
            let now = Utc::now();
            task.transition_to(TaskState::Canceled, now).map_err(|e| {
                (
                    code::BAD_PARAMS.to_string(),
                    format!("cannot cancel task: {e}"),
                )
            })?;
            (task.clone(), prev_state)
        };
        let ev = self.emit_task_updated(&task_clone, Some(prev_state));
        Ok(ev)
    }

    /// The background submit wrote the prompt but saw no activity: park in
    /// `input_required` instead of `failed`, so a nudge (`pane.submit`) or
    /// the worker starting late resumes it and its work stays finishable.
    pub fn task_submit_unconfirmed(&mut self, task_id: &str, error: &str) -> Option<StoredEvent> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id)?;
            if task.state != TaskState::Pending {
                return None;
            }
            let prev_state = task.state;
            let now = Utc::now();
            task.transition_to(TaskState::Working, now).ok()?;
            // Activity that landed right after the gate's deadline confirms it.
            let started = task
                .pane_id
                .as_deref()
                .and_then(|pid| self.panes.get(pid))
                .is_some_and(|p| matches!(p.lifecycle, Lifecycle::Working | Lifecycle::Blocked));
            if let Some(evidence) = Self::decision_evidence(&self.panes, task) {
                task.transition_to(TaskState::InputRequired, now).ok()?;
                task.status_reason = Some(evidence);
                (task.clone(), prev_state)
            } else if started {
                (task.clone(), prev_state)
            } else {
                task.transition_to(TaskState::InputRequired, now).ok()?;
                task.status_reason = Some(json!({
                    "reason": SUBMIT_UNCONFIRMED,
                    "stage": "activity_gate",
                    "error": error,
                }));
                (task.clone(), prev_state)
            }
        };
        Some(self.emit_task_updated(&task_clone, Some(prev_state)))
    }

    pub fn task_fail(&mut self, task_id: &str, evidence: Option<Value>) -> Option<StoredEvent> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id)?;
            if task.state.is_terminal() {
                return None;
            }
            let prev_state = task.state;
            let now = Utc::now();
            task.transition_to(TaskState::Failed, now).ok()?;
            if evidence.is_some() {
                task.status_reason = evidence;
            }
            (task.clone(), prev_state)
        };
        let ev = self.emit_task_updated(&task_clone, Some(prev_state));
        Some(ev)
    }

    pub fn task_finish_record(
        &mut self,
        task_id: &str,
        disposition: Disposition,
        finish_error: Option<Value>,
    ) -> Result<StoredEvent, (String, String)> {
        let task_clone = {
            let task = self.tasks.get_mut(task_id).ok_or_else(|| {
                (
                    code::NO_SUCH_TASK.to_string(),
                    format!("no such task '{task_id}'"),
                )
            })?;
            if task.disposition.outcome != DispositionOutcome::None {
                return Err((
                    code::BAD_PARAMS.to_string(),
                    "task disposition has already been recorded".to_string(),
                ));
            }
            task.disposition = disposition;
            task.finish_error = finish_error;
            task.updated_at = Utc::now();
            task.clone()
        };
        let ev = self.emit_task_updated(&task_clone, None);
        Ok(ev)
    }

    pub fn task_set_pr(
        &mut self,
        task_id: &str,
        pr: TaskPr,
    ) -> Result<StoredEvent, (String, String)> {
        let task_clone = {
            let task = self.tasks.get_mut(task_id).ok_or_else(|| {
                (
                    code::NO_SUCH_TASK.to_string(),
                    format!("no such task '{task_id}'"),
                )
            })?;
            task.pr = Some(pr);
            task.updated_at = Utc::now();
            task.clone()
        };
        let ev = self.emit_task_updated(&task_clone, None);
        Ok(ev)
    }

    /// Replace finish evidence and emit `task.updated` with `task_id`.
    /// `None` clears a previous cleanup or conflict note. Does not change
    /// disposition, so a recorded finish can retry cleanup.
    pub fn task_set_finish_error(
        &mut self,
        task_id: &str,
        finish_error: Option<Value>,
    ) -> Result<StoredEvent, (String, String)> {
        let task_clone = {
            let task = self.tasks.get_mut(task_id).ok_or_else(|| {
                (
                    code::NO_SUCH_TASK.to_string(),
                    format!("no such task '{task_id}'"),
                )
            })?;
            task.finish_error = finish_error;
            task.updated_at = Utc::now();
            task.clone()
        };
        Ok(self.emit_task_updated(&task_clone, None))
    }

    pub fn task_input_required_on_turn_end(
        &mut self,
        task_id: &str,
        evidence: Option<Value>,
    ) -> Option<StoredEvent> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id)?;
            let prev_state = task.state;
            let now = Utc::now();
            if !task.apply_turn_ended_without_report(evidence, now) {
                return None;
            }
            (task.clone(), prev_state)
        };
        let ev = self.emit_task_updated(&task_clone, Some(prev_state));
        Some(ev)
    }

    pub fn task_for_pane(&self, pane_id: &str) -> Option<&Task> {
        self.tasks
            .values()
            .find(|t| t.pane_id.as_deref() == Some(pane_id))
    }

    pub fn task_for_pane_mut(&mut self, pane_id: &str) -> Option<&mut Task> {
        self.tasks
            .values_mut()
            .find(|t| t.pane_id.as_deref() == Some(pane_id))
    }

    pub fn task_resume_working(&mut self, task_id: &str) -> Option<StoredEvent> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id)?;
            if task.state != TaskState::InputRequired {
                return None;
            }
            if Self::decision_evidence(&self.panes, task).is_some() {
                return None;
            }
            let now = Utc::now();
            // A follow-up whose Stop arrives before the resume commits must
            // land: refresh the turn-end evidence instead of going working.
            if let Some(evidence) = Self::turn_end_evidence(&self.panes, task) {
                task.status_reason = Some(evidence);
                task.updated_at = now;
                (task.clone(), None)
            } else {
                let prev_state = task.state;
                task.transition_to(TaskState::Working, now).ok()?;
                // The interrupt is resolved; its evidence is stale on a working task.
                task.status_reason = None;
                task.updated_at = now;
                (task.clone(), Some(prev_state))
            }
        };
        Some(self.emit_task_updated(&task_clone, prev_state))
    }

    /// A pending decision blocks the worker: `working` → `input_required`
    /// with the decision as evidence, or refreshes an existing interrupt's
    /// evidence. A `pending` task's first submit is still in flight; terminal
    /// tasks never move.
    pub fn task_input_required_on_decision(
        &mut self,
        task_id: &str,
        decision_id: &str,
    ) -> Option<StoredEvent> {
        let (task_clone, prev_state) = {
            let task = self.tasks.get_mut(task_id)?;
            if !matches!(task.state, TaskState::Working | TaskState::InputRequired) {
                return None;
            }
            let prev_state = task.state;
            let now = Utc::now();
            task.transition_to(TaskState::InputRequired, now).ok()?;
            task.status_reason = Some(json!({
                "reason": "decision_required",
                "decision_id": decision_id,
            }));
            task.updated_at = now;
            (task.clone(), prev_state)
        };
        Some(self.emit_task_updated(&task_clone, Some(prev_state)))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use signaltty_core::model::PtySize;

    fn pane_with(att: Attention, mins_ago: i64) -> Pane {
        let now = Utc::now();
        let mut p = Pane::new(
            "ws_1".into(),
            "tab_1".into(),
            "/tmp".into(),
            vec!["sh".into()],
            PtySize::default(),
            now,
        );
        p.lifecycle = Lifecycle::Working;
        p.attention = att;
        p.last_activity_at = now - chrono::Duration::minutes(mins_ago);
        p
    }

    #[test]
    fn replay_filters_before_cap_and_reports_incomplete_history() {
        let d = std::env::temp_dir().join(signaltty_core::ids::new_pane_id());
        let log = AuditLog::open(&d).unwrap();
        let sequence = EventSequence::open(&d, 0).unwrap();
        let (publisher, _) = tokio::sync::broadcast::channel(8);
        let mut store = Store::new();
        store.configure_events(log, sequence, publisher);
        for _ in 0..=crate::audit::REPLAY_CAP {
            store.emit("excluded", Value::Null);
        }
        store.next_seq(); // PTY numbers are legitimate holes.
        store.emit("included", Value::Null);
        let (coverage, events) = store.replay(0, &["included".into()], None);
        assert_eq!(coverage["status"], "complete");
        assert_eq!(events.len(), 1);
        let (coverage, events) = store.replay(0, &["*".into()], None);
        assert_eq!(coverage["status"], "truncated");
        assert!(events.is_empty());
        assert_eq!(
            store.replay(store.seq + 1, &["*".into()], None).0["status"],
            "cursor_ahead"
        );
        store.audit = None;
        assert_eq!(
            store.replay(0, &["*".into()], None).0["status"],
            "unavailable"
        );
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn replay_applies_task_filter_before_cap_and_count() {
        let d = std::env::temp_dir().join(signaltty_core::ids::new_pane_id());
        let log = AuditLog::open(&d).unwrap();
        let sequence = EventSequence::open(&d, 0).unwrap();
        let (publisher, _) = tokio::sync::broadcast::channel(8);
        let mut store = Store::new();
        store.configure_events(log, sequence, publisher);
        for _ in 0..=crate::audit::REPLAY_CAP {
            store.emit("task.updated", json!({"task_id": "other"}));
        }
        store.emit("task.updated", json!({"task_id": "mine"}));
        store.emit("pane.updated", Value::Null);
        let mine = vec!["mine".to_string()];
        let (coverage, events) = store.replay(0, &["*".into()], Some(&mine));
        assert_eq!(coverage["status"], "complete");
        assert_eq!(coverage["returned"], 2);
        assert_eq!(events.len(), 2);
        std::fs::remove_dir_all(d).unwrap();
    }

    #[test]
    fn screen_states_map_to_lifecycle_and_attention() {
        use signaltty_agent::screen::ScreenState::{Blocked, Idle, Working};
        use Attention::{Error, InputRequired, Unread};
        let none = Attention::None;
        // (from lifecycle, from attention, screen state, lifecycle, attention)
        let cases = [
            (Lifecycle::Unknown, none, Working, Lifecycle::Working, none),
            (
                Lifecycle::Unknown,
                none,
                Blocked,
                Lifecycle::Blocked,
                InputRequired,
            ),
            (Lifecycle::Working, none, Idle, Lifecycle::Done, Unread),
            // Leaving blocked withdraws the input_required it raised...
            (
                Lifecycle::Blocked,
                InputRequired,
                Idle,
                Lifecycle::Idle,
                none,
            ),
            (
                Lifecycle::Blocked,
                InputRequired,
                Working,
                Lifecycle::Working,
                none,
            ),
            // ...but never another signal's attention.
            (
                Lifecycle::Blocked,
                Error,
                Working,
                Lifecycle::Working,
                Error,
            ),
            (Lifecycle::Unknown, none, Idle, Lifecycle::Unknown, none),
            (Lifecycle::Done, none, Idle, Lifecycle::Done, none),
        ];
        for (from, from_attention, state, lifecycle, attention) in cases {
            let mut store = Store::new();
            let mut pane = pane_with(from_attention, 0);
            pane.lifecycle = from;
            let id = pane.id.clone();
            store.panes.insert(id.clone(), pane.clone());
            store.publish_pane(&pane, false);
            let events = store.apply_screen_state(&id, state);
            let p = &store.panes[&id];
            assert_eq!(
                (p.lifecycle, p.attention),
                (lifecycle, attention),
                "{from:?} + {state:?}"
            );
            assert_eq!(events.is_empty(), from == lifecycle, "{from:?} + {state:?}");
        }
    }

    #[test]
    fn an_answered_screen_prompt_still_ends_the_turn_unread() {
        use signaltty_agent::screen::ScreenState::{Blocked, Idle, Working};
        let mut store = Store::new();
        let mut pane = pane_with(Attention::None, 0);
        pane.lifecycle = Lifecycle::Unknown;
        let id = pane.id.clone();
        store.panes.insert(id.clone(), pane.clone());
        store.publish_pane(&pane, false);
        for state in [Blocked, Working, Idle] {
            store.apply_screen_state(&id, state);
        }
        let p = &store.panes[&id];
        assert_eq!(
            (p.lifecycle, p.attention),
            (Lifecycle::Done, Attention::Unread)
        );
    }

    #[test]
    fn a_hook_withdraws_only_screen_raised_attention() {
        use signaltty_agent::screen::ScreenState::Blocked;
        let mut store = Store::new();
        let mut pane = pane_with(Attention::None, 0);
        pane.lifecycle = Lifecycle::Unknown;
        let id = pane.id.clone();
        store.panes.insert(id.clone(), pane.clone());
        store.publish_pane(&pane, false);
        store.apply_screen_state(&id, Blocked);
        assert!(store.mark_hooked(&id).is_some());
        assert_eq!(store.panes[&id].attention, Attention::None);
        assert!(store.mark_hooked(&id).is_none());

        // input_required raised by anything else survives the takeover.
        store.publish_pane(&pane, true);
        store.raise_attention(&id, Attention::InputRequired);
        assert!(store.mark_hooked(&id).is_none());
        assert_eq!(store.panes[&id].attention, Attention::InputRequired);
    }

    #[test]
    fn a_hold_rule_changes_nothing() {
        use signaltty_agent::screen::ScreenState::{Blocked, Hold};
        for from in [Lifecycle::Working, Lifecycle::Blocked, Lifecycle::Unknown] {
            let mut store = Store::new();
            let mut pane = pane_with(Attention::None, 0);
            pane.lifecycle = from;
            let id = pane.id.clone();
            store.panes.insert(id.clone(), pane.clone());
            store.publish_pane(&pane, false);
            if from == Lifecycle::Blocked {
                store.panes.get_mut(&id).unwrap().lifecycle = Lifecycle::Unknown;
                store.apply_screen_state(&id, Blocked);
            }
            let before = (store.panes[&id].lifecycle, store.panes[&id].attention);
            assert!(store.apply_screen_state(&id, Hold).is_empty(), "{from:?}");
            assert_eq!(
                (store.panes[&id].lifecycle, store.panes[&id].attention),
                before
            );
        }
    }

    #[test]
    fn hooks_and_exit_stop_screen_states_until_a_new_process() {
        use signaltty_agent::screen::ScreenState::Working;
        let mut store = Store::new();
        let mut pane = pane_with(Attention::None, 0);
        pane.lifecycle = Lifecycle::Idle;
        let id = pane.id.clone();
        store.panes.insert(id.clone(), pane.clone());
        store.publish_pane(&pane, false);
        assert!(!store.is_hooked(&id));
        store.mark_hooked(&id);
        assert!(store.is_hooked(&id));
        assert!(store.apply_screen_state(&id, Working).is_empty());
        // A new process (spawn/resume) starts unhooked.
        store.publish_pane(&pane, true);
        assert!(!store.is_hooked(&id));
        assert!(!store.apply_screen_state(&id, Working).is_empty());
        store.set_exited(&id, Some(0));
        let exited = store.panes[&id].lifecycle;
        assert!(store.apply_screen_state(&id, Working).is_empty());
        assert_eq!(store.panes[&id].lifecycle, exited);
    }

    #[test]
    fn lifecycle_baseline_is_independent_of_attention_and_same_state_hooks() {
        let mut store = Store::new();
        let pane = pane_with(Attention::None, 0);
        let id = pane.id.clone();
        store.panes.insert(id.clone(), pane.clone());
        store.publish_pane(&pane, false);
        store.set_lifecycle(&id, Lifecycle::Done);
        let old = store.wait_baseline(&id).unwrap();
        store.raise_attention(&id, Attention::Unread);
        store.set_lifecycle(&id, Lifecycle::Done);
        let current = store.wait_baseline(&id).unwrap();
        assert_eq!(current.lifecycle_seq, old.lifecycle_seq);
        assert!(current.attention_seq > old.attention_seq);
        store.set_agent_session(&id, "one".into());
        assert_eq!(store.wait_baseline(&id).unwrap().session_generation, 0);
        store.set_agent_session(&id, "two".into());
        store.set_agent_session(&id, "one".into());
        assert_eq!(store.wait_baseline(&id).unwrap().session_generation, 2);
    }

    #[test]
    fn next_unread_orders_by_severity_then_recency() {
        let mut store = Store::new();
        let unread_old = pane_with(Attention::Unread, 10);
        let unread_new = pane_with(Attention::Unread, 1);
        let err = pane_with(Attention::Error, 30);
        let none = pane_with(Attention::None, 0);
        let ids = (unread_old.id.clone(), unread_new.id.clone(), err.id.clone());
        for p in [unread_old, unread_new, err, none] {
            store.panes.insert(p.id.clone(), p);
        }
        // Error wins despite age.
        assert_eq!(store.next_unread(), Some(ids.2.clone()));
        store.panes.get_mut(&ids.2).unwrap().attention = Attention::None;
        // Then newest unread.
        assert_eq!(store.next_unread(), Some(ids.1));
        assert_ne!(store.next_unread(), Some(ids.0.clone()));
    }

    #[test]
    fn raise_attention_emits_created_then_updated() {
        let mut store = Store::new();
        let p = pane_with(Attention::None, 0);
        let id = p.id.clone();
        store.panes.insert(id.clone(), p);
        let e1 = store.raise_attention(&id, Attention::Unread).unwrap();
        assert_eq!(e1.name, "attention.created");
        assert!(store.raise_attention(&id, Attention::Unread).is_none());
        let e2 = store.raise_attention(&id, Attention::Error).unwrap();
        assert_eq!(e2.name, "attention.updated");
    }

    #[test]
    fn set_lifecycle_emits_only_on_change() {
        let mut store = Store::new();
        let p = pane_with(Attention::None, 0);
        let id = p.id.clone();
        store.panes.insert(id.clone(), p);
        assert!(store.set_lifecycle(&id, Lifecycle::Working).is_none());
        let e = store.set_lifecycle(&id, Lifecycle::Done).unwrap();
        assert_eq!(e.name, "agent.done");
        assert!(store.set_lifecycle(&id, Lifecycle::Done).is_none());
    }

    #[test]
    fn decision_lifecycle_created_answered_cleared() {
        use signaltty_core::model::{Decision, DecisionOption};
        let mut store = Store::new();
        let p = pane_with(Attention::None, 0);
        let id = p.id.clone();
        store.panes.insert(id.clone(), p);
        let decision = |did: &str| Decision {
            id: did.to_string(),
            prompt: "Allow?".to_string(),
            options: vec![DecisionOption {
                id: "once".into(),
                label: "Once".into(),
            }],
            answerable: true,
            received_at: Utc::now(),
        };
        let e = store.set_decision(&id, decision("d1")).unwrap();
        assert_eq!(e.name, "decision.created");
        assert_eq!(e.payload["prev"], serde_json::Value::Null);
        // Supersede folds into one created carrying prev.
        let e = store.set_decision(&id, decision("d2")).unwrap();
        assert_eq!(e.payload["prev"], serde_json::json!("d1"));
        // Stale id never consumes.
        assert!(store.answer_decision(&id, "d1", "once").is_none());
        let e = store.answer_decision(&id, "d2", "once").unwrap();
        assert_eq!(e.name, "decision.answered");
        // Answered twice is a no-op (caller reports answered:false).
        assert!(store.answer_decision(&id, "d2", "once").is_none());
        assert!(store.clear_decision(&id, "test").is_none());
        // Clearing an absent decision on an unknown pane is a no-op.
        assert!(store.clear_decision("pane_nope", "test").is_none());
        store.set_decision(&id, decision("d3")).unwrap();
        let e = store.clear_decision(&id, "attention_cleared").unwrap();
        assert_eq!(e.name, "decision.cleared");
        assert_eq!(e.payload["reason"], serde_json::json!("attention_cleared"));
    }

    #[test]
    fn answered_decision_resumes_only_a_blocked_pane() {
        use signaltty_core::model::{Decision, DecisionOption};
        let decision = |did: &str| Decision {
            id: did.to_string(),
            prompt: "Allow?".to_string(),
            options: vec![DecisionOption {
                id: "once".into(),
                label: "Once".into(),
            }],
            answerable: true,
            received_at: Utc::now(),
        };
        let mut store = Store::new();
        let p = pane_with(Attention::None, 0);
        let id = p.id.clone();
        store.panes.insert(id.clone(), p);
        store.set_lifecycle(&id, Lifecycle::Blocked);
        store.set_decision(&id, decision("d1")).unwrap();
        // Stale answers and non-answer clears leave the pane blocked.
        assert!(store.answer_decision(&id, "stale", "once").is_none());
        assert_eq!(store.panes[&id].lifecycle, Lifecycle::Blocked);
        store.clear_decision(&id, "native_cancelled").unwrap();
        assert_eq!(store.panes[&id].lifecycle, Lifecycle::Blocked);
        // Consuming the answer is not delivery: the pane stays blocked until
        // the answer demonstrably landed.
        store.set_decision(&id, decision("d2")).unwrap();
        store.answer_decision(&id, "d2", "once").unwrap();
        assert_eq!(store.panes[&id].lifecycle, Lifecycle::Blocked);
        // Delivery success returns Blocked to Working.
        store.resume_after_answer(&id).unwrap();
        assert_eq!(store.panes[&id].lifecycle, Lifecycle::Working);
        // A newer decision that arrived during delivery keeps its own gate.
        store.set_lifecycle(&id, Lifecycle::Blocked);
        store.set_decision(&id, decision("d2b")).unwrap();
        assert!(store.resume_after_answer(&id).is_none());
        assert_eq!(store.panes[&id].lifecycle, Lifecycle::Blocked);
        store.clear_decision(&id, "moved_on").unwrap();
        // An unrelated lifecycle is never overwritten.
        store.set_lifecycle(&id, Lifecycle::Done);
        store.set_decision(&id, decision("d3")).unwrap();
        store.answer_decision(&id, "d3", "once").unwrap();
        assert!(store.resume_after_answer(&id).is_none());
        assert_eq!(store.panes[&id].lifecycle, Lifecycle::Done);
    }

    #[test]
    fn clear_attention_emits_only_when_set() {
        let mut store = Store::new();
        let p = pane_with(Attention::Unread, 0);
        let id = p.id.clone();
        store.panes.insert(id.clone(), p);
        let e = store.clear_attention(&id, "test").unwrap();
        assert_eq!(e.name, "attention.cleared");
        assert!(store.clear_attention(&id, "test").is_none());
        assert!(store.clear_attention("pane_nope", "test").is_none());
    }

    #[test]
    fn exit_demotes_input_attention_and_is_idempotent() {
        let mut store = Store::new();
        let p = pane_with(Attention::InputRequired, 0);
        let id = p.id.clone();
        store.panes.insert(id.clone(), p);
        store.set_exited(&id, Some(0));
        assert_eq!(store.panes[&id].attention, Attention::Unread);
        // A late PTY on_exit after a server-side close re-raises nothing.
        store.clear_attention(&id, "task_closed");
        store.set_exited(&id, Some(0));
        assert_eq!(store.panes[&id].attention, Attention::None);
    }

    #[test]
    fn generic_exit_maps_code_to_done_or_failed() {
        let mut store = Store::new();
        let mut ok = pane_with(Attention::None, 0);
        ok.agent.kind = AgentKind::Generic;
        ok.lifecycle = Lifecycle::Unknown;
        let ok_id = ok.id.clone();
        store.panes.insert(ok_id.clone(), ok);
        store.set_exited(&ok_id, Some(0));
        assert_eq!(store.panes[&ok_id].lifecycle, Lifecycle::Done);
        assert!(matches!(
            store.panes[&ok_id].live,
            signaltty_core::model::LiveState::Exited { code: Some(0) }
        ));

        let mut bad = pane_with(Attention::None, 0);
        bad.agent.kind = AgentKind::None;
        bad.lifecycle = Lifecycle::Unknown;
        let bad_id = bad.id.clone();
        store.panes.insert(bad_id.clone(), bad);
        store.set_exited(&bad_id, Some(7));
        assert_eq!(store.panes[&bad_id].lifecycle, Lifecycle::Failed);
    }

    #[test]
    fn known_agent_exit_keeps_exited_unless_already_terminal() {
        let mut store = Store::new();
        let mut working = pane_with(Attention::None, 0);
        working.agent.kind = AgentKind::Claude;
        working.lifecycle = Lifecycle::Working;
        let working_id = working.id.clone();
        store.panes.insert(working_id.clone(), working);
        store.set_exited(&working_id, Some(0));
        assert_eq!(store.panes[&working_id].lifecycle, Lifecycle::Exited);

        let mut done = pane_with(Attention::None, 0);
        done.agent.kind = AgentKind::Codex;
        done.lifecycle = Lifecycle::Done;
        let done_id = done.id.clone();
        store.panes.insert(done_id.clone(), done);
        store.set_exited(&done_id, Some(0));
        assert_eq!(store.panes[&done_id].lifecycle, Lifecycle::Done);
    }

    fn exit_after(
        kind: AgentKind,
        lifecycle: Lifecycle,
        attention: Attention,
        decision: bool,
    ) -> (Store, String) {
        let mut store = Store::new();
        let mut pane = pane_with(attention, 0);
        pane.agent.kind = kind;
        pane.lifecycle = lifecycle;
        if decision {
            pane.pending_decision = Some(signaltty_core::model::Decision {
                id: "d1".into(),
                prompt: "Allow?".into(),
                options: vec![signaltty_core::model::DecisionOption {
                    id: "once".into(),
                    label: "Once".into(),
                }],
                answerable: true,
                received_at: Utc::now(),
            });
        }
        let id = pane.id.clone();
        store.panes.insert(id.clone(), pane);
        store.set_exited(&id, Some(0));
        (store, id)
    }

    #[test]
    fn reviewed_terminal_exit_stays_reviewed() {
        for (kind, lifecycle) in [
            (AgentKind::Claude, Lifecycle::Done),
            (AgentKind::Codex, Lifecycle::Failed),
            (AgentKind::Generic, Lifecycle::Done),
        ] {
            let (store, id) = exit_after(kind, lifecycle, Attention::None, false);
            let pane = &store.panes[&id];
            assert_eq!(pane.attention, Attention::None, "{kind:?} {lifecycle:?}");
            assert_eq!(pane.lifecycle, lifecycle);
            assert!(matches!(
                pane.live,
                signaltty_core::model::LiveState::Exited { code: Some(0) }
            ));
            assert!(!store
                .events
                .iter()
                .any(|e| e.name == signaltty_proto::event::ATTENTION_CREATED));
        }
    }

    #[test]
    fn exit_still_alerts_when_nothing_was_reviewed() {
        // First completion of a one-shot, a working agent, an idle session,
        // an unreviewed outcome and a dropped decision all stay unread.
        for (kind, lifecycle, attention, decision) in [
            (
                AgentKind::Generic,
                Lifecycle::Unknown,
                Attention::None,
                false,
            ),
            (
                AgentKind::Claude,
                Lifecycle::Working,
                Attention::None,
                false,
            ),
            (AgentKind::Claude, Lifecycle::Idle, Attention::None, false),
            (AgentKind::Claude, Lifecycle::Done, Attention::Unread, false),
            (
                AgentKind::Claude,
                Lifecycle::Failed,
                Attention::Error,
                false,
            ),
            (AgentKind::Claude, Lifecycle::Done, Attention::None, true),
        ] {
            let (store, id) = exit_after(kind, lifecycle, attention, decision);
            let expected = attention.raise(Attention::Unread);
            assert_eq!(
                store.panes[&id].attention, expected,
                "{kind:?} {lifecycle:?} {attention:?} decision={decision}"
            );
            assert!(store.panes[&id].pending_decision.is_none());
        }
    }

    #[test]
    fn task_store_transition_methods_pair_mutate_and_emit() {
        use signaltty_core::model::{Contract, Relationship, TaskResultStatus};
        use std::path::PathBuf;

        let mut store = Store::new();
        let now = Utc::now();
        let contract = Contract::new("Do work").unwrap();
        let task_id = signaltty_core::ids::new_task_id();
        let task = Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: Some("p1".to_string()),
            parent_pane_id: None,
            root_pane_id: None,
            relationship: Relationship::Subagent,
            label: "store-test".to_string(),
            contract,
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: Some("main".to_string()),
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-1".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "1234abcd".to_string(),
            state: TaskState::Pending,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: None,
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: now,
            updated_at: now,
        };

        // 1. Create
        let ev = store.task_create(task);
        assert_eq!(ev.name, "task.created");
        assert_eq!(ev.payload["task_id"], task_id.as_str());
        assert_eq!(
            ev.payload["context_id"],
            store.tasks[&task_id].context_id.as_str()
        );
        assert!(store.tasks.contains_key(&task_id));

        // 2. Background ready
        let ev = store.task_background_ready(&task_id).unwrap();
        assert_eq!(ev.name, "task.updated");
        assert_eq!(store.tasks[&task_id].state, TaskState::Working);

        // 3. Turn end without report
        let ev = store
            .task_input_required_on_turn_end(
                &task_id,
                Some(serde_json::json!({"reason": "turn_ended_without_report"})),
            )
            .unwrap();
        assert_eq!(ev.name, "task.updated");
        assert_eq!(store.tasks[&task_id].state, TaskState::InputRequired);

        // 4. Report
        let result = TaskResult {
            status: TaskResultStatus::Completed,
            summary: "Finished".to_string(),
            artifacts: vec![],
            evidence: None,
            reported_at: Utc::now(),
        };
        let (res_ev, upd_ev) = store.task_report(&task_id, result).unwrap();
        assert_eq!(res_ev.name, "task.result");
        assert_eq!(upd_ev.name, "task.updated");
        assert_eq!(store.tasks[&task_id].state, TaskState::Completed);

        // Report on terminal task is refused
        assert!(store
            .task_report(
                &task_id,
                TaskResult {
                    status: TaskResultStatus::Completed,
                    summary: "Again".to_string(),
                    artifacts: vec![],
                    evidence: None,
                    reported_at: Utc::now(),
                }
            )
            .is_err());

        // Cancel on terminal task is refused
        assert!(store.task_cancel(&task_id).is_err());

        // 5. Finish record
        let disp = Disposition {
            outcome: DispositionOutcome::Merged,
            target_ref: Some("main".to_string()),
            merged_sha: Some("sha".to_string()),
            branch_deleted: Some(true),
            at: Some(Utc::now()),
        };
        let ev = store
            .task_finish_record(
                &task_id,
                disp.clone(),
                Some(json!({ "cleanup_error": "left in place" })),
            )
            .unwrap();
        assert_eq!(ev.name, "task.updated");
        assert_eq!(ev.payload["task_id"], task_id);
        assert_eq!(
            store.tasks[&task_id].disposition.outcome,
            DispositionOutcome::Merged
        );
        assert_eq!(
            store.tasks[&task_id].finish_error.as_ref().unwrap()["cleanup_error"],
            "left in place"
        );

        // Second finish record refused
        assert!(store.task_finish_record(&task_id, disp, None).is_err());
        // Cleanup evidence can still be replaced so a later finish can retry.
        store.task_set_finish_error(&task_id, None).unwrap();
        assert!(store.tasks[&task_id].finish_error.is_none());
    }

    #[test]
    fn clear_decision_never_resumes_task_for_non_answer_reasons() {
        use signaltty_core::model::{Contract, Decision, DecisionOption, Relationship};
        use std::path::PathBuf;

        let mut store = Store::new();
        let pane = pane_with(Attention::None, 0);
        let pane_id = pane.id.clone();
        store.panes.insert(pane_id.clone(), pane);
        let decision = || Decision {
            id: "d1".to_string(),
            prompt: "Allow?".to_string(),
            options: vec![DecisionOption {
                id: "once".into(),
                label: "Once".into(),
            }],
            answerable: true,
            received_at: Utc::now(),
        };
        let task_id = signaltty_core::ids::new_task_id();
        let task = Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: Some(pane_id.clone()),
            parent_pane_id: None,
            root_pane_id: None,
            relationship: Relationship::Subagent,
            label: "decision-test".to_string(),
            contract: Contract::new("Objective").unwrap(),
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: None,
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-d".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "sha".to_string(),
            state: TaskState::InputRequired,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: Some(serde_json::json!({"reason": "decision_required"})),
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        store.task_create(task);

        // Non-answer drops (timeout, disconnect, turn end) must park the
        // task in input_required, never resume it to working unanswered.
        for reason in [
            "native_cancelled",
            "timeout",
            "moved_on",
            "attention_cleared",
        ] {
            store.set_decision(&pane_id, decision());
            store.clear_decision(&pane_id, reason);
            assert_eq!(
                store.tasks[&task_id].state,
                TaskState::InputRequired,
                "clear_decision({reason}) must not resume the task"
            );
        }
        // The answer path resumes once delivery succeeded, not on consume.
        store.set_decision(&pane_id, decision());
        store.answer_decision(&pane_id, "d1", "once");
        assert_eq!(store.tasks[&task_id].state, TaskState::InputRequired);
        store.resume_after_answer(&pane_id);
        assert_eq!(store.tasks[&task_id].state, TaskState::Working);
        // A fast permission hook can settle a follow-up before its submit
        // activity gate resumes the task. That gate must preserve the decision.
        store.task_input_required_on_turn_end(
            &task_id,
            Some(json!({"reason": "turn_ended_without_report"})),
        );
        store.set_decision(&pane_id, decision());
        store.task_resume_working(&task_id);
        assert_eq!(store.tasks[&task_id].state, TaskState::InputRequired);
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(json!({"reason": "decision_required", "decision_id": "d1"}))
        );
        store.answer_decision(&pane_id, "d1", "once");
        store.resume_after_answer(&pane_id);
        assert_eq!(store.tasks[&task_id].state, TaskState::Working);
    }

    #[test]
    fn background_ready_applies_turn_end_when_pane_already_done() {
        use signaltty_core::model::{Contract, Relationship};
        use std::path::PathBuf;

        let mut store = Store::new();
        let mut pane = pane_with(Attention::None, 0);
        pane.lifecycle = Lifecycle::Done;
        pane.last_message = Some("half an answer".to_string());
        let pane_id = pane.id.clone();
        store.panes.insert(pane_id.clone(), pane);
        let task_id = signaltty_core::ids::new_task_id();
        let mk = |state: TaskState| Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: Some(pane_id.clone()),
            parent_pane_id: None,
            root_pane_id: None,
            relationship: Relationship::Subagent,
            label: "turn-end".to_string(),
            contract: Contract::new("Objective").unwrap(),
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: None,
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-t".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "sha".to_string(),
            state,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: None,
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        // Stop landed while the task was still pending: entering working
        // must apply turn_ended_without_report in the same write.
        store.task_create(mk(TaskState::Pending));
        store.task_background_ready(&task_id).unwrap();
        assert_eq!(store.tasks[&task_id].state, TaskState::InputRequired);
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(serde_json::json!({
                "reason": "turn_ended_without_report",
                "last_message": "half an answer",
            }))
        );
        // A follow-up whose Stop arrives before the resume lands back in
        // input_required with fresh turn-end evidence, not working.
        store.tasks.get_mut(&task_id).unwrap().state = TaskState::InputRequired;
        store.task_resume_working(&task_id).unwrap();
        assert_eq!(store.tasks[&task_id].state, TaskState::InputRequired);
        assert_eq!(
            store.tasks[&task_id]
                .status_reason
                .as_ref()
                .and_then(|v| v.get("reason")),
            Some(&serde_json::json!("turn_ended_without_report"))
        );
    }

    #[test]
    fn background_ready_stays_working_when_pane_idle_or_live() {
        use signaltty_core::model::{Contract, Relationship};
        use std::path::PathBuf;

        // Idle is not turn end: a task entering working with an idle pane
        // stays working.
        let mut store = Store::new();
        let mut pane = pane_with(Attention::None, 0);
        pane.lifecycle = Lifecycle::Idle;
        let pane_id = pane.id.clone();
        store.panes.insert(pane_id.clone(), pane);
        let task_id = signaltty_core::ids::new_task_id();
        store.task_create(Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: Some(pane_id),
            parent_pane_id: None,
            root_pane_id: None,
            relationship: Relationship::Subagent,
            label: "idle".to_string(),
            contract: Contract::new("Objective").unwrap(),
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: None,
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-i".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "sha".to_string(),
            state: TaskState::Pending,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: None,
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        });
        store.task_background_ready(&task_id).unwrap();
        assert_eq!(store.tasks[&task_id].state, TaskState::Working);
        // Replay a first submit whose permission arrives before the background
        // commit: set_decision cannot yet transition the pending task.
        let pending = Task {
            state: TaskState::Pending,
            ..store.tasks[&task_id].clone()
        };
        store.task_create(pending);
        let pane_id = store.tasks[&task_id].pane_id.clone().unwrap();
        store.set_lifecycle(&pane_id, Lifecycle::Blocked);
        store.set_decision(
            &pane_id,
            signaltty_core::model::Decision {
                id: "first-decision".into(),
                prompt: "Allow?".into(),
                options: vec![],
                answerable: false,
                received_at: Utc::now(),
            },
        );
        store.task_background_ready(&task_id).unwrap();
        assert_eq!(store.tasks[&task_id].state, TaskState::InputRequired);
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(json!({"reason": "decision_required", "decision_id": "first-decision"}))
        );
    }

    #[test]
    fn pane_exit_fails_non_terminal_task() {
        use signaltty_core::model::{Contract, Relationship};
        use std::path::PathBuf;

        let mut store = Store::new();
        let pane = pane_with(Attention::None, 0);
        let pane_id = pane.id.clone();
        store.panes.insert(pane_id.clone(), pane);

        let task_id = signaltty_core::ids::new_task_id();
        let task = Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: Some(pane_id.clone()),
            parent_pane_id: None,
            root_pane_id: None,
            relationship: Relationship::Subagent,
            label: "exit-test".to_string(),
            contract: Contract::new("Objective").unwrap(),
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: None,
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-b".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "sha".to_string(),
            state: TaskState::Working,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: None,
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        store.task_create(task);

        // Pane exits with code 1
        store.set_exited(&pane_id, Some(1));
        assert_eq!(store.tasks[&task_id].state, TaskState::Failed);
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(serde_json::json!({"reason": "pane_exited", "exit_code": 1}))
        );
        // Exit repeated (close-after-exit): no second transition, reason stable.
        store.set_exited(&pane_id, Some(1));
        assert_eq!(store.tasks[&task_id].state, TaskState::Failed);
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(serde_json::json!({"reason": "pane_exited", "exit_code": 1}))
        );
    }

    #[test]
    fn pane_close_fails_non_terminal_task_exactly_once() {
        use signaltty_core::model::{Contract, Relationship};
        use std::path::PathBuf;

        let mut store = Store::new();
        let pane = pane_with(Attention::None, 0);
        let pane_id = pane.id.clone();
        store.panes.insert(pane_id.clone(), pane);

        let task_id = signaltty_core::ids::new_task_id();
        let task = Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: Some(pane_id.clone()),
            parent_pane_id: None,
            root_pane_id: None,
            relationship: Relationship::Subagent,
            label: "close-test".to_string(),
            contract: Contract::new("Objective").unwrap(),
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: None,
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-b".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "sha".to_string(),
            state: TaskState::Working,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: None,
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: Utc::now(),
            updated_at: Utc::now(),
        };
        store.task_create(task);

        // Operator close fails the task once; exit racing after close is silent.
        store.remove_pane(&pane_id);
        assert_eq!(store.tasks[&task_id].state, TaskState::Failed);
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(serde_json::json!({"reason": "pane_closed"}))
        );
        store.set_exited(&pane_id, Some(1));
        assert_eq!(
            store.tasks[&task_id].status_reason,
            Some(serde_json::json!({"reason": "pane_closed"}))
        );
    }

    #[test]
    fn test_task_set_pr() {
        use std::path::PathBuf;
        let mut store = Store::new();
        let task_id = signaltty_core::ids::new_task_id();
        let pr = TaskPr {
            number: 123,
            url: "https://github.com/foo/bar/pull/123".to_string(),
            state: signaltty_core::model::PrState::Open,
            checks: signaltty_core::model::PrChecks::Pending,
            review: signaltty_core::model::PrReview::ReviewRequired,
            checked_at: None,
        };

        // Non-existent task
        assert_eq!(
            store.task_set_pr("non_existent", pr.clone()).unwrap_err().0,
            code::NO_SUCH_TASK
        );

        let now = Utc::now();
        let task = Task {
            id: task_id.clone(),
            context_id: signaltty_core::ids::new_context_id(),
            parent_task_id: None,
            pane_id: None,
            parent_pane_id: None,
            root_pane_id: None,
            relationship: signaltty_core::model::Relationship::Subagent,
            label: "test".to_string(),
            contract: signaltty_core::model::Contract::new("objective").unwrap(),
            agent: None,
            source_repo: PathBuf::from("/tmp/repo"),
            target_branch: Some("main".to_string()),
            worktree_path: PathBuf::from("/tmp/wt"),
            branch: "task-1".to_string(),
            preexisting_branch: false,
            base_ref: "main".to_string(),
            base_sha: "1234abcd".to_string(),
            state: TaskState::Completed,
            result: None,
            disposition: Disposition::default(),
            pr: None,
            status_reason: None,
            finish_error: None,
            worker_pid: None,
            worker_cmd: None,
            client_request_id: None,
            created_at: now,
            updated_at: now,
        };
        store.task_create(task);

        let ev = store.task_set_pr(&task_id, pr.clone()).unwrap();
        assert_eq!(ev.name, "task.updated");
        assert_eq!(ev.payload["task"]["pr"]["number"], 123);
        assert_eq!(store.tasks[&task_id].pr, Some(pr));
    }
}