aboutsummaryrefslogtreecommitdiff
path: root/src/terminal
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:49:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-24 02:50:58 +0300
commite608a75159fc5cca70485d83ffb8ee681c9da385 (patch)
tree5ffdec62d0bee9a798418d6414ecbddae7ff8ae5 /src/terminal
parent169a4fd90c20a00aec77d684ecec98b9f0f996cc (diff)
fix: make known-agent exit process-owned
refs #1792 refs #1648
Diffstat (limited to 'src/terminal')
-rw-r--r--src/terminal/metadata.rs115
-rw-r--r--src/terminal/state.rs1899
2 files changed, 1233 insertions, 781 deletions
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs
index 27ac8528..2a912b27 100644
--- a/src/terminal/metadata.rs
+++ b/src/terminal/metadata.rs
@@ -58,11 +58,47 @@ impl TerminalState {
crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq)
}
+ pub(crate) fn metadata_report_agent(
+ source: &str,
+ agent_label: Option<&str>,
+ applies_to_source: Option<&str>,
+ ) -> Option<crate::detect::Agent> {
+ agent_label
+ .and_then(crate::detect::parse_agent_label)
+ .or_else(|| {
+ crate::detect::Agent::ALL.iter().copied().find(|agent| {
+ let agent_label = crate::detect::agent_label(*agent);
+ crate::agent_resume::is_official_agent_source(source, agent_label)
+ || applies_to_source.is_some_and(|source| {
+ crate::agent_resume::is_official_agent_source(source, agent_label)
+ })
+ })
+ })
+ }
+
+ pub(crate) fn metadata_report_blocked_by_process_exit(
+ &self,
+ source: &str,
+ agent_label: Option<&str>,
+ applies_to_source: Option<&str>,
+ ) -> bool {
+ let Some(exit) = self.recent_agent_process_exit else {
+ return false;
+ };
+ let exited_agent_label = crate::detect::agent_label(exit.agent);
+ agent_label.and_then(crate::detect::parse_agent_label) == Some(exit.agent)
+ || crate::agent_resume::is_official_agent_source(source, exited_agent_label)
+ || applies_to_source.is_some_and(|source| {
+ crate::agent_resume::is_official_agent_source(source, exited_agent_label)
+ })
+ }
+
pub(crate) fn accept_metadata_report(
&mut self,
source: &str,
seq: Option<u64>,
includes_tokens: bool,
+ agent: Option<crate::detect::Agent>,
) -> Result<bool, ()> {
let Some(seq) = seq else {
return Ok(true);
@@ -79,6 +115,10 @@ impl TerminalState {
}
self.metadata_report_sequences
.insert(source.to_string(), seq);
+ if let Some(agent) = agent {
+ self.metadata_report_agents
+ .insert(source.to_string(), agent);
+ }
if includes_tokens {
self.metadata_token_sequence_sources
.insert(source.to_string());
@@ -105,8 +145,20 @@ impl TerminalState {
&mut self,
report: AgentMetadataReport,
) -> Option<TerminalStateMutation> {
+ if self.metadata_report_blocked_by_process_exit(
+ &report.source,
+ report.agent_label.as_deref(),
+ report.applies_to_source.as_deref(),
+ ) {
+ return None;
+ }
+ let report_agent = Self::metadata_report_agent(
+ &report.source,
+ report.agent_label.as_deref(),
+ report.applies_to_source.as_deref(),
+ );
if !matches!(
- self.accept_metadata_report(&report.source, report.seq, false),
+ self.accept_metadata_report(&report.source, report.seq, false, report_agent),
Ok(true)
) {
return None;
@@ -482,13 +534,13 @@ mod tests {
let mut terminal = test_terminal();
for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
assert_eq!(
- terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false),
+ terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false, None,),
Ok(true)
);
}
for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
assert_eq!(
- terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true),
+ terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true, None,),
Ok(true)
);
}
@@ -497,12 +549,69 @@ mod tests {
&format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES),
Some(2),
true,
+ None,
),
Err(())
);
}
#[test]
+ fn custom_metadata_reanchors_sequence_after_process_restart() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ let report = |seq, ttl| AgentMetadataReport {
+ source: "custom:pi-metadata".into(),
+ agent_label: Some("pi".into()),
+ applies_to_source: None,
+ title: Some("Pi task".into()),
+ display_agent: None,
+ state_labels: HashMap::new(),
+ clear_title: false,
+ clear_display_agent: false,
+ clear_state_labels: false,
+ ttl,
+ seq: Some(seq),
+ };
+ assert!(terminal
+ .set_agent_metadata(report(100, Some(Duration::ZERO)))
+ .is_some());
+ let deadline = terminal.next_agent_metadata_expiry().unwrap();
+ terminal.expire_agent_metadata_at(deadline, deadline);
+ assert!(terminal.agent_metadata.is_empty());
+ let exit_at = Instant::now() + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ exit_at,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ exit_at + Duration::from_millis(1),
+ );
+ assert!(terminal.set_agent_metadata(report(1, None)).is_none());
+
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ exit_at + Duration::from_millis(2),
+ );
+ assert!(terminal.set_agent_metadata(report(1, None)).is_some());
+ }
+
+ #[test]
fn user_agent_metadata_overrides_presentation_fields_only() {
let mut terminal = test_terminal();
terminal.set_hook_authority(
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 38e4739e..4485c773 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -30,6 +30,14 @@ struct SuppressedFullLifecycleHookReport {
session_ref: Option<crate::agent_resume::AgentSessionRef>,
observed_at: Instant,
reason: FullLifecycleHookSuppressionReason,
+ replacement_session_ref: Option<crate::agent_resume::AgentSessionRef>,
+ pending_replacement_report: Option<PendingFullLifecycleHookReport>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct PendingFullLifecycleHookReport {
+ authority: HookAuthority,
+ seq: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -38,6 +46,12 @@ enum FullLifecycleHookSuppressionReason {
ProcessExit,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum FullLifecycleHookReportRoute {
+ Accept { reanchor_sequence: bool },
+ Ignore,
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
struct StaleFullLifecycleHookSession {
agent_label: String,
@@ -91,6 +105,12 @@ struct AgentNameOwner {
session_ref: Option<crate::agent_resume::AgentSessionRef>,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct RecentAgentProcessExit {
+ agent: Agent,
+ observed_at: Instant,
+}
+
/// Pure state for a server-owned terminal.
///
/// During the migration this is still one-to-one with a pane-backed PTY, but
@@ -116,13 +136,14 @@ pub struct TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
stale_full_lifecycle_hook_sessions: HashMap<String, Vec<StaleFullLifecycleHookSession>>,
metadata_report_sequences: HashMap<String, u64>,
+ metadata_report_agents: HashMap<String, Agent>,
metadata_token_sequence_sources: std::collections::HashSet<String>,
pub state: AgentState,
pub last_agent_state_change_seq: Option<u64>,
pub revision: u64,
pub launch_argv: Option<Vec<String>>,
pub respawn_shell_on_exit: bool,
- recent_agent_process_exit_at: Option<Instant>,
+ recent_agent_process_exit: Option<RecentAgentProcessExit>,
pub pending_agent_resume_plan: Option<crate::agent_resume::AgentResumePlan>,
}
@@ -148,13 +169,14 @@ impl TerminalState {
suppressed_full_lifecycle_hook_reports: HashMap::new(),
stale_full_lifecycle_hook_sessions: HashMap::new(),
metadata_report_sequences: HashMap::new(),
+ metadata_report_agents: HashMap::new(),
metadata_token_sequence_sources: std::collections::HashSet::new(),
state: AgentState::Unknown,
last_agent_state_change_seq: None,
revision: 0,
launch_argv: None,
respawn_shell_on_exit: false,
- recent_agent_process_exit_at: None,
+ recent_agent_process_exit: None,
pending_agent_resume_plan: None,
}
}
@@ -193,8 +215,8 @@ impl TerminalState {
#[cfg(any(windows, test))]
pub(crate) fn agent_process_exited_within(&self, now: Instant, max_age: Duration) -> bool {
- self.recent_agent_process_exit_at
- .is_some_and(|exited_at| now.saturating_duration_since(exited_at) <= max_age)
+ self.recent_agent_process_exit
+ .is_some_and(|exit| now.saturating_duration_since(exit.observed_at) <= max_age)
}
pub fn with_pending_agent_resume_plan(
@@ -268,8 +290,17 @@ impl TerminalState {
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_detected_agent = self.detected_agent;
let previous_session = self.current_session_identity_for_persistence();
+ let newer_custom_authority = process_exited
+ && self.hook_authority.as_ref().is_some_and(|authority| {
+ crate::detect::parse_agent_label(&authority.agent_label) == agent
+ && !crate::agent_resume::is_official_agent_source(
+ &authority.source,
+ &authority.agent_label,
+ )
+ && authority.reported_at > now
+ });
let agent_released = process_exited
- && self.hook_authority_not_newer_than(now)
+ && !newer_custom_authority
&& (previous_agent_label.is_some() || self.agent_name.is_some());
if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) {
if self
@@ -293,6 +324,11 @@ impl TerminalState {
agent_released: false,
};
}
+ let replacement_process_detected = !process_exited
+ && agent.is_some()
+ && self
+ .recent_agent_process_exit
+ .is_some_and(|exit| Some(exit.agent) == agent && exit.observed_at < now);
if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) {
return TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
@@ -314,43 +350,168 @@ impl TerminalState {
}
if !process_exited {
self.clear_full_lifecycle_hook_suppression_for_detected_agent(
- previous_detected_agent,
+ if replacement_process_detected {
+ None
+ } else {
+ previous_detected_agent
+ },
agent,
);
}
self.fallback_state = fallback_state;
self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked;
self.fallback_observed_at = Some(now);
- if process_exited && agent.is_some() {
- self.recent_agent_process_exit_at = Some(now);
+ if process_exited {
+ if let Some(agent) = agent {
+ self.recent_agent_process_exit = Some(RecentAgentProcessExit {
+ agent,
+ observed_at: now,
+ });
+ }
} else if agent.is_some() {
- self.recent_agent_process_exit_at = None;
+ self.recent_agent_process_exit = None;
}
- if process_exited
- && self.hook_authority_not_newer_than(now)
- && self.hook_authority.as_ref().is_some_and(|authority| {
- crate::detect::parse_agent_label(&authority.agent_label) == agent
- })
- {
- let cleared_source = self
+ if process_exited {
+ let mut reset_sources = Vec::new();
+ let mut stale_sessions = Vec::new();
+ for (source, suppressed) in &mut self.suppressed_full_lifecycle_hook_reports {
+ if crate::detect::parse_agent_label(&suppressed.agent_label) != agent
+ || suppressed.reason == FullLifecycleHookSuppressionReason::HookClear
+ {
+ continue;
+ }
+ let exited_session_ref = suppressed
+ .replacement_session_ref
+ .take()
+ .or_else(|| {
+ suppressed
+ .pending_replacement_report
+ .as_ref()
+ .and_then(|pending| pending.authority.session_ref.clone())
+ })
+ .or_else(|| suppressed.session_ref.clone());
+ if let (Some(previous), Some(exited)) =
+ (suppressed.session_ref.as_ref(), exited_session_ref.as_ref())
+ {
+ if previous != exited {
+ stale_sessions.push((
+ source.clone(),
+ suppressed.agent_label.clone(),
+ previous.clone(),
+ ));
+ }
+ }
+ suppressed.session_ref = exited_session_ref;
+ suppressed.pending_replacement_report = None;
+ suppressed.observed_at = now;
+ reset_sources.push(source.clone());
+ }
+ for (source, agent_label, session_ref) in stale_sessions {
+ self.remember_stale_full_lifecycle_hook_session(source, agent_label, session_ref);
+ }
+ for source in reset_sources {
+ self.hook_report_sequences.remove(&source);
+ }
+
+ let official_session = self
.hook_authority
.as_ref()
- .map(|authority| authority.source.clone());
- self.suppress_current_full_lifecycle_hook_authority(
- FullLifecycleHookSuppressionReason::ProcessExit,
- );
- if let Some(source) = cleared_source {
+ .filter(|authority| {
+ crate::agent_resume::is_official_agent_source(
+ &authority.source,
+ &authority.agent_label,
+ ) && crate::detect::parse_agent_label(&authority.agent_label) == agent
+ })
+ .map(|authority| {
+ (
+ authority.source.clone(),
+ authority.agent_label.clone(),
+ authority.session_ref.clone(),
+ )
+ })
+ .or_else(|| {
+ self.persisted_agent_session.as_ref().and_then(|session| {
+ (crate::agent_resume::is_official_agent_source(
+ &session.source,
+ &session.agent,
+ ) && crate::detect::parse_agent_label(&session.agent) == agent)
+ .then(|| {
+ (
+ session.source.clone(),
+ session.agent.clone(),
+ Some(session.session_ref.clone()),
+ )
+ })
+ })
+ });
+ if let Some((source, agent_label, session_ref)) = official_session {
self.hook_report_sequences.remove(&source);
+ self.suppress_full_lifecycle_hook_report_with_session_ref(
+ source,
+ agent_label,
+ session_ref,
+ FullLifecycleHookSuppressionReason::ProcessExit,
+ now,
+ );
+ }
+ let cleared_hook_source = self.hook_authority.as_ref().and_then(|authority| {
+ (crate::detect::parse_agent_label(&authority.agent_label) == agent
+ && !newer_custom_authority)
+ .then(|| authority.source.clone())
+ });
+ if let Some(source) = cleared_hook_source {
+ self.hook_report_sequences.remove(&source);
+ self.hook_authority = None;
+ }
+ if !newer_custom_authority
+ && self
+ .persisted_agent_session
+ .as_ref()
+ .is_some_and(|session| {
+ crate::detect::parse_agent_label(&session.agent) == agent
+ })
+ {
+ self.persisted_agent_session = None;
+ }
+ if let Some(agent) = agent {
+ let agent_label = crate::detect::agent_label(agent);
+ let mut cleared_metadata_sources = Vec::new();
+ self.agent_metadata.retain(|source, metadata| {
+ let official_metadata = crate::agent_resume::is_official_agent_source(
+ &metadata.source,
+ agent_label,
+ ) || metadata.applies_to_source.as_deref().is_some_and(
+ |applies_to| {
+ crate::agent_resume::is_official_agent_source(applies_to, agent_label)
+ },
+ );
+ let matches_agent =
+ metadata.agent_label.as_deref() == Some(agent_label) || official_metadata;
+ let clear = matches_agent && (official_metadata || metadata.reported_at <= now);
+ if clear {
+ cleared_metadata_sources.push(source.clone());
+ }
+ !clear
+ });
+ for source in cleared_metadata_sources {
+ self.metadata_report_sequences.remove(&source);
+ self.metadata_report_agents.remove(&source);
+ self.metadata_token_sequence_sources.remove(&source);
+ }
+ let mut exited_generation_sources = Vec::new();
+ self.metadata_report_agents.retain(|source, owner| {
+ if *owner == agent {
+ exited_generation_sources.push(source.clone());
+ false
+ } else {
+ true
+ }
+ });
+ for source in exited_generation_sources {
+ self.metadata_report_sequences.remove(&source);
+ self.metadata_token_sequence_sources.remove(&source);
+ }
}
- self.hook_authority = None;
- }
- if process_exited
- && self
- .persisted_agent_session
- .as_ref()
- .is_some_and(|session| crate::detect::parse_agent_label(&session.agent) == agent)
- {
- self.persisted_agent_session = None;
}
if self.hook_authority_not_newer_than(now)
&& (self.hook_authority_conflicts_with_detected_agent(agent)
@@ -444,26 +605,25 @@ impl TerminalState {
seq: Option<u64>,
now: Instant,
) -> Option<TerminalStateMutation> {
- if self.full_lifecycle_hook_report_is_suppressed(&source, &agent_label, &session_ref) {
+ if !crate::detect::full_lifecycle_hook_authority(&source, &agent_label)
+ && self.recent_agent_process_exit.is_some_and(|exit| {
+ crate::detect::parse_agent_label(&agent_label) == Some(exit.agent)
+ })
+ {
return None;
}
- if self.full_lifecycle_hook_report_matches_stale_session(
+ let reanchor_sequence = match self.route_full_lifecycle_hook_report(
&source,
&agent_label,
+ state,
+ message.as_deref(),
&session_ref,
+ seq,
+ now,
) {
- return None;
- }
- let reanchor_sequence =
- self.full_lifecycle_hook_report_has_fresh_session_after_suppression(
- &source,
- &agent_label,
- &session_ref,
- ) || self.full_lifecycle_hook_report_has_fresh_session_after_stale_session(
- &source,
- &agent_label,
- &session_ref,
- );
+ FullLifecycleHookReportRoute::Accept { reanchor_sequence } => reanchor_sequence,
+ FullLifecycleHookReportRoute::Ignore => return None,
+ };
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
return None;
}
@@ -514,7 +674,7 @@ impl TerminalState {
FullLifecycleHookSuppressionReason::HookClear,
);
}
- if session_ref.is_some() {
+ if session_ref.is_some() || reanchor_sequence {
if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.remove(&source) {
if let Some(suppressed_ref) = suppressed.session_ref {
self.remember_stale_full_lifecycle_hook_session(
@@ -611,6 +771,7 @@ impl TerminalState {
agent_label,
session_ref,
reason,
+ Instant::now(),
);
}
}
@@ -631,6 +792,7 @@ impl TerminalState {
agent_label.to_string(),
session_ref,
reason,
+ Instant::now(),
);
}
}
@@ -641,65 +803,143 @@ impl TerminalState {
agent_label: String,
session_ref: Option<crate::agent_resume::AgentSessionRef>,
reason: FullLifecycleHookSuppressionReason,
+ observed_at: Instant,
) {
self.suppressed_full_lifecycle_hook_reports.insert(
source,
SuppressedFullLifecycleHookReport {
agent_label,
session_ref,
- observed_at: Instant::now(),
+ observed_at,
reason,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
},
);
}
- fn full_lifecycle_hook_report_is_suppressed(
- &self,
+ fn route_full_lifecycle_hook_report(
+ &mut self,
source: &str,
agent_label: &str,
+ state: AgentState,
+ message: Option<&str>,
session_ref: &Option<crate::agent_resume::AgentSessionRef>,
- ) -> bool {
+ seq: Option<u64>,
+ reported_at: Instant,
+ ) -> FullLifecycleHookReportRoute {
if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
- return false;
+ return FullLifecycleHookReportRoute::Accept {
+ reanchor_sequence: false,
+ };
}
- self.suppressed_full_lifecycle_hook_reports
- .get(source)
- .is_some_and(|suppressed| {
- if suppressed.agent_label != agent_label {
- return false;
- }
- if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit {
- return true;
- }
- match (&suppressed.session_ref, session_ref) {
- (Some(suppressed_ref), Some(incoming_ref)) => incoming_ref == suppressed_ref,
- (Some(_), None) => true,
- (None, Some(_)) => false,
- (None, None) => true,
- }
+ if self.full_lifecycle_hook_report_matches_stale_session(source, agent_label, session_ref) {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
+
+ let known_agent = crate::detect::parse_agent_label(agent_label);
+ let process_present = known_agent.is_some()
+ && self.detected_agent == known_agent
+ && self.recent_agent_process_exit.is_none();
+ let session_anchored = self
+ .hook_authority
+ .as_ref()
+ .filter(|authority| authority.source == source && authority.agent_label == agent_label)
+ .and_then(|authority| authority.session_ref.as_ref())
+ .or_else(|| {
+ self.persisted_agent_session
+ .as_ref()
+ .filter(|session| session.source == source && session.agent == agent_label)
+ .map(|session| &session.session_ref)
})
- }
+ .is_some_and(|anchored| {
+ session_ref
+ .as_ref()
+ .is_none_or(|incoming| incoming == anchored)
+ });
+ if let Some(suppressed) = self.suppressed_full_lifecycle_hook_reports.get(source) {
+ if suppressed.agent_label != agent_label {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
+ if suppressed.reason == FullLifecycleHookSuppressionReason::HookClear {
+ let reanchor_sequence = matches!(
+ (&suppressed.session_ref, session_ref),
+ (Some(previous), Some(incoming)) if previous != incoming
+ );
+ return if reanchor_sequence {
+ FullLifecycleHookReportRoute::Accept {
+ reanchor_sequence: true,
+ }
+ } else {
+ FullLifecycleHookReportRoute::Ignore
+ };
+ }
+ }
- fn full_lifecycle_hook_report_has_fresh_session_after_suppression(
- &self,
- source: &str,
- agent_label: &str,
- session_ref: &Option<crate::agent_resume::AgentSessionRef>,
- ) -> bool {
- if !crate::detect::full_lifecycle_hook_authority(source, agent_label) {
- return false;
+ if process_present
+ && session_anchored
+ && !self
+ .suppressed_full_lifecycle_hook_reports
+ .contains_key(source)
+ {
+ return FullLifecycleHookReportRoute::Accept {
+ reanchor_sequence: self
+ .full_lifecycle_hook_report_has_fresh_session_after_stale_session(
+ source,
+ agent_label,
+ session_ref,
+ ),
+ };
}
- self.suppressed_full_lifecycle_hook_reports
+
+ let Some(session_ref) = session_ref.clone() else {
+ return FullLifecycleHookReportRoute::Ignore;
+ };
+ let Some(seq) = seq else {
+ return FullLifecycleHookReportRoute::Ignore;
+ };
+ if self
+ .hook_report_sequences
.get(source)
- .is_some_and(|suppressed| {
- suppressed.agent_label == agent_label
- && suppressed.reason != FullLifecycleHookSuppressionReason::ProcessExit
- && matches!(
- (&suppressed.session_ref, session_ref),
- (Some(suppressed_ref), Some(incoming_ref))
- if incoming_ref != suppressed_ref
- )
- })
+ .is_some_and(|previous| seq <= *previous)
+ {
+ return FullLifecycleHookReportRoute::Ignore;
+ }
+
+ let previous_session_ref = self
+ .persisted_agent_session
+ .as_ref()
+ .filter(|session| session.source == source && session.agent == agent_label)
+ .map(|session| session.session_ref.clone());
+ let suppressed = self
+ .suppressed_full_lifecycle_hook_reports
+ .entry(source.to_string())
+ .or_insert_with(|| SuppressedFullLifecycleHookReport {
+ agent_label: agent_label.to_string(),
+ session_ref: previous_session_ref,
+ observed_at: reported_at,
+ reason: FullLifecycleHookSuppressionReason::ProcessExit,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
+ });
+ let replace_pending = suppressed
+ .pending_replacement_report
+ .as_ref()
+ .is_none_or(|pending| seq > pending.seq);
+ if replace_pending {
+ suppressed.pending_replacement_report = Some(PendingFullLifecycleHookReport {
+ authority: HookAuthority {
+ source: source.to_string(),
+ agent_label: agent_label.to_string(),
+ state,
+ message: message.map(str::to_string),
+ reported_at,
+ session_ref: Some(session_ref),
+ },
+ seq,
+ });
+ }
+ FullLifecycleHookReportRoute::Ignore
}
fn full_lifecycle_hook_report_matches_stale_session(
@@ -801,22 +1041,59 @@ impl TerminalState {
}
let detected_label = crate::detect::agent_label(detected_agent);
let mut stale_sessions = Vec::new();
+ let mut validated_replacement_sessions = Vec::new();
self.suppressed_full_lifecycle_hook_reports
.retain(|source, suppressed| {
let should_clear = crate::detect::parse_agent_label(&suppressed.agent_label)
== Some(detected_agent);
- if should_clear {
- if let Some(session_ref) = suppressed.session_ref.clone() {
- stale_sessions.push((
+ if !should_clear {
+ return true;
+ }
+ if suppressed.reason == FullLifecycleHookSuppressionReason::ProcessExit {
+ if let Some(session_ref) = suppressed.replacement_session_ref.take() {
+ if let Some(exited_session_ref) = suppressed
+ .session_ref
+ .as_ref()
+ .filter(|exited_session_ref| *exited_session_ref != &session_ref)
+ .cloned()
+ {
+ stale_sessions.push((
+ source.clone(),
+ StaleFullLifecycleHookSession {
+ agent_label: suppressed.agent_label.clone(),
+ session_ref: exited_session_ref,
+ },
+ ));
+ }
+ let session_start_seq = self.hook_report_sequences.get(source).copied();
+ let pending =
+ suppressed
+ .pending_replacement_report
+ .take()
+ .filter(|pending| {
+ pending.authority.session_ref.as_ref() == Some(&session_ref)
+ && session_start_seq.is_none_or(|seq| pending.seq > seq)
+ });
+ validated_replacement_sessions.push((
source.clone(),
- StaleFullLifecycleHookSession {
- agent_label: suppressed.agent_label.clone(),
- session_ref,
- },
+ suppressed.agent_label.clone(),
+ session_ref,
+ pending,
));
+ return false;
}
+ return true;
+ }
+ if let Some(session_ref) = suppressed.session_ref.clone() {
+ stale_sessions.push((
+ source.clone(),
+ StaleFullLifecycleHookSession {
+ agent_label: suppressed.agent_label.clone(),
+ session_ref,
+ },
+ ));
}
- !should_clear
+ false
});
for (source, stale_session) in stale_sessions {
self.remember_stale_full_lifecycle_hook_session(
@@ -826,8 +1103,24 @@ impl TerminalState {
);
}
self.hook_report_sequences.retain(|source, _| {
- !crate::detect::full_lifecycle_hook_authority(source, detected_label)
+ validated_replacement_sessions
+ .iter()
+ .any(|(validated_source, _, _, _)| validated_source == source)
+ || !crate::detect::full_lifecycle_hook_authority(source, detected_label)
});
+ for (source, agent_label, session_ref, pending) in validated_replacement_sessions {
+ self.forget_stale_full_lifecycle_hook_session(&source, &agent_label, &session_ref);
+ self.reconcile_agent_name_owner(&agent_label, Some(&session_ref));
+ self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession {
+ source: source.clone(),
+ agent: agent_label,
+ session_ref,
+ });
+ if let Some(pending) = pending {
+ self.hook_report_sequences.insert(source, pending.seq);
+ self.hook_authority = Some(pending.authority);
+ }
+ }
}
fn remember_stale_full_lifecycle_hook_session(
@@ -986,7 +1279,8 @@ impl TerminalState {
"herdr:codex",
"codex",
Some("startup" | "clear" | "resume" | "compact")
- ) | ("herdr:opencode", "opencode", Some("new"))
+ ) | ("herdr:mastracode", "mastracode", Some("startup"))
+ | ("herdr:opencode", "opencode", Some("new"))
| ("herdr:pi", "pi", Some("new" | "resume" | "fork"))
| (
"herdr:omp",
@@ -1029,6 +1323,86 @@ impl TerminalState {
session_start_source: Option<String>,
) -> Option<TerminalStateMutation> {
let session_ref = session_ref?;
+ let known_agent = crate::detect::parse_agent_label(&agent_label);
+ let process_present = known_agent.is_some()
+ && self.detected_agent == known_agent
+ && self.recent_agent_process_exit.is_none();
+ let full_lifecycle_source =
+ crate::detect::full_lifecycle_hook_authority(&source, &agent_label);
+ let generation_gated = self
+ .suppressed_full_lifecycle_hook_reports
+ .get(&source)
+ .is_some_and(|suppressed| {
+ suppressed.agent_label == agent_label
+ && suppressed.reason != FullLifecycleHookSuppressionReason::HookClear
+ });
+ let session_anchored = self.hook_authority.as_ref().is_some_and(|authority| {
+ authority.source == source
+ && authority.agent_label == agent_label
+ && authority.session_ref.is_some()
+ }) || self.persisted_agent_session_matches(&source, &agent_label);
+ if full_lifecycle_source && (!process_present || generation_gated || !session_anchored) {
+ if !Self::session_start_source_is_recognized(session_start_source.as_deref()) {
+ return None;
+ }
+ let seq = seq?;
+ if self
+ .hook_report_sequences
+ .get(&source)
+ .is_some_and(|previous| seq <= *previous)
+ {
+ return None;
+ }
+
+ let previous_agent_label = self.effective_agent_label().map(str::to_string);
+ let previous_known_agent = self.effective_known_agent();
+ let previous_state = self.state;
+ let now = Instant::now();
+ let previous_presentation =
+ self.effective_presentation_for_state_at(previous_state, now);
+ let previous_session = self.current_session_identity_for_persistence();
+ let suppressed = self
+ .suppressed_full_lifecycle_hook_reports
+ .entry(source.clone())
+ .or_insert_with(|| SuppressedFullLifecycleHookReport {
+ agent_label: agent_label.clone(),
+ session_ref: None,
+ observed_at: now,
+ reason: FullLifecycleHookSuppressionReason::ProcessExit,
+ replacement_session_ref: None,
+ pending_replacement_report: None,
+ });
+ if suppressed.replacement_session_ref.as_ref() != Some(&session_ref) {
+ if suppressed
+ .pending_replacement_report
+ .as_ref()
+ .is_some_and(|pending| {
+ pending.authority.session_ref.as_ref() != Some(&session_ref)
+ })
+ {
+ suppressed.pending_replacement_report = None;
+ }
+ suppressed.replacement_session_ref = Some(session_ref);
+ }
+ self.hook_report_sequences.insert(source.clone(), seq);
+
+ if process_present {
+ self.clear_full_lifecycle_hook_suppression_for_detected_agent(None, known_agent);
+ let current_session = self.current_session_identity_for_persistence();
+ return Some(TerminalStateMutation {
+ effective_state_change: self.recompute_effective_state(
+ previous_agent_label,
+ previous_known_agent,
+ previous_state,
+ previous_presentation,
+ now,
+ ),
+ session_ref_changed: previous_session != current_session,
+ agent_released: false,
+ });
+ }
+ return None;
+ }
if !self.accept_hook_report(&source, seq) {
return None;
}
@@ -1193,6 +1567,13 @@ impl TerminalState {
.as_ref()
.map(|authority| authority.source.clone())
});
+ let should_clear = self
+ .hook_authority
+ .as_ref()
+ .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
+ if !should_clear {
+ return None;
+ }
if let Some(source) = sequence_source.as_deref() {
if !self.accept_hook_report(source, seq) {
return None;
@@ -1205,13 +1586,6 @@ impl TerminalState {
let previous_state = self.state;
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
- let should_clear = self
- .hook_authority
- .as_ref()
- .is_some_and(|authority| source.is_none_or(|source| authority.source == source));
- if !should_clear {
- return None;
- }
self.suppress_current_full_lifecycle_hook_authority(
FullLifecycleHookSuppressionReason::HookClear,
);
@@ -1230,27 +1604,12 @@ impl TerminalState {
})
}
- #[cfg(test)]
- pub fn release_agent(
- &mut self,
- source: &str,
- agent_label: &str,
- seq: Option<u64>,
- ) -> Option<EffectiveStateChange> {
- self.release_agent_with_mutation(source, agent_label, seq)
- .and_then(|mutation| mutation.effective_state_change)
- }
-
pub fn release_agent_with_mutation(
&mut self,
source: &str,
agent_label: &str,
seq: Option<u64>,
) -> Option<TerminalStateMutation> {
- if !self.accept_hook_report(source, seq) {
- return None;
- }
-
if self.hook_authority.as_ref().is_some_and(|authority| {
authority.agent_label != agent_label || authority.source != source
}) {
@@ -1262,10 +1621,17 @@ impl TerminalState {
if !matches_current_agent && !matches_persisted_session {
return None;
}
+ if !self.accept_hook_report(source, seq) {
+ return None;
+ }
let preserve_foreign_persisted_session = self
.persisted_agent_session
.as_ref()
.is_some_and(|session| session.source != source || session.agent != agent_label);
+ let process_owns_agent =
+ crate::detect::parse_agent_label(agent_label).is_some_and(|agent| {
+ self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none()
+ });
let now = Instant::now();
let previous_agent_label = self.effective_agent_label().map(str::to_string);
@@ -1278,12 +1644,14 @@ impl TerminalState {
agent_label,
FullLifecycleHookSuppressionReason::HookClear,
);
- self.detected_agent = None;
- self.fallback_state = AgentState::Unknown;
- self.fallback_visible_blocker = false;
- self.fallback_observed_at = None;
+ if !process_owns_agent {
+ self.detected_agent = None;
+ self.fallback_state = AgentState::Unknown;
+ self.fallback_visible_blocker = false;
+ self.fallback_observed_at = None;
+ self.clear_agent_name();
+ }
self.hook_authority = None;
- self.clear_agent_name();
if !preserve_foreign_persisted_session {
self.persisted_agent_session = None;
}
@@ -1297,22 +1665,33 @@ impl TerminalState {
now,
),
session_ref_changed: previous_session != current_session,
- agent_released: true,
+ agent_released: !process_owns_agent,
})
}
+ fn hook_authority_is_effective(&self, authority: &HookAuthority) -> bool {
+ !crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
+ || crate::detect::parse_agent_label(&authority.agent_label).is_none_or(|agent| {
+ self.detected_agent == Some(agent) && self.recent_agent_process_exit.is_none()
+ })
+ }
+
pub fn effective_agent_label(&self) -> Option<&str> {
self.hook_authority
.as_ref()
+ .filter(|authority| self.hook_authority_is_effective(authority))
.map(|authority| authority.agent_label.as_str())
- .or_else(|| self.detected_agent.map(crate::detect::agent_label))
+ .or_else(|| {
+ self.recent_agent_process_exit
+ .is_none()
+ .then(|| self.detected_agent.map(crate::detect::agent_label))
+ .flatten()
+ })
}
pub fn effective_known_agent(&self) -> Option<Agent> {
- if let Some(authority) = &self.hook_authority {
- return crate::detect::parse_agent_label(&authority.agent_label);
- }
- self.detected_agent
+ self.effective_agent_label()
+ .and_then(crate::detect::parse_agent_label)
}
pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange {
@@ -1351,7 +1730,11 @@ impl TerminalState {
fn live_full_lifecycle_hook_authority(&self) -> bool {
self.hook_authority.as_ref().is_some_and(|authority| {
- crate::detect::full_lifecycle_hook_authority(&authority.source, &authority.agent_label)
+ self.hook_authority_is_effective(authority)
+ && crate::detect::full_lifecycle_hook_authority(
+ &authority.source,
+ &authority.agent_label,
+ )
})
}
@@ -1533,13 +1916,14 @@ impl TerminalState {
self.hook_authority = None;
self.persisted_agent_session = None;
self.agent_metadata.clear();
+ self.metadata_report_agents.clear();
self.suppressed_full_lifecycle_hook_reports.clear();
self.stale_full_lifecycle_hook_sessions.clear();
self.state = AgentState::Unknown;
self.last_agent_state_change_seq = None;
self.launch_argv = None;
self.respawn_shell_on_exit = false;
- self.recent_agent_process_exit_at = None;
+ self.recent_agent_process_exit = None;
self.pending_agent_resume_plan = None;
self.clear_agent_name();
}
@@ -1612,6 +1996,7 @@ impl TerminalState {
} else {
self.hook_authority
.as_ref()
+ .filter(|authority| self.hook_authority_is_effective(authority))
.map(|authority| authority.state)
.unwrap_or(self.fallback_state)
};
@@ -1663,6 +2048,21 @@ mod tests {
.to_string()
}
+ fn anchor_full_lifecycle_session(
+ terminal: &mut TerminalState,
+ agent: Agent,
+ source: &str,
+ agent_label: &str,
+ session_ref: crate::agent_resume::AgentSessionRef,
+ ) {
+ terminal.set_detected_state(Some(agent), terminal.fallback_state);
+ terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
+ source: source.into(),
+ agent: agent_label.into(),
+ session_ref,
+ });
+ }
+
#[test]
fn managed_agent_activates_only_after_matching_settled_detection() {
let mut terminal = test_terminal();
@@ -1740,6 +2140,13 @@ mod tests {
fn hook_authority_overrides_fallback_for_same_agent() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -1776,6 +2183,13 @@ mod tests {
fn omp_hook_authority_overrides_detected_fallback() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Omp), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Omp,
+ "herdr:omp",
+ "omp",
+ crate::agent_resume::AgentSessionRef::id("omp-root").unwrap(),
+ );
terminal.set_hook_authority(
"herdr:omp".into(),
"omp".into(),
@@ -1838,6 +2252,45 @@ mod tests {
}
#[test]
+ fn startup_session_claim_activates_full_lifecycle_integrations() {
+ for (agent, source, label) in [
+ (Agent::Kimi, "herdr:kimi", "kimi"),
+ (Agent::Kilo, "herdr:kilo", "kilo"),
+ (Agent::Hermes, "herdr:hermes", "hermes"),
+ ] {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(agent), AgentState::Idle);
+ let session_ref = crate::agent_resume::AgentSessionRef::id(format!("{label}-root"));
+
+ let session = terminal.set_agent_session_ref_for_session_start(
+ source.into(),
+ label.into(),
+ session_ref.clone(),
+ Some(10),
+ Some("startup".into()),
+ );
+ let working = terminal.set_hook_authority_with_session_ref(
+ source.into(),
+ label.into(),
+ AgentState::Working,
+ None,
+ session_ref,
+ Some(11),
+ );
+
+ assert!(
+ session.is_some(),
+ "{label} should accept its startup session"
+ );
+ assert!(
+ working.is_some(),
+ "{label} should accept state after startup"
+ );
+ assert_eq!(terminal.state, AgentState::Working);
+ }
+ }
+
+ #[test]
fn pi_session_replacement_reports_reanchor_full_lifecycle_authority() {
for reason in ["new", "resume", "fork"] {
let mut terminal = test_terminal();
@@ -1995,6 +2448,13 @@ mod tests {
let old_session = test_session_path("pi-current.jsonl");
let new_session = test_session_path("pi-unexpected.jsonl");
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -2095,91 +2555,6 @@ mod tests {
}
#[test]
- fn process_exit_clears_matching_full_lifecycle_hook_authority() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(10),
- now,
- );
-
- let change = terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
-
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- assert_eq!(
- change.effective_state_change.unwrap().previous_state,
- AgentState::Working
- );
-
- let stale = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(9),
- now + Duration::from_millis(2),
- );
-
- assert!(stale.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- }
-
- #[test]
- fn late_full_lifecycle_hook_after_process_exit_does_not_reacquire_authority() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
- terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(20),
- now,
- );
-
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
- let late = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
- Some(21),
- now + Duration::from_millis(2),
- );
-
- assert!(late.is_none());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Idle);
- }
-
- #[test]
fn late_full_lifecycle_hook_with_same_session_after_process_exit_does_not_reacquire_authority()
{
let now = Instant::now();
@@ -2219,249 +2594,252 @@ mod tests {
}
#[test]
- fn late_full_lifecycle_hook_after_release_does_not_reacquire_authority() {
+ fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let late = terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(22),
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")).unwrap(),
);
-
- assert!(late.is_none());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Unknown);
- }
-
- #[test]
- fn late_full_lifecycle_hook_with_same_session_after_release_does_not_reacquire_authority() {
- let mut terminal = test_terminal();
- let session_path = test_session_path("pi.jsonl");
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")),
Some(20),
);
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let late = terminal.set_hook_authority_with_session_ref(
+ let mutation = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
+ AgentState::Idle,
None,
- crate::agent_resume::AgentSessionRef::path(session_path),
- Some(22),
+ crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")),
+ Some(21),
);
- assert!(late.is_none());
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.state, AgentState::Unknown);
+ assert!(mutation.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .and_then(|authority| authority.session_ref.as_ref())
+ .map(|session_ref| session_ref.value.as_str()),
+ Some(test_session_path("one.jsonl").as_str())
+ );
}
#[test]
- fn changed_session_ref_allows_full_lifecycle_hook_after_suppression() {
+ fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() {
let mut terminal = test_terminal();
+ let old_session = test_session_path("old-process-exit.jsonl");
+ let new_session = test_session_path("new-process-exit.jsonl");
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
- Some(20),
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()),
+ Some(1000),
+ );
+ let process_exit_seen_at = Instant::now() + Duration::from_secs(1);
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ process_exit_seen_at,
);
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let fresh = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
+ let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
+ terminal.set_detected_state_with_screen_signals_at(
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
- Some(22),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ fresh_process_seen_at + Duration::from_millis(1),
);
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn changed_session_ref_reanchors_hook_sequence_after_release() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ let late_old = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl")),
- Some(1000),
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(500),
);
- terminal.release_agent("herdr:pi", "pi", Some(3000));
-
- let fresh = terminal.set_hook_authority_with_session_ref(
+ let fresh_new = terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
- Some(1500),
+ crate::agent_resume::AgentSessionRef::path(new_session.clone()),
+ Some(501),
);
- assert!(fresh.is_some());
+ assert!(late_old.is_none());
+ assert!(fresh_new.is_none());
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::path(new_session),
+ Some(400),
+ Some("startup".into()),
+ )
+ .expect("fresh session should activate the buffered report");
assert!(terminal.hook_authority.is_some());
assert_eq!(terminal.state, AgentState::Working);
}
#[test]
- fn stale_session_suppression_survives_multiple_release_generations() {
+ fn rapid_restart_replays_reports_that_arrive_before_process_evidence() {
let mut terminal = test_terminal();
- let session_a = test_session_path("release-generation-a.jsonl");
- let session_b = test_session_path("release-generation-b.jsonl");
- let session_c = test_session_path("release-generation-c.jsonl");
+ let session_path = test_session_path("reports-before-process-evidence.jsonl");
+ let now = Instant::now();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_a.clone()),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(1000),
+ now,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ true,
+ false,
+ true,
+ now + Duration::from_millis(1),
);
- terminal.release_agent("herdr:pi", "pi", Some(2000));
- let generation_b = terminal.set_hook_authority_with_session_ref(
+ let lower_sequence = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
+ AgentState::Idle,
None,
- crate::agent_resume::AgentSessionRef::path(session_b),
- Some(1500),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ Some(1001),
+ now + Duration::from_millis(2),
);
- assert!(generation_b.is_some());
- terminal.release_agent("herdr:pi", "pi", Some(3000));
-
- let late_generation_a = terminal.set_hook_authority_with_session_ref(
+ let missing_sequence = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
+ AgentState::Idle,
+ None,
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
None,
- crate::agent_resume::AgentSessionRef::path(session_a),
- Some(2500),
+ now + Duration::from_millis(3),
);
- let generation_c = terminal.set_hook_authority_with_session_ref(
+ let buffered_working = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_c),
- Some(2500),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
+ Some(2001),
+ now + Duration::from_millis(4),
);
-
- assert!(late_generation_a.is_none());
- assert!(generation_c.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn live_full_lifecycle_hook_rejects_different_session_ref_for_same_source() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ let startup = terminal.set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("one.jsonl")),
- Some(20),
+ crate::agent_resume::AgentSessionRef::path(session_path),
+ Some(2000),
+ Some("startup".into()),
);
+ assert!(startup.is_none());
+ assert!(lower_sequence.is_none());
+ assert!(missing_sequence.is_none());
+ assert!(buffered_working.is_none());
+ assert!(!terminal.full_lifecycle_hook_authority_active());
- let mutation = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
AgentState::Idle,
- None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("two.jsonl")),
- Some(21),
+ false,
+ true,
+ false,
+ false,
+ now + Duration::from_millis(5),
);
- assert!(mutation.is_none());
+ assert!(terminal.full_lifecycle_hook_authority_active());
assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(
- terminal
- .hook_authority
- .as_ref()
- .and_then(|authority| authority.session_ref.as_ref())
- .map(|session_ref| session_ref.value.as_str()),
- Some(test_session_path("one.jsonl").as_str())
- );
}
#[test]
- fn fresh_detected_process_allows_full_lifecycle_hook_after_suppression() {
+ fn process_exit_discards_unclaimed_buffered_state_from_that_generation() {
let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
+ let old_session = test_session_path("buffered-exit-old.jsonl");
+ let shared_session = test_session_path("buffered-exit-shared.jsonl");
+ let now = Instant::now();
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(old_session.clone()).unwrap(),
+ );
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- Some(20),
+ crate::agent_resume::AgentSessionRef::path(old_session),
+ Some(1000),
+ now,
);
- terminal.release_agent("herdr:pi", "pi", Some(21));
- let now = Instant::now();
-
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
- AgentState::Unknown,
+ AgentState::Idle,
false,
+ true,
false,
+ true,
+ now + Duration::from_millis(1),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
false,
+ true,
false,
- now,
+ false,
+ now + Duration::from_millis(2),
);
- let fresh = terminal.set_hook_authority(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- Some(22),
+ crate::agent_resume::AgentSessionRef::path(shared_session.clone()),
+ Some(500),
+ now + Duration::from_millis(3),
);
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn fresh_detected_process_reanchors_hook_sequence_after_process_exit() {
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(1000),
- );
- let process_exit_seen_at = Instant::now() + Duration::from_millis(1);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
@@ -2469,56 +2847,46 @@ mod tests {
true,
false,
true,
- process_exit_seen_at,
- );
-
- let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
- terminal.set_detected_state_with_screen_signals_at(
- None,
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- fresh_process_seen_at,
+ now + Duration::from_millis(4),
);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
- AgentState::Unknown,
- false,
+ AgentState::Idle,
false,
+ true,
false,
false,
- fresh_process_seen_at + Duration::from_millis(1),
- );
- let fresh = terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(500),
+ now + Duration::from_millis(5),
);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::path(shared_session),
+ Some(100),
+ Some("startup".into()),
+ )
+ .expect("new generation session claim");
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.state, AgentState::Idle);
}
#[test]
- fn fresh_detected_process_keeps_old_session_suppressed_after_process_exit() {
+ fn queued_fresh_process_evidence_uses_process_exit_observation_time() {
let mut terminal = test_terminal();
- let old_session = test_session_path("old-process-exit.jsonl");
- let new_session = test_session_path("new-process-exit.jsonl");
+ let session_path = test_session_path("queued-after-process-exit.jsonl");
+ let process_exit_at = Instant::now() - Duration::from_secs(1);
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_with_session_ref(
+ terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(old_session.clone()),
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()),
Some(1000),
+ process_exit_at - Duration::from_millis(1),
);
- let process_exit_seen_at = Instant::now() + Duration::from_secs(1);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
AgentState::Idle,
@@ -2526,10 +2894,9 @@ mod tests {
true,
false,
true,
- process_exit_seen_at,
+ process_exit_at,
);
- let fresh_process_seen_at = process_exit_seen_at + Duration::from_millis(1);
terminal.set_detected_state_with_screen_signals_at(
None,
AgentState::Unknown,
@@ -2537,39 +2904,26 @@ mod tests {
false,
false,
false,
- fresh_process_seen_at,
+ process_exit_at + Duration::from_millis(1),
);
terminal.set_detected_state_with_screen_signals_at(
Some(Agent::Pi),
- AgentState::Unknown,
- false,
+ AgentState::Idle,
false,
+ true,
false,
false,
- fresh_process_seen_at + Duration::from_millis(1),
- );
-
- let late_old = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(old_session),
- Some(500),
+ process_exit_at + Duration::from_millis(2),
);
- let fresh_new = terminal.set_hook_authority_with_session_ref(
+ let startup = terminal.set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(new_session),
- Some(500),
+ crate::agent_resume::AgentSessionRef::path(session_path),
+ Some(2000),
+ Some("startup".into()),
);
- assert!(late_old.is_none());
- assert!(fresh_new.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
+ assert!(startup.is_some());
}
#[test]
@@ -2629,14 +2983,12 @@ mod tests {
false,
now + Duration::from_millis(4),
);
- let fresh_new = terminal.set_hook_authority_at(
+ let fresh_new = terminal.set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
- AgentState::Working,
- None,
crate::agent_resume::AgentSessionRef::path(new_session),
- Some(500),
- now + Duration::from_millis(5),
+ Some(400),
+ Some("startup".into()),
);
assert!(fresh_new.is_some());
@@ -2709,277 +3061,64 @@ mod tests {
Some(500),
now + Duration::from_millis(5),
);
+ assert!(fresh_without_session.is_none());
+ assert!(terminal.hook_authority.is_none());
- assert!(fresh_without_session.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- #[test]
- fn stale_session_suppression_survives_multiple_process_generations() {
- let mut terminal = test_terminal();
- let session_a = test_session_path("generation-a.jsonl");
- let session_b = test_session_path("generation-b.jsonl");
- let session_c = test_session_path("generation-c.jsonl");
- let now = Instant::now();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_a.clone()),
- Some(1000),
- now,
- );
-
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(1),
- );
- terminal.set_detected_state_with_screen_signals_at(
- None,
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- now + Duration::from_millis(2),
- );
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- now + Duration::from_millis(3),
- );
- let generation_b = terminal.set_hook_authority_at(
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::path(test_session_path(
+ "fresh-after-nosession-process-exit.jsonl",
+ )),
+ Some(600),
+ Some("startup".into()),
+ )
+ .expect("fresh root session should claim the process generation");
+ let child_update = terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
- crate::agent_resume::AgentSessionRef::path(session_b),
- Some(500),
- now + Duration::from_millis(4),
- );
- assert!(generation_b.is_some());
-
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- true,
- now + Duration::from_millis(5),
- );
- terminal.set_detected_state_with_screen_signals_at(
None,
- AgentState::Unknown,
- false,
- false,
- false,
- false,
+ Some(601),
now + Duration::from_millis(6),
);
- terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Unknown,
- false,
- false,
- false,
- false,
- now + Duration::from_millis(7),
- );
-
- let late_generation_a = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_a),
- Some(250),
- now + Duration::from_millis(8),
- );
- let generation_c = terminal.set_hook_authority_at(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_c),
- Some(250),
- now + Duration::from_millis(9),
- );
- assert!(late_generation_a.is_none());
- assert!(generation_c.is_some());
- assert!(terminal.hook_authority.is_some());
+ assert!(child_update.is_some());
assert_eq!(terminal.state, AgentState::Working);
}
#[test]
- fn release_suppression_ignores_same_agent_idle_publish() {
- let now = Instant::now();
- let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
- terminal.release_agent("herdr:pi", "pi", Some(21));
-
- let change = terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- AgentState::Idle,
- false,
- true,
- false,
- false,
- now,
- );
- let late = terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(22),
- );
-
- assert!(change.effective_state_change.is_none());
- assert!(late.is_none());
- assert_eq!(terminal.detected_agent, None);
- assert_eq!(terminal.state, AgentState::Unknown);
- }
-
- #[test]
- fn fresh_session_ref_allows_full_lifecycle_hook_after_suppression() {
+ fn mastracode_session_start_replaces_current_root_session() {
let mut terminal = test_terminal();
- terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
- terminal.release_agent("herdr:pi", "pi", Some(21));
-
- let fresh = terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id("fresh-session"),
- Some(22),
- );
-
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
- }
-
- // Regression for #614: a same-pane restart must reacquire lifecycle authority.
- #[test]
- fn omp_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() {
- assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref(
- "herdr:omp",
- "omp",
- );
- }
-
- #[test]
- fn mastracode_reacquires_full_lifecycle_hook_after_release_with_fresh_session_ref() {
- assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref(
- "herdr:mastracode",
- "mastracode",
- );
- }
-
- #[test]
- fn mastracode_lifecycle_report_replaces_restored_thread_ref() {
- let mut terminal = test_terminal();
- terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
- source: "herdr:mastracode".into(),
- agent: "mastracode".into(),
- session_ref: crate::agent_resume::AgentSessionRef::id("mastracode-old").unwrap(),
- });
-
- let mutation = terminal
- .set_hook_authority_with_session_ref(
+ terminal.set_detected_state(Some(Agent::Mastracode), AgentState::Idle);
+ terminal
+ .set_agent_session_ref_for_session_start(
"herdr:mastracode".into(),
"mastracode".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id("mastracode-new"),
+ crate::agent_resume::AgentSessionRef::id("mastracode-old"),
Some(20),
+ Some("startup".into()),
)
- .expect("fresh MastraCode thread should replace restored thread id");
-
- assert!(mutation.session_ref_changed);
- assert_eq!(
- terminal.current_session_identity_for_persistence(),
- Some((
- "herdr:mastracode".into(),
- "mastracode".into(),
- crate::agent_resume::AgentSessionRefKind::Id,
- "mastracode-new".into()
- ))
- );
- }
+ .expect("initial root session");
- fn assert_full_lifecycle_hook_reacquires_after_release_with_fresh_session_ref(
- source: &str,
- agent_label: &str,
- ) {
- let mut terminal = test_terminal();
- let old_session = format!("{agent_label}-old");
- let new_session = format!("{agent_label}-new");
-
- terminal.set_hook_authority_with_session_ref(
- source.into(),
- agent_label.into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id(&old_session),
- Some(20),
- );
- terminal.release_agent(source, agent_label, Some(21));
-
- // A late report from the released run keeps its old session ref and stays
- // suppressed, so a just-exited agent cannot resurrect the pane.
- let stale = terminal.set_hook_authority_with_session_ref(
- source.into(),
- agent_label.into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id(&old_session),
- Some(22),
+ let replacement = terminal.set_agent_session_ref_for_session_start(
+ "herdr:mastracode".into(),
+ "mastracode".into(),
+ crate::agent_resume::AgentSessionRef::id("mastracode-new"),
+ Some(21),
+ Some("startup".into()),
);
- assert!(stale.is_none());
- assert!(terminal.hook_authority.is_none());
- // A fresh run carries a new session ref and reacquires authority.
- let fresh = terminal.set_hook_authority_with_session_ref(
- source.into(),
- agent_label.into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::id(&new_session),
- Some(23),
+ assert!(replacement.is_some_and(|mutation| mutation.session_ref_changed));
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("mastracode-new")
);
- assert!(fresh.is_some());
- assert!(terminal.hook_authority.is_some());
- assert_eq!(terminal.state, AgentState::Working);
}
#[test]
@@ -3035,6 +3174,15 @@ mod tests {
false,
now + Duration::from_millis(3),
);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:omp".into(),
+ "omp".into(),
+ crate::agent_resume::AgentSessionRef::id("omp-new"),
+ Some(400),
+ Some("startup".into()),
+ )
+ .expect("fresh process and session should claim the pane");
let fresh = terminal.set_hook_authority_with_session_ref(
"herdr:omp".into(),
"omp".into(),
@@ -3078,6 +3226,13 @@ mod tests {
fn visible_blocker_does_not_override_full_lifecycle_hook_authority() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -3206,6 +3361,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ crate::agent_resume::AgentSessionRef::id("opencode-root").unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:opencode".into(),
"opencode".into(),
@@ -3264,6 +3426,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Hermes,
+ "herdr:hermes",
+ "hermes",
+ crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:hermes".into(),
"hermes".into(),
@@ -3294,6 +3463,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Kilo,
+ "herdr:kilo",
+ "kilo",
+ crate::agent_resume::AgentSessionRef::id("kilo-root").unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:kilo".into(),
"kilo".into(),
@@ -3502,6 +3678,13 @@ mod tests {
let now = Instant::now();
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority_at(
"herdr:pi".into(),
"pi".into(),
@@ -3592,7 +3775,7 @@ mod tests {
assert!(terminal.hook_authority.is_none());
assert_eq!(terminal.detected_agent, Some(Agent::Codex));
- assert_eq!(terminal.effective_agent_label(), Some("codex"));
+ assert_eq!(terminal.effective_agent_label(), None);
assert_eq!(terminal.state, AgentState::Idle);
}
@@ -3633,7 +3816,119 @@ mod tests {
}
#[test]
- fn stale_process_exit_does_not_clear_newer_same_agent_hook_authority() {
+ fn stale_process_exit_preserves_newer_custom_authority() {
+ let mut terminal = test_terminal();
+ let observed = Instant::now();
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ observed,
+ );
+ terminal.set_hook_authority_at(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(100),
+ observed + Duration::from_secs(1),
+ );
+
+ let mutation = terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ observed,
+ );
+
+ assert!(!mutation.agent_released);
+ assert_eq!(terminal.state, AgentState::Working);
+ assert_eq!(
+ terminal
+ .hook_authority
+ .as_ref()
+ .map(|hook| hook.source.as_str()),
+ Some("custom:pi")
+ );
+ }
+
+ #[test]
+ fn custom_authority_reanchors_sequence_after_process_restart() {
+ let mut terminal = test_terminal();
+ let observed = Instant::now();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal.set_hook_authority_at(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(100),
+ observed,
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ true,
+ observed + Duration::from_millis(1),
+ );
+ terminal.set_detected_state_with_screen_signals_at(
+ None,
+ AgentState::Unknown,
+ false,
+ false,
+ false,
+ false,
+ observed + Duration::from_millis(2),
+ );
+
+ assert!(terminal
+ .release_agent_with_mutation("custom:pi", "pi", Some(200))
+ .is_none());
+ assert!(terminal
+ .clear_hook_authority_with_mutation(Some("custom:pi"), Some(201))
+ .is_none());
+ assert!(terminal
+ .set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(1),
+ )
+ .is_none());
+ terminal.set_detected_state_with_screen_signals_at(
+ Some(Agent::Pi),
+ AgentState::Idle,
+ false,
+ false,
+ false,
+ false,
+ observed + Duration::from_millis(3),
+ );
+ assert!(terminal
+ .set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(1),
+ )
+ .is_some());
+ }
+
+ #[test]
+ fn process_exit_clears_newer_same_agent_hook_authority() {
let mut terminal = test_terminal();
let observed = Instant::now();
terminal.set_detected_state_with_screen_signals_at(
@@ -3674,10 +3969,9 @@ mod tests {
observed,
);
- let authority = terminal.hook_authority.as_ref().expect("hook authority");
- assert_eq!(authority.reported_at, observed + Duration::from_secs(1));
- assert_eq!(terminal.state, AgentState::Working);
- assert_eq!(terminal.effective_agent_label(), Some("codex"));
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.state, AgentState::Idle);
+ assert_eq!(terminal.effective_agent_label(), None);
}
#[test]
@@ -3701,28 +3995,16 @@ mod tests {
}
#[test]
- fn release_agent_clears_identity_immediately() {
+ fn stale_hook_report_sequence_is_ignored_for_same_source() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- None,
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
);
-
- terminal.release_agent("herdr:pi", "pi", None);
-
- assert!(terminal.hook_authority.is_none());
- assert_eq!(terminal.detected_agent, None);
- assert_eq!(terminal.fallback_state, AgentState::Unknown);
- assert_eq!(terminal.state, AgentState::Unknown);
- }
-
- #[test]
- fn stale_hook_report_sequence_is_ignored_for_same_source() {
- let mut terminal = test_terminal();
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -3751,6 +4033,13 @@ mod tests {
fn accepted_hook_report_stores_session_ref() {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
let mutation = terminal
.set_hook_authority_with_session_ref(
"herdr:pi".into(),
@@ -3762,7 +4051,7 @@ mod tests {
)
.expect("accepted report");
- assert!(mutation.session_ref_changed);
+ assert!(!mutation.session_ref_changed);
assert_eq!(
terminal
.hook_authority
@@ -3781,6 +4070,13 @@ mod tests {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
let new_session_path = test_session_path("new.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -3814,6 +4110,13 @@ mod tests {
fn accepted_hook_report_without_session_ref_clears_previous_ref() {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -3845,30 +4148,6 @@ mod tests {
}
#[test]
- fn accepted_hook_report_marks_changed_when_same_owner_session_identity_changes() {
- let mut terminal = test_terminal();
- terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
- source: "herdr:pi".into(),
- agent: "pi".into(),
- session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("old.jsonl"))
- .unwrap(),
- });
-
- let mutation = terminal
- .set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(test_session_path("new.jsonl")),
- Some(20),
- )
- .expect("accepted report");
-
- assert!(mutation.session_ref_changed);
- }
-
- #[test]
fn different_same_agent_session_ref_is_ignored_until_current_session_clears() {
let mut terminal = test_terminal();
terminal
@@ -4008,12 +4287,14 @@ mod tests {
#[test]
fn opencode_new_session_ref_replaces_existing_session_ref() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
terminal
- .set_agent_session_ref(
+ .set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
Some(20),
+ Some("new".into()),
)
.expect("initial session should be accepted");
@@ -4042,11 +4323,12 @@ mod tests {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal
- .set_agent_session_ref(
+ .set_agent_session_ref_for_session_start(
"herdr:pi".into(),
"pi".into(),
crate::agent_resume::AgentSessionRef::id("pi-old"),
Some(20),
+ Some("new".into()),
)
.expect("initial session should be accepted");
terminal.set_agent_name("reviewer".into());
@@ -4104,12 +4386,14 @@ mod tests {
#[test]
fn opencode_session_ref_without_start_source_does_not_replace_existing() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
terminal
- .set_agent_session_ref(
+ .set_agent_session_ref_for_session_start(
"herdr:opencode".into(),
"opencode".into(),
crate::agent_resume::AgentSessionRef::id("opencode-old"),
Some(20),
+ Some("new".into()),
)
.expect("initial session should be accepted");
@@ -4303,6 +4587,13 @@ mod tests {
fn foreground_agent_session_replaces_stale_different_owner_hook_authority() {
let mut terminal = test_terminal();
let now = std::time::Instant::now();
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(),
+ );
terminal
.set_hook_authority_at(
"herdr:opencode".into(),
@@ -4356,13 +4647,22 @@ mod tests {
assert!(late_old_session.is_none());
terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id("opencode-new-session"),
+ Some(23),
+ Some("new".into()),
+ )
+ .expect("fresh root session");
let fresh_session = terminal.set_hook_authority_with_session_ref(
"herdr:opencode".into(),
"opencode".into(),
AgentState::Working,
None,
crate::agent_resume::AgentSessionRef::id("opencode-new-session"),
- Some(23),
+ Some(24),
);
assert!(fresh_session.is_some());
}
@@ -4425,8 +4725,16 @@ mod tests {
}
#[test]
- fn hook_authority_preserves_current_session_ref_when_incoming_ref_differs() {
+ fn hook_authority_rejects_state_from_a_different_session() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::OpenCode,
+ "herdr:opencode",
+ "opencode",
+ crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(),
+ );
terminal
.set_hook_authority_with_session_ref(
"herdr:opencode".into(),
@@ -4438,19 +4746,17 @@ mod tests {
)
.expect("initial session should be accepted");
- let mutation = terminal
- .set_hook_authority_with_session_ref(
- "herdr:opencode".into(),
- "opencode".into(),
- AgentState::Blocked,
- Some("needs approval".into()),
- crate::agent_resume::AgentSessionRef::id("nested-session"),
- Some(21),
- )
- .expect("state update should still be accepted");
+ let mutation = terminal.set_hook_authority_with_session_ref(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ AgentState::Blocked,
+ Some("needs approval".into()),
+ crate::agent_resume::AgentSessionRef::id("nested-session"),
+ Some(21),
+ );
- assert!(!mutation.session_ref_changed);
- assert_eq!(terminal.state, AgentState::Blocked);
+ assert!(mutation.is_none());
+ assert_eq!(terminal.state, AgentState::Working);
assert_eq!(
terminal
.hook_authority
@@ -4498,6 +4804,13 @@ mod tests {
fn clearing_hook_authority_clears_session_ref() {
let mut terminal = test_terminal();
let session_path = test_session_path("pi.jsonl");
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:pi".into(),
"pi".into(),
@@ -4516,28 +4829,7 @@ mod tests {
}
#[test]
- fn release_agent_clears_session_ref() {
- let mut terminal = test_terminal();
- let session_path = test_session_path("pi.jsonl");
- terminal.set_hook_authority_with_session_ref(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- crate::agent_resume::AgentSessionRef::path(session_path),
- Some(20),
- );
-
- let mutation = terminal
- .release_agent_with_mutation("herdr:pi", "pi", Some(21))
- .expect("accepted release");
-
- assert!(mutation.session_ref_changed);
- assert!(terminal.hook_authority.is_none());
- }
-
- #[test]
- fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() {
+ fn agent_alias_survives_detection_uncertainty_and_reported_release_but_not_replacement() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
terminal.set_agent_name("reviewer".into());
@@ -4549,10 +4841,37 @@ mod tests {
assert!(terminal.agent_name.is_none());
terminal.set_agent_name("replacement".into());
- terminal
+ let mutation = terminal
.release_agent_with_mutation("herdr:codex", "codex", None)
.expect("detected agent release should be accepted");
- assert!(terminal.agent_name.is_none());
+ assert!(!mutation.agent_released);
+ assert_eq!(terminal.agent_name.as_deref(), Some("replacement"));
+ assert_eq!(terminal.detected_agent, Some(Agent::Codex));
+ }
+
+ #[test]
+ fn custom_release_preserves_process_owned_agent_state() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal
+ .set_hook_authority(
+ "custom:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ Some(10),
+ )
+ .expect("custom state should be accepted");
+
+ let mutation = terminal
+ .release_agent_with_mutation("custom:pi", "pi", Some(11))
+ .expect("custom release should be accepted");
+
+ assert!(!mutation.agent_released);
+ assert!(terminal.hook_authority.is_none());
+ assert_eq!(terminal.detected_agent, Some(Agent::Pi));
+ assert_eq!(terminal.effective_agent_label(), Some("pi"));
+ assert_eq!(terminal.state, AgentState::Idle);
}
#[test]
@@ -4608,6 +4927,14 @@ mod tests {
#[test]
fn accepted_same_kind_hook_owner_replacement_clears_the_alias() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")).unwrap(),
+ );
terminal
.set_hook_authority_at(
"herdr:pi".into(),
@@ -4694,11 +5021,12 @@ mod tests {
#[test]
fn process_exit_clears_matching_persisted_session_ref() {
let mut terminal = test_terminal();
+ let session_ref =
+ crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl")).unwrap();
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
source: "herdr:pi".into(),
agent: "pi".into(),
- session_ref: crate::agent_resume::AgentSessionRef::path(test_session_path("pi.jsonl"))
- .unwrap(),
+ session_ref: session_ref.clone(),
});
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
@@ -4714,6 +5042,15 @@ mod tests {
assert!(mutation.session_ref_changed);
assert!(terminal.persisted_agent_session.is_none());
+
+ let delayed = terminal.set_agent_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ Some(session_ref),
+ Some(21),
+ );
+ assert!(delayed.is_none());
+ assert!(terminal.persisted_agent_session.is_none());
}
#[test]
@@ -4831,6 +5168,13 @@ mod tests {
fn detected_agent_disappearance_does_not_clear_full_lifecycle_hook_session_ref() {
let mut terminal = test_terminal();
terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Hermes,
+ "herdr:hermes",
+ "hermes",
+ crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(),
+ );
terminal.set_hook_authority_with_session_ref(
"herdr:hermes".into(),
"hermes".into(),
@@ -4884,6 +5228,14 @@ mod tests {
#[test]
fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -4905,26 +5257,16 @@ mod tests {
}
#[test]
- fn stale_release_sequence_is_ignored_for_same_source() {
- let mut terminal = test_terminal();
- terminal.set_hook_authority(
- "herdr:pi".into(),
- "pi".into(),
- AgentState::Working,
- None,
- Some(20),
- );
-
- let change = terminal.release_agent("herdr:pi", "pi", Some(19));
-
- assert!(change.is_none());
- assert_eq!(terminal.state, AgentState::Working);
- assert!(terminal.hook_authority.is_some());
- }
-
- #[test]
fn stale_clear_all_sequence_is_checked_against_current_authority_source() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ anchor_full_lifecycle_session(
+ &mut terminal,
+ Agent::Pi,
+ "herdr:pi",
+ "pi",
+ crate::agent_resume::AgentSessionRef::path(test_session_path("root.jsonl")).unwrap(),
+ );
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
@@ -4943,6 +5285,7 @@ mod tests {
#[test]
fn same_sequence_from_different_sources_is_independent() {
let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
terminal.set_hook_authority(
"herdr:pi".into(),
"pi".into(),