Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 74 additions & 6 deletions src-tauri/crates/acorn-session/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,17 @@ struct HookRuntimeState {
/// Kept apart from `permission_waiting_at`, whose clearing rules belong
/// to the Codex turn lifecycle.
attention_at: Option<SystemTime>,
/// Claude's most recent `Stop` hook boundary. A separate Stop hook can
/// reject completion and resume the same turn without emitting a new
/// `UserPromptSubmit`; later agent-owned transcript activity closes this
/// boundary.
claude_stop_at: Option<SystemTime>,
}

#[derive(Clone, Copy)]
enum ClaudeWaitingBoundary {
Attention,
Stop,
}

impl HookRuntimeState {
Expand All @@ -619,6 +630,7 @@ impl HookRuntimeState {
self.turn_id = None;
self.permission_waiting_at = None;
self.attention_at = None;
self.claude_stop_at = None;
}
}

Expand Down Expand Up @@ -1040,10 +1052,10 @@ impl SessionStore {

/// Record that an attention request without a resolving hook is open.
pub fn mark_attention_at(&self, id: &Uuid, requested_at: SystemTime) {
self.lock_hook_runtime()
.entry(*id)
.or_default()
.attention_at = Some(requested_at);
let mut runtime = self.lock_hook_runtime();
let state = runtime.entry(*id).or_default();
state.attention_at = Some(requested_at);
state.claude_stop_at = None;
}

pub fn attention_at(&self, id: &Uuid) -> Option<SystemTime> {
Expand All @@ -1052,9 +1064,25 @@ impl SessionStore {
.and_then(|state| state.attention_at)
}

pub fn clear_attention(&self, id: &Uuid) {
/// Record a Claude Stop boundary that may still be rejected by another
/// session-scoped Stop hook.
pub fn mark_claude_stop_at(&self, id: &Uuid, stopped_at: SystemTime) {
let mut runtime = self.lock_hook_runtime();
let state = runtime.entry(*id).or_default();
state.attention_at = None;
state.claude_stop_at = Some(stopped_at);
}

pub fn claude_stop_at(&self, id: &Uuid) -> Option<SystemTime> {
self.lock_hook_runtime()
.get(id)
.and_then(|state| state.claude_stop_at)
}

pub fn clear_claude_waiting(&self, id: &Uuid) {
if let Some(state) = self.lock_hook_runtime().get_mut(id) {
state.attention_at = None;
state.claude_stop_at = None;
}
}

Expand All @@ -1074,26 +1102,66 @@ impl SessionStore {
expected_source: Option<AgentStatusSource>,
expected_lifecycle_revision: u64,
resumed_at: SystemTime,
) -> SessionResult<Option<u64>> {
self.resolve_claude_waiting_if_current(
id,
expected_source,
expected_lifecycle_revision,
resumed_at,
ClaudeWaitingBoundary::Attention,
)
}

/// Resolve a Stop that another hook rejected after Claude resumed the
/// same turn without a new prompt lifecycle event.
pub fn resolve_claude_stop_if_current(
&self,
id: &Uuid,
expected_source: Option<AgentStatusSource>,
expected_lifecycle_revision: u64,
resumed_at: SystemTime,
) -> SessionResult<Option<u64>> {
self.resolve_claude_waiting_if_current(
id,
expected_source,
expected_lifecycle_revision,
resumed_at,
ClaudeWaitingBoundary::Stop,
)
}

fn resolve_claude_waiting_if_current(
&self,
id: &Uuid,
expected_source: Option<AgentStatusSource>,
expected_lifecycle_revision: u64,
resumed_at: SystemTime,
boundary: ClaudeWaitingBoundary,
) -> SessionResult<Option<u64>> {
let mut runtime = self.lock_hook_runtime();
let mut entry = self
.inner
.get_mut(id)
.ok_or_else(|| SessionError::NotFound(id.to_string()))?;
let state = runtime.entry(*id).or_default();
let waiting_at = match boundary {
ClaudeWaitingBoundary::Attention => state.attention_at,
ClaudeWaitingBoundary::Stop => state.claude_stop_at,
};
if entry.status != SessionStatus::WaitingForInput
|| state.status_source != expected_source
|| state.lifecycle_revision != expected_lifecycle_revision
|| !state.confirmed
|| !entry.hook_active
|| state.attention_at.is_none_or(|at| resumed_at <= at)
|| waiting_at.is_none_or(|at| resumed_at <= at)
{
return Ok(None);
}

entry.status = SessionStatus::Working;
state.status_source = Some(AgentStatusSource::TranscriptFallback);
state.attention_at = None;
state.claude_stop_at = None;
Ok(Some(state.advance_lifecycle_revision()))
}

Expand Down
46 changes: 44 additions & 2 deletions src-tauri/crates/acorn-session/src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ pub struct StatusDetection {
/// Provider timestamp of the transcript line this detection classified.
/// Only transcript evidence carries one.
pub turn_timestamp: Option<String>,
/// Newest provider line that proves the main agent loop is actively
/// running. Claude uses this to distinguish a Stop hook that actually
/// returned control from one that rejected completion and resumed.
pub agent_activity_timestamp: Option<String>,
}

impl StatusDetection {
Expand All @@ -95,6 +99,7 @@ impl StatusDetection {
evidence,
completed_provider_turn_id: None,
turn_timestamp: None,
agent_activity_timestamp: None,
}
}

Expand All @@ -107,6 +112,11 @@ impl StatusDetection {
self.turn_timestamp = timestamp;
self
}

fn with_agent_activity_timestamp(mut self, timestamp: Option<String>) -> Self {
self.agent_activity_timestamp = timestamp;
self
}
}

/// Infer a session's status from its transcript tail (when one was resolved)
Expand Down Expand Up @@ -167,19 +177,23 @@ pub fn detect_with_reason(
state: TurnState::Ready,
provider_turn_id,
timestamp,
agent_activity_timestamp,
}) => StatusDetection::new(
completed_turn_status(kind),
Some(StatusReason::TurnComplete),
StatusEvidence::Transcript,
)
.with_completed_provider_turn_id(provider_turn_id)
.with_turn_timestamp(timestamp),
.with_turn_timestamp(timestamp)
.with_agent_activity_timestamp(agent_activity_timestamp),
Some(TurnObservation {
state: TurnState::Working,
timestamp,
agent_activity_timestamp,
..
}) => StatusDetection::new(SessionStatus::Working, None, StatusEvidence::Transcript)
.with_turn_timestamp(timestamp),
.with_turn_timestamp(timestamp)
.with_agent_activity_timestamp(agent_activity_timestamp),
// Transcript exists but the tail held no turn lines; keep
// whatever the caller previously observed instead of regressing
// to Ready. The next poll that lands on a real turn line corrects
Expand Down Expand Up @@ -552,6 +566,34 @@ mod tests {
);
}

#[test]
fn detect_carries_claude_agent_activity_for_blocked_stop_recovery() {
let path = write_status_transcript(concat!(
r#"{"timestamp":"2026-08-05T07:38:01Z","type":"user","isMeta":true,"message":{"role":"user","content":"Stop hook feedback: keep working"}}"#,
"\n",
r#"{"timestamp":"2026-08-05T07:38:08Z","type":"assistant","message":{"role":"assistant","stop_reason":"tool_use","content":[]}}"#,
"\n",
r#"{"timestamp":"2026-08-05T07:38:09Z","type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"done"}]}}"#,
));

let detection = detect_with_reason(
Some((path, AgentKind::Claude)),
SessionStatus::WaitingForInput,
Some(ShellHint::Running),
);

assert_eq!(detection.status, SessionStatus::Working);
assert_eq!(detection.evidence, StatusEvidence::Transcript);
assert_eq!(
detection.turn_timestamp.as_deref(),
Some("2026-08-05T07:38:09Z")
);
assert_eq!(
detection.agent_activity_timestamp.as_deref(),
Some("2026-08-05T07:38:08Z")
);
}

#[test]
fn detect_reports_turn_complete_reason_for_finished_transcript() {
let path = write_status_transcript(
Expand Down
Loading
Loading