From 45b90a746dc38b99f5b39f8c4aff761ba2b0a1c5 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 21 Jul 2026 15:53:29 -0300 Subject: [PATCH 1/6] fix(context-manager): count the wire view, not stored details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chars/4 estimator counted the full serialized FunctionResult message, but no provider wire adapter ever sends `details` (content text/images only; the denied envelope is the one exception and stays counted). Fat tool results — a file read carries the whole file in BOTH content and details — were double-billed, halving the effective usable window. Observed live (session scan-x7k2-c11-l2): 22 file reads inflated an ~88k wire request to 148602 estimated > 148000 usable and the turn died terminally in a 200k-window model. --- context-manager/src/core/estimate.rs | 59 +++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/context-manager/src/core/estimate.rs b/context-manager/src/core/estimate.rs index f01b0e056..4c5221241 100644 --- a/context-manager/src/core/estimate.rs +++ b/context-manager/src/core/estimate.rs @@ -43,7 +43,25 @@ impl Estimator for HeuristicEstimator { } fn message(&self, message: &AgentMessage) -> u64 { - let chars = serde_json::to_string(message).map(|s| s.len()).unwrap_or(0); + // Provider wire adapters send a function result's rendered `content` + // only — `details` never crosses the wire except inside the denied + // envelope (see provider-*/src/wire `format_function_result_content`). + // A fat details payload (file reads duplicate the whole file there) + // must not consume budget, or the effective window halves. + let chars = match message { + AgentMessage::FunctionResult { details, .. } + if !details.is_null() + && details.get("status").and_then(serde_json::Value::as_str) + != Some("denied") => + { + let mut wire_view = message.clone(); + if let AgentMessage::FunctionResult { details, .. } = &mut wire_view { + *details = serde_json::Value::Null; + } + serde_json::to_string(&wire_view).map(|s| s.len()).unwrap_or(0) + } + _ => serde_json::to_string(message).map(|s| s.len()).unwrap_or(0), + }; (chars / 4) as u64 } @@ -120,6 +138,45 @@ mod tests { assert_eq!(HeuristicEstimator.text(""), 0); } + #[test] + fn function_result_details_are_not_counted() { + // Live incident (2026-07-21, session scan-x7k2-c11-l2): 22 file reads + // carried the full file in BOTH content and details; the double bill + // inflated an ~88k wire request to 148_602 estimated > 148_000 usable + // and killed the turn terminally. Details never cross the provider + // wire, so they must not weigh in. + let est = HeuristicEstimator; + let fat = msg(json!({ + "role": "function_result", "function_call_id": "c", "function_id": "f", + "content": [{ "type": "text", "text": "body" }], + "details": { "content": "x".repeat(4000) }, "timestamp": 3 + })); + let null_details = msg(json!({ + "role": "function_result", "function_call_id": "c", "function_id": "f", + "content": [{ "type": "text", "text": "body" }], + "details": null, "timestamp": 3 + })); + assert_eq!(est.message(&fat), est.message(&null_details)); + } + + #[test] + fn denied_details_envelope_is_counted() { + // The denied envelope IS serialized into the wire body + // ([PERMISSION_DENIED] + JSON), so it keeps weighing in. + let est = HeuristicEstimator; + let denied = msg(json!({ + "role": "function_result", "function_call_id": "c", "function_id": "f", + "content": [], "details": { "status": "denied", "reason": "x".repeat(400) }, + "timestamp": 3 + })); + let plain = msg(json!({ + "role": "function_result", "function_call_id": "c", "function_id": "f", + "content": [], "details": { "status": "ok", "reason": "x".repeat(400) }, + "timestamp": 3 + })); + assert!(est.message(&denied) > est.message(&plain)); + } + #[test] fn by_role_partitions_every_role() { let messages = vec![ From 2063a0e5b7534ba0e7902b370169e97900afa45d Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 21 Jul 2026 15:53:29 -0300 Subject: [PATCH 2/6] fix(context-manager): never compact the entire working set during assembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit select() legally returns a whole-head selection (tail_turns 0, or a view with no user turn — e.g. a harness candidate window opened at a prior compaction's assistant-boundary tail_start), and try_compact only guarded against an empty head. Compaction then summarized everything, split_off emptied the messages, and assemble returned an empty model-facing context — which providers hard-reject and the harness turns into a terminal context_overflow. Skip compaction whenever it would consume the whole working set; emergency reduction (replaces-but-never-removes) does the shrinking instead. The guard lives in assemble, not select(), because context::compact legitimately summarizes whole histories. Observed live (session scan-batch-4-7x2k): second compaction on a user-less window killed the turn with "context::assemble returned an empty model-facing context". BDD scenario fails without the guard. --- context-manager/src/functions/assemble.rs | 11 ++++++++++ .../tests/features/assemble.feature | 20 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/context-manager/src/functions/assemble.rs b/context-manager/src/functions/assemble.rs index 258d58ebf..8bc63d18d 100644 --- a/context-manager/src/functions/assemble.rs +++ b/context-manager/src/functions/assemble.rs @@ -356,6 +356,17 @@ async fn try_compact( let outcome = async { let budget = preserve_recent_budget(usable_budget, None); let selection = select(working, sizes, budget, tail_turns); + // Never compact the ENTIRE working set during assembly: an empty + // verbatim tail assembles into an empty messages array, which + // providers hard-reject ("messages: at least one message is + // required") and the harness turns into a terminal context_overflow. + // select() legally returns a whole-head selection for tail_turns == 0 + // or a view with no user turn (e.g. a harness candidate window opened + // at a prior compaction's assistant-boundary tail_start); skip + // compaction and let emergency reduction do the shrinking. + if selection.head_len >= working.len() { + return None; + } let head = &working[..selection.head_len]; if head.is_empty() { return None; diff --git a/context-manager/tests/features/assemble.feature b/context-manager/tests/features/assemble.feature index 4789f72e9..2040aca6e 100644 --- a/context-manager/tests/features/assemble.feature +++ b/context-manager/tests/features/assemble.feature @@ -178,6 +178,26 @@ Feature: context::assemble — the model-ready context pipeline And the response messages are not empty And the response messages start at request message 2 + # Prevents: a candidate window with NO user turn (the harness opens + # windows at a prior compaction's tail_start, which may be an assistant + # boundary) being summarised into an EMPTY model context. select() + # returns a whole-head selection for a user-less view; compaction must + # skip it and leave the shrinking to emergency reduction. Observed live + # 2026-07-21: turn failed "context::assemble returned an empty + # model-facing context". + Scenario: a user-less window is never compacted to empty + Given inline model "small" with context window 5000 and max output 500 + And the summariser returns "## Goal\n- never used" + And an assistant function call "c1" to "coder::read-file" + And a function result for call "c1" from "coder::read-file" of ~6000 tokens + When I assemble the history with model "small" + Then the call succeeds + And the response field "applied.compacted" is false + And the response messages are not empty + And the response field "token_count" does not exceed 4000 + And the summariser was invoked 0 times + And call/result pairing is intact in the response messages + # Prevents: both passes fighting instead of stacking — prune frees # the tool output, compaction then folds the rest of the head. Scenario: prune and compaction stack when one is not enough From a70db0c7380c17be26e28055856a66cd0efca893 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 21 Jul 2026 15:54:10 -0300 Subject: [PATCH 3/6] fix(harness): effective re-assembly recovery and loud child failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two turn-loop gaps observed live on 2026-07-21, one commit because the changes share turn_loop.rs hunks: Re-assembly deficit vs believed size: the one-shot re-assembly folded final - usable into the reservation, but post-assembly additions (the fp::inject-guidance prompt append is ~2.5k tokens vs the 256 allowance) can dwarf that overshoot. Assembly stayed under its own ceiling, rebuilt the identical request, and the turn died twice on the same count (148602 > 148000, session scan-x7k2-c11-l2). Measure the deficit against assembled.token_count — everything the request grew beyond what assembly believed — via reassembly_deficit(). Loud child failures: a fire-and-forget spawn settles its call Done at spawn time and the parent turn has usually completed, so resolve_parent(failed) no-oped at both resolve() gates and a dead child was structurally silent — it can never write the state keys its spawner watches, and turn-completed events reach only registered subscriptions (session console-42ae032b: two dead scanners, coordinator waited forever). resolve_parent now reports delivery; when unconsumed, finalize_failed sends an idempotent [child-failure] message to the spawner session (model/provider inherited, loop-safe: send-created turns carry no ParentLink). The spawn result text documents the automatic failure delivery. --- harness/src/deferred.rs | 25 +++++--- harness/src/subagent.rs | 3 +- harness/src/turn_loop.rs | 128 ++++++++++++++++++++++++++++++++++++--- 3 files changed, 141 insertions(+), 15 deletions(-) diff --git a/harness/src/deferred.rs b/harness/src/deferred.rs index 32cc86a66..38b000983 100644 --- a/harness/src/deferred.rs +++ b/harness/src/deferred.rs @@ -337,13 +337,20 @@ async fn find_call_arguments( /// Resolve a parked parent call from a finishing child (harness.md § /// Sub-agents). `completed` delivers the child's result; `failed`/`cancelled` /// deliver an `is_error`. +/// +/// Returns whether the parent turn actually consumed the resolution. A +/// fire-and-forget spawn settles its call `Done` at spawn time and the parent +/// turn has usually completed by the time the child finishes, so both +/// `resolve` gates return `not_resolved` — the caller must then pick another +/// channel if the outcome matters (see the child-failure notification in +/// `finalize_failed`). pub async fn resolve_parent( deps: &Deps, parent: &crate::types::turn::ParentLink, status: &str, result: Option<&Value>, reason: Option<&str>, -) { +) -> bool { let (content, details, is_error) = if status == "completed" { let text = result.map(render_text).unwrap_or_default(); ( @@ -369,12 +376,16 @@ pub async fn resolve_parent( is_error: Some(is_error), details: Some(details), }; - if let Err(e) = resolve(deps, req).await { - tracing::warn!( - parent_session = %parent.session_id, - error = %e, - "resolving parent call from child completion failed" - ); + match resolve(deps, req).await { + Ok(resp) => resp.resolved, + Err(e) => { + tracing::warn!( + parent_session = %parent.session_id, + error = %e, + "resolving parent call from child completion failed" + ); + false + } } } diff --git a/harness/src/subagent.rs b/harness/src/subagent.rs index 0e6c167a9..cf6cdce52 100644 --- a/harness/src/subagent.rs +++ b/harness/src/subagent.rs @@ -99,7 +99,8 @@ pub fn spawned_result(child: &ChildIds) -> ResultData { }); let text = format!( "{ids}\nfire-and-forget: this turn will NOT receive the child's result; consume it via \ - the triggers/state you registered." + the triggers/state you registered. If the child FAILS terminally, a [child-failure] \ + message is delivered to this session automatically — no failure listener needed." ); ResultData { content: vec![ContentBlock::text(text)], diff --git a/harness/src/turn_loop.rs b/harness/src/turn_loop.rs index 65409ed61..f07cad3dd 100644 --- a/harness/src/turn_loop.rs +++ b/harness/src/turn_loop.rs @@ -464,13 +464,13 @@ pub async fn run_step( ) .await; } - // One-shot recovery: fold the measured overshoot (plus margin for - // hook variance on the retry — hooks re-run against the smaller - // context) into the reservation and re-assemble. The compaction - // bookkeeping entry id is per (turn, step), so a re-assembled - // compaction dedupes instead of double-writing. + // One-shot recovery: fold the measured post-assembly additions (plus + // margin for hook variance on the retry — hooks re-run against the + // smaller context) into the reservation and re-assemble. The + // compaction bookkeeping entry id is per (turn, step), so a + // re-assembled compaction dedupes instead of double-writing. reassembled = true; - let deficit = final_request_tokens - assembled.usable; + let deficit = reassembly_deficit(final_request_tokens, assembled.token_count); extra_overhead_tokens = extra_overhead_tokens .saturating_add(deficit) .saturating_add(REASSEMBLY_HEADROOM_MARGIN_TOKENS); @@ -1323,7 +1323,7 @@ async fn finalize_failed( ) .await; if let Some(parent) = record.parent.clone() { - crate::deferred::resolve_parent( + let delivered = crate::deferred::resolve_parent( deps, &parent, "failed", @@ -1331,6 +1331,17 @@ async fn finalize_failed( Some(reason), ) .await; + // Fire-and-forget spawns settle their call `Done` at spawn time and + // the parent turn has usually completed by now, so the resolve above + // no-ops — and a dead child can never write the state keys or + // completion markers its spawner is watching. Wake the parent SESSION + // with the failure instead: spawned-child death must be loud by + // default, not structurally silent (observed live 2026-07-21: two + // spawned scanners died instantly and their coordinator waited + // forever on state triggers that could never fire). + if !delivered { + notify_parent_of_child_failure(deps, &parent.session_id, record, reason, failure).await; + } } Ok(TurnStepResult { session_id: record.session_id.clone(), @@ -1340,6 +1351,61 @@ async fn finalize_failed( }) } +/// Best-effort child-failure wake-up: send a user-visible failure notice to +/// the spawner's session, starting (or steering) a turn there so the model +/// can respawn, reroute, or report. Idempotent per child turn. +async fn notify_parent_of_child_failure( + deps: &Deps, + parent_session_id: &str, + record: &TurnRecord, + reason: &str, + failure: FailureInfo, +) { + let notice = child_failure_notice( + &record.session_id, + &record.turn_id, + failure.code, + failure.retryable, + reason, + ); + let req = crate::functions::send::SendRequest { + session_id: Some(parent_session_id.to_string()), + message: crate::functions::send::MessageInput::Text(notice), + model: None, + provider: None, + idempotency_key: Some(format!("child_failure_{}", record.turn_id)), + session: None, + options: None, + }; + if let Err(e) = crate::functions::send::handle(deps, req).await { + tracing::warn!( + parent_session = %parent_session_id, + child_session = %record.session_id, + child_turn = %record.turn_id, + error = %e, + "child-failure notification to the parent session failed" + ); + } +} + +/// The wake-up text the spawner's model sees when a spawned child dies +/// terminally without a parked parent call to deliver into. +fn child_failure_notice( + child_session_id: &str, + child_turn_id: &str, + code: &str, + retryable: bool, + reason: &str, +) -> String { + format!( + "[child-failure] Spawned child session '{child_session_id}' (turn {child_turn_id}) \ + FAILED terminally [{code}] and will deliver no result: {reason} (retryable: \ + {retryable}). Any state keys or completion markers that child was expected to write \ + will never arrive — stop waiting on them. Recover now: respawn the work, reassign \ + it, or report the failure." + ) +} + fn llm_failure_info(error_kind: Option) -> FailureInfo { match error_kind { Some(ErrorKind::AuthExpired) => FailureInfo { @@ -1984,6 +2050,17 @@ fn final_request_unchanged( !hook_appended && patched == 0 && gen_system_prompt == assembled_system_prompt } +/// The reservation fold for the one-shot re-assembly: everything the final +/// request grew beyond what assembly believed it built (`token_count`), NOT +/// merely the overshoot past `usable`. Post-assembly additions can dwarf both +/// the up-front allowance and the ceiling overshoot (fp::inject-guidance +/// appends ~2.5k tokens vs the 256-token allowance); folding only the +/// overshoot can leave assembly under its own ceiling, so it rebuilds the +/// identical request and the turn dies terminally on the same count. +fn reassembly_deficit(final_request_tokens: u64, believed_token_count: u64) -> u64 { + final_request_tokens.saturating_sub(believed_token_count) +} + fn patch_orphaned_calls(messages: &mut Vec) -> usize { let mut resolved: std::collections::HashSet = std::collections::HashSet::new(); for m in messages.iter() { @@ -2194,6 +2271,43 @@ mod tests { assert!(!super::final_request_unchanged(false, 0, &None, &prompt)); } + #[test] + fn child_failure_notice_names_the_child_and_the_consequence() { + // Live incident (2026-07-21, console-42ae032b): two fire-and-forget + // spawned scanners failed instantly; their coordinator had only + // success-path state triggers and waited forever. The wake-up notice + // must name the child, the failure code, and the key consequence — + // the child's completion markers will never arrive. + let notice = super::child_failure_notice( + "dcmcp-scan-a2k9", + "t_6d37d87e", + "llm.permanent", + false, + "model not found: gpt-4o-mini", + ); + assert!(notice.contains("dcmcp-scan-a2k9")); + assert!(notice.contains("t_6d37d87e")); + assert!(notice.contains("[llm.permanent]")); + assert!(notice.contains("retryable: false")); + assert!(notice.contains("will never arrive")); + assert!(notice.contains("model not found: gpt-4o-mini")); + } + + #[test] + fn reassembly_deficit_covers_post_assembly_additions_not_just_the_overshoot() { + // Live incident (2026-07-21, session scan-x7k2-c11-l2): assembly + // measured ~146_050 under a 148_000 ceiling, the guidance hook + // appended ~2_552 tokens, final count 148_602. The old + // ceiling-relative fold (148_602 - 148_000 = 602) left re-assembly + // a no-op — assembly stayed under its own ceiling, rebuilt the + // identical request, and the turn died terminally on the same + // count. The believed-relative fold reserves the full addition. + assert_eq!(super::reassembly_deficit(148_602, 146_050), 2_552); + assert!(super::reassembly_deficit(148_602, 146_050) > 148_602 - 148_000); + // Saturates if the final request came out smaller than believed. + assert_eq!(super::reassembly_deficit(100, 200), 0); + } + #[test] fn context_overflow_classification_requires_the_stable_context_code() { assert!(super::is_context_overflow_error( From 20e2eb94292f87e0a3b79294a2970e0a7cdf5f5b Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 21 Jul 2026 15:54:10 -0300 Subject: [PATCH 4/6] fix(harness): raise default max_transient_resumes to 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider overloads (Anthropic 529s) cluster in bursts, and only mid-stream errors burn this budget — pre-stream errors already get llm-router backoff retries. A budget of 1 killed a turn one generation step from done when a second overload landed after the first resume (session dcmcp-scan-p6w4-c-aq: both findings INSERTed, bookkeeping never ran). Config and per-turn serde defaults stay in lockstep. --- harness/src/config.rs | 9 +++++++-- harness/src/types/turn.rs | 7 +++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/harness/src/config.rs b/harness/src/config.rs index 8f05013f9..6d1b98913 100644 --- a/harness/src/config.rs +++ b/harness/src/config.rs @@ -211,7 +211,12 @@ fn default_max_validation_retries() -> u32 { 2 } fn default_max_transient_resumes() -> u32 { - 1 + // Provider overloads (Anthropic 529s) cluster in bursts, and only + // mid-stream errors burn this budget (pre-stream errors are retried + // with backoff inside llm-router). A budget of 1 killed a turn that + // was one generation step from done when a second overload landed + // (observed live 2026-07-21, session dcmcp-scan-p6w4-c-aq). + 3 } fn default_idem_ttl_secs() -> u64 { 86_400 @@ -335,7 +340,7 @@ mod tests { assert_eq!(cfg.default_max_turns, 500); assert_eq!(cfg.max_depth, 3); assert_eq!(cfg.max_children, 8); - assert_eq!(cfg.max_transient_resumes, 1); + assert_eq!(cfg.max_transient_resumes, 3); assert_eq!(cfg.sweep_expression, "0 0 0 * * *"); } diff --git a/harness/src/types/turn.rs b/harness/src/types/turn.rs index 49fbed4a9..b9d084424 100644 --- a/harness/src/types/turn.rs +++ b/harness/src/types/turn.rs @@ -101,7 +101,10 @@ fn default_max_validation_retries() -> u32 { } fn default_max_transient_resumes() -> u32 { - 1 + // Keep in lockstep with `config::default_max_transient_resumes` — + // overload bursts cluster; a budget of 1 dies on the second + // mid-stream 529 in a turn. + 3 } impl TurnOptions { @@ -413,7 +416,7 @@ mod tests { assert!(!r.abort); assert!(r.calls.is_empty()); assert_eq!(r.options.max_validation_retries, 2); - assert_eq!(r.options.max_transient_resumes, 1); + assert_eq!(r.options.max_transient_resumes, 3); assert_eq!(r.transient_resumes, 0); assert_eq!(r.options.output, OutputContract::Text); } From c4e5a4ff833cadefc5726f051838415c37ce2ef4 Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 21 Jul 2026 15:54:10 -0300 Subject: [PATCH 5/6] fix(shell): front-load the coder::read-file redirect in fs::read docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shell::fs::read returns a ContentRef handle, not file text, but the coder::read-file hint sat at the docstring tail where weak models miss it — a haiku scanner read 22 files through it, got useless stubs, and re-read everything via coder::read-file, doubling round-trips on the way to a context-overflow death. State the handle-not-text nature and the text-file redirect in the first two sentences. --- shell/src/main.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/shell/src/main.rs b/shell/src/main.rs index b47da50d9..b21857047 100644 --- a/shell/src/main.rs +++ b/shell/src/main.rs @@ -626,10 +626,12 @@ fn register_fs(iii: &iii_sdk::IIIClient, state: &AppState) { coder::create-file (batched) avoids the streaming channel." ); fs_fn!("shell::fs::read", fs_read, fs::ReadRequest, fs::ReadResponseWire, - "Stream a file from a path. Returns a ContentRef the caller reads from, plus size/mode/mtime. \ - Errors return { code, message }; common: S211 not found or not accessible, S212 path is a \ - directory, S215 jail escape, S218 file exceeds max_read_bytes, S216 channel/IO error. For \ - text files, coder::read-file returns content inline (windowed, batched) with no channel."); + "Stream a file from a path — returns a ContentRef HANDLE (channel_id/access_key), NOT the \ + file text. For reading TEXT files use coder::read-file instead: it returns the content \ + inline (windowed, batched) with no channel. This function is for binary/streamed payloads; \ + the response carries the ContentRef plus size/mode/mtime. Errors return { code, message }; \ + common: S211 not found or not accessible, S212 path is a directory, S215 jail escape, \ + S218 file exceeds max_read_bytes, S216 channel/IO error."); } /// Wait for SIGINT or, on Unix, SIGTERM so `docker stop` / `kubectl delete` From b279ae1c129d71451a601885188b882701c6516e Mon Sep 17 00:00:00 2001 From: Anderson Leal Date: Tue, 21 Jul 2026 15:55:23 -0300 Subject: [PATCH 6/6] style(context-manager): apply cargo fmt --- context-manager/src/core/estimate.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/context-manager/src/core/estimate.rs b/context-manager/src/core/estimate.rs index 4c5221241..514a0457a 100644 --- a/context-manager/src/core/estimate.rs +++ b/context-manager/src/core/estimate.rs @@ -58,7 +58,9 @@ impl Estimator for HeuristicEstimator { if let AgentMessage::FunctionResult { details, .. } = &mut wire_view { *details = serde_json::Value::Null; } - serde_json::to_string(&wire_view).map(|s| s.len()).unwrap_or(0) + serde_json::to_string(&wire_view) + .map(|s| s.len()) + .unwrap_or(0) } _ => serde_json::to_string(message).map(|s| s.len()).unwrap_or(0), };