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