fix(tui): pre-release repair batch for 0.9.4 — stall honesty, idle wakes, truncation recovery, wait ergonomics

- turn_loop: a mid-stream chunk-timeout now counts toward the stream-error
  budget (stall with nothing streamed retries transparently; an exhausted
  budget fails the turn with the real reason instead of ending Completed
  over a frozen block).
- idle engine: a finished background shell task wakes and starts an ordinary
  runtime turn even without an active goal; a dead provider route claims the
  completion once and reports where the output lives.
- subagent: over-budget final reports spill to a session artifact and the
  truncation footer names the retrieve_tool_result ref; write failures
  degrade to the honest no-ref footer. Test-only wrappers marked cfg(test).
- waits: agents/wait and agent action=wait default to 30 s and cap at 120 s
  (blocked waits deafen the session; settled children report back as
  sentinels). Bash action=wait honors timeout_secs/timeout aliases and
  block; result metadata reports the real wait_timeout_ms.
- todo_write canonical naming: constructor is new(); work_update/TodoWrite/
  todo stay hidden compat aliases; user-visible copy and docs updated.
- behavioral tips: DurableStateWritten fires on successful remember calls;
  enum allow removed. voice.rs and work_surface model use let-chains.
- test: Windows path-separator tolerant artifact footer assertion.
- changelog: 0.9.4 additions (Agent Plugins v1.0.0, send_later, /advisor,
  quiet mode, automation forms, resume_from, transport resilience,
  durability, zh-Hant, update chip, RLM groundwork, stall/wake/truncation/
  wait fixes). Dead-code budget re-baselined to 452.
This commit is contained in:
CodeWhale Bot
2026-08-07 02:45:59 -07:00
parent d57ce9d06f
commit 21ed173cf1
26 changed files with 425 additions and 136 deletions
+62
View File
@@ -140,6 +140,39 @@ File edits, terminal width, and Windows installation.
- Acceptance-level Gherkin coverage locking the existing user-command
precedence, alias shadowing, fallback, and invalid-command error contract
(PR #4992).
- Agent Plugins v1.0.0: consume, publish, and slugify packaged sub-agent
briefs, with an install/update/uninstall on-ramp in the TUI (PR #5182). A
plugin bundles a prompt, posture, and routing as one shareable artifact;
on-disk migration of the older `plugin.toml` scaffold is deliberately out
of scope for this train.
- `send_later`: a model-callable one-shot delayed continuation tool, so the
model can schedule a single future nudge without an operator-approved
durable automation (PR #5138).
- `/advisor`: an opt-in background advisor watcher for live turns (PR #5139).
- Notification quiet mode with per-category switches and action-first copy
(PR #5066).
- Automation scheduling forms — one-shot `ONCE`, five-field cron, and honest
watcher modes — created through the approval-gated `automation` tool
(PR #5183).
- Sub-agent `resume_from` continuation chains (PR #5142), child-result
diff-tainting when a claimed diff is not visible to git, per-turn usage
receipts on the exec stream-json stream, and spawn receipts that report
the model each sub-agent actually ran on.
- Transport resilience: sub-agent exec transport retries with a 600 s
default (PR #5210), SSE header stalls retryable instead of fatal, and
headless turn resume after mid-stream network drops with an `EX_TEMPFAIL`
exit.
- Session durability and control: a deterministic compaction continuation
contract (PR #5064), persisting interrupted output (PR #5206), stop-word
cancellation (PR #5207), token-counter refresh (PR #5204), deny-by-default
approval cards (PR #5090), and the Operate completion gate (PR #5067).
- zh-Hant promoted to a full shipped locale with complete `en.json` parity
(PR #5143).
- A persistent update-available chip in the header, with the startup update
check throttled and naming the right command.
- RLM static intent extraction for code blocks (`rlm_block_intent.rs`)
landed as groundwork for a future code-mode approval flow; it is not yet
wired into the turn pipeline and ships dormant by design.
### Changed
@@ -186,6 +219,20 @@ File edits, terminal width, and Windows installation.
futures-util to 0.3.33, libc to 0.2.189, actions/stale to 11.0.0, and
docker/login-action to 4.5.2. The locked graph also includes the
event-listener 5.4.2 fix for RUSTSEC-2026-0221.
- The progress surface now speaks plainly everywhere: the last user-visible
"Work update is pending" notices say "To-do list", the tool constructor and
the docs name `todo_write` as the single canonical progress tool, and
`work_update`, `TodoWrite`, and `todo` stay registered as hidden
compatibility aliases so saved transcripts keep replaying.
- Sub-agent and `agents/wait` waits stay short by default and by cap:
blocking waits default to 30 s and refuse to block past 120 s, because a
blocked wait deafens the session to typed input and settled children
already report back as `<codewhale:subagent.done>` sentinels.
- `Bash` `action=wait` honors `timeout_secs` (seconds) and bare `timeout`
(milliseconds) alongside canonical `timeout_ms`, and `block` as an alias
for `wait`, so a habit formed on other wait tools gets the duration it
asked for instead of silently falling back to the 30 s default; the result
metadata reports the real `wait_timeout_ms` applied.
### Fixed
@@ -362,6 +409,21 @@ File edits, terminal width, and Windows installation.
- Transcript wheel scrolling under iTerm2: xterm alternate-scroll (DECSET
1007) now stays off while mouse capture is active, so wheel events arrive as
mouse events instead of being converted into arrow keys (#5223, PR #5234).
- A stalled model stream no longer ends the turn as `Completed` over a
frozen reasoning block: a mid-stream chunk-timeout now counts toward the
stream-error budget, so a stall with nothing streamed retries the request
transparently, and a stall that exhausts the retry budget fails the turn
with the real reason instead of reporting success.
- A finished background shell task now wakes the engine even when no goal is
active: the idle loop starts an ordinary runtime turn so the completion
reaches the model immediately instead of sitting unclaimed until the user
types (a dead provider route claims the completion once and reports where
the output lives instead of re-arming the same error every tick).
- Sub-agent final reports that exceed the summary budget are now spilled to
a session artifact, and the truncation footer names the
`retrieve_tool_result` ref for the elided middle instead of telling the
model the bytes are unrecoverable; write failures degrade to the honest
no-ref footer.
### Removed
+62
View File
@@ -140,6 +140,39 @@ File edits, terminal width, and Windows installation.
- Acceptance-level Gherkin coverage locking the existing user-command
precedence, alias shadowing, fallback, and invalid-command error contract
(PR #4992).
- Agent Plugins v1.0.0: consume, publish, and slugify packaged sub-agent
briefs, with an install/update/uninstall on-ramp in the TUI (PR #5182). A
plugin bundles a prompt, posture, and routing as one shareable artifact;
on-disk migration of the older `plugin.toml` scaffold is deliberately out
of scope for this train.
- `send_later`: a model-callable one-shot delayed continuation tool, so the
model can schedule a single future nudge without an operator-approved
durable automation (PR #5138).
- `/advisor`: an opt-in background advisor watcher for live turns (PR #5139).
- Notification quiet mode with per-category switches and action-first copy
(PR #5066).
- Automation scheduling forms — one-shot `ONCE`, five-field cron, and honest
watcher modes — created through the approval-gated `automation` tool
(PR #5183).
- Sub-agent `resume_from` continuation chains (PR #5142), child-result
diff-tainting when a claimed diff is not visible to git, per-turn usage
receipts on the exec stream-json stream, and spawn receipts that report
the model each sub-agent actually ran on.
- Transport resilience: sub-agent exec transport retries with a 600 s
default (PR #5210), SSE header stalls retryable instead of fatal, and
headless turn resume after mid-stream network drops with an `EX_TEMPFAIL`
exit.
- Session durability and control: a deterministic compaction continuation
contract (PR #5064), persisting interrupted output (PR #5206), stop-word
cancellation (PR #5207), token-counter refresh (PR #5204), deny-by-default
approval cards (PR #5090), and the Operate completion gate (PR #5067).
- zh-Hant promoted to a full shipped locale with complete `en.json` parity
(PR #5143).
- A persistent update-available chip in the header, with the startup update
check throttled and naming the right command.
- RLM static intent extraction for code blocks (`rlm_block_intent.rs`)
landed as groundwork for a future code-mode approval flow; it is not yet
wired into the turn pipeline and ships dormant by design.
### Changed
@@ -186,6 +219,20 @@ File edits, terminal width, and Windows installation.
futures-util to 0.3.33, libc to 0.2.189, actions/stale to 11.0.0, and
docker/login-action to 4.5.2. The locked graph also includes the
event-listener 5.4.2 fix for RUSTSEC-2026-0221.
- The progress surface now speaks plainly everywhere: the last user-visible
"Work update is pending" notices say "To-do list", the tool constructor and
the docs name `todo_write` as the single canonical progress tool, and
`work_update`, `TodoWrite`, and `todo` stay registered as hidden
compatibility aliases so saved transcripts keep replaying.
- Sub-agent and `agents/wait` waits stay short by default and by cap:
blocking waits default to 30 s and refuse to block past 120 s, because a
blocked wait deafens the session to typed input and settled children
already report back as `<codewhale:subagent.done>` sentinels.
- `Bash` `action=wait` honors `timeout_secs` (seconds) and bare `timeout`
(milliseconds) alongside canonical `timeout_ms`, and `block` as an alias
for `wait`, so a habit formed on other wait tools gets the duration it
asked for instead of silently falling back to the 30 s default; the result
metadata reports the real `wait_timeout_ms` applied.
### Fixed
@@ -362,6 +409,21 @@ File edits, terminal width, and Windows installation.
- Transcript wheel scrolling under iTerm2: xterm alternate-scroll (DECSET
1007) now stays off while mouse capture is active, so wheel events arrive as
mouse events instead of being converted into arrow keys (#5223, PR #5234).
- A stalled model stream no longer ends the turn as `Completed` over a
frozen reasoning block: a mid-stream chunk-timeout now counts toward the
stream-error budget, so a stall with nothing streamed retries the request
transparently, and a stall that exhausts the retry budget fails the turn
with the real reason instead of reporting success.
- A finished background shell task now wakes the engine even when no goal is
active: the idle loop starts an ordinary runtime turn so the completion
reaches the model immediately instead of sitting unclaimed until the user
types (a dead provider route claims the completion once and reports where
the output lives instead of re-arming the same error every tick).
- Sub-agent final reports that exceed the summary budget are now spilled to
a session artifact, and the truncation footer names the
`retrieve_tool_result` ref for the elided middle instead of telling the
model the bytes are unrecoverable; write failures degrade to the honest
no-ref footer.
### Removed
+14 -14
View File
@@ -480,21 +480,21 @@ async fn transcribe_local_whisper(audio_samples: &[i16]) -> Result<String, Strin
.arg("auto")
.arg("--output-txt")
.output();
if let Ok(out) = output {
if out.status.success() {
let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
if let Ok(out) = output
&& out.status.success()
{
let txt = String::from_utf8_lossy(&out.stdout).trim().to_string();
let _ = std::fs::remove_file(&tmp);
if !txt.is_empty() {
return Ok(txt);
}
// Some builds write to .txt sidecar
let sidecar = tmp.with_extension("txt");
if let Ok(s) = std::fs::read_to_string(&sidecar) {
let _ = std::fs::remove_file(&sidecar);
let _ = std::fs::remove_file(&tmp);
if !txt.is_empty() {
return Ok(txt);
}
// Some builds write to .txt sidecar
let sidecar = tmp.with_extension("txt");
if let Ok(s) = std::fs::read_to_string(&sidecar) {
let _ = std::fs::remove_file(&sidecar);
let _ = std::fs::remove_file(&tmp);
if !s.trim().is_empty() {
return Ok(s.trim().to_string());
}
if !s.trim().is_empty() {
return Ok(s.trim().to_string());
}
}
}
+2 -2
View File
@@ -703,7 +703,7 @@ mod tests {
let mut context = crate::tools::spec::ToolContext::new(app.workspace.clone());
context.runtime.work = Some(work);
crate::tools::todo::TodoWriteTool::work_update(app.todos.clone())
crate::tools::todo::TodoWriteTool::new(app.todos.clone())
.execute(
serde_json::json!({
"todos": [{"content": "relay the staged graph", "status": "in_progress"}]
@@ -711,7 +711,7 @@ mod tests {
&context,
)
.await
.expect("graph-backed work_update");
.expect("graph-backed todo_write");
assert!(
app.todos.lock().await.snapshot().is_empty(),
+76 -19
View File
@@ -1891,19 +1891,12 @@ impl Engine {
}
}
/// Whether the idle loop should poll for background shell completion:
/// only while a goal is active and a background job is running or has
/// finished without being claimed yet.
/// Whether the idle loop should poll for background shell completion: a
/// background job is running or has finished without being claimed yet.
/// Plain interactive sessions arm exactly like goal sessions — a finished
/// background task must reach the model without waiting for the user to
/// type, the same wake an idle sub-agent completion already gets.
fn idle_shell_wake_armed(&self) -> bool {
let goal_active = self
.config
.goal_state
.lock()
.map(|state| state.snapshot().is_active())
.unwrap_or(false);
if !goal_active {
return false;
}
self.shell_manager
.lock()
.map(|manager| manager.may_have_undelivered_completion())
@@ -1918,18 +1911,82 @@ impl Engine {
.unwrap_or(false)
}
/// An idle-engine wake for finished background shell work: queue a goal
/// continuation. The evidence itself is claimed by the boundary drain in
/// `handle_send_message`, so the continuation turn reads the completion
/// payload the same way a user-initiated turn would.
/// An idle-engine wake for finished background shell work. With an active
/// goal this queues a goal continuation; without one it starts an ordinary
/// runtime turn so the completion reaches the model immediately instead of
/// sitting unclaimed until the user types. Either way the evidence itself
/// is claimed by the boundary drain in `handle_send_message`, so the
/// follow-up turn reads the completion payload the same way a
/// user-initiated turn would.
async fn handle_idle_shell_completion_wake(&mut self) {
let goal_active = self
.config
.goal_state
.lock()
.map(|state| state.snapshot().is_active())
.unwrap_or(false);
if goal_active {
let _ = self
.tx_event
.send(Event::status(
"Background shell work finished; continuing the active goal".to_string(),
))
.await;
self.schedule_goal_continuation(Vec::new());
return;
}
let route = match self.current_runtime_route() {
Ok(route) => route,
Err(err) => {
// No route, no turn. Claim the once-only completion now so a
// dead route cannot re-arm the wake into the same error every
// poll tick; the user sees what finished and where the output
// lives, and the next healthy turn proceeds normally.
let finished = self
.shell_manager
.lock()
.map(|mut manager| manager.drain_finished_jobs_with_evidence().len())
.unwrap_or(0);
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
"{finished} background shell task(s) finished, but the turn cannot resume because the provider route is no longer valid: {err}. Their output stays available via /jobs."
))))
.await;
return;
}
};
let _ = self
.tx_event
.send(Event::status(
"Background shell work finished; continuing the active goal".to_string(),
"Background shell work finished; resuming the turn".to_string(),
))
.await;
self.schedule_goal_continuation(Vec::new());
let _ = self
.handle_send_message(
"[runtime] A background shell task finished; its completion evidence follows."
.to_string(),
self.current_mode,
route,
self.config.compaction.clone(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
self.config.goal_status,
self.session.reasoning_effort.clone(),
self.session.reasoning_effort_auto,
self.session.auto_model,
self.session.allow_shell,
self.session.trust_mode,
self.session.auto_approve,
self.session.approval_mode,
self.config.translation_enabled,
self.config.allowed_tools.clone(),
Vec::new(),
self.config.hook_executor.clone(),
self.config.verbosity.clone(),
UserInputProvenance::Runtime,
)
.await;
}
/// Run the engine event loop
@@ -3544,7 +3601,7 @@ impl Engine {
Some(SubAgentForkContext {
messages: self.messages_with_turn_metadata(),
structured_state_block: state.to_system_block(),
// Resolve at spawn time so a work update earlier in this turn
// Resolve at spawn time so a todo_write earlier in this turn
// reaches the child rather than freezing turn-start state.
work_source: Some(self.work_state_source()),
})
+11 -8
View File
@@ -10900,10 +10900,10 @@ async fn run_graph_backed_work_update(
use crate::tools::spec::ToolSpec as _;
let mut context = crate::tools::spec::ToolContext::new(std::env::temp_dir());
context.runtime.work = Some(work.clone());
crate::tools::todo::TodoWriteTool::work_update(todos.clone())
crate::tools::todo::TodoWriteTool::new(todos.clone())
.execute(json!({ "todos": items }), &context)
.await
.expect("graph-backed work_update");
.expect("graph-backed todo_write");
}
/// #3983 runtime regression: a real graph-backed `work_update` stages the new
@@ -15737,13 +15737,16 @@ async fn idle_engine_wakes_for_finished_background_shell_only_while_goal_active(
tokio::time::sleep(Duration::from_millis(25)).await;
}
// No active goal: the wake stays disarmed and the idle receive keeps
// waiting — completions belong to the next user-initiated turn.
let disarmed =
tokio::time::timeout(Duration::from_millis(300), engine.next_run_input(false)).await;
// No active goal: the wake still arms — a finished background task must
// reach the model without waiting for the user to type, the same wake an
// idle sub-agent completion already gets.
let input = tokio::time::timeout(Duration::from_secs(10), engine.next_run_input(false))
.await
.expect("idle engine must wake for finished background shell work even without a goal")
.expect("engine input");
assert!(
disarmed.is_err(),
"without an active goal the engine must not start turns for shell completions"
matches!(input, EngineRunInput::ShellCompletionWake),
"wake input expected without an active goal"
);
engine
+14 -2
View File
@@ -353,7 +353,12 @@ impl Engine {
manager.terminal_results_excluding(&self.delivered_subagent_completion_ids)
};
for result in synthesized {
let completion = crate::tools::subagent::subagent_completion_from_result(&result);
let report_ref =
crate::tools::subagent::spill_subagent_final_report(&self.session.id, &result);
let completion = crate::tools::subagent::subagent_completion_from_result_with_ref(
&result,
report_ref.as_deref(),
);
if let Some(completion) = super::claim_subagent_completion(
&mut self.delivered_subagent_completion_ids,
completion,
@@ -991,6 +996,13 @@ impl Engine {
}
.into_envelope();
crate::logging::warn(&envelope.message);
// A stall is a stream error like any other:
// count it so the nothing-streamed retry can
// fire, and record it so an unrecovered stall
// fails the turn with the real reason instead
// of ending "Completed" over a frozen block.
stream_errors = stream_errors.saturating_add(1);
turn_error.get_or_insert(envelope.message.clone());
let _ = self.tx_event.send(Event::error(envelope)).await;
None
}
@@ -4102,7 +4114,7 @@ fn truncate_runtime_status_field(text: &str, max_chars: usize) -> String {
out
}
#[allow(dead_code)]
#[cfg(test)]
fn should_hold_turn_for_subagents(queued_completions: usize, running_children: usize) -> bool {
// #3216: launching sub-agents must NOT barrier the parent turn. Only queued
// completions (work already finished that must be surfaced into the
+1 -1
View File
@@ -1117,7 +1117,7 @@ impl ToolRegistryBuilder {
#[must_use]
pub fn with_todo_tool(self, todo_list: super::todo::SharedTodoList) -> Self {
use super::todo::TodoWriteTool;
self.with_tool(Arc::new(TodoWriteTool::work_update(todo_list.clone())))
self.with_tool(Arc::new(TodoWriteTool::new(todo_list.clone())))
.with_tool(Arc::new(TodoWriteTool::alias(
"work_update",
todo_list.clone(),
+28 -4
View File
@@ -2939,7 +2939,7 @@ impl ToolSpec for BashTool {
},
"timeout_ms": {
"type": "integer",
"description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000."
"description": "Timeout in milliseconds. The default depends on the action: action=run 120000 (capped at 600000), action=wait 30000, action=interact 1000. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases."
},
"background": {
"type": "boolean",
@@ -2983,7 +2983,7 @@ impl ToolSpec for BashTool {
},
"wait": {
"type": "boolean",
"description": "Block until task completes (action=wait, default: false)"
"description": "Block until task completes or the timeout elapses (action=wait, default: false; `block` is an accepted alias)"
},
"close_stdin": {
"type": "boolean",
@@ -3614,8 +3614,8 @@ impl BashTool {
context: &ToolContext,
) -> Result<ToolResult, ToolError> {
let task_id = required_task_id(input)?;
let wait = optional_bool(input, "wait", false)?;
let timeout_ms = optional_u64(input, "timeout_ms", 30_000)?;
let wait = optional_bool(input, "wait", false)? || optional_bool(input, "block", false)?;
let timeout_ms = wait_timeout_ms(input)?;
let (delta, wait_canceled) = if wait {
wait_for_shell_delta_cancellable(context, task_id, timeout_ms).await?
@@ -3632,6 +3632,11 @@ impl BashTool {
let status = delta.result.status.clone();
let mut result = build_shell_delta_tool_result(delta, context);
if let Some(metadata) = result.metadata.as_mut()
&& let Some(object) = metadata.as_object_mut()
{
object.insert("wait_timeout_ms".to_string(), json!(timeout_ms));
}
if wait_canceled {
if matches!(status, ShellStatus::Running) {
result.content = format!(
@@ -3819,6 +3824,25 @@ fn first_present_field<'a>(
})
}
/// Effective `action=wait` timeout in milliseconds. `timeout_ms` is
/// canonical; `timeout_secs` (seconds) and bare `timeout` (milliseconds) are
/// honored so a habit formed on other wait tools gets the duration it asked
/// for instead of silently falling back to the 30 s default.
fn wait_timeout_ms(input: &serde_json::Value) -> Result<u64, ToolError> {
match first_present_field(input, &["timeout_ms", "timeout_secs", "timeout"]) {
None => Ok(30_000),
Some(("timeout_secs", value)) => {
let secs = value
.as_u64()
.ok_or_else(|| type_mismatch("timeout_secs", value, "an integer"))?;
Ok(secs.saturating_mul(1_000))
}
Some((name, value)) => value
.as_u64()
.ok_or_else(|| type_mismatch(name, value, "an integer")),
}
}
fn build_shell_delta_tool_result(delta: ShellDeltaResult, context: &ToolContext) -> ToolResult {
let result = delta.result;
let network_restricted_hint =
+8 -5
View File
@@ -22,9 +22,12 @@ use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 300;
/// Bounds for `agents/wait`. Short on purpose: a blocked wait makes the
/// session deaf to typed input, and settled children already report back as
/// `<codewhale:subagent.done>` sentinels that start a fresh turn (#4097).
const COORD_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 30;
const COORD_WAIT_MIN_TIMEOUT_SECS: u64 = 1;
const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 1800;
const COORD_WAIT_MAX_TIMEOUT_SECS: u64 = 120;
const COORD_WAIT_CHECK_INTERVAL: Duration = Duration::from_millis(250);
const RECENT_PROGRESS_LIMIT: usize = 8;
pub(super) const COORDINATION_RECORD_LIMIT: usize = 128;
@@ -525,7 +528,7 @@ impl ToolSpec for AgentsWaitTool {
}
fn description(&self) -> &'static str {
"Block until watched children settle or the timeout elapses. One blocking wait is the right shape; polling agents/list in a loop is not. until=all is the fan-out join: it returns only when every child running at call time has left running, with each child's outcome. until=completion (default) returns as soon as any one child settles. until=activity also returns on progress."
"Block briefly until watched children settle or the timeout elapses. Keep waits short: on timeout, end your turn — settled children wake you automatically as completion sentinels; polling agents/list in a loop is not the right shape either. until=all is the fan-out join: it returns only when every child running at call time has left running, with each child's outcome. until=completion (default) returns as soon as any one child settles. until=activity also returns on progress."
}
fn input_schema(&self) -> Value {
@@ -539,8 +542,8 @@ impl ToolSpec for AgentsWaitTool {
"timeout_secs": {
"type": "integer",
"minimum": 1,
"maximum": 1800,
"description": "Maximum seconds to block. Default 300."
"maximum": 120,
"description": "Maximum seconds to block. Default 30. Keep it short — on timeout, end your turn; settled children report back as completion sentinels."
},
"until": {
"type": "string",
+72 -15
View File
@@ -1948,6 +1948,10 @@ struct SubAgentTerminalDeliveryContext {
parent_completion_tx: Option<mpsc::UnboundedSender<SubAgentCompletion>>,
mailbox: Option<Mailbox>,
event_tx: Option<mpsc::Sender<Event>>,
/// Shared session namespace (root session id), cloned down the spawn
/// tree; over-budget final reports are spilled under it so the truncation
/// footer can name a retrievable artifact.
session_id: String,
}
impl SubAgentTerminalDeliveryContext {
@@ -1957,6 +1961,7 @@ impl SubAgentTerminalDeliveryContext {
parent_completion_tx: runtime.parent_completion_tx.clone(),
mailbox: runtime.mailbox.clone(),
event_tx: runtime.event_tx.clone(),
session_id: runtime.context.state_namespace.clone(),
}
}
@@ -1964,7 +1969,8 @@ impl SubAgentTerminalDeliveryContext {
/// manager owns the terminal claim. The public agent/worker states remain
/// Running until all three sends have been attempted.
fn deliver(&self, result: &SubAgentResult) {
let completion = subagent_completion_from_result(result);
let report_ref = spill_subagent_final_report(&self.session_id, result);
let completion = subagent_completion_from_result_with_ref(result, report_ref.as_deref());
if self.spawn_depth > 0
&& let Some(tx) = self.parent_completion_tx.as_ref()
@@ -1973,7 +1979,7 @@ impl SubAgentTerminalDeliveryContext {
}
if let Some(mailbox) = self.mailbox.as_ref() {
let _ = mailbox.send(terminal_mailbox_message(result));
let _ = mailbox.send(terminal_mailbox_message(result, report_ref.as_deref()));
}
if let Some(event_tx) = self.event_tx.as_ref() {
@@ -1985,10 +1991,11 @@ impl SubAgentTerminalDeliveryContext {
}
}
fn terminal_mailbox_message(result: &SubAgentResult) -> MailboxMessage {
fn terminal_mailbox_message(result: &SubAgentResult, report_ref: Option<&str>) -> MailboxMessage {
match &result.status {
SubAgentStatus::Completed => {
let (summary, _) = stamp_subagent_summary(&summarize_subagent_result(result));
let (summary, _) =
stamp_subagent_summary_with_ref(&summarize_subagent_result(result), report_ref);
MailboxMessage::Completed {
agent_id: result.agent_id.clone(),
summary,
@@ -7055,7 +7062,7 @@ impl ToolSpec for AgentTool {
"timeout_secs": {
"type": "integer",
"minimum": 5,
"maximum": 1800,
"maximum": 120,
"description": "For action=wait: maximum seconds to block (default 30). Prefer ending the turn and staying reachable — results arrive automatically as <codewhale:subagent.done> sentinels — only wait when you must join before continuing."
},
"message": {
@@ -7594,7 +7601,7 @@ const SUBAGENT_WAIT_DEFAULT_TIMEOUT_SECS: u64 = 30;
/// Runtime floor is 1s (schema advertises 5) so tests can exercise the
/// timeout path without multi-second sleeps.
const SUBAGENT_WAIT_MIN_TIMEOUT_SECS: u64 = 1;
const SUBAGENT_WAIT_MAX_TIMEOUT_SECS: u64 = 1800;
const SUBAGENT_WAIT_MAX_TIMEOUT_SECS: u64 = 120;
/// Internal state-check cadence while blocked. Invisible to the model — the
/// #4097 anti-pattern is model-visible polling that burns turns and tokens,
/// not a cheap in-process timer.
@@ -8921,7 +8928,17 @@ pub(crate) fn emit_parent_completion(
true
}
#[cfg(test)]
pub(crate) fn subagent_completion_from_result(result: &SubAgentResult) -> SubAgentCompletion {
subagent_completion_from_result_with_ref(result, None)
}
/// Completion builder that names the persisted full report in the truncation
/// footer when `report_ref` is available; see `spill_subagent_final_report`.
pub(crate) fn subagent_completion_from_result_with_ref(
result: &SubAgentResult,
report_ref: Option<&str>,
) -> SubAgentCompletion {
let raw = summarize_subagent_result(result);
let mut evidence_truncated = false;
let evidence_block = match &result.status {
@@ -8944,7 +8961,7 @@ pub(crate) fn subagent_completion_from_result(result: &SubAgentResult) -> SubAge
.as_ref()
.map(|_| strip_evidence_block(&raw))
.unwrap_or(raw);
let (summary, truncated) = stamp_subagent_summary(&summary_source);
let (summary, truncated) = stamp_subagent_summary_with_ref(&summary_source, report_ref);
let summary_truncated = truncated || evidence_truncated;
let sentinel = match &result.status {
SubAgentStatus::Failed(error) => subagent_failed_sentinel(result, error),
@@ -13472,12 +13489,12 @@ fn annotate_child_model_error(
/// Char budget above which a sub-agent summary is treated as a large dump and
/// head+tail truncated. Mirrors `TOOL_RESULT_SENT_CHAR_BUDGET` in
/// `crates/tui/src/client/chat.rs:702` so sub-agent summaries use the same
/// `crates/tui/src/client/chat.rs:1377` so sub-agent summaries use the same
/// threshold as regular tool outputs. Duplicated locally to avoid coupling the
/// sub-agent module to the wire-compaction internals.
const SUBAGENT_SUMMARY_CHAR_BUDGET: usize = 12_000;
/// Head/tail slice sizes when truncating; mirror the wire constants
/// (`TOOL_RESULT_HEAD_CHARS`/`TOOL_RESULT_TAIL_CHARS`, chat.rs:703-704).
/// (`TOOL_RESULT_HEAD_CHARS`/`TOOL_RESULT_TAIL_CHARS`, chat.rs:1378-1379).
const SUBAGENT_SUMMARY_HEAD_CHARS: usize = 4_000;
const SUBAGENT_SUMMARY_TAIL_CHARS: usize = 4_000;
@@ -13494,12 +13511,20 @@ run the relevant tests) before relying on it.]";
/// note and report `truncated: false`.
/// - When it exceeds the budget, keep a head+tail slice and stamp it with the
/// existing `[Output truncated ...]` vocabulary (reused from tool-output
/// truncation), adapted to be honest that the elided middle is NOT in the
/// spillover store — there is no `retrieve_tool_result` handle for
/// sub-agent summaries. Report `truncated: true`.
/// truncation). When `report_ref` names the persisted full report (see
/// `spill_subagent_final_report`), the footer points at it so the elided
/// middle stays retrievable via `retrieve_tool_result`; with no ref the
/// footer stays honest that the middle cannot be retrieved. Report
/// `truncated: true` either way.
///
/// Every summary therefore gets exactly one boundary marker, never both.
#[cfg(test)]
fn stamp_subagent_summary(raw: &str) -> (String, bool) {
stamp_subagent_summary_with_ref(raw, None)
}
/// The ref-aware stamper; see `stamp_subagent_summary`.
fn stamp_subagent_summary_with_ref(raw: &str, report_ref: Option<&str>) -> (String, bool) {
let total = raw.chars().count();
if total <= SUBAGENT_SUMMARY_CHAR_BUDGET {
return (format!("{raw}{SUBAGENT_SELF_REPORT_NOTE}"), false);
@@ -13513,15 +13538,47 @@ fn stamp_subagent_summary(raw: &str) -> (String, bool) {
let omitted = total
.saturating_sub(SUBAGENT_SUMMARY_HEAD_CHARS)
.saturating_sub(SUBAGENT_SUMMARY_TAIL_CHARS);
let retrieval = match report_ref {
Some(reference) => format!(
"the full report is retained as artifact {reference} — read the elided middle ({omitted} \
chars) with retrieve_tool_result using that ref (mode=lines/query/bytes). Re-verify material claims \
before relying on them."
),
None => format!(
"the elided middle ({omitted} chars) is not in the spillover store and cannot be \
retrieved via retrieve_tool_result. Re-open the child or read changed files directly to verify \
material claims."
),
};
let stamped = format!(
"{head}\n\n[Sub-agent summary truncated: {SUBAGENT_SUMMARY_HEAD_CHARS} + {SUBAGENT_SUMMARY_TAIL_CHARS} of {total} \
chars shown. This is the child's self-report; the elided middle ({omitted} chars) is not in \
the spillover store and cannot be retrieved via retrieve_tool_result. Re-open the child or \
read changed files directly to verify material claims.]\n\n{tail}",
chars shown. This is the child's self-report; {retrieval}]\n\n{tail}",
);
(stamped, true)
}
/// Persist a final report that exceeds the summary budget so the truncated
/// summary can name a retrievable artifact instead of dropping the elided
/// middle. Returns the `retrieve_tool_result` ref on success; write failures
/// degrade to no ref, mirroring `apply_spillover`'s passthrough posture. The
/// artifact lands under the shared session root (`state_namespace`), which
/// every agent in the spawn tree clones, so the parent and any sibling can
/// resolve it. Same-id writes carry identical bytes, so a synthesized
/// re-delivery of the same terminal result is idempotent.
pub(crate) fn spill_subagent_final_report(
session_id: &str,
result: &SubAgentResult,
) -> Option<String> {
let raw = summarize_subagent_result(result);
if raw.chars().count() <= SUBAGENT_SUMMARY_CHAR_BUDGET {
return None;
}
let artifact_id = format!("art_sa_{}_report", result.agent_id);
crate::artifacts::write_session_artifact(session_id, &artifact_id, &raw)
.ok()
.map(|_| artifact_id)
}
fn summarize_subagent_result(result: &SubAgentResult) -> String {
if let Some(needs_input) = result.needs_input.as_ref() {
return format!("Needs input: {}", needs_input.question);
+3 -3
View File
@@ -8725,7 +8725,7 @@ async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) {
.iter()
.map(|content| json!({"content": content, "status": "pending"}))
.collect();
crate::tools::todo::TodoWriteTool::work_update(runtime.todos.clone())
crate::tools::todo::TodoWriteTool::new(runtime.todos.clone())
.execute(json!({"todos": items}), &runtime.context)
.await
.expect("work_update must succeed against the agent's own list");
@@ -14582,7 +14582,7 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() {
let empty = source.snapshot().await;
assert!(!work_state_worth_publishing(last.as_ref(), &empty));
crate::tools::todo::TodoWriteTool::work_update(child_todos.clone())
crate::tools::todo::TodoWriteTool::new(child_todos.clone())
.execute(
serde_json::json!({"todos": [{"content": "CHILD: write the projection", "status": "in_progress"}]}),
&context,
@@ -14608,7 +14608,7 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() {
assert!(!work_state_worth_publishing(last.as_ref(), &again));
// A real transition, including back to empty, is published.
crate::tools::todo::TodoWriteTool::work_update(child_todos.clone())
crate::tools::todo::TodoWriteTool::new(child_todos.clone())
.execute(serde_json::json!({"todos": []}), &context)
.await
.expect("child clears its list");
+8 -7
View File
@@ -252,14 +252,15 @@ pub struct TodoWriteTool {
impl TodoWriteTool {
/// Canonical model-facing progress surface (#4132).
pub fn work_update(todo_list: SharedTodoList) -> Self {
pub fn new(todo_list: SharedTodoList) -> Self {
Self {
name: CANONICAL_PROGRESS_TOOL,
todo_list,
}
}
/// Hidden compat alias for `work_update` — same handler, not model-visible.
/// Hidden compat alias (`work_update`, `TodoWrite`, `todo`, …) — same
/// handler, not model-visible.
pub fn alias(name: &'static str, todo_list: SharedTodoList) -> Self {
Self { name, todo_list }
}
@@ -424,7 +425,7 @@ mod tests {
// 2026-07-23 user report: models wrote the list once and never
// updated it while working. The canonical tool description must
// carry the upkeep contract every provider sees.
let tool = super::TodoWriteTool::work_update(super::new_shared_todo_list());
let tool = super::TodoWriteTool::new(super::new_shared_todo_list());
let description = crate::tools::spec::ToolSpec::description(&tool);
for phrase in [
"keep it live while you work",
@@ -462,7 +463,7 @@ mod tests {
serde_json::json!("cancelled")
);
let schema = TodoWriteTool::work_update(new_shared_todo_list()).input_schema();
let schema = TodoWriteTool::new(new_shared_todo_list()).input_schema();
let statuses = &schema["properties"]["todos"]["items"]["properties"]["status"]["enum"];
assert!(statuses.as_array().is_some_and(|values| {
values
@@ -551,7 +552,7 @@ mod tests {
// #5123-class: statuses like "blocked" / "in-progress" used to be
// recorded as pending with a success receipt on the canonical
// progress surface.
let tool = TodoWriteTool::work_update(new_shared_todo_list());
let tool = TodoWriteTool::new(new_shared_todo_list());
let context = ToolContext::new(std::env::temp_dir());
let err = tool
.execute(
@@ -576,7 +577,7 @@ mod tests {
#[tokio::test]
async fn work_update_returns_canonical_task_update_metadata() {
let tool = TodoWriteTool::work_update(new_shared_todo_list());
let tool = TodoWriteTool::new(new_shared_todo_list());
let context = ToolContext::new(std::env::temp_dir());
let result = tool
.execute(
@@ -622,7 +623,7 @@ mod tests {
let mut context = ToolContext::new(std::env::temp_dir());
context.runtime.work = Some(work.clone());
TodoWriteTool::work_update(todos.clone())
TodoWriteTool::new(todos.clone())
.execute(
json!({"todos": [
{"content": "Graph-owned", "status": "completed"},
-1
View File
@@ -16,7 +16,6 @@ const MAX_LIFETIME_IMPRESSIONS: u8 = 2;
const MAX_TRACKED_MANUAL_COMMANDS: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[allow(dead_code)]
pub enum BehavioralTip {
PlanningMode,
BackgroundJobReceipt,
+1 -1
View File
@@ -838,7 +838,7 @@ pub(crate) fn build_dispatch_success_closure(
PersistRequest::SaveCheckpoint { session },
) {
app.status_message = Some(format!(
"Work update is pending: turn checkpoint could not be queued ({err})"
"To-do list update pending: turn checkpoint could not be queued ({err})"
));
}
}
+12 -3
View File
@@ -1200,6 +1200,15 @@ pub(crate) async fn run_event_loop(
);
}
// Every `remember` action mutates durable memory, so a
// successful call is the moment the first-run tip
// points at /memory (one-shot per session, lifetime-capped).
if name == "remember" && matches!(&result, Ok(output) if output.success) {
let _ = app.maybe_show_behavioral_tip(
crate::tui::behavioral_tips::BehavioralTip::DurableStateWritten,
);
}
if result.is_ok()
&& is_work_graph_mutation_tool(&name)
&& let Err(err) = persist_pending_work_checkpoint(app).await
@@ -1210,7 +1219,7 @@ pub(crate) async fn run_event_loop(
"Work Graph checkpoint was not enqueued; projections remain unpublished"
);
app.status_message = Some(format!(
"Work update is pending: checkpoint could not be queued ({err})"
"To-do list update pending: checkpoint could not be queued ({err})"
));
}
@@ -1701,7 +1710,7 @@ pub(crate) async fn run_event_loop(
.is_some_and(|work| work.has_pending_publish())
{
app.status_message = Some(
"Work update is pending: session snapshot could not be queued"
"To-do list update pending: session snapshot could not be queued"
.to_string(),
);
}
@@ -1880,7 +1889,7 @@ pub(crate) async fn run_event_loop(
PersistRequest::SaveCheckpoint { session },
) {
app.status_message = Some(format!(
"Work update is pending: checkpoint could not be queued ({err})"
"To-do list update pending: checkpoint could not be queued ({err})"
));
}
}
+2 -2
View File
@@ -204,7 +204,7 @@ pub(crate) fn persist_recovery_snapshot(app: &mut App) {
persist_with_pending_work_boundary(app, PersistRequest::SaveCheckpoint { session })
{
app.status_message = Some(format!(
"Work update is pending: recovery snapshot could not be queued ({err})"
"To-do list update pending: recovery snapshot could not be queued ({err})"
));
}
}
@@ -219,7 +219,7 @@ pub(crate) fn persist_full_reset_snapshot(app: &mut App) {
persist_with_pending_work_boundary(app, PersistRequest::SessionSnapshot(session))
{
app.status_message = Some(format!(
"Work update is pending: reset snapshot could not be queued ({err})"
"To-do list update pending: reset snapshot could not be queued ({err})"
));
}
}
+3 -4
View File
@@ -1596,10 +1596,9 @@ fn agent_elapsed_ms(app: &App, agent: &crate::tools::subagent::SubAgentResult) -
if matches!(
agent.status,
crate::tools::subagent::SubAgentStatus::Running
) {
if let Some(started_at) = agent.started_at {
return u64::try_from(started_at.elapsed().as_millis()).unwrap_or(agent.duration_ms);
}
) && let Some(started_at) = agent.started_at
{
return u64::try_from(started_at.elapsed().as_millis()).unwrap_or(agent.duration_ms);
}
app.work_surface
.frozen_agent_elapsed_ms
+2 -2
View File
@@ -585,13 +585,13 @@ mod tests {
assert!(source.is_graph_backed());
assert!(source.tail_message().await.is_none(), "no work yet");
crate::tools::todo::TodoWriteTool::work_update(todos.clone())
crate::tools::todo::TodoWriteTool::new(todos.clone())
.execute(
serde_json::json!({"todos": [{"content": "staged item", "status": "in_progress"}]}),
&context,
)
.await
.expect("work_update");
.expect("todo_write");
assert!(
todos.lock().await.snapshot().is_empty(),
@@ -60,7 +60,10 @@ async fn headless_bash_success_and_failure_are_distinct_bounded_exact_evidence()
"model-facing truncation must name the recovery path"
);
assert!(
receipt.contains("/artifacts/"),
// The footer prints the artifact directory with the platform's
// path separator; compare on a normalized view so Windows
// backslashes don't fail an otherwise-correct footer.
receipt.replace('\\', "/").contains("/artifacts/"),
"the footer names where the omitted bytes live on disk"
);
// The receipt must name a route the model can take *from this
+2 -2
View File
@@ -42,7 +42,7 @@ Make the model-facing runtime smaller, calmer, and easier for models to use by:
| `rlm` | durable RLM family (existing; deferred by default) |
| `agent` | sub-agent dispatch |
| `remember` | opt-in durable user-memory capture; eager whenever registered |
| `work_update` | progress / plan-of-work updates |
| `todo_write` | progress / plan-of-work updates |
| `update_plan` | plan artifact updates |
| `tool_search` | on-demand discovery of deferred tools |
@@ -113,7 +113,7 @@ including opt-in `remember`:
| Full system-prompt bytes | 15,842 | 15,368 |
The final active names are `Bash`, `File`, `Git`, `Run`, `agent`, `remember`,
`tasks`, `update_plan`, `work_update`, and `tool_search`. `remember` is present
`tasks`, `update_plan`, `todo_write`, and `tool_search`. `remember` is present
only when built-in memory is enabled; it is eager whenever registered. `File`
advertises only read actions in Plan mode, and its `patch` action appears only
when the existing apply-patch feature is enabled. Hidden aliases remain
+5 -5
View File
@@ -94,16 +94,16 @@ task depends on decisions, files, todos, or plan state already in the parent
transcript.
Forked state renders concrete Work progress from the To-do ledger — the sole
canonical Work surface, written by `work_update`. The child's
canonical Work surface, written by `todo_write`. The child's
`<codewhale:fork_state>` block carries the same bounded body
(`crates/tui/src/work_grounding.rs`) that the parent's own requests carry, so a
fork continues from the parent's real progress position rather than a
paraphrase. That Work section is resolved when the spawn happens, so a
`work_update` earlier in the same parent turn is included.
`todo_write` earlier in the same parent turn is included.
Each agent then grounds on **its own** ledger: every sub-agent request carries
the same transient `<codewhale:work_state>` tail rendered from that agent's
private To-do list (#4810), refreshed after the agent's own `work_update`. It is
private To-do list (#4810), refreshed after the agent's own `todo_write`. It is
request-scoped — never stored in the child transcript or its system prefix — so
a worker can never read or write a parent's or sibling's **private transient
tail**. A deliberately forked child still receives the bounded immutable parent
@@ -129,7 +129,7 @@ longer reachable by a model: `model_visible()` returns `false`
(`crates/tui/src/tools/plan.rs:408-413`), so it is filtered out of the API tool
list and never appears to a child. It survives only to replay older transcripts.
Strategy that used to go there now goes in the response body, and lifecycle
state goes in `work_update`.
state goes in `todo_write`.
## Worktree isolation
@@ -223,7 +223,7 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT.
likely scope, and return `path:line-range` evidence instead of a narrative
tour. The role name to use is `scout`.
- **`planner`** — when the parent has an objective but no executable
decomposition. Planners write artifacts (`work_update` items for the ledger,
decomposition. Planners write artifacts (`todo_write` items for the ledger,
strategy in the response body) but don't carry them out.
- **`reviewer`** — when there's already a change and the parent wants
it graded. Reviewers don't patch — they describe the fix in the
+24 -26
View File
@@ -46,8 +46,9 @@ tools** that map to the *same* implementation under different names:
(`registry.rs:527,530`).
- `tts` and `speech` are both `SpeechTool`
(`registry.rs:787-792`, both deferred).
- `work_update`, `checklist_*`, and `todo_*` are the *same*
`TodoWriteTool` surface, with only `work_update` visible to models.
- `todo_write` is the single model-visible `TodoWriteTool` surface;
`todo_write`, `TodoWrite`, `todo`, `checklist_write`, and
`checklist_update` are hidden compat aliases of it.
For a strong model, redundant names are harmless noise. For **weaker / smaller
models** (the Arcee Trinity lane, `deepseek-v4-flash` child executors, and any
@@ -67,7 +68,7 @@ referenced a now-retired name.
### Canonical work-tracking surface for v0.9.1
The model-visible progress surface is a single tool: `work_update` (#4132).
The model-visible progress surface is a single tool: `todo_write` (#4132).
Agents and Fleet workers use it for concrete To-do / Work progress under the
active runtime thread or durable task.
@@ -86,21 +87,21 @@ the To-do snapshot once, hard-bounded in both item count and characters, with
the in-progress item preserved preferentially and any elision marked. That body
is appended to each parent turn-loop and sub-agent step request as a transient
`<codewhale:work_state>` block — rebuilt per request, so a mid-turn
`work_update` is visible on the next step — and is never written to session
`todo_write` is visible on the next step — and is never written to session
history or the stable system prefix.
Forked agents (`<codewhale:fork_state>`) and `/relay` reuse the same body.
Three properties of that seam are load-bearing:
- **Authority.** The snapshot is read from the `WorkRuntime` graph projection
when a runtime owns that list, because `work_update` stages there and only
when a runtime owns that list, because `todo_write` stages there and only
publishes into the legacy `SharedTodoList` view later. Sessions with no
attached runtime read the list directly.
- **Per-agent isolation.** Every sub-agent gets the same tail rendered from
*its own* list (`#4810`), so a worker sees its own progress and never a
parent's or sibling's. The parent's ledger reaches a forked child only as the
immutable `<codewhale:fork_state>` Work section, resolved at the spawn seam so
a same-turn `work_update` is included.
a same-turn `todo_write` is included.
- **Context accounting.** The parent turn-loop preflight token estimate runs
over the tail message that request actually carries, so it cannot approve a
request that goes over-limit once the block is appended. Offline counts stay
@@ -169,14 +170,11 @@ pub(super) const HIDDEN_COMPATIBILITY_TOOLS: &[&str] = &[
"exec_wait", // == exec_shell_wait (ShellWaitTool)
"exec_interact", // == exec_shell_interact (ShellInteractTool)
"tts", // == speech (SpeechTool)
"checklist_write", // == work_update (TodoWriteTool)
"checklist_add", // == work_update single-item add
"checklist_update", // == work_update single-item update
"checklist_list", // == work_update list
"todo_write", // == work_update
"todo_add", // == work_update single-item add
"todo_update", // == work_update single-item update
"todo_list", // == work_update list
"work_update", // == todo_write (TodoWriteTool)
"TodoWrite", // == todo_write (TodoWriteTool)
"todo", // == todo_write (TodoWriteTool)
"checklist_write", // == todo_write (TodoWriteTool)
"checklist_update", // == todo_write (TodoWriteTool)
];
/// Deprecated aliases: invisible + dispatchable, with a replacement notice
@@ -188,8 +186,8 @@ pub(super) struct DeprecatedAlias {
}
pub(super) const DEPRECATED_ALIASES: &[DeprecatedAlias] = &[
// Empty in the #4132 work-surface cutover: checklist_* and todo_* are
// silent hidden-compatibility aliases of work_update for transcript replay.
// Empty in the #4132 work-surface cutover: the legacy names above are
// silent hidden-compatibility aliases of todo_write for transcript replay.
];
#[inline]
@@ -254,17 +252,17 @@ This was the proposed manifest. Columns are the #2681 AC columns. No entry was
| `exec_wait` | `exec_shell_wait` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `exec_interact` | `exec_shell_interact` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `tts` | `speech` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `checklist_write` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `checklist_add` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `checklist_update` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `checklist_list` | `work_update` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `todo_write` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `todo_add` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `todo_update` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `todo_list` | `work_update` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `checklist_write` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `checklist_add` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `checklist_update` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `checklist_list` | `todo_write` | hidden-compatibility | 0.9.0 | TBD (≥ 0.9.x) | Yes |
| `todo_write` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `todo_add` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `todo_update` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
| `todo_list` | `todo_write` | hidden-compatibility | 0.8.53 | TBD (≥ 0.9.x) | Yes |
The `todo_*` aliases first entered hidden compatibility in v0.8.53. v0.9.0
changes their canonical replacement to `work_update`; it does not reset their
changes their canonical replacement to `todo_write`; it does not reset their
first-deprecated version.
**Legacy subagent names — removed, no manifest entry needed.**
@@ -333,7 +331,7 @@ else or an explicit budget bump in this doc.
|---|---|---|---|
| **Shell wait** | `exec_shell_wait` | `exec_wait` → hidden-compat | Same `ShellWaitTool` (`registry.rs:526,529`); router already unifies (`tool_routing.rs:1139`) |
| **Shell interact** | `exec_shell_interact` | `exec_interact` → hidden-compat | Same `ShellInteractTool` (`registry.rs:527,530`) |
| **Work progress / checklist / todo** | `work_update` | `checklist_write/add/update/list`, `todo_write/add/update/list` → hidden-compat | Same `TodoWriteTool`; compatibility names replay old transcripts only |
| **Work progress / checklist / todo** | `todo_write` | `checklist_write/add/update/list`, `todo_write/add/update/list` → hidden-compat | Same `TodoWriteTool`; compatibility names replay old transcripts only |
| **Speech / tts** | `speech` | `tts` → hidden-compat | Same `SpeechTool` (`registry.rs:787-792`) |
| **Subagent lifecycle** | `agent` | old lifecycle names and tool-agent lane removed | Single async launcher. (The "child agents are leaf workers" note here did not ship — see §7.) |
| **Edit family** | `apply_patch`, `edit_file`, `write_file`, `fim_edit` | none — **all distinct niches** | NOT touched (per #2681 non-goals); doc-only canonical guidance |
+6 -6
View File
@@ -26,7 +26,7 @@ The default-active policy contains exactly these nine names:
5. `agent`
6. `remember`
7. `tasks`
8. `work_update`
8. `todo_write`
9. `tool_search`
The first eight are `DEFAULT_ACTIVE_NATIVE_TOOLS` in
@@ -47,7 +47,7 @@ compatibility tool for loading older Plan artifacts", and
`update_plan_is_hidden_replay_compatibility` (`plan.rs:598-605`) pins that.
Plan mode narrows the active set: `Bash` and `Run` drop out, leaving `File`,
`Git`, `agent`, `tasks`, `work_update`, `tool_search`, and — when memory is
`Git`, `agent`, `tasks`, `todo_write`, `tool_search`, and — when memory is
enabled — `remember` (`should_register_remember_tool`,
`crates/tui/src/core/engine/tool_setup.rs:113-118`).
@@ -82,11 +82,11 @@ by the former spellings remain in force.
| `agent` | Dispatch one focused sub-agent run and return an id, compact receipt, and transcript handle. |
| `remember` | Append one terse durable preference or convention when the user has enabled built-in memory. |
| `tasks` | Create, list, read, cancel, gate, and inspect durable task work through one action family. |
| `update_plan` | Registered but not model-visible; replays older Plan artifacts only. New work uses `work_update` plus a normal Plan-mode response. |
| `work_update` | Replace the concrete To-do / Work progress projection for the active thread or durable task. |
| `update_plan` | Registered but not model-visible; replays older Plan artifacts only. New work uses `todo_write` plus a normal Plan-mode response. |
| `todo_write` | Replace the concrete To-do / Work progress projection for the active thread or durable task. |
| `tool_search` | Discover and load a deferred tool only when the current turn needs it. |
`work_update` writes the **sole canonical Work ledger**. `update_plan` is
`todo_write` writes the **sole canonical Work ledger**. `update_plan` is
conversational reasoning — strategy, constraints, and route notes that help a
reader understand the approach. It is not a second Work surface, and plan-only
state never becomes model-facing Work grounding.
@@ -197,7 +197,7 @@ compatibility for them. Tests pin the removals:
| `github_issue_context`, `github_pr_context`, `github_comment`, `github_close_issue`, `github_close_pr` | `github` | same test |
| `automation_create/list/read/update/pause/resume/delete/run` | `automation` | same test |
| `rlm_session_objects`, `rlm_open`, `rlm_eval`, `rlm_configure`, `rlm_close` | `rlm` | `rlm_is_the_only_registered_session_surface`, registry.rs:1519-1538 |
| `checklist_write/add/update/list`, `todo_write/add/update/list` | `work_update` | registry.rs:1476-1490 |
| `todo_add/update/list`, `checklist_add/list` (removed); `work_update`, `TodoWrite`, `todo`, `checklist_write/update` (registered hidden aliases) | `todo_write` | registry alias assertions (`registry.rs`) |
This matches the "Removed spellings" section above rather than contradicting
it. Replay compatibility does not make an alias a supported spelling for new
+1 -1
View File
@@ -107,7 +107,7 @@
"agent",
"remember",
"tasks",
"work_update",
"todo_write",
"tool_search"
],
"actions": {
+2 -2
View File
@@ -1,10 +1,10 @@
{
"_comment": "Ceiling for `#[allow(dead_code)]` across crates/. This number may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update",
"_issue": "https://github.com/Hmbown/CodeWhale/issues/4785",
"total": 451,
"total": 452,
"per_crate": {
"config": 1,
"tools": 2,
"tui": 448
"tui": 449
}
}