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