1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
//! Platform shell selection for running a full command string.
//!
//! One source of truth for the (program, arg) pair that executes a
//! shell-command string: `cmd /C` on Windows, `sh -c` everywhere else.
//!
//! Why this module exists: five call sites each hand-rolled this decision
//! (or skipped it). Four of them hardcoded `sh -c` — `background_tasks.rs`
//! (detached command runner), `dynamic/tool.rs` (dynamic shell tools),
//! `plan_tool.rs` (Ralph verification gate) and `tui/app/input.rs` (the
//! `!command` bang operator). On Windows none of those spawn: `sh` is not
//! on PATH outside WSL/Git-Bash setups, so every detached background task
//! died with `program not found` while the inline bash tool — the one site
//! that had the `cfg!` — kept working. Linux CI never noticed because `sh`
//! exists there; Windows is not built in CI (#627) and the release job only
//! compiles, it never runs these paths.
//!
//! Use [`shell_pair`] + [`PushShellCommand`] whenever a full command STRING
//! must run through a shell. Do not add another inline
//! `cfg!(target_os = "windows")` — call these, so the platform decision
//! cannot drift between sites again.
/// The (program, flag) pair that runs a command string through the platform
/// shell: `("cmd", "/C")` on Windows, `("sh", "-c")` elsewhere.
///
/// Usage: `Command::new(shell).push_shell_command(shell_arg, command)`.
/// Append the platform shell flag and a full command string to a
/// [`std::process::Command`] or [`tokio::process::Command`].
///
/// The naive spelling — `Command::new(shell).arg(flag).arg(cmd)` — is
/// correct on Unix and WRONG on Windows: `arg()` applies MSVC-style
/// escaping (quote-wrap + backslash-escape inner quotes), but `cmd.exe /C`
/// re-parses the command line with its own quoting rules, so any command
/// containing quotes is corrupted. Live repros (guardrail-0003):
/// `python "C:/x/probe.py"` receives `C:\Windows\System32\"C:\Users\…"` as
/// argv; `dir "C:\Program Files"` fails with "filename syntax incorrect".
/// On Windows this appends both parts with `raw_arg` (verbatim, no
/// escaping); elsewhere it is a plain `arg`.
/// Kill a process's descendant tree, best-effort, on the timeout path.
///
/// tokio's `kill()`/`kill_on_drop` terminates only the DIRECT child — the
/// shell — so the actual work process spawned by the command (cargo,
/// cmake, ping, …) survives as an orphan, still holding locks: a timed-out
/// `cargo build` keeps the target-dir and package-cache locks taken for
/// minutes afterwards. Failure (pid already exited, helper missing) never
/// aborts the caller — this is a best-effort sweep on the timeout error
/// path, never a reason to mask the Timeout itself — but it is reported,
/// see [`log_sweep`].
///
/// Windows: `taskkill /T /F` walks the whole tree including the shell pid
/// and force-terminates it.
/// Unix counterpart: signal the work processes the shell spawned.
///
/// The shells here are not process-group leaders, so a group kill
/// (`kill -pgid`) would signal the agent itself; instead `pkill -TERM -P`
/// targets the direct children of `pid` while the caller's
/// `kill_on_drop`/`kill()` reaps the shell. If the shell already exited,
/// its children are re-parented and pkill finds nothing — same best-effort
/// contract as the Windows path. pkill ships with procps on Linux and is
/// standard on macOS; if absent, the failed spawn is ignored.
/// Record what the sweep did. Best-effort is a decision about whether to
/// abort, not a licence to say nothing: a helper that is missing from the
/// image looks exactly like a sweep that worked, and the orphan holding the
/// cargo lock is the only symptom anyone ever sees. Exit status 1 is the
/// ordinary "no such children" case for both helpers, so it stays at debug.