fix: resolve CommandPoll race in command_output wait loop

Replace the ambiguous `idle: bool` field in `CommandPoll` with an unambiguous `running: bool`. Previously `idle` was `exit_code.is_none() && new_output.is_empty()`, so it was `false` for two distinct reasons: task exited, OR task still running but produced new output. The wait loop used `!poll.idle` as its "done, return now" signal, which caused a premature return in the second case — the tool replied "still running" while actually reporting buffered output, because `try_wait()` hadn't observed the exit yet. Fix: `running` is simply `exit_code.is_none()`. The loop now continues while `poll.running` regardless of whether new output appeared mid-run, and only returns early once the process has actually exited. Verified with a 20-run repeat loop: 0 failures. Also includes the owner-race fix for background task cleanup from the prior session (tasks now matched by owner id so a reused slot can't accidentally reap a task it didn't start). Co-Authored-By: siGit Code v1.5.12-acp <noreply@sigit.si>

Seto Elkahfi committed Oct 1, 2026 at 11:40 UTC c708940c729ec2f6330beff3ad7fe04ac2abe64f
5 files changed +34 -16
.gitignore
+1
@@ -1,3 +1,4 @@
1 /target
2 /.zed
3 /.idea
4 +issues:*
CHANGELOG.md
+16 -1
@@ -1,6 +1,6 @@
1 # Changelog
2
3 -## Unreleased
3 +## 1.5.12
4
5 ### Fixed
6
@@ -11,6 +11,21 @@
11 post-install check. The C runtime is now linked statically, and CI and the
12 release build fail if a Windows exe picks up that dependency again.
13
14 +- **Watching a slow background command no longer ends the turn early.**
15 + Polling `command_output` on the same task id three times tripped the
16 + repeated-tool-call guard, which refused the call and dropped tools for the
17 + rest of the turn. `command_output` now long-polls — it waits up to
18 + `wait_seconds` (default 10, max 30) for new output or an exit before
19 + returning — and the repeat guard exempts it, since polling the same task is
20 + the whole point of the tool.
21 +
22 +- **A turn that ends with no visible text says why.** When the
23 + repeated-tool-call guard blocked a call and the forced no-tools round came
24 + back empty, sigit sent nothing more — the turn just stopped with no answer
25 + and no question. When the last round of a tool-using turn produces no
26 + visible text, sigit now sends a short closing message naming the repeated
27 + tool and asking you to reply "continue" or redirect.
28 +
29 ## 1.5.11
30
31 ### Added
Cargo.lock
+1 -1
@@ -5778,7 +5778,7 @@ checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
5778
5779 [[package]]
5780 name = "sigit"
5781 -version = "1.5.11"
5781 +version = "1.5.12"
5782 dependencies = [
5783 "agent-client-protocol",
5784 "anyhow",
Cargo.toml
+1 -1
@@ -1,6 +1,6 @@
1 [package]
2 name = "sigit"
3 -version = "1.5.11"
3 +version = "1.5.12"
4 edition = "2024"
5 description = "siGit Code — ACP-compatible AI coding agent. Sí, git."
6 documentation = "https://github.com/getsigit/sigit"
src/tools.rs
+15 -13
@@ -465,7 +465,7 @@ async fn execute_tool_impl(name: &str, arguments: &str) -> String {
465 match name {
466 TASK_TOOL_NAME => exec_task(arguments).await,
467 WEB_SEARCH_TOOL_NAME => exec_web_search(arguments).await,
468 - "command_output" => exec_command_output_wait(arguments).await,
468 + "command_output" => exec_command_output_wait(arguments, active_session()).await,
469 // Tools discovered from MCP servers are namespaced `mcp__<server>__<tool>`
470 // and forwarded to the owning server.
471 _ if crate::mcp::is_mcp_tool(name) => crate::mcp::call_tool(name, arguments).await,
@@ -2516,18 +2516,21 @@ fn exec_command_output(arguments: &str, owner: Option<&str>) -> String {
2516 poll_command_output(arguments, owner).text
2517 }
2518
2519 -/// One `command_output` check, with the tool text and whether anything
2520 -/// changed, so the long-poll loop never has to parse its own wording.
2519 +/// One `command_output` check, with the tool text and whether the task is
2520 +/// still running, so the long-poll loop knows when to keep waiting.
2521 struct CommandPoll {
2522 text: String,
2523 - /// Still running and nothing new printed since the last check.
2524 - idle: bool,
2523 + /// True while the task's child process is still running.
2524 + running: bool,
2525 }
2526
2527 impl CommandPoll {
2528 /// A result that ends the wait: an error, or a task that isn't ours.
2529 fn done(text: String) -> Self {
2530 - Self { text, idle: false }
2530 + Self {
2531 + text,
2532 + running: false,
2533 + }
2534 }
2535 }
2536
@@ -2562,7 +2565,7 @@ fn poll_command_output(arguments: &str, owner: Option<&str>) -> CommandPoll {
2565 Some(code) => format!("Task {task_id} (`{command}`) exited with code {code}."),
2566 };
2567
2565 - let idle = exit_code.is_none() && new_output.is_empty();
2568 + let running = exit_code.is_none();
2569 let text = if new_output.is_empty() {
2570 format!(
2571 "{status} No new output since the last check.{}",
@@ -2574,13 +2577,13 @@ fn poll_command_output(arguments: &str, owner: Option<&str>) -> CommandPoll {
2577 dropped_note(dropped)
2578 )
2579 };
2577 - CommandPoll { text, idle }
2580 + CommandPoll { text, running }
2581 }
2582
2583 /// Long-poll a background task without occupying Tokio's blocking pool while
2584 /// nothing changes. This keeps model tool rounds from being consumed by a
2585 /// tight status-check loop.
2583 -async fn exec_command_output_wait(arguments: &str) -> String {
2586 +async fn exec_command_output_wait(arguments: &str, owner: Option<String>) -> String {
2587 const DEFAULT_WAIT_SECONDS: u64 = 10;
2588 const MAX_WAIT_SECONDS: u64 = 30;
2589 const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(200);
@@ -2590,7 +2593,6 @@ async fn exec_command_output_wait(arguments: &str) -> String {
2593 .and_then(|args| args.get("wait_seconds").and_then(Value::as_u64))
2594 .unwrap_or(DEFAULT_WAIT_SECONDS)
2595 .min(MAX_WAIT_SECONDS);
2593 - let owner = active_session();
2596 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(wait_seconds);
2597
2598 loop {
@@ -2603,7 +2605,7 @@ async fn exec_command_output_wait(arguments: &str) -> String {
2605 CommandPoll::done(format!("Error: command_output task failed: {err}"))
2606 });
2607
2606 - if wait_seconds == 0 || !poll.idle || tokio::time::Instant::now() >= deadline {
2608 + if wait_seconds == 0 || !poll.running || tokio::time::Instant::now() >= deadline {
2609 return poll.text;
2610 }
2611
@@ -3867,7 +3869,7 @@ mod tests {
3869 "wait_seconds": 5
3870 })
3871 .to_string();
3870 - let poll = exec_command_output_wait(&poll_args).await;
3872 + let poll = exec_command_output_wait(&poll_args, None).await;
3873
3874 assert!(poll.contains("exited with code 0"), "got: {poll}");
3875 assert!(poll.contains("done"), "got: {poll}");
@@ -3890,7 +3892,7 @@ mod tests {
3892
3893 let poll_args = serde_json::json!({ "task_id": task_id, "wait_seconds": 1 }).to_string();
3894 let started = std::time::Instant::now();
3893 - let poll = exec_command_output_wait(&poll_args).await;
3895 + let poll = exec_command_output_wait(&poll_args, None).await;
3896
3897 assert!(poll.contains("still running"), "got: {poll}");
3898 assert!(