devflow_core/monitor.rs
1//! Background monitor daemon.
2//!
3//! Spawns a detached child process that *owns* the coding agent: it launches
4//! the agent, captures its stdout and exit code into `.devflow/`, and — when
5//! the agent exits — runs `devflow advance` to advance the stage machine.
6//!
7//! Owning the agent is the key fix over a CLI-scoped capture thread: because
8//! the monitor outlives `devflow start`, the agent's stdout keeps flowing into
9//! the capture file and its exit code is still reaped after the CLI exits.
10//!
11//! This is the core automation primitive — no cron, no scheduler,
12//! no agent cooperation needed.
13
14use crate::agent_result::{IdleTimeoutCommit, IdleTimeoutRecord};
15use crate::git::hermetic_command;
16use crate::state::State;
17use std::io::{BufRead, BufReader, Write};
18use std::os::unix::process::CommandExt;
19use std::path::Path;
20use std::process::Stdio;
21use std::sync::mpsc;
22use std::time::Duration;
23use tracing::{debug, info, warn};
24
25/// Errors produced by monitor operations.
26#[derive(Debug, thiserror::Error)]
27pub enum MonitorError {
28 /// Spawning the monitor process failed.
29 #[error("failed to spawn monitor: {0}")]
30 Io(#[from] std::io::Error),
31 /// Project path is not valid UTF-8.
32 #[error("project path is not valid UTF-8")]
33 NonUtf8Path,
34 /// Could not determine the current executable path.
35 #[error("could not determine devflow binary path")]
36 NoBinaryPath,
37 /// A child spawned with piped stdio did not expose one of its pipes.
38 #[error("supervised child exposed no {0} pipe")]
39 NoChildPipe(&'static str),
40}
41
42/// Idle-timeout default in seconds (D-02): the measured constraint-8 floor.
43///
44/// Plan 31-02 supplies the configurable-and-clamped reader that can only raise
45/// this. Until then `spawn_monitor` passes this literal to the monitor process.
46///
47/// Raised 30s -> 120s on 2026-08-03 by direct measurement; see
48/// [`IDLE_TIMEOUT_FLOOR_SECS`] for the trials and the reasoning. The previous
49/// value's "~4.2x margin" was computed against a workload that never entered a
50/// long foreground tool call, and did not transfer to one.
51pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 120;
52
53/// The floor an idle timeout can never be configured below (D-02/D-04, 31-02).
54///
55/// **Raised 30s -> 120s on 2026-08-03, and the reasoning that set 30s was
56/// wrong — read this before touching it again.**
57///
58/// The original ≥30s floor cited "~4.2x margin" against an every-line signal
59/// whose observed max was 7.09s. Both numbers were real; the inference was not.
60/// Phase 30d measured *backgrounded* 10s/22s sleeps, where the agent is never
61/// sitting inside a long foreground tool call. Under one, the CLI emits
62/// `tool_progress` keepalives on a **fixed 30.00s interval**, so a healthy,
63/// hard-working child produces a 30.00s gap between stream lines — dead level
64/// with a 30s timeout, and on the wrong side of it, since the timer starts when
65/// the previous line is *processed* while the keepalive arrives 30s after it
66/// was *sent*, plus pipe latency.
67///
68/// Measured 2026-08-03, CLI 2.1.220, five workload-controlled trials across two
69/// unrelated workload types (each verified to have actually run — elapsed >=
70/// the workload duration, no `tool_use_error`), plus a negative control:
71///
72/// | workload | gaps > 5s |
73/// |---------------------------|------------------------|
74/// | 90s busy loop x3 | ~26.4, **30.00**, ~30.0 |
75/// | `cargo test --workspace` x2 | ~26.4, **30.00**, ~16 |
76/// | control (no long call) | max 2.2 |
77///
78/// Variance across all five: ±0.02s. `cargo test --workspace` is not a contrived
79/// case — it sits inside DevFlow's own post-merge gate, so the old floor would
80/// have killed healthy Code stages on the common path.
81///
82/// 120s is 4x the measured cadence: it survives **three** consecutive missed
83/// keepalives. That headroom is the point — the hazard is not a slightly larger
84/// gap but a *dropped* keepalive, which doubles the interval outright. 90s
85/// (two missed) is the lowest defensible value; do not go below it.
86///
87/// Do NOT lower it, and note that no configuration can. Phase 30d measured a
88/// 12-second bound killing a LIVE, HEALTHY run in 2 of 7 trials.
89///
90/// **What the five trials do not establish:** one machine, idle, one CLI
91/// version, two workload types. They show the 30.00s cadence is real and
92/// reproducible; they do not prove the interval is fixed across load, hardware,
93/// or CLI versions. That is precisely why this floor sits well above the
94/// observed maximum rather than near it.
95///
96/// Because the default IS the floor, the value can only ever be raised.
97pub const IDLE_TIMEOUT_FLOOR_SECS: u64 = 120;
98
99/// The environment variable that raises the idle timeout above its floor.
100pub const IDLE_TIMEOUT_ENV: &str = "DEVFLOW_CLAUDE_IDLE_TIMEOUT_SECS";
101
102/// How [`parse_idle_timeout_secs`] arrived at the timeout now in force.
103///
104/// A distinct enum rather than the plain `clamped: bool` the plan sketched:
105/// there are FOUR distinguishable resolutions, not two, and the loud operator
106/// notice needs to name the value that was configured — which a bool cannot
107/// carry. `ValidateOutcome` in `pipeline_outcomes.rs` makes the same argument
108/// for the same reason.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum IdleTimeoutResolution {
111 /// Nothing was configured; the default — which is the floor — is in force.
112 Default,
113 /// A configured value at or above the floor is in force verbatim.
114 Configured,
115 /// A configured value BELOW the floor was raised to it (D-04).
116 Clamped {
117 /// What the operator asked for, for the notice to name.
118 configured: u64,
119 },
120 /// A value was set but could not be parsed; the default is in force.
121 ///
122 /// Loud for the same reason the clamp is. An operator who meant `600` and
123 /// typed `60O` silently gets the 120s default, and a legitimately slow stage then dies
124 /// on a timeout nobody chose. `parse_gate_max_unattended_age` substitutes
125 /// silently in this case and is the anti-pattern here, not the precedent.
126 Unparseable {
127 /// The raw value, echoed back so the typo is visible.
128 raw: String,
129 },
130}
131
132/// A resolved idle timeout together with how it was arrived at.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct IdleTimeoutSetting {
135 /// The window that must elapse with NO line on the child's stdout.
136 pub timeout: Duration,
137 /// How that value was reached — observable to the caller as a VALUE, not
138 /// only as a log line, so a test can assert on it directly.
139 pub resolution: IdleTimeoutResolution,
140}
141
142impl IdleTimeoutSetting {
143 /// Whether the floor clamp engaged.
144 pub fn clamped(&self) -> bool {
145 matches!(self.resolution, IdleTimeoutResolution::Clamped { .. })
146 }
147
148 /// The loud, operator-facing notice this resolution owes, if any.
149 ///
150 /// `None` for the two unremarkable cases. `Some` exactly when a value the
151 /// operator supplied is NOT the value in force — the case that must never
152 /// pass silently.
153 pub fn notice(&self) -> Option<String> {
154 match &self.resolution {
155 IdleTimeoutResolution::Default | IdleTimeoutResolution::Configured => None,
156 IdleTimeoutResolution::Clamped { configured } => Some(format!(
157 "{IDLE_TIMEOUT_ENV}={configured} is below the {IDLE_TIMEOUT_FLOOR_SECS}s floor \
158 and was CLAMPED; {}s is in force. A shorter window kills healthy runs: a 12s \
159 bound terminated a live, healthy run in 2 of 7 measured trials.",
160 self.timeout.as_secs()
161 )),
162 IdleTimeoutResolution::Unparseable { raw } => Some(format!(
163 "{IDLE_TIMEOUT_ENV}={raw:?} could not be parsed as a whole number of seconds; \
164 the {}s default is in force. If you meant to RAISE the timeout, this did not \
165 do it.",
166 self.timeout.as_secs()
167 )),
168 }
169 }
170}
171
172/// Resolve a raw idle-timeout override into the value actually in force.
173///
174/// Pure — no environment access — so it is unit-testable directly rather than
175/// by mutating process-global env. That shape is copied from
176/// `devflow-cli`'s four `parse_*` timeout readers; their BEHAVIOUR is
177/// deliberately not copied, because none of them clamps against a floor and
178/// none logs when a fallback engages. There is no clamp-and-log precedent
179/// anywhere in this workspace; this is the first (D-04).
180pub fn parse_idle_timeout_secs(raw: Option<String>) -> IdleTimeoutSetting {
181 let floor = Duration::from_secs(IDLE_TIMEOUT_FLOOR_SECS);
182
183 // An unset variable and an EMPTY one are the same intent: nothing chosen.
184 // Only a non-empty value that fails to parse is a typo worth shouting at.
185 let Some(trimmed) = raw.as_deref().map(str::trim).filter(|s| !s.is_empty()) else {
186 return IdleTimeoutSetting {
187 timeout: floor,
188 resolution: IdleTimeoutResolution::Default,
189 };
190 };
191
192 let Ok(configured) = trimmed.parse::<u64>() else {
193 return IdleTimeoutSetting {
194 timeout: floor,
195 resolution: IdleTimeoutResolution::Unparseable {
196 raw: trimmed.to_string(),
197 },
198 };
199 };
200
201 if configured < IDLE_TIMEOUT_FLOOR_SECS {
202 IdleTimeoutSetting {
203 timeout: floor,
204 resolution: IdleTimeoutResolution::Clamped { configured },
205 }
206 } else {
207 IdleTimeoutSetting {
208 timeout: Duration::from_secs(configured),
209 resolution: IdleTimeoutResolution::Configured,
210 }
211 }
212}
213
214/// The thin environment wrapper over [`parse_idle_timeout_secs`].
215///
216/// The variable name is spelled out as a STRING LITERAL here rather than
217/// passed as [`IDLE_TIMEOUT_ENV`], and that is deliberate.
218/// `doc_check::source_read_env_vars` recognises a variable only when it is read
219/// through a literal inside `std::env::var("...")`; reading it through the
220/// const compiles and works identically but makes the variable INVISIBLE to
221/// the operator-doc parity gate, which would then pass green while the
222/// variable went undocumented. Verified by removing this variable's row from
223/// `OPERATIONS.md` and confirming `doc_check` reddens.
224pub fn idle_timeout_setting() -> IdleTimeoutSetting {
225 parse_idle_timeout_secs(std::env::var("DEVFLOW_CLAUDE_IDLE_TIMEOUT_SECS").ok())
226}
227
228/// Which supervision shape [`spawn_monitor`] should launch.
229///
230/// This is a MODE selection on one supervisor, not two monitors: both arms
231/// write the same capture, exit-code and agent-pid files under `.devflow/`,
232/// and both end by advancing the same stage machine. Nothing downstream needs
233/// to know which arm ran.
234pub enum MonitorLaunch {
235 /// Phase 31: a Rust supervisor that owns BOTH of the child's pipes,
236 /// delivers `prompt` as a JSON user turn on the child's stdin, and holds
237 /// that stdin open past the child's first turn so a task-notification turn
238 /// can still be delivered (constraint 4).
239 PipeOwning {
240 /// The stage prompt, delivered on the child's stdin rather than argv.
241 prompt: String,
242 },
243 /// The pre-31 detached `sh` script: stdin is `/dev/null`, stdout is
244 /// redirected to the capture file by the shell, and the script waits on
245 /// the agent then runs `devflow advance`. Every non-Claude adapter, every
246 /// stage not yet widened by D-09/D-10's rollout, and the checkpoint-resume
247 /// relaunch all run through here, unchanged.
248 Legacy,
249}
250
251/// Spawn a background monitor that owns the agent for the given workflow state.
252///
253/// The monitor is a detached process that:
254/// 1. Launches the agent (`program` + `args`) with stdout captured to the
255/// phase stdout file, recording the agent PID to the agent-pid file
256/// 2. Waits for the agent to exit and records its exit code to the exit file
257/// 3. Runs `devflow advance --phase N` to advance the workflow through its
258/// remaining stages
259///
260/// `launch` selects the supervision shape — see [`MonitorLaunch`].
261///
262/// Returns the PID of the spawned monitor.
263pub fn spawn_monitor(
264 state: &State,
265 program: &str,
266 args: &[String],
267 envs: &[(String, String)],
268 launch: MonitorLaunch,
269) -> Result<u32, MonitorError> {
270 spawn_monitor_inner(state, program, args, envs, launch, true)
271}
272
273fn spawn_monitor_inner(
274 state: &State,
275 program: &str,
276 args: &[String],
277 envs: &[(String, String)],
278 launch: MonitorLaunch,
279 run_advance: bool,
280) -> Result<u32, MonitorError> {
281 let project_root = state
282 .project_root
283 .to_str()
284 .ok_or(MonitorError::NonUtf8Path)?;
285
286 let binary = std::env::current_exe()
287 .map_err(|_| MonitorError::NoBinaryPath)?
288 .to_str()
289 .ok_or(MonitorError::NonUtf8Path)?
290 .to_string();
291
292 info!(
293 "spawning monitor for phase {}: {program} {}",
294 state.phase,
295 args.join(" ")
296 );
297
298 let stdout_file = crate::agent_result::stdout_path(&state.project_root, state.phase);
299 let stderr_file = crate::agent_result::stderr_path(&state.project_root, state.phase);
300 let exit_file = crate::agent_result::exit_code_path(&state.project_root, state.phase);
301 let pid_file = crate::agent_result::agent_pid_path(&state.project_root, state.phase);
302
303 // Ensure the capture directory exists before the detached process runs.
304 if let Some(parent) = stdout_file.parent() {
305 crate::workflow::ensure_devflow_dir(parent)?;
306 }
307
308 let stdout_file = stdout_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
309 let stderr_file = stderr_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
310 let exit_file = exit_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
311 let pid_file = pid_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
312
313 // The agent runs in its worktree when worktree mode is active; otherwise it
314 // runs in the project root. Capture/state files and the `devflow check`
315 // calls below always use the main project root, regardless of cwd.
316 let workdir_path = state
317 .worktree_path
318 .as_deref()
319 .unwrap_or(&state.project_root);
320 let workdir = workdir_path.to_str().ok_or(MonitorError::NonUtf8Path)?;
321
322 if let MonitorLaunch::PipeOwning { prompt } = launch {
323 // `run_advance` is not consulted on this arm: the `__monitor`
324 // subcommand always advances after reaping, and `spawn_monitor` is the
325 // only caller of this function — it hardcodes `true`. Adding a
326 // `--no-advance` flag for a case nothing exercises would be an
327 // untested branch; add it when a caller actually needs it.
328 let _ = run_advance;
329
330 // The adapter's extra env rides down by INHERITANCE here (set via
331 // `.envs(...)` on the `__monitor` process below), and that is only
332 // sufficient because the sole adapter routed through this arm —
333 // Claude — declares no extra env at all
334 // (`codex_disables_signing_via_env_others_do_not` asserts this).
335 // Widening this arm to an adapter that DOES set env requires
336 // threading it explicitly to `run_pipe_owning_monitor`: the inner
337 // `hermetic_command` scrubs `GIT_CONFIG_COUNT`, which neutralises any
338 // inherited `GIT_CONFIG_KEY_n` pair (Codex's unsigned-commit
339 // override is exactly that shape). Loud rather than silent, and in
340 // the CLI process where an operator can actually see it.
341 if !envs.is_empty() {
342 warn!(
343 "pipe-owning monitor: {} adapter env var(s) will not survive the \
344 inner hermetic_command scrub — thread them explicitly before \
345 routing an env-setting adapter through this arm",
346 envs.len()
347 );
348 }
349
350 // D-04: resolve and clamp the idle timeout HERE, in the parent, and
351 // hand the monitor the already-resolved integer.
352 //
353 // The placement is the whole point. `spawn_monitor` runs inside
354 // `devflow start`, attached to the operator's terminal; the monitor is
355 // a detached process whose stdio is all `Stdio::null()`, so a warning
356 // logged there scrolls into nothing. A silent clamp is the exact
357 // failure class this project keeps paying for, so the notice goes to
358 // BOTH `tracing::warn!` and stdout — the log for the record, stdout
359 // for the human who is watching right now.
360 let idle = idle_timeout_setting();
361 if let Some(notice) = idle.notice() {
362 warn!("{notice}");
363 println!("{notice}");
364 }
365
366 // The prompt travels as a FILE, not argv: argv has a hard length
367 // ceiling and DevFlow stage prompts routinely exceed what is safe to
368 // pass positionally.
369 let prompt_file = crate::agent_result::prompt_path(&state.project_root, state.phase);
370 std::fs::write(&prompt_file, &prompt)?;
371 let prompt_file = prompt_file.to_str().ok_or(MonitorError::NonUtf8Path)?;
372
373 // Re-exec THIS binary as its hidden `__monitor` subcommand. The
374 // monitor must outlive `devflow start`/`advance`, so it has to be a
375 // distinct OS process; re-exec needs no daemonization primitive beyond
376 // `spawn()`-without-`wait()`, which is exactly what the `sh` monitor
377 // below already relies on.
378 //
379 // Ordering is load-bearing for the same reason the Legacy arm's
380 // comment gives: `hermetic_command` does its `env_remove`s at
381 // construction and `.envs(...)` runs after, so deliberate
382 // configuration survives while inherited pollution does not.
383 let child = hermetic_command(&binary, workdir_path)
384 .arg("__monitor")
385 .arg("--project")
386 .arg(project_root)
387 .arg("--phase")
388 .arg(state.phase.to_string())
389 .arg("--workdir")
390 .arg(workdir)
391 .arg("--prompt-file")
392 .arg(prompt_file)
393 .arg("--idle-timeout-secs")
394 .arg(idle.timeout.as_secs().to_string())
395 .arg("--")
396 .arg(program)
397 .args(args)
398 .envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
399 .stdin(Stdio::null())
400 .stdout(Stdio::null())
401 .stderr(Stdio::null())
402 .spawn()?;
403
404 let pid = child.id();
405 info!("pipe-owning monitor spawned with pid {pid}");
406 return Ok(pid);
407 }
408
409 // Shell script that launches the agent in the background, captures its
410 // stdout and exit code, then advances the workflow. Because this process
411 // is the agent's parent, capture survives the CLI exiting.
412 //
413 // stderr is captured to a separate file so it cannot corrupt the (possibly
414 // JSON) stdout capture that DevFlow parses for DEVFLOW_RESULT. Inspect
415 // .devflow/phase-NN-stderr.log for agent error output on failures.
416 //
417 // `devflow advance --phase N` evaluates the agent result, moves the stage
418 // machine forward, and (for an agent stage) spawns the next monitor
419 // itself. The phase is recorded here at spawn time so advance's identity
420 // never depends on a shared state singleton (13-DEFERRED-CR-03): under
421 // `devflow parallel`, each phase's monitor advances exactly its own
422 // stage machine.
423 //
424 // Traps SIGTERM and SIGINT for clean shutdown. WR-08 (13-REVIEW.md):
425 // the trap must also kill the backgrounded agent ($apid) — previously
426 // it only exited the monitor shell itself, orphaning the agent so it
427 // kept running/committing unsupervised with nothing left to call
428 // `devflow advance` once it finished. `apid` is initialized to empty
429 // before the trap is installed so a signal arriving before the agent is
430 // even backgrounded doesn't reference an unset variable.
431 let advance_tail = if run_advance {
432 format!(
433 "; {binary} advance {project_root} --phase {phase}",
434 binary = shell_escape(&binary),
435 project_root = shell_escape(project_root),
436 phase = state.phase,
437 )
438 } else {
439 String::new()
440 };
441 let script = format!(
442 "apid=''; cleanup() {{ [ -n \"$apid\" ] && kill \"$apid\" 2>/dev/null; exit 0; }}; \
443 trap cleanup TERM INT; \
444 cd {workdir} || exit 1; \
445 \"$@\" > {stdout_file} 2>{stderr_file} & \
446 apid=$!; echo $apid > {pid_file}; \
447 wait $apid; echo $? > {exit_file}{advance_tail}",
448 workdir = shell_escape(workdir),
449 stdout_file = shell_escape(stdout_file),
450 stderr_file = shell_escape(stderr_file),
451 exit_file = shell_escape(exit_file),
452 pid_file = shell_escape(pid_file),
453 );
454
455 // 27-REVIEW WR-03: built through `hermetic_command`, not a bare
456 // `Command::new("sh")`. This is the spawn that launches the coding agent
457 // itself, and the comment below is precisely the hazard: whatever
458 // environment this `sh` carries rides down into the agent and into every
459 // git command the agent runs. An inherited `GIT_DIR` here would silently
460 // retarget the phase's real commits at a repository the operator never
461 // named — the worst case this phase exists to prevent, on its
462 // highest-consequence call site.
463 //
464 // Ordering is load-bearing: `hermetic_command` does its `env_remove`s at
465 // construction, and `.envs(...)` below runs after, so an adapter that
466 // deliberately sets one of these variables still wins. Deliberate
467 // configuration survives; inherited pollution does not. That is what
468 // keeps Codex's unsigned-commit override (`GIT_CONFIG_*`) working.
469 let child = hermetic_command("sh", workdir_path)
470 .arg("-c")
471 .arg(&script)
472 .arg("sh")
473 .arg(program)
474 .args(args)
475 // Adapter-scoped env (e.g. Codex's unsigned-commit override) rides
476 // the whole monitor chain: sh → agent → its git children (13-06).
477 .envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
478 .stdin(Stdio::null())
479 .stdout(Stdio::null())
480 .stderr(Stdio::null())
481 .spawn()?;
482
483 let pid = child.id();
484 info!("monitor spawned with pid {pid}");
485 Ok(pid)
486}
487
488/// Constraint 4's close rule as a pure, line-fed state machine: stdin may be
489/// released only once a `DEVFLOW_RESULT` marker has appeared inside a
490/// TOP-LEVEL `result` event **and** the background-task list has drained.
491///
492/// An `AND` of two arms, neither sufficient alone:
493///
494/// - **Marker arm.** Satisfied only by
495/// [`crate::agent_result::event_is_top_level_result_marker`] — a composition
496/// of the existing `is_top_level` predicate and the existing marker parser,
497/// never a looser text search. The CLI echoes the operator's prompt back
498/// into the same stdout, and DevFlow's own stage prompts discuss
499/// `DEVFLOW_RESULT` markers at length, so marker text alone is not evidence
500/// (T-31-01; the same echo produced the checkpoint false positive 30-05
501/// fixed).
502/// - **Drain arm.** Satisfied when no `background_tasks_changed` event has
503/// ever announced anything (vacuous — the common single-plan case) or when
504/// the most recent one carried an empty list.
505///
506/// **The drain alone is never a stop signal.** 30c/30d measured the
507/// drain-to-final-`result` lag at 4.54–11.51s across 14 trials; closing at the
508/// drain would have truncated the final orchestrator turn in all seven 30d
509/// trials.
510///
511/// **Never count `result` events.** Constraint 7: the CLI coalesces
512/// completions, so a wave whose children finish together produces one `result`
513/// for several of them — a shape superficially indistinguishable from "one
514/// child delivered, one lost". The drained list is the only thing separating
515/// those two. Per 30-04 the drain arm is *defensive rather than load-bearing*
516/// (n=2 Mode B trials delivered everything without it); that is the recorded
517/// reason to keep it cheaply, not a reason to drop it.
518///
519/// **A line that does not parse as JSON is ignored by this rule** — it can
520/// neither satisfy nor block either arm — but it is still teed verbatim to the
521/// capture file by the reader thread. A torn line therefore cannot silently
522/// decide anything, and cannot be silently lost either.
523#[derive(Default)]
524pub struct CloseRule {
525 marker_seen: bool,
526 /// `None` = nothing was ever announced; `Some(n)` = the last announcement
527 /// carried `n` tasks. The two are deliberately distinguishable: `None` is
528 /// vacuously drained, and conflating it with `Some(0)` would erase the
529 /// difference between "no children" and "children, all finished".
530 pending_background_tasks: Option<usize>,
531}
532
533impl CloseRule {
534 /// Fold one raw stdout line into the rule.
535 pub fn observe(&mut self, line: &str) {
536 let Ok(event) = serde_json::from_str::<serde_json::Value>(line) else {
537 return;
538 };
539 if crate::agent_result::event_is_top_level_result_marker(&event) {
540 self.marker_seen = true;
541 }
542 if event.get("type").and_then(serde_json::Value::as_str) == Some("system")
543 && event.get("subtype").and_then(serde_json::Value::as_str)
544 == Some("background_tasks_changed")
545 && let Some(tasks) = event.get("tasks").and_then(serde_json::Value::as_array)
546 {
547 // Only a readable `tasks` array updates the count. An announcement
548 // whose list cannot be read leaves the previous state standing
549 // rather than being treated as a drain — the conservative
550 // direction, since an early close truncates while a late one
551 // merely costs an idle timeout.
552 self.pending_background_tasks = Some(tasks.len());
553 }
554 }
555
556 /// Whether both arms hold and the child's stdin may be released.
557 pub fn should_close(&self) -> bool {
558 self.marker_seen && matches!(self.pending_background_tasks, None | Some(0))
559 }
560}
561
562/// The single place the stdin wire shape is constructed: one line of JSON
563/// carrying the initial user turn for a `--input-format stream-json` child.
564///
565/// Shape (`{"type":"user","message":{"role":"user","content":<prompt>}}`) is
566/// reproduced from the three archived Phase 30 harnesses, which all wrote
567/// exactly this and got a working turn back.
568///
569/// Built with `serde_json` rather than `format!` so the prompt is ESCAPED, not
570/// interpolated. A stage prompt is arbitrary text containing quotes, newlines
571/// and backslashes; interpolating it would produce a torn JSON line the CLI
572/// rejects, and a prompt could then alter the surrounding document's structure.
573pub fn user_turn_line(prompt: &str) -> String {
574 serde_json::json!({
575 "type": "user",
576 "message": { "role": "user", "content": prompt },
577 })
578 .to_string()
579}
580
581/// Supervise a `stream-json` child, owning both of its pipes, until the close
582/// rule is satisfied and the child exits. Returns the child's exit code, which
583/// is also written to the phase exit file.
584///
585/// This runs INSIDE the detached `__monitor` process, not in the CLI.
586///
587/// Threading model (constraint 4 / T-31-04). Three participants:
588/// - a **writer thread** owning the child's stdin: it writes the initial user
589/// turn, then BLOCKS on a channel rather than returning. It drops stdin only
590/// when told to, because constraint 4's `AND` can never be honoured if stdin
591/// is already gone — a task-notification turn arriving after the child's
592/// first turn would have nowhere to be delivered.
593/// - a **reader thread** owning the child's stdout: it tees each line verbatim
594/// to the capture file and forwards it to the supervisor. Dropping its
595/// sender at EOF is what surfaces `Disconnected` below.
596/// - the **supervisor** (this function's own thread), which applies the close
597/// rule and reaps.
598///
599/// The write and the read MUST be on independent threads. Writing the prompt
600/// synchronously before reading stdout is the textbook two-pipe deadlock: it
601/// passes every short-prompt smoke test and hangs on exactly the context-heavy
602/// production stages that matter (the Linux pipe buffer is commonly 64KiB and
603/// a DevFlow stage prompt can exceed that in one write).
604#[allow(clippy::too_many_arguments)]
605pub fn run_pipe_owning_monitor(
606 project_root: &Path,
607 phase: u32,
608 workdir: &Path,
609 prompt: &str,
610 idle_timeout: Duration,
611 program: &str,
612 args: &[String],
613 envs: &[(String, String)],
614) -> Result<i32, MonitorError> {
615 let stdout_file = crate::agent_result::stdout_path(project_root, phase);
616 let stderr_file = crate::agent_result::stderr_path(project_root, phase);
617 let exit_file = crate::agent_result::exit_code_path(project_root, phase);
618 let pid_file = crate::agent_result::agent_pid_path(project_root, phase);
619 if let Some(parent) = stdout_file.parent() {
620 crate::workflow::ensure_devflow_dir(parent)?;
621 }
622
623 // stderr goes to its own file so it cannot corrupt the JSONL stdout
624 // capture DevFlow parses — the same separation the Legacy script's
625 // `2>{stderr_file}` provides.
626 let stderr_handle = std::fs::File::create(&stderr_file)?;
627 // One handle, opened once, truncating at open and appending line by line.
628 // Truncate-at-open reproduces the Legacy arm's `>` redirection exactly, so
629 // a capture from a previous attempt can never be mixed into this one's
630 // (the launch path archives the prior capture first, but relying on that
631 // to make an append-mode open safe would be an unstated coupling).
632 let mut capture = std::fs::File::create(&stdout_file)?;
633
634 let mut child = hermetic_command(program, workdir)
635 .args(args)
636 .envs(envs.iter().map(|(k, v)| (k.as_str(), v.as_str())))
637 .stdin(Stdio::piped())
638 .stdout(Stdio::piped())
639 .stderr(Stdio::from(stderr_handle))
640 // T-31-05: make the child its own process-group leader so a later
641 // group signal cannot reach this monitor's own ancestors. Verified
642 // source shows the pre-31 `spawn_monitor` had NO session or group
643 // configuration at all — detachment came only from the parent not
644 // waiting — so this closes a gap rather than preserving one.
645 // Full `setsid()` session detachment is deliberately NOT done: no
646 // forensics record cites a SIGHUP-related monitor loss, so there is
647 // no evidence it buys anything. `pre_exec` calling `libc::setsid()`
648 // is the one-line follow-on if such a loss ever surfaces.
649 .process_group(0)
650 .spawn()?;
651
652 // Recorded immediately, before any pipe work: `wait_for_agent_pid` polls
653 // for this and the rest of DevFlow's liveness reporting depends on it.
654 let child_pid = child.id();
655 std::fs::write(&pid_file, format!("{child_pid}\n"))?;
656
657 let mut child_stdin = child
658 .stdin
659 .take()
660 .ok_or(MonitorError::NoChildPipe("stdin"))?;
661 let child_stdout = child
662 .stdout
663 .take()
664 .ok_or(MonitorError::NoChildPipe("stdout"))?;
665
666 let (close_tx, close_rx) = mpsc::channel::<()>();
667 let turn = user_turn_line(prompt);
668 let writer = std::thread::spawn(move || {
669 let wrote = child_stdin
670 .write_all(turn.as_bytes())
671 .and_then(|()| child_stdin.write_all(b"\n"))
672 .and_then(|()| child_stdin.flush());
673 if let Err(err) = wrote {
674 warn!("could not write the initial user turn to the child's stdin: {err}");
675 return;
676 }
677 // Deliberately NOT dropping stdin here — see this function's doc.
678 // Either signal (an explicit close, or the supervisor dropping its
679 // sender) means the same thing: stop holding the pipe open.
680 let _ = close_rx.recv();
681 drop(child_stdin);
682 });
683
684 let (line_tx, line_rx) = mpsc::channel::<String>();
685 let reader = std::thread::spawn(move || {
686 // `read_until` + `from_utf8_lossy`, NOT `BufRead::lines()` (peer review
687 // 2026-08-03, CRITICAL). `lines()` yields `Err(InvalidData)` on a single
688 // non-UTF-8 byte, and the previous code treated any read error as EOF —
689 // so one bad byte silently truncated the capture and dropped every later
690 // line INCLUDING the terminal `DEVFLOW_RESULT` marker. That is precisely
691 // the boundary-truncation class constraint 9 exists for, manufactured by
692 // the supervisor itself rather than by a dying writer.
693 //
694 // Decoding is now lossy and NON-fatal: undecodable bytes become U+FFFD
695 // and the line still reaches the capture and the close rule. A genuine
696 // I/O error still ends the loop, because that one really is EOF.
697 let mut reader_buf = BufReader::new(child_stdout);
698 let mut raw = Vec::new();
699 loop {
700 raw.clear();
701 match reader_buf.read_until(b'\n', &mut raw) {
702 Ok(0) => break, // real EOF
703 Ok(_) => {}
704 Err(err) => {
705 warn!("stdout read error, treating as EOF: {err}");
706 break;
707 }
708 }
709 while raw.last().is_some_and(|b| *b == b'\n' || *b == b'\r') {
710 raw.pop();
711 }
712 let line = String::from_utf8_lossy(&raw).into_owned();
713 // Tee VERBATIM before any interpretation: the whole Layer 1
714 // cascade reads this file, and a line the close rule ignores
715 // (unparseable noise, interleaved prose) must still reach it.
716 if let Err(err) = writeln!(capture, "{line}") {
717 warn!("could not append to the capture file: {err}");
718 }
719 let _ = capture.flush();
720 if line_tx.send(line).is_err() {
721 break;
722 }
723 }
724 // Dropping `line_tx` here is what surfaces `Disconnected` below.
725 });
726
727 // Constraint 4's close rule lives in `CloseRule` so it can be unit-tested
728 // by feeding it lines, with no child process per case.
729 let mut rule = CloseRule::default();
730 let mut close_signalled = false;
731
732 loop {
733 match line_rx.recv_timeout(idle_timeout) {
734 Ok(line) => {
735 if close_signalled {
736 continue;
737 }
738 rule.observe(&line);
739 if rule.should_close() {
740 let _ = close_tx.send(());
741 close_signalled = true;
742 }
743 }
744 Err(mpsc::RecvTimeoutError::Disconnected) => break,
745 Err(mpsc::RecvTimeoutError::Timeout) => {
746 // AFTER a deliberate close, silence is EXPECTED, not a hang
747 // (peer review 2026-08-03, CRITICAL). The close rule fires only
748 // once the agent has emitted its terminal marker AND background
749 // tasks have drained — at which point it has said everything it
750 // intends to say and is merely winding down. Firing the idle
751 // timeout here wrote an authoritative `IdleTimeout` verdict OVER
752 // a completed, successful stage; and because `evaluate_layer1`
753 // reads that side channel FIRST, by design, so that nothing can
754 // shadow a real timeout, the bogus verdict outranked the real
755 // success and could not be recovered from. The mechanism that
756 // protects a true timeout is what made a false one fatal.
757 //
758 // Break instead: the reap path below already bounds a child that
759 // will not exit, via `terminate_and_verify`.
760 if close_signalled {
761 info!(
762 "no output for {idle_timeout:?} after the close rule released stdin; \
763 the stage already reported — proceeding to reap, NOT recording a timeout"
764 );
765 break;
766 }
767 // No outer wall-clock bound exists anywhere in this loop, and
768 // none may be added (D-03). `recv_timeout` measures the gap
769 // since the LAST LINE, so a healthy 47-minute stage that keeps
770 // emitting is never touched — every line the reader thread
771 // forwards resets the window naturally, which is D-01's
772 // every-line signal rather than a milestone-only one. There is
773 // no single wall-clock value that is safe for both a hang and
774 // a legitimately long stage, which is why constraint 5
775 // rejected one.
776 fire_idle_timeout(project_root, phase, workdir, child_pid, idle_timeout);
777 break;
778 }
779 }
780 }
781
782 // Guarantee stdin is released before waiting. A child still holding an
783 // open stdin may never exit, and `child.wait()` would then block forever.
784 drop(close_tx);
785
786 let status = child.wait()?;
787 // A signal-killed child has NO exit code — `status.code()` is `None`, and
788 // the previous `unwrap_or(-1)` threw the signal away (peer review
789 // 2026-08-03, found independently by both reviewers and by the 31-04 plan
790 // review as W1). That silently defeated the classification 31-04 took care
791 // to preserve: `evaluate_layer2` and
792 // `reconcile_stream_success_against_exit_code` map **137** to
793 // `ResourceKilled` (routed to `GateInfra` — an infrastructure fault) and
794 // **127** to `AgentUnavailable`. Recording `-1` matched neither, so a real
795 // OOM kill arrived as a generic `Failed` and routed to `GateReview`, asking
796 // an operator to code-review a stage that was killed by the kernel.
797 //
798 // `128 + signal` is the shell convention those constants already encode:
799 // SIGKILL(9) -> 137, SIGTERM(15) -> 143. `-1` is now reachable only when a
800 // status is neither exited nor signalled, which POSIX does not define.
801 let code = status.code().unwrap_or_else(|| {
802 use std::os::unix::process::ExitStatusExt;
803 status.signal().map_or(-1, |signal| 128 + signal)
804 });
805 std::fs::write(&exit_file, format!("{code}\n"))?;
806
807 let _ = writer.join();
808 let _ = reader.join();
809
810 info!("supervised child {child_pid} exited with code {code}");
811 Ok(code)
812}
813
814/// The idle-timeout firing sequence, in the ONE order it may run (D-05).
815///
816/// 1. Enumerate the commits the agent made.
817/// 2. Write the authoritative verdict to its side-channel file, and fsync it.
818/// 3. **Only then** terminate the child.
819/// 4. Append a loud entry to the monitor's own log.
820///
821/// Step 3 must not precede step 2, and reversing them is not a stylistic
822/// choice. Between "the child is dead" and "an authoritative result exists"
823/// there is a window in which the verdict cascade sees a dead process, no
824/// Layer-1 answer, and some commits on the branch — and Layer 2 scores exactly
825/// that as `Success`. That is 999.64 reborn inside its own fix. A bare kill
826/// with no record is the other half of the same failure: exit code 137 reads
827/// as `ResourceKilled`, blaming an OOM that never happened.
828///
829/// **Nothing here rolls back, resets, or reverts a commit** (D-07, T-31-09).
830/// The commit log is READ and never written. A timeout can be a false
831/// positive, and destroying real work on a false positive is unrecoverable —
832/// this repo treats irreversible operations as needing review, not tests.
833///
834/// Scoped to the `PipeOwning` arm alone: `Legacy` keeps today's behaviour, and
835/// Codex/OpenCode keep theirs. The 120-second floor was measured against
836/// Claude's stream cadence (a fixed 30.00s `tool_progress` keepalive), and
837/// applying it to an agent whose output cadence has never been measured would
838/// be a behaviour prediction — the thing constraint 1 forbids.
839///
840/// Every step is best-effort and none can abort the sequence. A failure to
841/// enumerate, write, or log must still leave the child terminated and the
842/// stage machine advancing to a never-silent gate; the operator loses detail,
843/// never the verdict.
844fn fire_idle_timeout(
845 project_root: &Path,
846 phase: u32,
847 workdir: &Path,
848 child_pid: u32,
849 idle: Duration,
850) {
851 let idle_secs = idle.as_secs();
852 warn!("idle timeout: no output from the supervised child for {idle_secs}s");
853
854 // 1. Enumerate. A failure degrades to an empty list plus a note; it never
855 // aborts, because a missing commit list must not cost the verdict.
856 let (commits, enumeration_note) = enumerate_phase_commits(workdir, phase);
857
858 // 2. Write, flush, fsync. This completing is the ONLY thing that stops
859 // Layer 2 from later scoring partial commits as Success.
860 let write_error =
861 write_idle_timeout_record(project_root, phase, idle_secs, child_pid, &commits)
862 .err()
863 .map(|err| err.to_string());
864 if let Some(err) = &write_error {
865 warn!("idle timeout: could not persist the verdict: {err}");
866 }
867
868 // 3. Only now is it safe to kill.
869 let terminated = terminate_child_group(child_pid);
870
871 // 4. Loud, durable, and readable after the fact.
872 let named: Vec<String> = commits
873 .iter()
874 .map(|commit| {
875 let short: String = commit.sha.chars().take(7).collect();
876 format!("{short} {}", commit.subject)
877 })
878 .collect();
879 let mut entry = format!(
880 "[idle-timeout] no output for {idle_secs}s; terminated agent pid {child_pid} \
881 (verified dead: {terminated}). {} commit(s) on the phase branch, NONE rolled back{}{}",
882 named.len(),
883 if named.is_empty() {
884 String::new()
885 } else {
886 format!(": {}", named.join("; "))
887 },
888 enumeration_note
889 .map(|note| format!(" [commit enumeration degraded: {note}]"))
890 .unwrap_or_default(),
891 );
892 if let Some(err) = write_error {
893 entry.push_str(&format!(" [verdict file could not be written: {err}]"));
894 }
895 warn!("{entry}");
896 append_monitor_log(project_root, phase, &entry);
897}
898
899/// Enumerate the commits on this phase's feature branch, as
900/// `(commits, degradation note)`.
901///
902/// Same range construction `evaluate_layer2`'s commit COUNT uses
903/// (`{develop}..{feature_prefix}phase-NN`) — the same question asked with
904/// `git log` instead of `rev-list --count`, so the two can never disagree
905/// about which commits are the agent's.
906///
907/// Never returns an error. Every failure path yields an empty list and a note
908/// naming what went wrong: the operator losing the commit NAMES is bad, the
909/// operator losing the VERDICT is the failure this whole plan exists to
910/// prevent.
911fn enumerate_phase_commits(workdir: &Path, phase: u32) -> (Vec<IdleTimeoutCommit>, Option<String>) {
912 let git_flow = crate::config::GitFlowConfig::default();
913 let branch = format!("{}phase-{:02}", git_flow.feature_prefix, phase);
914 let range = format!("{}..{branch}", git_flow.develop);
915
916 let output = match crate::git::git_command(workdir)
917 .args(["log", "--format=%H %s", &range])
918 .output()
919 {
920 Ok(output) => output,
921 Err(err) => return (Vec::new(), Some(format!("git log could not run: {err}"))),
922 };
923
924 if !output.status.success() {
925 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
926 return (
927 Vec::new(),
928 Some(format!("git log {range} failed: {stderr}")),
929 );
930 }
931
932 let commits = String::from_utf8_lossy(&output.stdout)
933 .lines()
934 .filter_map(|line| {
935 let line = line.trim();
936 if line.is_empty() {
937 return None;
938 }
939 // `%H %s` — a sha, one space, then the subject, which may itself
940 // contain spaces. `split_once` is therefore correct and `split`
941 // is not. A subject-less commit still yields an empty subject
942 // rather than being dropped.
943 let (sha, subject) = line.split_once(' ').unwrap_or((line, ""));
944 Some(IdleTimeoutCommit {
945 sha: sha.to_string(),
946 subject: subject.to_string(),
947 })
948 })
949 .collect();
950
951 (commits, None)
952}
953
954/// Write the idle-timeout verdict and get it onto the platter before returning.
955///
956/// `sync_all` is not decoration: D-05's guarantee is that the result exists
957/// before anything can race it, and a buffered write that is still in the page
958/// cache when the process is signalled has not achieved that.
959fn write_idle_timeout_record(
960 project_root: &Path,
961 phase: u32,
962 idle_secs: u64,
963 child_pid: u32,
964 commits: &[IdleTimeoutCommit],
965) -> std::io::Result<()> {
966 let record = IdleTimeoutRecord {
967 status: crate::agent_result::AgentStatus::IdleTimeout
968 .as_wire_str()
969 .to_string(),
970 idle_secs,
971 agent_pid: child_pid,
972 written_at: std::time::SystemTime::now()
973 .duration_since(std::time::UNIX_EPOCH)
974 .map(|d| d.as_secs())
975 .unwrap_or(0),
976 commits: commits.to_vec(),
977 };
978 let json = serde_json::to_string(&record)
979 .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
980
981 let path = crate::agent_result::idle_timeout_path(project_root, phase);
982 if let Some(parent) = path.parent() {
983 crate::workflow::ensure_devflow_dir(parent)?;
984 }
985 let mut file = std::fs::File::create(&path)?;
986 file.write_all(json.as_bytes())?;
987 file.flush()?;
988 file.sync_all()
989}
990
991/// Terminate the supervised child's whole process group, returning the
992/// VERIFIED fact of whether the leader is dead.
993///
994/// Acts on `child_pid`, which came from the in-memory `Child` handle — never
995/// on the on-disk pid file (T-31-07). That distinction is what makes the
996/// negative-pid signal below safe at all: while this monitor still holds the
997/// unwaited `Child`, the kernel cannot recycle that pid, so it cannot come to
998/// mean some unrelated process between spawn and now. A pid re-read from disk
999/// carries no such guarantee.
1000///
1001/// Three steps, and the middle one is borrowed whole rather than reimplemented:
1002///
1003/// 1. `SIGTERM` to the GROUP. `.process_group(0)` at spawn made the child its
1004/// own group leader, so its pid IS its pgid and `-pid` reaches its whole
1005/// subtree — the tool subprocesses a coding agent leaves behind, which a
1006/// leader-only signal would orphan. It cannot reach this monitor: the
1007/// monitor stayed in its own inherited group, which is precisely what
1008/// `.process_group(0)` bought (T-31-05).
1009/// 2. [`crate::agent::terminate_and_verify`] for the leader — reused, not
1010/// rewritten. It owns the `SIGTERM` → poll → `SIGKILL` → re-poll
1011/// escalation and returns a verified liveness fact instead of an
1012/// assumption. 999.44 measured 15 of 15 orphaned wrappers surviving
1013/// `SIGTERM`, so the escalation is not optional.
1014/// 3. `SIGKILL` to the group, sweeping any survivor the leader's own
1015/// escalation did not cover. Unconditional by design: at this point the run
1016/// is over, everything in the group is the agent's subtree, and a `kill` to
1017/// an empty group is a no-op `ESRCH`.
1018///
1019/// The `signed > 1` guard is load-bearing twice over. `kill(-1, sig)` signals
1020/// every process the caller may signal, and `kill(0, sig)` signals the
1021/// caller's own group — the two catastrophic cases `agent::terminate` already
1022/// documents, reachable here through the negation rather than through a
1023/// hostile pid file.
1024fn terminate_child_group(child_pid: u32) -> bool {
1025 let Ok(signed) = libc::pid_t::try_from(child_pid) else {
1026 warn!("idle timeout: child pid {child_pid} does not fit pid_t; not signalling");
1027 return false;
1028 };
1029 if signed <= 1 {
1030 warn!("idle timeout: refusing to signal group for pid {signed}");
1031 return false;
1032 }
1033
1034 // SAFETY: `signed > 1`, so `-signed < -1` and the two catastrophic
1035 // targets (`0` = our own group, `-1` = everything) are both excluded.
1036 unsafe {
1037 libc::kill(-signed, libc::SIGTERM);
1038 }
1039
1040 let dead = crate::agent::terminate_and_verify(
1041 child_pid,
1042 crate::agent::TERMINATE_VERIFY_WAIT,
1043 crate::agent::TERMINATE_VERIFY_POLL,
1044 );
1045
1046 // SAFETY: same guard as above.
1047 unsafe {
1048 libc::kill(-signed, libc::SIGKILL);
1049 }
1050
1051 dead
1052}
1053
1054/// Append one line to the monitor's own log, creating it if needed.
1055///
1056/// Best-effort: the monitor's stdio is null, so this file is the only place a
1057/// "log loudly" obligation can actually land, but failing to write it must
1058/// never abort a termination sequence already in progress.
1059fn append_monitor_log(project_root: &Path, phase: u32, entry: &str) {
1060 let path = crate::agent_result::monitor_log_path(project_root, phase);
1061 if let Ok(mut file) = std::fs::OpenOptions::new()
1062 .create(true)
1063 .append(true)
1064 .open(&path)
1065 {
1066 let _ = writeln!(file, "{entry}");
1067 }
1068}
1069
1070/// Poll for the agent PID that the monitor records, for up to ~1 second.
1071///
1072/// Returns the PID once the monitor has launched the agent, or `None` if it
1073/// does not appear in time (the monitor still runs; only the display PID is lost).
1074pub fn wait_for_agent_pid(project_root: &Path, phase: u32) -> Option<u32> {
1075 let path = crate::agent_result::agent_pid_path(project_root, phase);
1076 debug!("polling for agent PID for phase {phase}");
1077 for _ in 0..50 {
1078 if let Ok(contents) = std::fs::read_to_string(&path)
1079 && let Ok(pid) = contents.trim().parse::<u32>()
1080 {
1081 return Some(pid);
1082 }
1083 std::thread::sleep(Duration::from_millis(20));
1084 }
1085 debug!("agent PID not found for phase {phase} after polling");
1086 None
1087}
1088
1089/// Escape a string for safe use in a single-quoted shell context.
1090fn shell_escape(s: &str) -> String {
1091 format!("'{}'", s.replace('\'', "'\\''"))
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use super::*;
1097 use crate::mode::Mode;
1098 use crate::stage::Stage;
1099 use crate::state::{AgentKind, State};
1100
1101 fn state_in(root: &Path) -> State {
1102 let mut state = State::new(4, AgentKind::Claude, Mode::Auto, root.to_path_buf());
1103 state.stage = Stage::Code;
1104 state
1105 }
1106
1107 // ---- close-rule fixtures ------------------------------------------
1108 //
1109 // Key names, nesting and event types are taken from the real archived
1110 // capture at
1111 // `.planning/phases/30-keep-the-session-alive-past-turn-end/30a-evidence/raw_output_v3.jsonl`
1112 // (lines 5, 8, 19, 44 and 54), not invented: `tasks` is an array of
1113 // objects with `task_id`/`task_type`/`description`, the drained event is
1114 // the same event with `tasks":[]`, and a coalesced completion carries
1115 // `origin.kind == "task-notification"` on an ordinary `result`. Volumes
1116 // and identifiers are generalized; shapes are not.
1117
1118 const INIT_LINE: &str = r#"{"type":"system","subtype":"init","cwd":"/tmp/work","session_id":"s-1","tools":["Task","Bash"],"uuid":"u-init"}"#;
1119
1120 /// A `system`/`background_tasks_changed` event announcing `count` tasks.
1121 /// `count == 0` is the DRAINED shape (v3 line 44).
1122 fn bg_tasks_line(count: usize) -> String {
1123 let tasks: Vec<String> = (0..count)
1124 .map(|i| {
1125 format!(
1126 r#"{{"task_id":"t{i}","task_type":"local_agent","description":"child {i}"}}"#
1127 )
1128 })
1129 .collect();
1130 format!(
1131 r#"{{"type":"system","subtype":"background_tasks_changed","tasks":[{}],"uuid":"u-bg{count}","session_id":"s-1"}}"#,
1132 tasks.join(",")
1133 )
1134 }
1135
1136 /// A top-level `result` event. `marker` is the `result` field's text —
1137 /// the agent's own final message, where a `DEVFLOW_RESULT:` line lives.
1138 fn result_line(marker: &str) -> String {
1139 format!(
1140 r#"{{"type":"result","subtype":"success","is_error":false,"num_turns":3,"stop_reason":"end_turn","session_id":"s-1","uuid":"u-res","result":"{marker}"}}"#
1141 )
1142 }
1143
1144 /// The v3 line-54 shape: ONE `result` closing out work that several
1145 /// children contributed to, tagged with the task-notification origin.
1146 fn coalesced_result_line(marker: &str) -> String {
1147 format!(
1148 r#"{{"type":"result","subtype":"success","is_error":false,"num_turns":2,"stop_reason":"end_turn","origin":{{"kind":"task-notification"}},"session_id":"s-1","uuid":"u-res-coalesced","result":"{marker}"}}"#
1149 )
1150 }
1151
1152 /// Same envelope, forwarded from a subagent rather than authored by the
1153 /// orchestrator session.
1154 fn subagent_result_line(marker: &str) -> String {
1155 result_line(marker).replacen('{', r#"{"parent_tool_use_id":"toolu_child","#, 1)
1156 }
1157
1158 /// A success marker as it appears INSIDE a `result` string field — the
1159 /// quotes are escaped because the field is itself JSON.
1160 const MARKER: &str = r#"All done.\nDEVFLOW_RESULT: {\"status\":\"success\",\"commits\":3}"#;
1161 const NO_MARKER: &str = "Acknowledged; nothing to report.";
1162
1163 fn observe_all(lines: &[String]) -> CloseRule {
1164 let mut rule = CloseRule::default();
1165 for line in lines {
1166 rule.observe(line);
1167 }
1168 rule
1169 }
1170
1171 /// Constraint 4 is an `AND`, and neither arm is sufficient alone. Both
1172 /// halves are asserted here because a rule that accidentally became an
1173 /// `OR` still passes any test that only ever feeds it both.
1174 #[test]
1175 fn close_rule_requires_both_marker_and_drained_background_tasks() {
1176 // Arm A: the drain lands, but no marker ever appears in a top-level
1177 // result. Closing here truncates the run before its verdict exists.
1178 // The torn line carrying marker TEXT is the negative control: a line
1179 // that does not parse as JSON must not be able to satisfy the marker
1180 // arm through the back door.
1181 let drained_but_unmarked = observe_all(&[
1182 INIT_LINE.to_string(),
1183 bg_tasks_line(1),
1184 bg_tasks_line(0),
1185 r#"{"type":"result","result":"DEVFLOW_RESULT: {\"status\":\"succ"#.to_string(),
1186 "progress: still working".to_string(),
1187 result_line(NO_MARKER),
1188 ]);
1189 assert!(
1190 !drained_but_unmarked.should_close(),
1191 "the drain alone must never close stdin: 30c/30d measured the \
1192 drain-to-final-result lag at 4.54-11.51s across 14 trials, and \
1193 closing at the drain would have truncated the final orchestrator \
1194 turn in all seven 30d trials"
1195 );
1196
1197 // Arm B: the marker lands while a child is still pending.
1198 let marked_but_pending =
1199 observe_all(&[INIT_LINE.to_string(), bg_tasks_line(1), result_line(MARKER)]);
1200 assert!(
1201 !marked_but_pending.should_close(),
1202 "a marker while a background task is still announced must not \
1203 close stdin — the pending child's task-notification turn would \
1204 have nowhere to be delivered"
1205 );
1206 }
1207
1208 /// The common case: a single-plan stage that never dispatches anything.
1209 /// The drain arm is satisfied VACUOUSLY, because nothing was ever
1210 /// announced — an implementation that waited for a literal empty-list
1211 /// event would hang every such stage until the idle timeout.
1212 ///
1213 /// The interleaved noise lines also pin the other half of the rule's
1214 /// tolerance: a torn JSON line and a prose line are ignored for the rule
1215 /// (they can neither satisfy nor block it) while still being teed to the
1216 /// capture by the reader thread.
1217 #[test]
1218 fn close_rule_is_vacuously_drained_when_no_background_tasks_event_appears() {
1219 let rule = observe_all(&[
1220 INIT_LINE.to_string(),
1221 "starting up".to_string(),
1222 r#"{"type":"assist"#.to_string(),
1223 result_line(MARKER),
1224 ]);
1225 assert!(
1226 rule.should_close(),
1227 "a stage that never announced a background task is drained by \
1228 definition; only the marker arm has anything to satisfy"
1229 );
1230 }
1231
1232 /// Constraint 7. The CLI COALESCES completions: two children can finish
1233 /// into one `result` event, and two announced tasks can drain to an empty
1234 /// list in a single `background_tasks_changed`. Counting `result` events
1235 /// therefore silently undercounts any wave whose completions cluster —
1236 /// and that shape is superficially indistinguishable from "one child
1237 /// delivered, one lost". The drained list is the only thing separating
1238 /// them, so the rule asserts on the list state and never on a count.
1239 ///
1240 /// Per 30-04 the drain arm is DEFENSIVE rather than load-bearing: n=2
1241 /// Mode B trials delivered everything without it. That is the documented
1242 /// reason to keep it cheaply — "defensive" is not "removable".
1243 #[test]
1244 fn coalesced_completions_do_not_undercount_children() {
1245 let rule = observe_all(&[
1246 INIT_LINE.to_string(),
1247 bg_tasks_line(2),
1248 // BOTH children drain in ONE event...
1249 bg_tasks_line(0),
1250 // ...and complete into ONE result.
1251 coalesced_result_line(MARKER),
1252 ]);
1253 assert!(
1254 rule.should_close(),
1255 "two announced children, one drain event and one coalesced result \
1256 must still close — a rule that matched result events against \
1257 child count would stall here forever"
1258 );
1259
1260 // Negative control: the SAME single coalesced result with the drain
1261 // withheld must NOT close. Without this, the assertion above is also
1262 // satisfied by a rule that simply closes on any result event, and the
1263 // test would be measuring nothing.
1264 let undrained = observe_all(&[
1265 INIT_LINE.to_string(),
1266 bg_tasks_line(2),
1267 coalesced_result_line(MARKER),
1268 ]);
1269 assert!(
1270 !undrained.should_close(),
1271 "control: it is the drained list that decides, not the arrival of \
1272 a result event"
1273 );
1274 }
1275
1276 /// T-31-01. The CLI echoes the operator's prompt back into the same
1277 /// stdout, and DevFlow's own stage prompts discuss `DEVFLOW_RESULT`
1278 /// markers at length — so marker TEXT is not evidence of a verdict. Only
1279 /// a marker inside an event that is both `type: "result"` and top-level
1280 /// counts, reusing the one provenance predicate rather than inventing a
1281 /// second notion of trustworthiness.
1282 #[test]
1283 fn marker_inside_a_non_top_level_result_does_not_satisfy_the_close_rule() {
1284 let subagent = observe_all(&[INIT_LINE.to_string(), subagent_result_line(MARKER)]);
1285 assert!(
1286 !subagent.should_close(),
1287 "a subagent-origin result carrying a marker must not close the \
1288 stream — same provenance hole constraint 9 item 2 closed for the \
1289 stage verdict"
1290 );
1291
1292 // Control: the identical envelope WITHOUT the planted parent id is
1293 // top-level and legitimately closes. Without this the assertion above
1294 // would also pass against a rule that never closes at all.
1295 let top_level = observe_all(&[INIT_LINE.to_string(), result_line(MARKER)]);
1296 assert!(
1297 top_level.should_close(),
1298 "control: the same event without a parent id is authoritative"
1299 );
1300 }
1301
1302 #[test]
1303 fn shell_escape_wraps_basic_strings() {
1304 assert_eq!(shell_escape("hello"), "'hello'");
1305 assert_eq!(shell_escape("hello world"), "'hello world'");
1306 assert_eq!(shell_escape("/tmp/devflow"), "'/tmp/devflow'");
1307 }
1308
1309 /// The Phase 31 tracer: ONE Claude-shaped stage driven end to end through
1310 /// the pipe-owning supervisor.
1311 ///
1312 /// The stub behaves like the real CLI on the two axes under test and no
1313 /// others: it takes its initial turn from stdin, and it keeps stdin open
1314 /// as a channel it can still be spoken to on. It is a `sh` script because
1315 /// the wire behaviour is the subject, not the binary.
1316 ///
1317 /// **The early-close negative control is the point of the probe files.**
1318 /// A stub that merely blocks on stdin EOF before exiting cannot fail:
1319 /// whether the monitor closes stdin immediately after the write or only
1320 /// after the close rule is satisfied, the stub still eventually sees EOF
1321 /// and still exits 0. So the stub instead SAMPLES stdin liveness at a
1322 /// moment when a correct monitor provably has not closed it — after the
1323 /// drain, before any marker — and records `EARLY` if it is already gone.
1324 /// Two files that must disagree: `eof` must exist at the end, `early`
1325 /// must never exist.
1326 ///
1327 /// **The prompt sentinel is a negative control on JSON escaping.** The
1328 /// sentinel sits on the SECOND line of a multi-line prompt containing a
1329 /// double quote. `user_turn_line` escapes it, so the whole prompt arrives
1330 /// as one physical line and the stub's single `read` sees the sentinel. A
1331 /// `format!`-interpolated implementation would emit a torn two-line
1332 /// document, the stub's `read` would return only the first line, and the
1333 /// sentinel check would fail — which is exactly what should happen.
1334 #[test]
1335 fn pipe_owning_monitor_delivers_prompt_via_stdin_and_captures_stream() {
1336 const SENTINEL: &str = "TRACER-PROMPT-SENTINEL";
1337
1338 let dir = tempfile::tempdir().unwrap();
1339 let root = dir.path();
1340 let phase = 4u32;
1341 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1342
1343 let eof_file = root.join("stdin-eof");
1344 let early_file = root.join("stdin-closed-early");
1345
1346 // A quote on line one, the sentinel on line two — see the doc above.
1347 let prompt = format!("first line with a \" quote\n{SENTINEL}");
1348
1349 let script = format!(
1350 r#"
1351set -u
1352IFS= read -r turn || {{ echo "NO_INITIAL_TURN_ON_STDIN" >&2; exit 91; }}
1353case "$turn" in
1354 *{SENTINEL}*) ;;
1355 *) echo "INITIAL_TURN_MISSING_PROMPT: $turn" >&2; exit 92 ;;
1356esac
1357
1358# Probe: block on stdin until EOF, then record it. stdout is redirected so
1359# this subshell does not hold the capture pipe open after the main shell exits.
1360#
1361# `exec 3<&0` then `cat <&3` is load-bearing, not a flourish: POSIX assigns
1362# /dev/null to a BACKGROUNDED list's stdin before any explicit redirection
1363# when job control is off. A bare `( cat > /dev/null ) &` therefore reads EOF
1364# instantly and reports an early close that never happened. The explicit
1365# `<&3` is applied after that default and overrides it.
1366exec 3<&0
1367( cat <&3 > /dev/null; printf 'EOF\n' > '{eof}' ) > /dev/null 2>&1 &
1368
1369printf '%s\n' '{{"type":"system","subtype":"init","session_id":"tracer-1"}}'
1370printf '%s\n' '{{"type":"system","subtype":"background_tasks_changed","tasks":[{{"task_id":"t1","task_type":"local_agent"}}]}}'
1371printf '%s\n' '{{"type":"system","subtype":"background_tasks_changed","tasks":[]}}'
1372
1373# The drain has landed but no marker has. A correct monitor is still holding
1374# stdin open here; sample it and record the violation if it is not.
1375sleep 0.5
1376if [ -f '{eof}' ]; then printf 'EARLY\n' > '{early}'; fi
1377
1378printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"session_id":"tracer-1","result":"DEVFLOW_RESULT: {{\"status\":\"success\",\"commits\":2}}"}}'
1379
1380# Bounded wait for EOF: a monitor that never closes stdin must fail the
1381# assertions below, not hang the suite.
1382i=0
1383while [ $i -lt 100 ] && [ ! -f '{eof}' ]; do
1384 sleep 0.1
1385 i=$((i+1))
1386done
1387exit 0
1388"#,
1389 eof = eof_file.display(),
1390 early = early_file.display(),
1391 );
1392
1393 let code = run_pipe_owning_monitor(
1394 root,
1395 phase,
1396 root,
1397 &prompt,
1398 Duration::from_secs(20),
1399 "sh",
1400 &["-c".to_string(), script],
1401 &[],
1402 )
1403 .expect("pipe-owning monitor should supervise the stub to completion");
1404
1405 let stderr = std::fs::read_to_string(crate::agent_result::stderr_path(root, phase))
1406 .unwrap_or_default();
1407 assert_eq!(
1408 code, 0,
1409 "stub exited {code}; 91 = no initial turn arrived on stdin, \
1410 92 = the turn arrived but did not carry the prompt (a JSON \
1411 escaping regression tears it across lines). stderr: {stderr:?}"
1412 );
1413
1414 assert!(
1415 !early_file.exists(),
1416 "the monitor closed the child's stdin BEFORE the close rule was \
1417 satisfied — the drain had landed but no DEVFLOW_RESULT marker had. \
1418 Constraint 4's AND cannot be honoured once stdin is gone: a \
1419 task-notification turn would have nowhere to be delivered."
1420 );
1421 assert!(
1422 eof_file.exists(),
1423 "the monitor never closed the child's stdin at all; the close rule \
1424 should have fired once the marker arrived with the task list drained"
1425 );
1426
1427 let capture =
1428 std::fs::read_to_string(crate::agent_result::stdout_path(root, phase)).unwrap();
1429 for expected in [
1430 r#""subtype":"init""#,
1431 r#""task_id":"t1""#,
1432 r#""tasks":[]"#,
1433 r#""type":"result""#,
1434 ] {
1435 assert!(
1436 capture.contains(expected),
1437 "capture is missing {expected}; got:\n{capture}"
1438 );
1439 }
1440 assert!(
1441 crate::agent_result::capture_is_claude_stream(&capture),
1442 "the capture must classify as a Claude stream-json document — \
1443 this is what makes 30b's stream parser reachable at all:\n{capture}"
1444 );
1445
1446 let result = crate::agent_result::evaluate_layer1(root, phase)
1447 .expect("Layer 1 must decide this capture");
1448 assert_eq!(
1449 result.status,
1450 crate::agent_result::AgentStatus::Success,
1451 "Layer 1 verdict from the stream capture: {result:?}"
1452 );
1453
1454 let exit = std::fs::read_to_string(crate::agent_result::exit_code_path(root, phase))
1455 .expect("the monitor must record the child's exit code");
1456 assert_eq!(exit.trim(), "0", "exit file contents: {exit:?}");
1457 }
1458
1459 /// Peer review 2026-08-03, CRITICAL: `BufRead::lines()` yields
1460 /// `Err(InvalidData)` on one non-UTF-8 byte, and the reader treated any read
1461 /// error as EOF — silently truncating the capture and dropping every later
1462 /// line, INCLUDING the terminal marker. The supervisor manufactured exactly
1463 /// the boundary-truncation failure constraint 9 exists to defend against.
1464 ///
1465 /// **What this does NOT establish:** that the real `claude` CLI ever emits
1466 /// non-UTF-8 on this stream. It emits JSON, which should be valid UTF-8. This
1467 /// pins the supervisor's robustness, not a demonstrated CLI behaviour.
1468 #[test]
1469 fn non_utf8_byte_does_not_truncate_the_capture() {
1470 let dir = tempfile::tempdir().unwrap();
1471 let root = dir.path();
1472 let phase = 11u32;
1473 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1474
1475 // A raw 0xFF is invalid UTF-8 in any position. It sits BETWEEN two good
1476 // lines, so a reader that dies on it loses the marker that follows.
1477 let script = r#"
1478set -u
1479IFS= read -r _turn || exit 91
1480printf '%s\n' '{"type":"system","subtype":"init","session_id":"utf8-1"}'
1481printf 'raw-\377-bytes\n'
1482printf '%s\n' '{"type":"system","subtype":"background_tasks_changed","tasks":[]}'
1483printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"utf8-1","result":"DEVFLOW_RESULT: {\"status\":\"success\"}"}'
1484exit 0
1485"#;
1486
1487 let code = run_pipe_owning_monitor(
1488 root,
1489 phase,
1490 root,
1491 "prompt",
1492 Duration::from_secs(20),
1493 "sh",
1494 &["-c".to_string(), script.to_string()],
1495 &[],
1496 )
1497 .expect("the monitor must survive a non-UTF-8 byte on the child's stdout");
1498 assert_eq!(code, 0, "stub should exit cleanly");
1499
1500 let capture =
1501 std::fs::read_to_string(crate::agent_result::stdout_path(root, phase)).unwrap();
1502 assert!(
1503 capture.contains(r#""type":"result""#),
1504 "the terminal result event was lost: a non-UTF-8 byte earlier in the \
1505 stream truncated the capture. This is the regression:\n{capture}"
1506 );
1507 assert!(
1508 capture.contains("raw-"),
1509 "the undecodable line itself must still be teed (lossily), since the \
1510 capture is the verbatim record:\n{capture}"
1511 );
1512 let result = crate::agent_result::evaluate_layer1(root, phase)
1513 .expect("Layer 1 must still decide a capture that contained a bad byte");
1514 assert_eq!(
1515 result.status,
1516 crate::agent_result::AgentStatus::Success,
1517 "verdict after lossy decode: {result:?}"
1518 );
1519 }
1520
1521 /// Peer review 2026-08-03, CRITICAL: after the close rule released stdin the
1522 /// supervisor kept timing out on silence and fired `fire_idle_timeout`,
1523 /// writing an authoritative `IdleTimeout` verdict OVER a stage that had
1524 /// already reported success. `evaluate_layer1` reads that side channel first
1525 /// — by design, so nothing can shadow a real timeout — so the bogus verdict
1526 /// won and was unrecoverable.
1527 ///
1528 /// The timeout here (600ms) is injected short deliberately; the child sleeps
1529 /// well past it AFTER the marker. **What this does NOT establish:** that the
1530 /// 120s production floor is right — that rests on the keepalive measurement
1531 /// in `31-IDLE-GAP-MEASUREMENTS.md`, not on this test.
1532 #[test]
1533 fn no_idle_timeout_is_recorded_when_the_child_is_merely_slow_to_exit() {
1534 let dir = tempfile::tempdir().unwrap();
1535 let root = dir.path();
1536 let phase = 12u32;
1537 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1538
1539 let script = r#"
1540set -u
1541IFS= read -r _turn || exit 91
1542printf '%s\n' '{"type":"system","subtype":"init","session_id":"slow-1"}'
1543printf '%s\n' '{"type":"system","subtype":"background_tasks_changed","tasks":[]}'
1544printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"slow-1","result":"DEVFLOW_RESULT: {\"status\":\"success\"}"}'
1545# Everything has been said; the close rule fires here. Now wind down slowly,
1546# well past the injected idle window, emitting nothing.
1547sleep 3
1548exit 0
1549"#;
1550
1551 let code = run_pipe_owning_monitor(
1552 root,
1553 phase,
1554 root,
1555 "prompt",
1556 Duration::from_millis(600),
1557 "sh",
1558 &["-c".to_string(), script.to_string()],
1559 &[],
1560 )
1561 .expect("a slow-exiting child that already reported is not a failure");
1562
1563 assert!(
1564 !crate::agent_result::idle_timeout_path(root, phase).exists(),
1565 "an idle-timeout verdict was written for a stage that had ALREADY \
1566 emitted its terminal marker and drained its tasks — silence after a \
1567 deliberate close is expected, not a hang"
1568 );
1569 assert_eq!(code, 0, "the child exited cleanly, if slowly");
1570
1571 let result = crate::agent_result::evaluate_layer1(root, phase)
1572 .expect("Layer 1 must decide this capture");
1573 assert_eq!(
1574 result.status,
1575 crate::agent_result::AgentStatus::Success,
1576 "a completed stage must not be reported as a timeout: {result:?}"
1577 );
1578 }
1579
1580 /// Peer review 2026-08-03 (found independently by BOTH reviewers and by the
1581 /// 31-04 plan review as W1): `status.code()` is `None` for a signal-killed
1582 /// child, and `unwrap_or(-1)` discarded the signal. `-1` matches neither the
1583 /// 137 nor the 127 arm, so a kernel OOM kill arrived as a generic `Failed`
1584 /// and routed to `GateReview` — asking a human to code-review a stage the
1585 /// kernel killed — instead of `GateInfra`.
1586 ///
1587 /// This asserts on what the monitor ACTUALLY writes for a real SIGKILL. The
1588 /// pre-existing arbitration test hardcoded `"137\n"` into its fixture, so it
1589 /// passed green against this defect the entire time — which is why this test
1590 /// spawns a child and kills it rather than writing the file itself.
1591 #[test]
1592 fn a_signal_killed_child_records_128_plus_signal_not_minus_one() {
1593 let dir = tempfile::tempdir().unwrap();
1594 let root = dir.path();
1595 let phase = 13u32;
1596 std::fs::create_dir_all(root.join(".devflow")).unwrap();
1597
1598 // SIGKILL itself: no exit code exists, only a termination signal.
1599 let script = r#"
1600set -u
1601IFS= read -r _turn || exit 91
1602printf '%s\n' '{"type":"system","subtype":"init","session_id":"sig-1"}'
1603kill -9 $$
1604"#;
1605
1606 let code = run_pipe_owning_monitor(
1607 root,
1608 phase,
1609 root,
1610 "prompt",
1611 Duration::from_secs(20),
1612 "sh",
1613 &["-c".to_string(), script.to_string()],
1614 &[],
1615 )
1616 .expect("the monitor must reap a signal-killed child");
1617
1618 assert_eq!(
1619 code, 137,
1620 "SIGKILL(9) must be recorded as 128+9=137, the value \
1621 `evaluate_layer2` and `reconcile_stream_success_against_exit_code` \
1622 map to ResourceKilled/GateInfra. -1 means the signal was discarded."
1623 );
1624 let exit = std::fs::read_to_string(crate::agent_result::exit_code_path(root, phase))
1625 .expect("the monitor must record the exit code");
1626 assert_eq!(exit.trim(), "137", "exit file contents: {exit:?}");
1627 }
1628
1629 #[test]
1630 fn shell_escape_handles_single_quotes() {
1631 assert_eq!(shell_escape("can't"), "'can'\\''t'");
1632 assert_eq!(shell_escape("a'b'c"), "'a'\\''b'\\''c'");
1633 }
1634
1635 #[test]
1636 fn shell_escape_handles_empty_string() {
1637 assert_eq!(shell_escape(""), "''");
1638 }
1639
1640 #[test]
1641 fn wait_for_agent_pid_returns_pid_when_file_exists() {
1642 let dir = tempfile::tempdir().unwrap();
1643 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1644 std::fs::write(
1645 crate::agent_result::agent_pid_path(dir.path(), 4),
1646 "12345\n",
1647 )
1648 .unwrap();
1649
1650 assert_eq!(wait_for_agent_pid(dir.path(), 4), Some(12345));
1651 }
1652
1653 #[test]
1654 fn wait_for_agent_pid_returns_none_when_file_missing() {
1655 let dir = tempfile::tempdir().unwrap();
1656
1657 assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
1658 }
1659
1660 #[test]
1661 fn wait_for_agent_pid_returns_none_for_garbage_content() {
1662 let dir = tempfile::tempdir().unwrap();
1663 std::fs::create_dir_all(dir.path().join(".devflow")).unwrap();
1664 std::fs::write(
1665 crate::agent_result::agent_pid_path(dir.path(), 4),
1666 "not-a-pid",
1667 )
1668 .unwrap();
1669
1670 assert_eq!(wait_for_agent_pid(dir.path(), 4), None);
1671 }
1672
1673 #[test]
1674 fn spawn_monitor_captures_agent_pid_and_output() {
1675 let dir = tempfile::tempdir().unwrap();
1676 let state = state_in(dir.path());
1677 // Stub agent: write a known marker to stdout, then exit cleanly.
1678 let args = vec!["-c".to_string(), "echo MONITOR_READY".to_string()];
1679
1680 let monitor_pid = spawn_monitor(&state, "sh", &args, &[], MonitorLaunch::Legacy).unwrap();
1681 assert!(monitor_pid > 0);
1682
1683 // Observable side effect #1: the monitor records the agent PID to its
1684 // pid file with valid numeric content.
1685 let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
1686 .expect("monitor should record the agent pid");
1687 assert!(agent_pid > 0);
1688
1689 // Observable side effect #2: the agent's stdout is captured to the
1690 // phase stdout file (proving the monitor actually ran the agent).
1691 let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
1692 let mut captured = String::new();
1693 for _ in 0..100 {
1694 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
1695 && contents.contains("MONITOR_READY")
1696 {
1697 captured = contents;
1698 break;
1699 }
1700 std::thread::sleep(Duration::from_millis(20));
1701 }
1702 assert!(
1703 captured.contains("MONITOR_READY"),
1704 "expected MONITOR_READY in captured stdout, got {captured:?}"
1705 );
1706 }
1707
1708 /// WR-08 (13-REVIEW.md): sending SIGTERM/SIGINT to the monitor must also
1709 /// terminate the agent it owns. Before the fix, `cleanup()` only exited
1710 /// the monitor shell, leaving the agent orphaned and running/committing
1711 /// unsupervised with nothing left to call `devflow advance` for it.
1712 /// A one-line identity/state summary of a pid, for failure diagnostics.
1713 /// `Name`/`State`/`PPid` come from `/proc/<pid>/status`; the cmdline
1714 /// distinguishes a shell that exec'd its command from one that forked it.
1715 /// Test-only; never used in a decision.
1716 fn proc_snapshot(pid: u32) -> String {
1717 let Ok(status) = std::fs::read_to_string(format!("/proc/{pid}/status")) else {
1718 return format!("GONE (no /proc/{pid})");
1719 };
1720 let field = |key: &str| {
1721 status
1722 .lines()
1723 .find(|l| l.starts_with(key))
1724 .map(|l| l.split_whitespace().skip(1).collect::<Vec<_>>().join(" "))
1725 .unwrap_or_else(|| "?".into())
1726 };
1727 let cmdline = std::fs::read(format!("/proc/{pid}/cmdline"))
1728 .map(|raw| {
1729 let joined = raw
1730 .split(|&b| b == 0)
1731 .filter(|a| !a.is_empty())
1732 .map(|a| String::from_utf8_lossy(a).into_owned())
1733 .collect::<Vec<_>>()
1734 .join(" ");
1735 if joined.is_empty() {
1736 "<empty>".to_string()
1737 } else {
1738 joined
1739 }
1740 })
1741 .unwrap_or_else(|e| format!("<unreadable: {e}>"));
1742 format!(
1743 "ALIVE Name={} State={} PPid={} cmdline=[{cmdline}]",
1744 field("Name:"),
1745 field("State:"),
1746 field("PPid:")
1747 )
1748 }
1749
1750 #[test]
1751 fn sigterm_to_monitor_also_kills_the_agent() {
1752 let dir = tempfile::tempdir().unwrap();
1753 let state = state_in(dir.path());
1754 // Stub agent that runs long enough to observe: sleeps well past the
1755 // window this test needs to send SIGTERM and check liveness.
1756 let args = vec!["-c".to_string(), "sleep 30".to_string()];
1757
1758 let monitor_pid = spawn_monitor(&state, "sh", &args, &[], MonitorLaunch::Legacy).unwrap();
1759 let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
1760 .expect("monitor should record the agent pid");
1761 assert!(
1762 crate::agent::agent_running(agent_pid),
1763 "agent should be running before SIGTERM"
1764 );
1765
1766 // Snapshot both processes before signalling. This assertion fails in
1767 // containerised CI and cannot be reproduced locally, and a bare
1768 // "still running" message discards everything that could explain it
1769 // — the same antipattern that made 999.47 expensive to diagnose.
1770 let monitor_before = proc_snapshot(monitor_pid);
1771 let agent_before = proc_snapshot(agent_pid);
1772
1773 // SIGTERM the monitor, as an operator (or lock.rs's stale-holder
1774 // reclaim path) would to abort a run.
1775 let kill_rc = unsafe { libc::kill(monitor_pid as libc::pid_t, libc::SIGTERM) };
1776 let kill_err = if kill_rc == 0 {
1777 "ok".to_string()
1778 } else {
1779 format!("errno {}", std::io::Error::last_os_error())
1780 };
1781
1782 // The agent should be killed promptly by the monitor's trap —
1783 // poll rather than sleep a fixed amount to keep this fast and
1784 // avoid flaking under load. (Window widened to 5s: at 2s this
1785 // still flaked under a fully parallel workspace test run.)
1786 //
1787 // 2026-07-26: this was widened 5s -> 15s for the containerised CI
1788 // job and STILL failed, then reverted to 5s. That widening was a
1789 // mistake: 15s is far beyond any plausible trap-and-kill latency,
1790 // so the agent is not being reaped SLOWLY, it is not being reaped.
1791 // Buying silence with a bigger number would have hidden a real
1792 // defect behind a green check — the exact false negative this
1793 // repository keeps getting bitten by.
1794 //
1795 // The trap mechanism itself is verified working: DevFlow's real
1796 // monitor script shape was run under both `bash` and `dash` (the
1797 // container's /bin/sh is dash, the Fedora host's is bash) and both
1798 // killed the backgrounded agent correctly. So the defect is in how
1799 // the agent is spawned or identified under container timing, not in
1800 // the shell trap — see 999.47, whose confirmed transient fork/exec
1801 // window is the prime suspect for the same class of failure here.
1802 //
1803 // Leave this red until that is fixed. Do NOT widen it again.
1804 let mut still_running = true;
1805 for _ in 0..250 {
1806 if !crate::agent::agent_running(agent_pid) {
1807 still_running = false;
1808 break;
1809 }
1810 std::thread::sleep(Duration::from_millis(20));
1811 }
1812 let monitor_after = proc_snapshot(monitor_pid);
1813 let agent_after = proc_snapshot(agent_pid);
1814 let pidfile =
1815 std::fs::read_to_string(crate::agent_result::agent_pid_path(dir.path(), state.phase))
1816 .unwrap_or_else(|e| format!("<unreadable: {e}>"));
1817
1818 assert!(
1819 !still_running,
1820 "agent (pid {agent_pid}) was orphaned — still running after monitor SIGTERM\n\
1821 \x20 monitor pid: {monitor_pid}\n\
1822 \x20 kill(TERM) rc: {kill_rc} ({kill_err})\n\
1823 \x20 monitor before: {monitor_before}\n\
1824 \x20 monitor after: {monitor_after}\n\
1825 \x20 agent pid: {agent_pid}\n\
1826 \x20 agent before: {agent_before}\n\
1827 \x20 agent after: {agent_after}\n\
1828 \x20 pidfile contents: {}\n\
1829 Read the monitor's `after` line first. GONE means the shell died \
1830 without running its trap — most likely SIGTERM arrived before \
1831 `trap` was installed, or it was killed rather than handling the \
1832 signal, either way leaving the agent unreaped. STILL ALIVE means \
1833 the trap never fired or `kill $apid` failed, so compare the agent \
1834 pid against the pidfile and check the agent's PPid: if PPid is not \
1835 the monitor, `$!` did not name the process we are polling. If the \
1836 agent's Name is `sh` rather than `sleep`, the agent shell forked \
1837 rather than exec'd, so killing it leaves its own child behind.",
1838 pidfile.trim()
1839 );
1840 }
1841
1842 #[test]
1843 fn spawn_monitor_runs_agent_in_worktree_but_captures_in_project_root() {
1844 let dir = tempfile::tempdir().unwrap();
1845 let worktree = dir.path().join(".worktrees/phase-04");
1846 std::fs::create_dir_all(&worktree).unwrap();
1847 let mut state = state_in(dir.path());
1848 state.worktree_path = Some(worktree.clone());
1849
1850 // Stub agent: print its cwd so the test proves the monitor changed
1851 // directories before launching the agent.
1852 let args = vec!["-c".to_string(), "pwd; echo WORKTREE_READY".to_string()];
1853
1854 let monitor_pid = spawn_monitor(&state, "sh", &args, &[], MonitorLaunch::Legacy).unwrap();
1855 assert!(monitor_pid > 0);
1856
1857 let agent_pid = wait_for_agent_pid(dir.path(), state.phase)
1858 .expect("monitor should record the agent pid in the main project");
1859 assert!(agent_pid > 0);
1860
1861 let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
1862 let mut captured = String::new();
1863 for _ in 0..100 {
1864 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
1865 && contents.contains("WORKTREE_READY")
1866 {
1867 captured = contents;
1868 break;
1869 }
1870 std::thread::sleep(Duration::from_millis(20));
1871 }
1872
1873 assert!(
1874 captured.contains(&worktree.display().to_string()),
1875 "agent did not run in worktree cwd; captured stdout: {captured:?}"
1876 );
1877 assert!(
1878 stdout_path.exists(),
1879 "stdout capture missing in main .devflow"
1880 );
1881 assert!(
1882 !crate::agent_result::stdout_path(&worktree, state.phase).exists(),
1883 "stdout capture should not be written under the worktree"
1884 );
1885 }
1886
1887 /// Build the fixture repositories through the scrubbing constructor, as
1888 /// every other test module in this phase does (`version.rs:1102`).
1889 ///
1890 /// A bare `Command::new("git")` here would itself inherit an ambient
1891 /// hostile `GIT_DIR` — so under this phase's own acceptance command
1892 /// (`GIT_DIR=<throwaway>/.git cargo test -p devflow-core ...`) the
1893 /// fixture setup would target the throwaway repository instead of
1894 /// `root`, and the test below would fail for a reason that has nothing
1895 /// to do with the behavior it is guarding.
1896 fn git(root: &Path, args: &[&str]) {
1897 let ok = crate::test_support::git_command(root)
1898 .args(args)
1899 .output()
1900 .unwrap()
1901 .status
1902 .success();
1903 assert!(ok, "git {args:?} failed");
1904 }
1905
1906 fn init_repo(root: &Path) {
1907 git(root, &["init", "-q"]);
1908 git(root, &["config", "user.email", "test@example.com"]);
1909 git(root, &["config", "user.name", "Test"]);
1910 }
1911
1912 /// 27-REVIEW WR-03: the `sh` this function spawns owns the coding
1913 /// agent, and whatever environment rides down with it reaches every git
1914 /// command the agent runs (`sh` -> agent -> agent's git children). This
1915 /// proves the scrub with a real spawned agent process, not by
1916 /// inspecting the `Command` object: the agent shells out to
1917 /// `git rev-parse --absolute-git-dir`, and the resolved path must be
1918 /// the caller's own workdir, never a hostile `GIT_DIR` pointed at an
1919 /// unrelated foreign repository.
1920 ///
1921 /// Mirrors `tag_reads_resolve_caller_root_under_a_hostile_git_dir`
1922 /// (version.rs, 27-03/WR-01): `GIT_DIR` is never set on this test
1923 /// process itself (Rust 2024 `unsafe`, unsound under threaded tests —
1924 /// Phase 25 D-14), only on one freshly spawned child re-invoking this
1925 /// binary filtered to this test.
1926 #[test]
1927 fn spawn_monitor_agent_git_calls_resolve_workdir_not_a_hostile_git_dir() {
1928 const INNER_ROOT: &str = "DEVFLOW_27_MONITOR_INNER_ROOT";
1929
1930 if let Ok(root) = std::env::var(INNER_ROOT) {
1931 // Inner mode: GIT_DIR points at a foreign repository unrelated
1932 // to `root`, scoped to this child process only.
1933 let root = std::path::PathBuf::from(root);
1934 let state = state_in(&root);
1935 let args = vec![
1936 "-c".to_string(),
1937 "git rev-parse --absolute-git-dir".to_string(),
1938 ];
1939
1940 spawn_monitor(&state, "sh", &args, &[], MonitorLaunch::Legacy).unwrap();
1941 wait_for_agent_pid(&root, state.phase).expect("monitor should record the agent pid");
1942
1943 let stdout_path = crate::agent_result::stdout_path(&root, state.phase);
1944 let mut captured = String::new();
1945 for _ in 0..100 {
1946 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
1947 && !contents.trim().is_empty()
1948 {
1949 captured = contents;
1950 break;
1951 }
1952 std::thread::sleep(Duration::from_millis(20));
1953 }
1954
1955 let resolved = std::fs::canonicalize(captured.trim())
1956 .expect("agent's reported git-dir must exist on disk");
1957 let expected =
1958 std::fs::canonicalize(root.join(".git")).expect("caller repo .git must exist");
1959 assert_eq!(
1960 resolved, expected,
1961 "agent's git call resolved to a hostile GIT_DIR's \
1962 repository instead of the caller's own workdir: \
1963 got {resolved:?}, want {expected:?}"
1964 );
1965 return;
1966 }
1967
1968 // Outer mode: a real repository at `root`, and an unrelated
1969 // foreign repository whose .git must never leak into the agent's
1970 // environment.
1971 let dir = tempfile::tempdir().unwrap();
1972 let root = dir.path().join("caller-repo");
1973 std::fs::create_dir_all(&root).unwrap();
1974 init_repo(&root);
1975
1976 let foreign = tempfile::tempdir().unwrap();
1977 init_repo(foreign.path());
1978
1979 let exe = std::env::current_exe().expect("current_exe for child re-invocation");
1980 let out = std::process::Command::new(&exe)
1981 // Substring filter, NOT `--exact`: the binary's real test name
1982 // is module-qualified (`monitor::tests::spawn_monitor_...`), so
1983 // `--exact` against the bare name matches nothing, runs zero
1984 // tests, and still exits 0 — a false green.
1985 .arg("spawn_monitor_agent_git_calls_resolve_workdir_not_a_hostile_git_dir")
1986 .arg("--test-threads=1")
1987 .env(INNER_ROOT, root.to_str().unwrap())
1988 .env("GIT_DIR", foreign.path().join(".git"))
1989 .output()
1990 .expect("spawn hostile child test process");
1991
1992 let stdout = String::from_utf8_lossy(&out.stdout);
1993 // Assert the child actually RAN the test, not merely that it
1994 // exited 0. A filter that matches nothing exits 0 with "0 passed".
1995 assert!(
1996 stdout.contains("1 passed"),
1997 "child test process must have run exactly the inner test; \
1998 stdout:\n{stdout}"
1999 );
2000 assert!(
2001 out.status.success(),
2002 "monitor-spawned agent (hostile GIT_DIR pointed at an \
2003 unrelated foreign repository) must still resolve its git \
2004 calls against the caller's own workdir; child exit status \
2005 {:?}\nstdout:\n{stdout}",
2006 out.status
2007 );
2008 }
2009
2010 #[test]
2011 fn spawn_monitor_treats_agent_args_as_literal_argv() {
2012 let dir = tempfile::tempdir().unwrap();
2013 let state = state_in(dir.path());
2014 let payload = "value; touch INJECTED";
2015 let args = vec![
2016 "-c".to_string(),
2017 "printf '%s\\n' \"$0\"; echo ARGV_SAFE".to_string(),
2018 payload.to_string(),
2019 ];
2020
2021 spawn_monitor(&state, "sh", &args, &[], MonitorLaunch::Legacy).unwrap();
2022 wait_for_agent_pid(dir.path(), state.phase).expect("monitor should record the agent pid");
2023
2024 let stdout_path = crate::agent_result::stdout_path(dir.path(), state.phase);
2025 let mut captured = String::new();
2026 for _ in 0..100 {
2027 if let Ok(contents) = std::fs::read_to_string(&stdout_path)
2028 && contents.contains("ARGV_SAFE")
2029 {
2030 captured = contents;
2031 break;
2032 }
2033 std::thread::sleep(Duration::from_millis(20));
2034 }
2035
2036 assert!(
2037 captured.contains(payload),
2038 "literal argv missing: {captured:?}"
2039 );
2040 assert!(captured.contains("ARGV_SAFE"));
2041 assert!(!dir.path().join("INJECTED").exists());
2042 }
2043
2044 // ---- idle timeout (31-02, D-01..D-08) --------------------------------
2045
2046 /// D-04: a value below the floor is raised to it, and the fact is
2047 /// observable to the CALLER as a value — not only as a log line a test
2048 /// would have to capture stdout to see.
2049 #[test]
2050 fn idle_timeout_secs_clamps_below_floor_and_logs() {
2051 let setting = parse_idle_timeout_secs(Some("5".to_string()));
2052
2053 assert_eq!(setting.timeout, Duration::from_secs(120));
2054 assert!(setting.clamped(), "the clamp must be observable as a value");
2055 assert_eq!(
2056 setting.resolution,
2057 IdleTimeoutResolution::Clamped { configured: 5 }
2058 );
2059
2060 // The notice must NAME the configured value, the floor, and the value
2061 // actually in force — a clamp that says only "clamped" leaves the
2062 // operator guessing which of the three numbers won.
2063 let notice = setting.notice().expect("a clamp owes a loud notice");
2064 for fragment in ["5", "120", IDLE_TIMEOUT_ENV] {
2065 assert!(
2066 notice.contains(fragment),
2067 "notice must name {fragment:?}; got: {notice}"
2068 );
2069 }
2070 }
2071
2072 /// The floor raises, it never lowers: a value above it survives verbatim
2073 /// and reports no clamp.
2074 #[test]
2075 fn idle_timeout_secs_accepts_values_above_floor() {
2076 let setting = parse_idle_timeout_secs(Some("300".to_string()));
2077
2078 assert_eq!(setting.timeout, Duration::from_secs(300));
2079 assert!(!setting.clamped());
2080 assert_eq!(setting.resolution, IdleTimeoutResolution::Configured);
2081 assert_eq!(
2082 setting.notice(),
2083 None,
2084 "an honoured value is unremarkable and must not shout"
2085 );
2086
2087 // Boundary: exactly the floor is CONFIGURED, not CLAMPED. An
2088 // off-by-one here would report a clamp that never happened and train
2089 // operators to ignore the notice.
2090 let exact = parse_idle_timeout_secs(Some("120".to_string()));
2091 assert_eq!(exact.resolution, IdleTimeoutResolution::Configured);
2092 assert!(!exact.clamped());
2093 }
2094
2095 /// Absent, empty, and unparseable all resolve to the floor. The three are
2096 /// NOT equivalent in loudness: nothing configured is silent, a typo is not.
2097 #[test]
2098 fn idle_timeout_secs_defaults_to_the_floor() {
2099 let floor = Duration::from_secs(IDLE_TIMEOUT_FLOOR_SECS);
2100
2101 for raw in [None, Some(String::new()), Some(" ".to_string())] {
2102 let setting = parse_idle_timeout_secs(raw.clone());
2103 assert_eq!(setting.timeout, floor, "raw {raw:?} must yield the floor");
2104 assert_eq!(setting.resolution, IdleTimeoutResolution::Default);
2105 assert_eq!(setting.notice(), None, "nothing chosen is not an error");
2106 }
2107
2108 for raw in ["banana", "60O", "-5", "30.5"] {
2109 let setting = parse_idle_timeout_secs(Some(raw.to_string()));
2110 assert_eq!(setting.timeout, floor, "raw {raw:?} must yield the floor");
2111 assert_eq!(
2112 setting.resolution,
2113 IdleTimeoutResolution::Unparseable {
2114 raw: raw.to_string()
2115 }
2116 );
2117 assert!(
2118 setting.notice().is_some(),
2119 "a typo that silently halves an intended timeout must be loud: {raw:?}"
2120 );
2121 }
2122 }
2123
2124 /// D-01/D-03: every line resets the window, and there is no outer
2125 /// wall-clock bound. A child that keeps talking for FOUR times the idle
2126 /// timeout is never terminated.
2127 ///
2128 /// The timeout is injected short (400ms) rather than using the 120s
2129 /// production default — this measures the RESET MECHANISM, and does so at
2130 /// a scale the suite can afford. **What it does not establish:** that 120s
2131 /// is the right production value. That rests on the 2026-08-03 keepalive
2132 /// measurement recorded on [`IDLE_TIMEOUT_FLOOR_SECS`], not on this test.
2133 #[test]
2134 fn idle_timer_resets_on_every_stream_line() {
2135 let dir = tempfile::tempdir().unwrap();
2136 let root = dir.path();
2137 let phase = 6u32;
2138 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2139
2140 // 12 lines x 100ms = 1.2s of talking against a 400ms window. Any
2141 // implementation that resets on milestones only, or that imposes an
2142 // outer bound, kills this child before it finishes.
2143 let script = r#"
2144set -u
2145IFS= read -r turn || exit 91
2146i=0
2147while [ $i -lt 12 ]; do
2148 printf '%s\n' '{"type":"system","subtype":"heartbeat","n":'"$i"'}'
2149 sleep 0.1
2150 i=$((i+1))
2151done
2152printf '%s\n' '{"type":"result","subtype":"success","is_error":false,"session_id":"idle-1","result":"DEVFLOW_RESULT: {\"status\":\"success\"}"}'
2153exit 0
2154"#;
2155
2156 let started = std::time::Instant::now();
2157 let code = run_pipe_owning_monitor(
2158 root,
2159 phase,
2160 root,
2161 "prompt",
2162 Duration::from_millis(400),
2163 "sh",
2164 &["-c".to_string(), script.to_string()],
2165 &[],
2166 )
2167 .expect("a chatty child must be supervised to completion");
2168 let elapsed = started.elapsed();
2169
2170 assert_eq!(code, 0, "the chatty child must exit cleanly, not be killed");
2171 assert!(
2172 !crate::agent_result::idle_timeout_path(root, phase).exists(),
2173 "no timeout may fire while the child is still emitting lines"
2174 );
2175 assert!(
2176 elapsed > Duration::from_millis(400),
2177 "the run must outlast the idle window, else it proves nothing \
2178 about resetting: {elapsed:?}"
2179 );
2180
2181 let capture =
2182 std::fs::read_to_string(crate::agent_result::stdout_path(root, phase)).unwrap();
2183 assert_eq!(
2184 capture.matches("heartbeat").count(),
2185 12,
2186 "all twelve resets must have been observed: {capture:?}"
2187 );
2188 }
2189
2190 /// D-05, and the assertion the whole ordering exists for.
2191 ///
2192 /// The observation is made LIVE, by a watcher thread sampling the child's
2193 /// liveness at the first instant the verdict file exists — not by
2194 /// inspecting order after the fact, which cannot distinguish
2195 /// write-then-kill from kill-then-write.
2196 ///
2197 /// Its own negative control is structural: if the implementation wrote the
2198 /// verdict AFTER terminating, the watcher would sample a dead child and
2199 /// this test fails with `Some(false)`. The stub ignores `SIGTERM` so the
2200 /// window in which "file exists AND child alive" is observable is the full
2201 /// `TERMINATE_VERIFY_WAIT`, rather than a microsecond race.
2202 ///
2203 /// **What the duration of this test measures:** almost entirely
2204 /// `agent::TERMINATE_VERIFY_WAIT` (3s), because the stub refuses `SIGTERM`
2205 /// and must be escalated to `SIGKILL`. The 250ms idle window is a rounding
2206 /// error against it.
2207 #[test]
2208 fn idle_timeout_writes_side_channel_before_terminating_child() {
2209 let dir = tempfile::tempdir().unwrap();
2210 let root = dir.path().to_path_buf();
2211 let phase = 7u32;
2212 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2213
2214 // One line, then silence. `trap '' TERM` widens the observation
2215 // window to the full SIGTERM->SIGKILL escalation.
2216 let script = r#"
2217set -u
2218IFS= read -r turn || exit 91
2219trap '' TERM
2220printf '%s\n' '{"type":"system","subtype":"init","session_id":"idle-2"}'
2221sleep 120
2222"#;
2223
2224 let verdict = crate::agent_result::idle_timeout_path(&root, phase);
2225 let pid_file = crate::agent_result::agent_pid_path(&root, phase);
2226 let watcher = std::thread::spawn(move || {
2227 let deadline = std::time::Instant::now() + Duration::from_secs(30);
2228 let mut pid: Option<u32> = None;
2229 while std::time::Instant::now() < deadline {
2230 if pid.is_none() {
2231 pid = std::fs::read_to_string(&pid_file)
2232 .ok()
2233 .and_then(|s| s.trim().parse::<u32>().ok());
2234 }
2235 if verdict.exists() {
2236 // Sample liveness at the FIRST moment the verdict exists.
2237 return pid.map(crate::agent::agent_running);
2238 }
2239 std::thread::sleep(Duration::from_millis(5));
2240 }
2241 None
2242 });
2243
2244 let code = run_pipe_owning_monitor(
2245 &root,
2246 phase,
2247 &root,
2248 "prompt",
2249 Duration::from_millis(250),
2250 "sh",
2251 &["-c".to_string(), script.to_string()],
2252 &[],
2253 )
2254 .expect("a silent child must still produce a supervised outcome");
2255
2256 let observed = watcher.join().expect("watcher thread panicked");
2257 assert_eq!(
2258 observed,
2259 Some(true),
2260 "the verdict must be on disk while the child is STILL ALIVE. \
2261 Some(false) = written after termination (the D-05 violation); \
2262 None = the verdict never appeared at all"
2263 );
2264
2265 // The verdict must also be readable and correct, not merely present.
2266 let raw = std::fs::read_to_string(crate::agent_result::idle_timeout_path(&root, phase))
2267 .expect("verdict file must be readable");
2268 let record: IdleTimeoutRecord = serde_json::from_str(&raw).expect("verdict must parse");
2269 assert_eq!(record.status, "idle_timeout");
2270 assert_eq!(record.idle_secs, 0, "250ms truncates to 0 whole seconds");
2271 assert!(record.agent_pid > 1);
2272
2273 // And the whole cascade must agree: Layer 1 reports the timeout.
2274 let result = crate::agent_result::evaluate_layer1(&root, phase)
2275 .expect("Layer 1 must decide a timed-out run");
2276 assert_eq!(
2277 result.status,
2278 crate::agent_result::AgentStatus::IdleTimeout,
2279 "the monitor's verdict must survive all the way to the oracle"
2280 );
2281
2282 // The child was killed, so it has no ordinary exit code — the point is
2283 // that the stage machine still reaches a gate rather than hanging.
2284 assert!(
2285 crate::agent_result::exit_code_path(&root, phase).exists(),
2286 "the exit file must still be written so advance() is reachable"
2287 );
2288 let _ = code;
2289
2290 // The loud monitor-log entry (D-04/D-07's readable-after-the-fact
2291 // obligation) must exist too — the monitor's stdio is null, so this
2292 // file is the only place it can land.
2293 let log = std::fs::read_to_string(crate::agent_result::monitor_log_path(&root, phase))
2294 .expect("the monitor must log its own timeout");
2295 assert!(log.contains("idle-timeout"), "log entry missing: {log:?}");
2296 }
2297
2298 /// Minimal git repo: `develop` plus a `feature/phase-NN` branch carrying
2299 /// `commits` extra commits.
2300 fn init_repo_with_feature_commits(root: &Path, phase: u32, commits: usize) {
2301 let git = |args: &[&str]| {
2302 let output = crate::git::git_command(root).args(args).output().unwrap();
2303 assert!(
2304 output.status.success(),
2305 "git {args:?} failed: {}",
2306 String::from_utf8_lossy(&output.stderr)
2307 );
2308 };
2309 git(&["init"]);
2310 git(&["config", "user.email", "devflow@example.com"]);
2311 git(&["config", "user.name", "DevFlow Tests"]);
2312 git(&["config", "commit.gpgsign", "false"]);
2313 git(&["config", "core.hooksPath", "/dev/null"]);
2314 git(&["checkout", "-b", "develop"]);
2315 std::fs::write(root.join("README.md"), "base\n").unwrap();
2316 git(&["add", "README.md"]);
2317 git(&["commit", "-m", "base"]);
2318
2319 let branch = format!("feature/phase-{phase:02}");
2320 git(&["checkout", "-b", &branch]);
2321 for i in 0..commits {
2322 let name = format!("work-{i}.txt");
2323 std::fs::write(root.join(&name), "work\n").unwrap();
2324 git(&["add", &name]);
2325 git(&["commit", "-m", &format!("feat: agent work {i}")]);
2326 }
2327 }
2328
2329 fn commit_count(root: &Path, phase: u32) -> u32 {
2330 let range = format!("develop..feature/phase-{phase:02}");
2331 let output = crate::git::git_command(root)
2332 .args(["rev-list", "--count", &range])
2333 .output()
2334 .unwrap();
2335 String::from_utf8_lossy(&output.stdout)
2336 .trim()
2337 .parse()
2338 .unwrap()
2339 }
2340
2341 /// D-07/T-31-09: a timeout READS the commit log and never writes to it. A
2342 /// timeout can be a false positive, and destroying real work on a false
2343 /// positive is unrecoverable.
2344 ///
2345 /// The "commits were enumerated" half is this test's negative control, and
2346 /// it is not optional: if enumeration silently returned nothing, "no
2347 /// commits were rolled back" would be trivially, vacuously true.
2348 #[test]
2349 fn idle_timeout_does_not_roll_back_commits() {
2350 let dir = tempfile::tempdir().unwrap();
2351 let root = dir.path();
2352 let phase = 8u32;
2353 init_repo_with_feature_commits(root, phase, 2);
2354 std::fs::create_dir_all(root.join(".devflow")).unwrap();
2355
2356 let before = commit_count(root, phase);
2357 assert_eq!(before, 2, "fixture precondition");
2358
2359 // No TERM trap here: the child dies promptly, keeping this test fast.
2360 let script = r#"
2361set -u
2362IFS= read -r turn || exit 91
2363printf '%s\n' '{"type":"system","subtype":"init","session_id":"idle-3"}'
2364sleep 120
2365"#;
2366
2367 run_pipe_owning_monitor(
2368 root,
2369 phase,
2370 root,
2371 "prompt",
2372 Duration::from_millis(250),
2373 "sh",
2374 &["-c".to_string(), script.to_string()],
2375 &[],
2376 )
2377 .expect("a silent child must still produce a supervised outcome");
2378
2379 assert_eq!(
2380 commit_count(root, phase),
2381 before,
2382 "an idle timeout must never roll back, reset, or revert a commit"
2383 );
2384
2385 // NEGATIVE CONTROL: enumeration must actually have found them, else
2386 // the assertion above is vacuous.
2387 let raw = std::fs::read_to_string(crate::agent_result::idle_timeout_path(root, phase))
2388 .expect("verdict file must exist");
2389 let record: IdleTimeoutRecord = serde_json::from_str(&raw).expect("verdict must parse");
2390 assert_eq!(
2391 record.commits.len(),
2392 2,
2393 "the verdict must NAME the commits, not merely leave them alone"
2394 );
2395 for commit in &record.commits {
2396 assert_eq!(commit.sha.len(), 40, "full sha expected: {commit:?}");
2397 assert!(
2398 commit.subject.starts_with("feat: agent work"),
2399 "subject must survive enumeration: {commit:?}"
2400 );
2401 }
2402
2403 // And the operator-facing reason names them.
2404 let result = crate::agent_result::evaluate_layer1(root, phase).unwrap();
2405 assert_eq!(result.commits, Some(2));
2406 let reason = result.reason.unwrap();
2407 assert!(
2408 reason.contains("NONE of them were rolled back"),
2409 "reason: {reason}"
2410 );
2411 }
2412}