fix(subagent): bound the completion-path awaits so teardown reaches Shutdown
A completed child parked forever behind a parent that never serviced its command channel, or behind its own frozen chat-state actor, leaking the child session thread, fs watchers and fds. Every await in the completion path that rides another actor is now bounded: child chat-state reads degrade to their fallbacks after 10 s, the usage-fold and not-applied acks give up after 30 s (an unacked mark falls back to the coordinator's sticky mark), list_tasks and reparent_notifications are bounded, and the post-Shutdown worktree snapshot is bounded while removal is awaited in full so no detached deletion can race a resume. Ported from upstream 1.0.14 (grok-build subagent/handle_request.rs PARENT_ACK_TIMEOUT / child_actor_query), reshaped without the drain-budget const assert. Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
This commit is contained in:
1 parent
fc7113ba3c
commit
91f14112bf
2 files changed
+215
-92
No files matched your search
@@ -19,6 +19,99 @@ use kigi_tools::implementations::kigi::task::types::*;
|
||||
use kigi_workspace::file_system::AsyncFileSystem;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use super::*;
|
||||
|
||||
/// Bounds each parent-side await in the child completion path so a starved parent `cmd_rx` cannot park a completed child forever.
|
||||
pub(super) const PARENT_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
/// Bounds reads of the child's own actors, which freeze with a tool that blocks the child thread; teardown must still reach `Shutdown`.
|
||||
pub(super) const CHILD_ACTOR_ACK_TIMEOUT: std::time::Duration =
|
||||
std::time::Duration::from_secs(10);
|
||||
|
||||
/// One bounded child-actor query, degrading to `fallback` when the actor never answers.
|
||||
pub(super) async fn child_actor_query<T>(
|
||||
what: &'static str,
|
||||
query: impl std::future::Future<Output = T>,
|
||||
fallback: T,
|
||||
) -> T {
|
||||
match tokio::time::timeout(CHILD_ACTOR_ACK_TIMEOUT, query).await {
|
||||
Ok(value) => value,
|
||||
Err(_) => {
|
||||
tracing::warn!(query = what, "child actor did not answer in time; using fallback");
|
||||
fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds the post-Shutdown worktree snapshot (a `git add` behind a stuck clean filter must not park completion).
|
||||
const WORKTREE_SNAPSHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
/// Snapshot the child worktree to `ref_name`, persist the ref, then remove the worktree; returns `(snapshot_ref, removed)`.
|
||||
async fn dispose_subagent_worktree(
|
||||
worktree: &Path,
|
||||
source_repo: &Path,
|
||||
ref_name: &str,
|
||||
meta_dir: &Path,
|
||||
final_status: &str,
|
||||
subagent_id: &str,
|
||||
) -> (Option<String>, bool) {
|
||||
let snapshot = tokio::time::timeout(
|
||||
WORKTREE_SNAPSHOT_TIMEOUT,
|
||||
crate::session::worktree::snapshot_subagent_worktree(worktree, source_repo, ref_name),
|
||||
)
|
||||
.await;
|
||||
let snapshot_ref = match snapshot {
|
||||
Ok(Ok(snapshot_ref)) => snapshot_ref,
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
|
||||
"Failed to snapshot subagent worktree; preserving for review"
|
||||
);
|
||||
return (None, false);
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::warn!(
|
||||
subagent_id = % subagent_id, worktree_path = % worktree.display(),
|
||||
"worktree snapshot did not finish in time; preserving worktree for resume"
|
||||
);
|
||||
return (None, false);
|
||||
}
|
||||
};
|
||||
if !update_subagent_meta_snapshot_ref(meta_dir, &snapshot_ref, final_status) {
|
||||
tracing::warn!(
|
||||
subagent_id = % subagent_id, worktree_path = % worktree.display(),
|
||||
"snapshot_ref not persisted; preserving worktree for resume"
|
||||
);
|
||||
return (None, false);
|
||||
}
|
||||
// Removal is awaited in full: a detached deletion could race a resume that rehydrates into this path.
|
||||
match crate::session::worktree::remove_subagent_worktree(worktree).await {
|
||||
Ok(()) => {
|
||||
tracing::info!(
|
||||
subagent_id = % subagent_id, worktree_path = % worktree.display(),
|
||||
"snapshotted and removed subagent worktree"
|
||||
);
|
||||
(Some(snapshot_ref), true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
subagent_id = % subagent_id, worktree_path = % worktree.display(), error = % e,
|
||||
"snapshotted subagent worktree but removal failed; ref persisted for resume"
|
||||
);
|
||||
(Some(snapshot_ref), false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Await a parent ack for at most `PARENT_ACK_TIMEOUT`; `false` when it never answers or drops the sender.
|
||||
pub(super) async fn parent_ack(what: &'static str, ack: oneshot::Receiver<()>) -> bool {
|
||||
match tokio::time::timeout(PARENT_ACK_TIMEOUT, ack).await {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(_)) => false,
|
||||
Err(_) => {
|
||||
tracing::warn!(ack = what, "parent did not ack in time; proceeding to teardown");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn task_model_override_error(
|
||||
requested: Option<&str>,
|
||||
provenance: ModelOverrideProvenance,
|
||||
@@ -1333,7 +1426,12 @@ pub(crate) async fn handle_subagent_request(
|
||||
let duration_ms = start.elapsed().as_millis() as u64;
|
||||
let mut result = match wait_outcome {
|
||||
SubagentWaitOutcome::Cancelled => {
|
||||
let (tool_calls, turns) = signals_snapshot_counts(&child_handle).await;
|
||||
let (tool_calls, turns) = child_actor_query(
|
||||
"signals_snapshot_counts",
|
||||
signals_snapshot_counts(&child_handle),
|
||||
(0, 0),
|
||||
)
|
||||
.await;
|
||||
SubagentResult {
|
||||
success: false,
|
||||
cancelled: true,
|
||||
@@ -1360,14 +1458,28 @@ pub(crate) async fn handle_subagent_request(
|
||||
},
|
||||
),
|
||||
) => (snapshot.current.tool_call_count, snapshot.current.turn_count),
|
||||
_ => signals_snapshot_counts(&child_handle).await,
|
||||
_ => {
|
||||
child_actor_query(
|
||||
"signals_snapshot_counts",
|
||||
signals_snapshot_counts(&child_handle),
|
||||
(0, 0),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
let final_text = child_handle
|
||||
.chat_state_handle
|
||||
.get_last_assistant_text()
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let result_tokens = child_handle.chat_state_handle.get_total_tokens().await;
|
||||
let final_text = child_actor_query(
|
||||
"get_last_assistant_text",
|
||||
child_handle.chat_state_handle.get_last_assistant_text(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let result_tokens = child_actor_query(
|
||||
"get_total_tokens",
|
||||
child_handle.chat_state_handle.get_total_tokens(),
|
||||
0,
|
||||
)
|
||||
.await;
|
||||
match *turn_result {
|
||||
Ok(
|
||||
Ok(
|
||||
@@ -1527,14 +1639,21 @@ pub(crate) async fn handle_subagent_request(
|
||||
let final_status = result.status().to_string();
|
||||
let snapshot_dispose_enabled = ctx.resolve_subagent_worktree_snapshot_enabled();
|
||||
let telemetry_tokens = if result.tool_calls > 0 || result.success {
|
||||
child_handle.chat_state_handle.get_total_tokens().await
|
||||
child_actor_query(
|
||||
"get_total_tokens",
|
||||
child_handle.chat_state_handle.get_total_tokens(),
|
||||
0,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_handle
|
||||
.chat_state_handle
|
||||
.try_get_session_usage()
|
||||
.await
|
||||
let (subagent_usage_by_model, subagent_usage_incomplete) = match child_actor_query(
|
||||
"try_get_session_usage",
|
||||
child_handle.chat_state_handle.try_get_session_usage(),
|
||||
Err(()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(u) => (Some(u.by_model.into_iter().collect::<Vec<_>>()), u.incomplete),
|
||||
Err(()) => (None, true),
|
||||
@@ -1553,7 +1672,7 @@ pub(crate) async fn handle_subagent_request(
|
||||
respond_to,
|
||||
})
|
||||
{
|
||||
Ok(()) => ack.await.is_ok(),
|
||||
Ok(()) => parent_ack("RecordSubagentUsage", ack).await,
|
||||
Err(_) => false,
|
||||
}
|
||||
} else {
|
||||
@@ -1570,53 +1689,71 @@ pub(crate) async fn handle_subagent_request(
|
||||
.parent_prompt_id
|
||||
.clone()
|
||||
.or_else(|| coordinator.borrow().parent_prompt_id_for(&request.id));
|
||||
if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
|
||||
// A slow parent may apply both the fold and this mark; both are idempotent.
|
||||
let marked_by_parent = if let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref() {
|
||||
let (respond_to, ack) = tokio::sync::oneshot::channel();
|
||||
if cmd_tx
|
||||
cmd_tx
|
||||
.send(crate::session::commands::SessionCommand::MarkSubagentUsageNotApplied {
|
||||
parent_prompt_id: sticky_prompt,
|
||||
parent_prompt_id: sticky_prompt.clone(),
|
||||
respond_to,
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
let _ = ack.await;
|
||||
}
|
||||
} else if let Some(ref pid) = sticky_prompt {
|
||||
&& parent_ack("MarkSubagentUsageNotApplied", ack).await
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if !marked_by_parent && let Some(ref pid) = sticky_prompt {
|
||||
coordinator.borrow_mut().mark_subagent_usage_not_applied(pid);
|
||||
}
|
||||
}
|
||||
match (&ctx.parent_terminal_backend, &ctx.parent_notification_handle) {
|
||||
(Some(parent_tb), Some(parent_notif_handle)) => {
|
||||
if !request.surface_completion {
|
||||
let reparented_task_ids: Vec<String> = parent_tb
|
||||
.list_tasks()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!t.completed
|
||||
&& t.owner_session_id.as_deref()
|
||||
== Some(&*child_session_id.0)
|
||||
})
|
||||
.map(|t| t.task_id)
|
||||
.collect();
|
||||
if !reparented_task_ids.is_empty()
|
||||
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
||||
{
|
||||
let _ = cmd_tx
|
||||
.send(SessionCommand::RecordGoalTurnTaskIds {
|
||||
task_ids: reparented_task_ids,
|
||||
});
|
||||
match tokio::time::timeout(PARENT_ACK_TIMEOUT, parent_tb.list_tasks()).await {
|
||||
Ok(tasks) => {
|
||||
let reparented_task_ids: Vec<String> = tasks
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!t.completed
|
||||
&& t.owner_session_id.as_deref()
|
||||
== Some(&*child_session_id.0)
|
||||
})
|
||||
.map(|t| t.task_id)
|
||||
.collect();
|
||||
if !reparented_task_ids.is_empty()
|
||||
&& let Some(cmd_tx) = ctx.parent_cmd_tx.as_ref()
|
||||
{
|
||||
let _ = cmd_tx.send(SessionCommand::RecordGoalTurnTaskIds {
|
||||
task_ids: reparented_task_ids,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(_) => tracing::warn!(
|
||||
child_session_id = % child_session_id.0,
|
||||
parent_session_id = % ctx.parent_session_id,
|
||||
"list_tasks not answered in time; skipping goal-turn task-id recording"
|
||||
),
|
||||
}
|
||||
}
|
||||
let parent_backend_weak = std::sync::Arc::downgrade(parent_tb);
|
||||
parent_tb
|
||||
.reparent_notifications(
|
||||
if tokio::time::timeout(
|
||||
PARENT_ACK_TIMEOUT,
|
||||
parent_tb.reparent_notifications(
|
||||
&child_session_id.0,
|
||||
&ctx.parent_session_id,
|
||||
parent_notif_handle.clone(),
|
||||
parent_backend_weak,
|
||||
)
|
||||
.await;
|
||||
),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
child_session_id = % child_session_id.0,
|
||||
parent_session_id = % ctx.parent_session_id,
|
||||
"reparent_notifications not acked in time; proceeding to child shutdown"
|
||||
);
|
||||
}
|
||||
}
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
tracing::warn!(
|
||||
@@ -1637,55 +1774,17 @@ pub(crate) async fn handle_subagent_request(
|
||||
if snapshot_dispose_enabled {
|
||||
let ref_name = format!("refs/kigi/subagents/{}", request.id);
|
||||
let source_repo = resolve_subagent_source_repo(&ctx);
|
||||
match crate::session::worktree::snapshot_subagent_worktree(
|
||||
wt_path,
|
||||
&source_repo,
|
||||
&ref_name,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(snapshot_ref) => {
|
||||
let persisted = update_subagent_meta_snapshot_ref(
|
||||
&subagent_meta_dir,
|
||||
&snapshot_ref,
|
||||
&final_status,
|
||||
);
|
||||
if persisted {
|
||||
disposed_snapshot_ref = Some(snapshot_ref);
|
||||
match crate::session::worktree::remove_subagent_worktree(wt_path)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
worktree_removed = true;
|
||||
tracing::info!(
|
||||
subagent_id = % request.id, worktree_path = % wt_path
|
||||
.display(), "snapshotted and removed subagent worktree"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
subagent_id = % request.id, worktree_path = % wt_path
|
||||
.display(), error = % e,
|
||||
"snapshotted subagent worktree but removal failed; ref persisted for resume"
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
subagent_id = % request.id, worktree_path = % wt_path
|
||||
.display(),
|
||||
"snapshot_ref not persisted; preserving worktree for resume"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
||||
error = % e,
|
||||
"Failed to snapshot subagent worktree; preserving for review"
|
||||
);
|
||||
}
|
||||
}
|
||||
let (snapshot_ref, removed) = dispose_subagent_worktree(
|
||||
wt_path,
|
||||
&source_repo,
|
||||
&ref_name,
|
||||
&subagent_meta_dir,
|
||||
&final_status,
|
||||
&request.id,
|
||||
)
|
||||
.await;
|
||||
disposed_snapshot_ref = snapshot_ref;
|
||||
worktree_removed = removed;
|
||||
} else {
|
||||
tracing::info!(
|
||||
subagent_id = % request.id, worktree_path = % wt_path.display(),
|
||||
|
||||
@@ -3298,3 +3298,27 @@ fn spawn_test_parent_chat_state(model_slug: &str) -> kigi_chat_state::ChatStateH
|
||||
)
|
||||
}
|
||||
mod rest;
|
||||
|
||||
/// A blocked child actor must not park teardown: the read degrades to the fallback in bounded time.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn child_actor_query_is_bounded_when_the_actor_never_answers() {
|
||||
use super::handle_request::{CHILD_ACTOR_ACK_TIMEOUT, child_actor_query};
|
||||
let tokens = tokio::time::timeout(
|
||||
20 * CHILD_ACTOR_ACK_TIMEOUT,
|
||||
child_actor_query("test_query", std::future::pending::<u64>(), 7),
|
||||
)
|
||||
.await
|
||||
.expect("child-actor queries must complete in bounded time");
|
||||
assert_eq!(tokens, 7, "a starved query must degrade to the fallback");
|
||||
}
|
||||
|
||||
/// A parent that never services `cmd_rx` must not park a completed child.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn parent_ack_is_bounded_when_the_parent_never_answers() {
|
||||
use super::handle_request::{PARENT_ACK_TIMEOUT, parent_ack};
|
||||
let (_never_answered, ack) = tokio::sync::oneshot::channel::<()>();
|
||||
let acked = tokio::time::timeout(20 * PARENT_ACK_TIMEOUT, parent_ack("test_ack", ack))
|
||||
.await
|
||||
.expect("parent acks must complete in bounded time");
|
||||
assert!(!acked, "an unanswered ack must report a miss");
|
||||
}
|
||||
Reference in new issue
Block a user