Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
53381ad
test(tui): require agent hook journal events to drive agent records
lawrence703 Aug 27, 2026
c51d1f3
fix(tui): feed agent records from committed hook journal events
lawrence703 Aug 27, 2026
938009d
fix(tui): drop the agent record when its session ends
lawrence703 Aug 27, 2026
0005cf4
test(tui): cover stale agent sequences and done restore
lawrencecchen Aug 27, 2026
316e49d
fix(tui): order hook projections and omit done restores
lawrencecchen Aug 27, 2026
c0f7b97
fix(tui): purge hook sequence state with terminals
lawrencecchen Aug 28, 2026
31da819
fix(tui): reconcile replayed hook projections
lawrencecchen Aug 28, 2026
ac66c61
Fix agent hook sequence fencing after projection
lawrencecchen Aug 28, 2026
b8a4269
fix(tui): serialize hook sequence projection updates
lawrencecchen Aug 28, 2026
849eb72
fix(tui): restore hook watermarks and terminal tombstones
lawrencecchen Aug 28, 2026
d8c0696
fix(tui): persist applied hook sequence watermarks
lawrencecchen Aug 28, 2026
8a0e0e2
fix(tui): suppress ended hook live updates
lawrencecchen Aug 28, 2026
4491d33
fix(tui): preserve hook markers in public agent views
lawrencecchen Aug 28, 2026
15bc536
fix(tui): fence all non-hook reports after hook end
lawrencecchen Aug 28, 2026
328cb4a
fix(tui): avoid hook sequence self-deadlock
lawrencecchen Aug 28, 2026
7fac01c
fix(tui): publish agent removal on hook end
lawrencecchen Aug 28, 2026
e36ea55
fix(tui): serialize agent watermark reports
lawrencecchen Aug 28, 2026
be1020d
fix(tui): fence legacy done hook projections
lawrencecchen Aug 28, 2026
ba42d96
fix(tui): restore socket done agent records
lawrencecchen Aug 28, 2026
883e830
fix(tui): persist typed hook lifecycle state
lawrencecchen Aug 28, 2026
525e222
fix(tui): retain hook input for public record
lawrencecchen Aug 28, 2026
08865a9
fix(tui): unify hook lifecycle fencing
lawrencecchen Aug 28, 2026
0005155
fix(tui): propagate hook projection failures
lawrencecchen Aug 28, 2026
0903b2f
fix(tui): hide completed hook projections
lawrencecchen Aug 28, 2026
a95ec21
fix(tui): durably retry failed hook projections
lawrencecchen Aug 28, 2026
19fd7c2
test(tui): cover durable hook projection retry
lawrencecchen Aug 28, 2026
3ebeca8
fix(tui): scope pending hooks by receipt identity
lawrencecchen Aug 28, 2026
d9a8370
fix(tui): stage hook retries with journal receipts
lawrencecchen Aug 28, 2026
2d236ce
test(tui): define terminal projection fixture helper
lawrencecchen Aug 28, 2026
d15ad30
fix(tui): wake pending hooks on agent availability
lawrencecchen Aug 28, 2026
50b9985
test(tui): clone terminal fixture before indexing
lawrencecchen Aug 28, 2026
fdafc88
fix(tui): scope pending hook retries by terminal
lawrencecchen Aug 28, 2026
50ec02a
fix(tui): bound lifecycle hook retries
lawrencecchen Aug 28, 2026
055a3b3
fix(tui): fence stale hook reports and cap retries
lawrencecchen Aug 28, 2026
55ae43f
fix(tui): preserve hook watermarks and retry liveness
lawrencecchen Aug 28, 2026
8e764c3
fix(tui): bound hook retries and isolate lifecycles
lawrencecchen Aug 28, 2026
36fbbd5
fix(tui): make agent hook replay durable and bounded
lawrencecchen Aug 28, 2026
7036bc9
fix(tui): remove localized error copy and document reports
lawrencecchen Aug 28, 2026
b0a9b57
fix(tui): expose hook retry limit to registry
lawrencecchen Aug 28, 2026
39be8fb
style(tui): apply rustfmt to agent replay test
lawrencecchen Aug 28, 2026
cc2602d
fix(tui): fence late sessionless hook events
lawrencecchen Aug 28, 2026
c26d609
fix(tui): persist socket session after ended hook
lawrencecchen Aug 28, 2026
07cfe51
Preserve retry budget for transient hook projection failures
lawrencecchen Aug 28, 2026
bec7503
fix(tui): preserve typed hook retry classes
lawrencecchen Aug 28, 2026
29288c9
test(tui): cover transient hook retry budget
lawrencecchen Aug 28, 2026
16eb379
style(tui): format agent hook retry tests
lawrencecchen Aug 28, 2026
5d16c3b
fix(tui): preserve sessions and durable hook fences
lawrencecchen Aug 28, 2026
b8f664e
fix(tui): bound unavailable hook receipts
lawrencecchen Aug 28, 2026
2cc4400
test(tui): remove duplicate hook fence regression
lawrencecchen Aug 28, 2026
dcd9b0b
fix(tui): bound terminal hook retry receipts
lawrencecchen Aug 28, 2026
f6ec97a
fix(tui): retire unavailable hook projections
lawrencecchen Aug 28, 2026
9bdc667
fix(tui): advance direct hook session fences
lawrencecchen Aug 28, 2026
f0549b7
fix(tui): persist agent hook apply cursor
lawrencecchen Aug 28, 2026
3ddcf87
fix(tui): make hook projection sequence durable
lawrencecchen Aug 28, 2026
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
2 changes: 1 addition & 1 deletion cmux-tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1,711 changes: 1,659 additions & 52 deletions cmux-tui/crates/cmux-tui-core/src/mux.rs

Large diffs are not rendered by default.

125 changes: 123 additions & 2 deletions cmux-tui/crates/cmux-tui-core/src/mux/public_projections.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub(super) struct RestoredPublicProjections {
pub(super) has_terminal_defaults: bool,
pub(super) next_notification_id: u64,
pub(super) agent_records: HashMap<TerminalPublicId, TerminalAgentRecord>,
pub(super) agent_hook_fences: HashMap<TerminalPublicId, super::HookFence>,
pub(super) terminal_notifications: HashMap<TerminalPublicId, SurfaceNotification>,
pub(super) notification_ledger: VecDeque<ResourceNotification>,
}
Expand Down Expand Up @@ -59,13 +60,56 @@ pub(super) fn restore_public_projections(
.saturating_add(1);

let mut agent_records = HashMap::with_capacity(projections.agents.len());
let mut agent_hook_fences = HashMap::new();
for hook_state in projections.agent_hook_states {
agent_hook_fences.insert(
hook_state.terminal_id,
super::HookFence {
session_id: hook_state.agent_session_id,
sequence: hook_state.applied_sequence,
ended: hook_state.ended,
},
);
}
for agent in projections.agents {
let state = agent_state(&agent.state)?;
let internal_marker = agent.source_session.as_deref().is_some_and(|value| {
value.starts_with("cmux-hook-sequence:") || value.starts_with("cmux-hook-ended:")
});
if let Some(source_session) = agent.source_session.as_deref() {
let marker = source_session.strip_prefix("cmux-hook-sequence:");
let ended = source_session.strip_prefix("cmux-hook-ended:");
if let Some(value) = marker.or(ended).and_then(|value| value.parse::<u64>().ok()) {
// Marker-only projections predate durable hook state. Use the
// marker sequence as their legacy generation token so a
// session-less event continues the same lifecycle after a
// restart without reusing a terminal-wide identity.
agent_hook_fences.entry(agent.terminal_id.clone()).or_insert(super::HookFence {
session_id: super::legacy_hook_session_id(&agent.terminal_id, value),
sequence: value,
ended: ended.is_some(),
});
}
}
if state == AgentState::Done && agent.source == "hook" {
// Older projections predate the internal ended marker. Keep
// their terminal fenced after restart so a late socket report
// cannot resurrect the completed session. Sequence zero is the
// one-release compatibility generation for records without a
// marker.
agent_hook_fences.entry(agent.terminal_id.clone()).or_insert(super::HookFence {
session_id: super::legacy_hook_session_id(&agent.terminal_id, 0),
sequence: 0,
ended: true,
});
continue;
}
let previous = agent_records.insert(
agent.terminal_id.clone(),
TerminalAgentRecord {
state: agent_state(&agent.state)?,
state,
source: agent_source(&agent.source)?,
session: agent.source_session,
session: (!internal_marker).then_some(agent.source_session).flatten(),
updated_at_ms: agent.updated_at_ms,
},
);
Expand All @@ -81,6 +125,7 @@ pub(super) fn restore_public_projections(
has_terminal_defaults,
next_notification_id,
agent_records,
agent_hook_fences,
terminal_notifications,
notification_ledger,
})
Expand Down Expand Up @@ -123,6 +168,10 @@ mod tests {
use crate::terminal_host_runtime::TerminalHostIdentity;
use crate::workspace_registry::{RegistryAgentProjection, RegistryNotificationProjection};

fn terminal_id(value: u8) -> TerminalPublicId {
TerminalPublicId::parse(format!("term_{value:032x}")).unwrap()
}

fn empty_state() -> State {
State {
workspaces: Vec::new(),
Expand Down Expand Up @@ -177,6 +226,7 @@ mod tests {
updated_at_ms: 1,
source_session: None,
}],
agent_hook_states: Vec::new(),
terminal_defaults: None,
frontend_projections: Vec::new(),
};
Expand Down Expand Up @@ -206,6 +256,7 @@ mod tests {
unread: true,
}],
agents: Vec::new(),
agent_hook_states: Vec::new(),
terminal_defaults: None,
frontend_projections: Vec::new(),
};
Expand All @@ -229,6 +280,7 @@ mod tests {
unread: true,
}],
agents: Vec::new(),
agent_hook_states: Vec::new(),
terminal_defaults: None,
frontend_projections: Vec::new(),
};
Expand All @@ -238,4 +290,73 @@ mod tests {
assert_eq!(restored.notification_ledger[0].terminal_id, Some(terminal));
assert!(restored.terminal_notifications.is_empty());
}

#[test]
fn done_agent_records_are_not_restored_into_live_roster() {
let terminal = terminal_id(9);
let projections = RegistryPublicProjections {
notifications: Vec::new(),
agents: vec![RegistryAgentProjection {
id: AgentPublicId::parse("agent_00000000000000000000000000000009").unwrap(),
terminal_id: terminal.clone(),
state: "done".into(),
source: "hook".into(),
updated_at_ms: 1,
source_session: None,
}],
agent_hook_states: Vec::new(),
terminal_defaults: None,
frontend_projections: Vec::new(),
};
let restored = restore_public_projections(&empty_state(), projections).unwrap();
assert!(restored.agent_records.is_empty());
assert!(restored.agent_hook_fences[&terminal].ended);
}

#[test]
fn hook_marker_restores_watermark_without_exposing_session() {
let terminal = terminal_id(10);
let projections = RegistryPublicProjections {
notifications: Vec::new(),
agents: vec![RegistryAgentProjection {
id: AgentPublicId::parse("agent_00000000000000000000000000000010").unwrap(),
terminal_id: terminal.clone(),
state: "working".into(),
source: "hook".into(),
updated_at_ms: 1,
source_session: Some("cmux-hook-sequence:12".into()),
}],
agent_hook_states: Vec::new(),
terminal_defaults: None,
frontend_projections: Vec::new(),
};
let restored = restore_public_projections(&empty_state(), projections).unwrap();
assert_eq!(restored.agent_hook_fences[&terminal].sequence, 12);
assert_eq!(restored.agent_records[&terminal].session, None);
}

#[test]
fn socket_done_agent_restores_into_live_record_map() {
let terminal = terminal_id(11);
let projections = RegistryPublicProjections {
notifications: Vec::new(),
agents: vec![RegistryAgentProjection {
id: AgentPublicId::parse("agent_00000000000000000000000000000011").unwrap(),
terminal_id: terminal.clone(),
state: "done".into(),
source: "socket".into(),
updated_at_ms: 3,
source_session: Some("socket-session".into()),
}],
agent_hook_states: Vec::new(),
terminal_defaults: None,
frontend_projections: Vec::new(),
};
let restored = restore_public_projections(&empty_state(), projections).unwrap();
let record = &restored.agent_records[&terminal];
assert_eq!(record.state, AgentState::Done);
assert_eq!(record.source, AgentSource::Socket);
assert_eq!(record.session.as_deref(), Some("socket-session"));
assert!(!restored.agent_hook_fences.contains_key(&terminal));
}
}
16 changes: 16 additions & 0 deletions cmux-tui/crates/cmux-tui-core/src/resource_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,22 @@ pub(crate) fn public_session_snapshot_with_journal_head(
let mut agents = public_projections
.agents
.into_iter()
.filter(|agent| {
!(agent.source == "hook" && agent.state == "done")
&& !agent
.source_session
.as_deref()
.is_some_and(|value| value.starts_with("cmux-hook-ended:"))
})
.map(|mut agent| {
if agent.source_session.as_deref().is_some_and(|value| {
value.starts_with("cmux-hook-sequence:")
|| value.starts_with("cmux-hook-ended:")
}) {
agent.source_session = None;
}
agent
})
.map(|agent| agent.into_public_snapshot(&topology.session_id))
.collect::<Vec<_>>();
agents.sort_by(|left, right| {
Expand Down
4 changes: 4 additions & 0 deletions cmux-tui/crates/cmux-tui-core/src/workspace_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ pub use public_projection_store::RegistryPublicProjections;
#[cfg(test)]
pub use public_projection_store::{RegistryAgentProjection, RegistryNotificationProjection};
pub(crate) use resource_store::validate_registry_screen_projection;
pub(crate) use resource_store::{
AGENT_HOOK_MAX_ATTEMPTS, AGENT_HOOK_MAX_RETRY_PAGES_PER_WAKE, AgentHookProjectionState,
AgentHookRetryClass,
};
#[allow(unused_imports)]
pub use resource_store::{
RegistryBrowser, RegistryBrowserLaunch, RegistryBrowserReconnect, RegistryBrowserSource,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,16 @@ fn append_journal_ingress_transaction(
if let Some(commit) =
ingress_receipt(tx, &ingress.producer_id, origin, idempotency_key, fingerprint.as_slice())?
{
if ingress.producer_id == crate::AGENT_HOOK_PRODUCER_ID {
WorkspaceRegistry::stage_agent_hook_pending(
tx,
&ingress.producer_id,
origin,
idempotency_key,
commit.sequence,
ingress,
)?;
}
return Ok(commit);
}
let installed = tx
Expand Down Expand Up @@ -1528,6 +1538,16 @@ fn append_journal_ingress_transaction(
canonical_json(&result)?,
],
)?;
if built_in_agent {
WorkspaceRegistry::stage_agent_hook_pending(
tx,
&ingress.producer_id,
origin,
idempotency_key,
sequence,
ingress,
)?;
}
Ok(JournalAppendCommit { sequence, event_id, replayed: false })
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,14 @@ pub struct RegistryAgentProjection {
pub source_session: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RegistryAgentHookState {
pub terminal_id: TerminalPublicId,
pub agent_session_id: String,
pub applied_sequence: u64,
pub ended: bool,
}

impl RegistryAgentProjection {
pub(crate) fn into_public_snapshot(self, session_id: &SessionPublicId) -> Value {
json!({
Expand All @@ -63,6 +71,7 @@ pub struct RegistryPublicProjections {
/// Oldest first, matching the in-memory notification ledger.
pub notifications: Vec<RegistryNotificationProjection>,
pub agents: Vec<RegistryAgentProjection>,
pub(crate) agent_hook_states: Vec<RegistryAgentHookState>,
pub terminal_defaults: Option<DefaultColors>,
pub frontend_projections: Vec<FrontendProjection>,
}
Expand Down Expand Up @@ -183,11 +192,13 @@ impl WorkspaceRegistry {
let live_terminals = self.live_terminal_public_ids()?;
let notifications = self.durable_notifications(&live_terminals)?;
let agents = self.durable_agents(None, None)?;
let agent_hook_states = self.durable_agent_hook_states()?;
let terminal_defaults = self.durable_terminal_defaults()?;
let frontend_projections = self.public_frontend_projections()?;
Ok(RegistryPublicProjections {
notifications,
agents,
agent_hook_states,
terminal_defaults,
frontend_projections,
})
Expand All @@ -198,7 +209,22 @@ impl WorkspaceRegistry {
terminal: Option<&TerminalPublicId>,
state: Option<&str>,
) -> anyhow::Result<Vec<RegistryAgentProjection>> {
self.durable_agents(terminal, state)
let mut agents = self.durable_agents(terminal, state)?;
agents.retain(|agent| {
!(agent.source == "hook" && agent.state == "done")
&& !agent
.source_session
.as_deref()
.is_some_and(|value| value.starts_with("cmux-hook-ended:"))
});
for agent in &mut agents {
if agent.source_session.as_deref().is_some_and(|value| {
value.starts_with("cmux-hook-sequence:") || value.starts_with("cmux-hook-ended:")
}) {
agent.source_session = None;
}
}
Ok(agents)
}

fn live_terminal_public_ids(&self) -> anyhow::Result<HashSet<TerminalPublicId>> {
Expand Down Expand Up @@ -338,6 +364,34 @@ impl WorkspaceRegistry {
Ok(agents)
}

fn durable_agent_hook_states(&self) -> anyhow::Result<Vec<RegistryAgentHookState>> {
let mut statement = self.connection.prepare(
"SELECT terminal_id, agent_session_id, applied_sequence, ended
FROM resource_agent_hook_state
ORDER BY terminal_id ASC",
)?;
statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, bool>(3)?,
))
})?
.map(|row| {
let (terminal_id, agent_session_id, applied_sequence, ended) = row?;
Ok(RegistryAgentHookState {
terminal_id: TerminalPublicId::parse(terminal_id)?,
agent_session_id,
applied_sequence: u64::try_from(applied_sequence)
.context("agent hook sequence is negative")?,
ended,
})
})
.collect()
}

fn durable_terminal_defaults(&self) -> anyhow::Result<Option<DefaultColors>> {
let stored = self
.connection
Expand Down
Loading
Loading