fix(engine): hand finished background shells to the model at step boundaries

codewhale-app#3233 item 1. In `codewhale exec`, a shell command that
auto-backgrounded never got its output to the model: completions were
drained only when the model stopped calling tools, appended without
another model round, and exec ends at the first TurnComplete. The DSH
parity runs saw 0 of 198 backgrounded outputs.

- One helper, Engine::deliver_shell_completions, replaces the three copies
  of drain + runtime message + status (turn start in send_message, and the
  final-answer and late drains in continuation).
- tool_batch calls it after a tool batch's results are appended, before
  the next model request. A job that finished while the model was working
  reaches it on its next step with no extra model round; the event is an
  appended user-role runtime message (no KV prefix change) recorded through
  add_session_message.
- No retry at final-answer time: 03ece7d8b1 removed that deliberately
  (timing-dependent token spend and flaky tests).
- Child engines no longer drain: they share the parent's shell manager and
  root session id, so their drains claimed the parent's unowned completions
  into the child transcript. ACP turns stay excluded.
- drain_finished_jobs_with_evidence_inner no longer claims child-owned jobs.
  It used to claim them, cut their retained output to a tail and then have
  the engine filter them out, so a child's later task_shell_wait lost
  output. The idle-wake predicate already ignored child-owned jobs.
- The background notices say when output arrives: before the model's next
  step, and a job still running when the turn ends is stopped unseen in a
  headless run. Child-owned jobs say their completion stays in task/status.
- deliver_shell_completions carries the known-limitation note.

Verified:
- Real exec run against a scripted local mock model (no provider traffic),
  same script on a baseline and a fixed build of this tree: bash
  `sleep 4; echo BGDONE-7f3` with timeout 1 (auto-backgrounds), then a 6 s
  foreground bash, then a final answer. Both builds make 3 model requests.
  Baseline request 3 never contains BGDONE; fixed request 3 carries it as a
  runtime_event message after the second tool result. Artifacts in
  CW/artifacts/exec-bg-completion-20261018/.
- scripts/dev-test.sh tui shell completion background: 730 run, 730 passed
  (including background_completion_after_a_turn_is_delivered_once_on_the_
  next_turn, child_owned_background_completion_is_not_delivered_to_parent,
  child_owned_background_completion_does_not_wake_parent,
  drain_finished_jobs_reports_once, background_start_advertises_task_
  status_completion, yolo_mode_does_not_prompt_for_background_shell).
- CI-form clippy -p codewhale-tui --all-targets --all-features -D warnings
  clean; cargo fmt --check clean; blocking-call budget 539 (unchanged);
  dead-code budget at budget; sync-changelog up to date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-17 23:56:32 -07:00
1 parent 7c3828d727
commit 8807152224
8 files changed
+60 -51

No files matched your search

+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- A background shell job that finishes while the agent is still working now
reaches the model before its next step, instead of only when the turn ends.
In `codewhale exec`, which stops at the end of the turn, that output used to
be lost. The background notice now says that a job still running when the
turn ends is stopped unseen in a headless run, so the model knows to wait
explicitly when its answer depends on it. Sub-agents no longer pick up the
parent's finished jobs, and the parent no longer consumes a sub-agent's.
- `codewhale review --base <ref>` gives the reviewer the `base..HEAD` commit
messages as the author's stated intent, ahead of the diff, so it stops
flagging changes their commit messages already explain. The messages are
+7
View File
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- A background shell job that finishes while the agent is still working now
reaches the model before its next step, instead of only when the turn ends.
In `codewhale exec`, which stops at the end of the turn, that output used to
be lost. The background notice now says that a job still running when the
turn ends is stopped unseen in a headless run, so the model knows to wait
explicitly when its answer depends on it. Sub-agents no longer pick up the
parent's finished jobs, and the parent no longer consumes a sub-agent's.
- `codewhale review --base <ref>` gives the reviewer the `base..HEAD` commit
messages as the author's stated intent, ahead of the diff, so it stops
flagging changes their commit messages already explain. The messages are
+1 -16
View File
@@ -6396,22 +6396,7 @@ impl Engine {
// Deliver completions that arrived after the previous turn before the
// next user request is sent. This keeps background shell work
// model-visible without requiring an explicit wait/poll tool call.
let shell_completions = if self.is_acp_turn() {
Vec::new()
} else {
self.drain_shell_completion_events()
};
if !shell_completions.is_empty() {
self.add_session_message(crate::runtime_handoff::shell_completion_runtime_message(
&shell_completions,
))
.await;
if let Some(status) =
crate::core::engine::turn_loop::shell_completion_status_text(&shell_completions, "")
{
let _ = self.send_event(Event::status(status)).await;
}
}
self.deliver_shell_completions("").await;
// A person writing to a goal that only the runtime stopped (a failed
// or timed-out continuation) is continuing the work: resume it as a
@@ -387,10 +387,10 @@ fn turn_metadata_carries_the_narrowing_only_on_a_narrowed_turn() {
/// first, and it is delivered exactly once.
///
/// This exercises the engine's own shell manager through the same
/// `drain_shell_completion_events` both delivery sites use — the next-turn
/// boundary drain in `Engine::send_message` and the late drain in the turn
/// loop — so the exactly-once guarantee holds across them rather than within
/// one of them.
/// `drain_shell_completion_events` every delivery site uses — turn start in
/// `Engine::send_message`, the step boundary after a tool batch, and the
/// finishing drains in the turn loop — so the exactly-once guarantee holds
/// across them rather than within one of them.
#[tokio::test]
async fn background_completion_after_a_turn_is_delivered_once_on_the_next_turn() {
let tmp = tempdir().expect("tempdir");
+25 -3
View File
@@ -1152,9 +1152,6 @@ impl Engine {
.unwrap_or_default();
completions
.into_iter()
// Child-owned output stays in task/status for explicit child
// waits. Only unowned jobs belong in the parent model stream.
.filter(|completion| completion.event.owner_agent_id.is_none())
.map(|mut completion| {
let tool_call_id =
format!("background-shell-completion-{}", completion.event.task_id);
@@ -1177,6 +1174,31 @@ impl Engine {
.collect()
}
/// Hand finished background shell jobs to the model as one runtime
/// message. Called at turn start, after each tool batch and when the turn
/// is finishing; returns whether anything was delivered.
///
/// Known limitation: a job still running when the model ends its turn
/// reaches it only through the idle wake, on hosts that keep the Engine
/// alive; headless exec shuts down and stops it unseen. ACP turns and
/// child engines never receive runtime shell completions and use
/// `task_shell_wait` instead.
pub(super) async fn deliver_shell_completions(&mut self, timing: &str) -> bool {
if self.child_host.is_some() {
return false;
}
let completions = self.drain_shell_completion_events();
if completions.is_empty() {
return false;
}
self.add_session_message(shell_completion_runtime_message(&completions))
.await;
if let Some(status) = shell_completion_status_text(&completions, timing) {
let _ = self.send_event(Event::status(status)).await;
}
true
}
/// Keep workers alive while their tracked background shell work is still
/// running. This is deliberately owner-based and read-only: an unowned
/// shell job cannot extend any worker heartbeat.
@@ -113,18 +113,7 @@ impl Engine {
return PhaseResult::Retry;
}
let shell_completions = if self.is_acp_turn() {
Vec::new()
} else {
self.drain_shell_completion_events()
};
if !shell_completions.is_empty() {
self.add_session_message(shell_completion_runtime_message(&shell_completions))
.await;
if let Some(status) = shell_completion_status_text(&shell_completions, "") {
let _ = self.send_event(Event::status(status)).await;
}
}
self.deliver_shell_completions("").await;
// Sub-agent completion handoff (issue #756). Resuming when
// queued completions exist is correct; #3216 says do not wait
@@ -175,19 +164,7 @@ impl Engine {
// arrived between the last hold check and now. If a child finished
// while we were running the thinking-only check, surface its
// sentinel rather than delaying it to the next turn.
let late_shell_completions = if self.is_acp_turn() {
Vec::new()
} else {
self.drain_shell_completion_events()
};
if !late_shell_completions.is_empty() {
self.add_session_message(shell_completion_runtime_message(&late_shell_completions))
.await;
if let Some(status) = shell_completion_status_text(&late_shell_completions, "late")
{
let _ = self.send_event(Event::status(status)).await;
}
}
self.deliver_shell_completions("late").await;
if !self.is_acp_turn() && self.drain_subagent_completion_events("late").await > 0 {
let _ = self
@@ -234,6 +234,8 @@ impl Engine {
.await;
}
self.deliver_shell_completions("").await;
// A successful tool step is productive progress, not a runaway
// synthetic resume. Declared per-task tool budgets and max_steps
// remain the explicit limits for tool-driven work.
+12 -3
View File
@@ -3576,7 +3576,11 @@ impl ShellManager {
let owned = active_session_id.is_none_or(|session_id| {
!session_id.is_empty() && shell.owner_session_id == session_id
});
if owned && shell.status != ShellStatus::Running && !shell.completion_reported {
if owned
&& shell.owner_agent.is_none()
&& shell.status != ShellStatus::Running
&& !shell.completion_reported
{
shell.completion_reported = true;
completions.push(shell.completion_evidence());
// The bytes are now in the caller's hands (they become a durable
@@ -5384,8 +5388,13 @@ fn finish_contract_bash_result(
tail_lines(output.trim(), 20)
};
let notice = promotion_notice(context, task_id, result.duration_ms / 1_000, &output);
let completion = if context.owner_agent_id.is_some() {
"Its completion stays in task/status and is not injected into the parent model."
} else {
"Its completion reaches you as a runtime event before your next step; if you end the turn first, a headless run stops it unseen."
};
return Ok(ToolResult::success(format!(
"{notice}\n\nOutput so far:\n{so_far}\n\nCompletion will appear as a runtime event. To see more output or block until it finishes, call `task_shell_wait` directly with task_id=\"{task_id}\"; do not sleep-poll with bash. To stop it, send the same call with cancel=true. It also stops when the session ends."
"{notice}\n\nOutput so far:\n{so_far}\n\n{completion} To see more output or block until it finishes, call `task_shell_wait` directly with task_id=\"{task_id}\"; do not sleep-poll with bash. To stop it, send the same call with cancel=true. It also stops when the session ends."
)).with_metadata(metadata));
}
if result.status != ShellStatus::Completed {
@@ -6491,7 +6500,7 @@ impl ToolSpec for BashTool {
let completion_contract = if context.owner_agent_id.is_some() {
"completion stays in task/status and is not injected into the parent model."
} else {
"completion is delivered to the model as an internal runtime event and shown in task/status state."
"completion is delivered to the model as an internal runtime event before its next step and shown in task/status state; if the turn ends first, a headless run stops the task unseen."
};
if backgrounded_foreground {
let seconds = result.duration_ms / 1_000;