Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cd3497c
fix(freshclaude): correlate sends and runtime ends by incarnation
danshapiro Oct 23, 2026
447d6ba
test(freshclaude): reserve durable ownership epochs in the sidecar rig
danshapiro Oct 23, 2026
b19dc42
feat(freshclaude): establish durable UUID identity before create ackn…
danshapiro Oct 23, 2026
084dee2
test(freshclaude): keep browser fixture identities on the requested UUID
danshapiro Oct 23, 2026
429712b
fix(freshclaude): retain ownership and retry details from refusals
danshapiro Oct 23, 2026
1a61986
feat(freshclaude): recover stopped conversations through one runtime …
danshapiro Oct 23, 2026
cee90c1
fix(freshclaude): surface correlated control failures
danshapiro Oct 23, 2026
0f577d1
feat(freshclaude): detect quiet busy runtimes with the shared deadman
danshapiro Oct 23, 2026
f88efb0
feat(freshclaude): stop owned runtimes for recovery without closing t…
danshapiro Oct 23, 2026
cbf8bee
feat(freshclaude): capture exact create cleanup authority
danshapiro Oct 23, 2026
fd8ed3d
test(freshclaude): prove native CLI crash and hung tool recovery
danshapiro Oct 23, 2026
bea2e0d
feat(freshclaude): await process recovery before resuming stuck panes
danshapiro Oct 23, 2026
2c35262
chore(freshclaude): clear lint leftovers from UUID test migration
danshapiro Oct 24, 2026
e5273c5
test(freshclaude): preserve handoff coverage with native create UUIDs
danshapiro Oct 24, 2026
df9f063
test(claude-sidecar): verify correlated failed Stop receipts
danshapiro Oct 24, 2026
da665d8
test(freshclaude): fence checkpointed sends with create-time identity
danshapiro Oct 24, 2026
24dcc14
test(freshclaude): observe create-time ownership before the first CLI…
danshapiro Oct 24, 2026
3019f8c
feat(freshclaude): acknowledge canonical attaches to the requesting page
danshapiro Oct 24, 2026
58959d1
test(freshclaude): retain Kilroy handoff owner notifications while aw…
danshapiro Oct 24, 2026
c733cce
Merge origin/main attention replay into Claude recovery
danshapiro Oct 24, 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
4 changes: 3 additions & 1 deletion crates/freshell-claude-sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,9 @@ function handleCreate(req) {
return
}

const sessionId = nanoid()
// Rust assigns the durable identity before create. Retain the bare-id
// fallback for an older host that has not adopted create-time UUIDs yet.
const sessionId = isUuid(req.sessionId) ? req.sessionId : nanoid()
const abort = new AbortController()
const state = {
abort,
Expand Down
2,008 changes: 918 additions & 1,090 deletions crates/freshell-freshagent/src/claude.rs

Large diffs are not rendered by default.

210 changes: 210 additions & 0 deletions crates/freshell-freshagent/src/claude/attach.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//! A page attaches to a durable conversation, without changing its runtime.

use super::*;
use freshell_terminal::FrameSink;

impl FreshClaudeState {
/// Reply to this page only. Status, content, and attention keep their
/// ordinary broadcast path and canonical session identity.
pub async fn handle_attach(&self, mut msg: FreshAgentAttach, reply: FrameSink) {
let session_type = session_type_str(msg.session_type);
let fence =
match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) {
Ok(fence) => fence,
Err(error) => {
attach_error(&reply, &msg, &msg.session_id, error.code(), error.message());
return;
}
};
let Some(candidate) = attach_durable_id(&msg) else {
reply(lost_session_frame(&msg.session_id, msg.session_type));
return;
};
if !is_canonical_claude_uuid(&msg.session_id)
&& self.identity_sink().is_some_and(|sink| {
sink.kill_tombstone_at_ms(PROVIDER, &msg.session_id)
.is_some()
})
{
attach_error(
&reply,
&msg,
&msg.session_id,
"SESSION_CLOSED",
"This pane was closed. Open the conversation again to continue.",
);
return;
}
let durable = self
.identity_sink()
.and_then(|sink| sink.chain_terminus(PROVIDER, &candidate))
.unwrap_or_else(|| candidate.clone());
let mut previous = msg.session_id.clone();
if durable != candidate && previous != durable {
// A durable ledger correction remains useful even when the
// subsequent owner fence refuses this delayed attach.
reply(
self.materialized_message(&previous, &durable, session_type)
.await,
);
previous = durable.clone();
}
msg.session_id = durable.clone();
msg.session_ref = Some(claude_session_ref(&durable));
msg.resume_session_id = None;

match self.reply_live_attach(&msg, &previous, fence, &reply).await {
Ok(true) | Err(()) => return,
Ok(false) => {}
}
// A competing attach may already be booting this conversation.
// Wait for it, then inspect the runtime again before launching.
let _flight = self.wait_resume_flight(&durable).await;
match self.reply_live_attach(&msg, &previous, fence, &reply).await {
Ok(true) | Err(()) => return,
Ok(false) => {}
}
match self.resume_untracked(&msg, &durable, fence, true).await {
Ok(()) => {
// This operation advanced its own claim. The incoming
// pre-launch fence no longer names its committed owner.
match self.reply_live_attach(&msg, &previous, None, &reply).await {
Ok(true) | Err(()) => {}
Ok(false) => attach_error(
&reply,
&msg,
&previous,
"CLAUDE_ATTACH_RESUME_FAILED",
"Claude stopped while attaching. Try again.",
),
}
}
Err(recovery::RuntimeResumeError::Lost) => {
reply(lost_session_frame(&previous, msg.session_type));
}
Err(recovery::RuntimeResumeError::Closed) => attach_error(
&reply,
&msg,
&previous,
"SESSION_CLOSED",
"This conversation was closed. Open it again to continue.",
),
Err(recovery::RuntimeResumeError::Unavailable { code, message, .. }) => {
attach_error(&reply, &msg, &previous, code, &message);
}
}
tracing::debug!(target: "freshell_freshagent::claude", provider = PROVIDER,
session_id = %durable, session_type, "freshagent.claude.attach_settled");
}

/// Hold the coordinator's observation window through the snapshot and
/// correction. A handoff cannot commit around this acknowledgement.
async fn reply_live_attach(
&self,
msg: &FreshAgentAttach,
previous: &str,
fence: Option<freshell_ownership::ObservedFence>,
reply: &FrameSink,
) -> Result<bool, ()> {
let Some(key) = self.resolve_session_key(&msg.session_id).await else {
return Ok(false);
};
let operation = format!("claude-attach-{}", uuid::Uuid::new_v4());
let guard = match fence {
Some(fence) => crate::ownership_lane::arm_adopt_guard(
&self.ownership,
PROVIDER,
&msg.session_id,
&operation,
&freshell_ownership::OwnerIdentity {
kind: freshell_ownership::RuntimeOwnerKind::FreshAgent,
terminal_id: None,
live_session_key: Some(key.clone()),
pid: None,
ownership_id: None,
},
fence,
"freshclaude/attach",
),
None => crate::ownership_lane::arm_attach_guard(
&self.ownership,
PROVIDER,
&msg.session_id,
&operation,
None,
"freshclaude/attach",
),
};
if matches!(guard, crate::ownership_lane::LaneAttachGuard::Refused) {
attach_error(
reply,
msg,
previous,
"SESSION_RESERVED",
"A lifecycle operation owns this session; retry after it settles",
);
return Err(());
}
let status = self
.sessions
.lock()
.await
.get(&key)
.map(ClaudeSession::current_status);
let Some(status) = status else {
return Ok(false);
};
if previous != msg.session_id {
reply(
self.materialized_message(
previous,
&msg.session_id,
session_type_str(msg.session_type),
)
.await,
);
}
reply(status_snapshot_frame(
&msg.session_id,
&msg.session_id,
&status,
session_type_str(msg.session_type),
));
drop(guard);
Ok(true)
}
}

#[cfg(test)]
impl FreshClaudeState {
/// In-crate tests observe replies through their existing capture bus.
pub(super) async fn attach_for_test(&self, msg: FreshAgentAttach) {
let bus = self.broadcast_tx.clone();
self.handle_attach(
msg,
Arc::new(move |frame| {
if let Ok(frame) = serde_json::to_string(&frame) {
let _ = bus.send(frame);
}
}),
)
.await;
}
}

fn attach_error(
reply: &FrameSink,
msg: &FreshAgentAttach,
session_id: &str,
code: &str,
message: &str,
) {
tracing::warn!(target: "freshell_freshagent::claude", provider = PROVIDER,
session_id, code, "freshagent.claude.attach_refused");
reply(ServerMessage::FreshAgentEvent(FreshAgentEvent {
provider: PROVIDER.into(),
session_id: session_id.into(),
session_type: session_type_str(msg.session_type).into(),
event: json!({"type":"freshAgent.error", "sessionId":session_id, "code":code, "message":message}),
}));
}
99 changes: 99 additions & 0 deletions crates/freshell-freshagent/src/claude/create_binding.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//! A create establishes recoverable identity before its acknowledgement. The
//! native transcript may not exist until the first prompt; Fresh genesis and
//! the create-request lineage make that zero-turn identity durable meanwhile.

use super::*;

impl FreshClaudeState {
pub(super) async fn record_create_binding(
&self,
session_id: &str,
msg: &FreshAgentCreate,
provenance: Option<&crate::BindProvenance>,
ticket: &Option<freshell_ownership::OperationTicket>,
) -> std::io::Result<()> {
let Some(sink) = self.identity_sink() else {
return Ok(());
};
let (settings, genesis) = {
let sessions = self.sessions.lock().await;
let session = sessions.get(session_id).ok_or_else(|| {
std::io::Error::other("Claude stopped while recording its create identity")
})?;
(
session.configuration.settings.clone(),
session.launch_genesis.clone(),
)
};
let generation = ticket.as_ref().map(|ticket| ticket.generation());
sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert {
provider: PROVIDER.into(),
session_id: session_id.into(),
mode: session_type_str(msg.session_type).into(),
create_request_id: Some(msg.request_id.clone()),
resolves_pending: None,
supersedes: None,
name_transition: None,
genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| {
crate::identity_sink::GenesisUpdate::Set(recorded.genesis)
}),
provenance: provenance.cloned().into(),
observed_epoch: generation.and(
self.ownership
.as_ref()
.map(|registry| registry.boot_epoch()),
),
observed_generation: generation,
authoritative: false,
settings,
})
.await
}

/// The persisted binding/genesis verifies durable routing independently of
/// native transcript flush. Naming can bind to this identity at create.
pub(super) async fn bind_create_name(
&self,
session_id: &str,
session_type: &str,
) -> Option<(
freshell_protocol::session_names::SessionNameRef,
freshell_protocol::session_names::SessionNameRecord,
)> {
if session_type == "kilroy"
|| !self.identity_sink().is_some_and(|sink| {
sink.load_genesis(PROVIDER, session_id).is_some()
|| sink.was_recorded(PROVIDER, session_id)
})
{
return None;
}
let handle = self.naming_handles.lock().await.get(session_id).cloned()?;
let target = freshell_protocol::session_names::SessionNameRef::Session {
provider: freshell_protocol::session_names::NamedProvider::Claude,
session_id: session_id.into(),
};
let transition = crate::naming::NameTransition::binding(
crate::naming::NameTransitionReason::InitialMaterialization,
freshell_protocol::native_location::NativeAcquisition {
location: freshell_protocol::native_location::NativeLocation::Claude {
config_root: self.transcript_roots().first()?.display().to_string(),
transcript_path: None,
project_directory_key: None,
transcript_cwd: None,
effective_project_key_override: None,
},
evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata,
persistence: freshell_protocol::native_location::NativePersistence::Verified,
},
handle,
);
let update =
crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone())
.await?;
if update.record.name_ref == target {
self.naming_handles.lock().await.remove(session_id);
}
Some((update.record.name_ref.clone(), update.record))
}
}
4 changes: 4 additions & 0 deletions crates/freshell-freshagent/src/claude/create_outcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ pub enum ClaudeCreateOutcome {
session_id: String,
/// The durable conversation, when known (a resume or an adopted session).
session_ref: Option<SessionLocator>,
/// Authority to abandon only the runtime established by this create.
/// Live adoption supplies none. This receipt never appears on the wire.
cleanup_ownership_id: Option<String>,
},
/// No session runs for this create.
Failed {
Expand Down Expand Up @@ -109,6 +112,7 @@ mod tests {
let created = ClaudeCreateOutcome::Created {
session_id: "s".into(),
session_ref: None,
cleanup_ownership_id: None,
};
assert!(created.create_failed_frame("req-3").is_none());
}
Expand Down
Loading
Loading