fix(acp): review fixes — transport-error death notices, threaded messages, keepalive arm

Address review findings from Thufir on PR #935:

- Post death notice on transport-error respawns (Io, WriteTimeout,
  Timeout, Protocol) — same user-facing silence as idle timeout
- Thread death notices into the original conversation via e-tag reply
  so users know which task died in busy channels
- Add explicit "keepalive" match arm in handle_session_update for
  documentation clarity
- Update idle-reset comment to clarify belt-and-suspenders intent

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-09 20:36:46 -04:00
co-authored by Will Pfleger
parent 903ab774bf
commit 7b666057e6
3 changed files with 47 additions and 5 deletions
+4 -2
View File
@@ -854,8 +854,9 @@ impl AcpClient {
match method {
"session/update" => {
if self.handle_session_update(&msg) {
// Tool call started — explicitly reset idle clock.
// The agent will be silent while the tool executes.
// Belt-and-suspenders — general reset already fired
// above, this is defense-in-depth in case the general
// reset is later narrowed.
tracing::debug!("idle clock reset: tool call started");
idle_deadline = Instant::now() + idle_timeout;
}
@@ -963,6 +964,7 @@ impl AcpClient {
);
false
}
"keepalive" => false,
other => {
tracing::debug!(target: "acp::update", "session/update: {other}");
false
+29 -1
View File
@@ -1996,6 +1996,15 @@ fn handle_prompt_result(
.retain(|_, meta| meta.agent_index != agent_index);
debug_assert_eq!(before, pool.task_map().len() + 1);
// Extract thread root from the batch before it's consumed by requeue.
// Used by death notices to thread the message into the original conversation.
let thread_root: Option<String> = result
.batch
.as_ref()
.and_then(|b| b.events.first())
.map(|e| queue::parse_thread_tags(&e.event))
.and_then(|tags| tags.root_event_id);
// Requeue BEFORE mark_complete: requeue() sets retry_after with a future
// deadline, and mark_complete() checks for it to decide whether to preserve
// retry_counts. If mark_complete runs first, retry_counts is cleared and
@@ -2087,7 +2096,7 @@ fn handle_prompt_result(
// Post a visible death notice to the channel so humans know why
// the agent went silent.
if let Some(ch) = channel_id {
match relay.build_death_notice(ch, death_message) {
match relay.build_death_notice(ch, death_message, thread_root.as_deref()) {
Ok(event) => {
if let Err(e) = relay.try_publish_event(event) {
tracing::warn!("failed to publish death notice: {e}");
@@ -2154,6 +2163,25 @@ fn handle_prompt_result(
"transport/protocol error — respawning agent"
);
emit_turn_error(&e.to_string());
// Post a visible death notice for transport errors too.
if let Some(ch) = channel_id {
match relay.build_death_notice(
ch,
"Agent connection lost (transport error)",
thread_root.as_deref(),
) {
Ok(event) => {
if let Err(e) = relay.try_publish_event(event) {
tracing::warn!("failed to publish death notice: {e}");
}
}
Err(e) => {
tracing::warn!("failed to build death notice: {e}");
}
}
}
let index = result.agent.index;
let slot_history = &mut crash_history[index];
if !spawn_respawn_task(
+14 -2
View File
@@ -750,13 +750,25 @@ impl HarnessRelay {
/// Build a channel message (kind:9) for death notices — posted when the
/// agent session ends due to timeout or unexpected exit.
pub fn build_death_notice(&self, channel_id: Uuid, message: &str) -> Result<Event, RelayError> {
/// If `thread_root` is provided, the message is threaded as a reply.
pub fn build_death_notice(
&self,
channel_id: Uuid,
message: &str,
thread_root: Option<&str>,
) -> Result<Event, RelayError> {
use sprout_core::kind::KIND_STREAM_MESSAGE;
let h_tag = Tag::parse(["h", &channel_id.to_string()])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?;
let mut tags = vec![h_tag];
if let Some(root_id) = thread_root {
let e_tag = Tag::parse(["e", root_id, "", "reply"])
.map_err(|e| RelayError::AuthFailed(e.to_string()))?;
tags.push(e_tag);
}
let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), message)
.tags([h_tag])
.tags(tags)
.sign_with_keys(&self.keys)?;
Ok(event)
}