Skip to main content

devflow_core/
gates.rs

1//! Gate file protocol — the handoff between DevFlow and a human (via Hermes).
2//!
3//! A *gate* is a pause point where DevFlow writes a request to
4//! `.devflow/gates/` and waits for a human (or the Hermes cron poller) to drop
5//! a response file. The protocol is three files per gated stage:
6//!
7//! - `NN-{stage}.json` — the gate request DevFlow writes (a [`GateFile`]).
8//! - `NN-{stage}.response.json` — the human's answer (a [`GateResponse`]).
9//! - `NN-{stage}.ack.json` — DevFlow's receipt (a [`GateAck`]) so the poller can
10//!   clean up.
11//!
12//! Writes are atomic (write-to-temp + rename) so a reader never sees a partial
13//! file. Polling uses exponential backoff so a long human wait costs little.
14
15use crate::phase_id::PhaseId;
16use crate::stage::Stage;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::process::Command;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21use tracing::{debug, info, warn};
22
23/// The gate request DevFlow writes when it pauses for a human decision.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct GateFile {
26    /// Phase the gate belongs to.
27    pub phase: PhaseId,
28    /// Stage that fired the gate.
29    pub stage: Stage,
30    /// Human-readable context explaining what is being asked.
31    pub context: String,
32    /// Unix timestamp (seconds) when the gate was written.
33    pub timestamp: String,
34}
35
36/// The human's response to a gate.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct GateResponse {
39    /// Whether the gated work is approved to advance.
40    pub approved: bool,
41    /// Optional free-text note (e.g. what to fix on a rejection).
42    #[serde(default)]
43    pub note: Option<String>,
44    /// Who responded (human name, or "hermes").
45    #[serde(default)]
46    pub responded_by: Option<String>,
47}
48
49/// DevFlow's receipt that it has read a [`GateResponse`].
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct GateAck {
52    /// Always `true` — presence of the file is the signal.
53    pub received: bool,
54}
55
56/// What DevFlow should do after reading a [`GateResponse`].
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum GateAction {
59    /// Approved — advance to the next stage.
60    Advance,
61    /// Rejected with fixable feedback — loop back to the given stage.
62    LoopBack(Stage),
63    /// Rejected and aborted — stop the workflow with a reason.
64    Abort(String),
65}
66
67impl GateAction {
68    /// Decide the action from a response: approval advances, a rejection loops
69    /// back to Code unless the note asks to abort.
70    pub fn from_response(response: &GateResponse) -> GateAction {
71        if response.approved {
72            return GateAction::Advance;
73        }
74        match response.note.as_deref() {
75            Some(note) if note.to_ascii_lowercase().contains("abort") => {
76                GateAction::Abort(note.to_string())
77            }
78            _ => GateAction::LoopBack(Stage::Code),
79        }
80    }
81}
82
83/// Errors produced by the gate protocol.
84#[derive(Debug, thiserror::Error)]
85pub enum GateError {
86    /// Filesystem operation failed.
87    #[error("gate I/O failed: {0}")]
88    Io(#[from] std::io::Error),
89    /// JSON parse or serialization failed.
90    #[error("gate JSON failed: {0}")]
91    Json(#[from] serde_json::Error),
92    /// Responding to a gate that was never fired (or already resolved).
93    #[error("no open gate for phase {phase} stage {stage} — see `devflow gate list`")]
94    NoOpenGate { phase: PhaseId, stage: Stage },
95    /// Responding to a gate that already has a response on disk.
96    #[error("gate for phase {phase} stage {stage} already has a response awaiting pickup")]
97    AlreadyResponded { phase: PhaseId, stage: Stage },
98}
99
100/// An open gate: a request the workflow wrote that has no response yet.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct OpenGate {
103    /// Phase the gate belongs to.
104    pub phase: PhaseId,
105    /// Stage that fired the gate.
106    pub stage: Stage,
107    /// Human-readable context from the request.
108    pub context: String,
109    /// Unix timestamp (seconds) when the gate was written.
110    pub timestamp: String,
111}
112
113/// The gate-file protocol, scoped to a project's `.devflow/gates/` directory.
114pub struct Gates;
115
116impl Gates {
117    /// The `.devflow/gates/` directory for a project.
118    pub fn dir(project_root: &Path) -> PathBuf {
119        project_root.join(".devflow").join("gates")
120    }
121
122    /// Path to the gate request file for a phase + stage.
123    pub fn gate_path(project_root: &Path, phase: PhaseId, stage: Stage) -> PathBuf {
124        Self::dir(project_root).join(format!("{padded}-{stage}.json", padded = phase.padded()))
125    }
126
127    /// Path to the response file for a phase + stage.
128    pub fn response_path(project_root: &Path, phase: PhaseId, stage: Stage) -> PathBuf {
129        Self::dir(project_root).join(format!(
130            "{padded}-{stage}.response.json",
131            padded = phase.padded()
132        ))
133    }
134
135    /// Path to the ack file for a phase + stage.
136    pub fn ack_path(project_root: &Path, phase: PhaseId, stage: Stage) -> PathBuf {
137        Self::dir(project_root).join(format!(
138            "{padded}-{stage}.ack.json",
139            padded = phase.padded()
140        ))
141    }
142
143    /// Every open gate (request written, no response yet), sorted by phase
144    /// then stage. Request files are `NN-{stage}.json`; `.response.json` and
145    /// `.ack.json` siblings are protocol artifacts, not requests, and any
146    /// unparsable file is skipped — listing must degrade, not die.
147    pub fn list_open(project_root: &Path) -> Vec<OpenGate> {
148        let mut open = Vec::new();
149        let Ok(entries) = std::fs::read_dir(Self::dir(project_root)) else {
150            return open;
151        };
152        for entry in entries.flatten() {
153            let name = entry.file_name();
154            let Some(name) = name.to_str() else { continue };
155            if !name.ends_with(".json")
156                || name.ends_with(".response.json")
157                || name.ends_with(".ack.json")
158            {
159                continue;
160            }
161            let Ok(contents) = std::fs::read_to_string(entry.path()) else {
162                continue;
163            };
164            let Ok(gate) = serde_json::from_str::<GateFile>(&contents) else {
165                continue;
166            };
167            if Self::response_path(project_root, gate.phase, gate.stage).exists() {
168                continue;
169            }
170            open.push(OpenGate {
171                phase: gate.phase,
172                stage: gate.stage,
173                context: gate.context,
174                timestamp: gate.timestamp,
175            });
176        }
177        open.sort_by_key(|g| (g.phase, g.stage.to_string()));
178        open
179    }
180
181    /// Answer an open gate by writing its response file atomically — the
182    /// programmatic form of what a human previously hand-edited. Refuses
183    /// when no gate request is open for the phase+stage, and when a
184    /// response is already on disk awaiting the workflow's poller (silently
185    /// replacing an unconsumed answer would race the poll).
186    pub fn respond(
187        project_root: &Path,
188        phase: PhaseId,
189        stage: Stage,
190        response: &GateResponse,
191    ) -> Result<PathBuf, GateError> {
192        if !Self::gate_path(project_root, phase, stage).exists() {
193            return Err(GateError::NoOpenGate { phase, stage });
194        }
195        let path = Self::response_path(project_root, phase, stage);
196        if path.exists() {
197            return Err(GateError::AlreadyResponded { phase, stage });
198        }
199        write_atomic(&path, &serde_json::to_string_pretty(response)?)?;
200        info!(
201            "gate response written for phase {phase} {stage}: approved={}",
202            response.approved
203        );
204        Ok(path)
205    }
206
207    /// Answer an abandoned gate with a rejection — the reaping half of 23b's
208    /// aged-gate sweep. Mitigation for T-23-41 (Elevation of Privilege): this
209    /// function takes **no** boolean parameter and hard-codes `approved:
210    /// false` at the literal below, so no caller — buggy or refactored — can
211    /// ever make it write an approval. It is the sweep's ONLY write path.
212    ///
213    /// The caller-supplied `note`'s lowercase form MUST contain the abort
214    /// keyword [`GateAction::from_response`] matches on (`"abort"`), so the
215    /// reap resolves to `GateAction::Abort` rather than
216    /// `GateAction::LoopBack(Stage::Code)` — a loop-back would relaunch an
217    /// agent on an abandoned run, the opposite of what a reap should do.
218    pub fn reap(
219        project_root: &Path,
220        phase: PhaseId,
221        stage: Stage,
222        note: &str,
223        responded_by: &str,
224    ) -> Result<PathBuf, GateError> {
225        let response = GateResponse {
226            approved: false,
227            note: Some(note.to_string()),
228            responded_by: Some(responded_by.to_string()),
229        };
230        Self::respond(project_root, phase, stage, &response)
231    }
232
233    /// Write a gate request, creating the gates directory if needed.
234    pub fn write_gate(
235        project_root: &Path,
236        phase: PhaseId,
237        stage: Stage,
238        context: &str,
239    ) -> Result<PathBuf, GateError> {
240        let gate = GateFile {
241            phase,
242            stage,
243            context: context.to_string(),
244            timestamp: unix_now(),
245        };
246        let path = Self::gate_path(project_root, phase, stage);
247        info!("writing gate {} for phase {phase}", stage);
248        write_atomic(&path, &serde_json::to_string_pretty(&gate)?)?;
249        Ok(path)
250    }
251
252    /// Poll for a response with exponential backoff (1s → 2s → 4s … capped at
253    /// 60s), giving up after `timeout_secs`. Returns the parsed response when it
254    /// appears, or `None` on timeout.
255    pub fn poll_response(
256        project_root: &Path,
257        phase: PhaseId,
258        stage: Stage,
259        timeout_secs: u64,
260    ) -> Option<GateResponse> {
261        let path = Self::response_path(project_root, phase, stage);
262        let deadline = Duration::from_secs(timeout_secs);
263        let mut waited = Duration::ZERO;
264        let mut backoff = Duration::from_secs(1);
265        let cap = Duration::from_secs(60);
266        debug!("polling for gate response at {}", path.display());
267        loop {
268            if let Ok(contents) = std::fs::read_to_string(&path)
269                && let Ok(response) = serde_json::from_str::<GateResponse>(&contents)
270            {
271                return Some(response);
272            }
273            if waited >= deadline {
274                return None;
275            }
276            let sleep = backoff.min(deadline - waited);
277            std::thread::sleep(sleep);
278            waited += sleep;
279            backoff = (backoff * 2).min(cap);
280        }
281    }
282
283    /// Write an ack file signalling the response was read.
284    pub fn ack(project_root: &Path, phase: PhaseId, stage: Stage) -> Result<PathBuf, GateError> {
285        let path = Self::ack_path(project_root, phase, stage);
286        write_atomic(
287            &path,
288            &serde_json::to_string_pretty(&GateAck { received: true })?,
289        )?;
290        Ok(path)
291    }
292
293    /// Remove the gate, response, and ack files for a stage. Idempotent.
294    pub fn cleanup(project_root: &Path, phase: PhaseId, stage: Stage) -> Result<(), GateError> {
295        for path in [
296            Self::gate_path(project_root, phase, stage),
297            Self::response_path(project_root, phase, stage),
298            Self::ack_path(project_root, phase, stage),
299        ] {
300            if path.exists() {
301                std::fs::remove_file(path)?;
302            }
303        }
304        Ok(())
305    }
306}
307
308/// Fire the operator-configured gate notify hook, if any.
309///
310/// Reads `DEVFLOW_GATE_NOTIFY_CMD`; if unset or empty, this is a silent no-op
311/// (no notify command configured). Otherwise delegates to
312/// [`run_notify_command`]. `unexpected` marks a gate fired on a stage the
313/// active [`crate::mode::Mode`] would not normally gate (e.g. a Define/Plan/Code
314/// failure in Auto mode) — a never-silent gate per WR-11.
315pub fn fire_gate_notify(phase: PhaseId, stage: Stage, context: &str, unexpected: bool) {
316    let cmd = match std::env::var("DEVFLOW_GATE_NOTIFY_CMD") {
317        Ok(cmd) if !cmd.is_empty() => cmd,
318        _ => return,
319    };
320    run_notify_command(&cmd, phase, stage, context, unexpected);
321}
322
323/// Run the notify `cmd` via `sh -c`, passing gate metadata to the child as
324/// environment variables — never interpolated into the command string
325/// (WR-01 argv-not-shell precedent; `context` may contain agent-generated,
326/// untrusted text). Fail-soft: a non-zero exit or spawn error is logged via
327/// `warn!` and otherwise ignored — this must never propagate an error that
328/// could abort `run_gate`.
329fn run_notify_command(cmd: &str, phase: PhaseId, stage: Stage, context: &str, unexpected: bool) {
330    let output = Command::new("sh")
331        .arg("-c")
332        .arg(cmd)
333        .env("DEVFLOW_GATE_PHASE", phase.to_string())
334        .env("DEVFLOW_GATE_STAGE", stage.to_string())
335        .env("DEVFLOW_GATE_CONTEXT", context)
336        .env(
337            "DEVFLOW_NON_SILENT_GATE",
338            if unexpected { "1" } else { "0" },
339        )
340        .output();
341    match output {
342        Ok(out) if out.status.success() => {
343            debug!("gate notify hook ran successfully");
344        }
345        Ok(out) => warn!(
346            "gate notify hook exited with status {:?}: {}",
347            out.status.code(),
348            String::from_utf8_lossy(&out.stderr)
349        ),
350        Err(err) => warn!("gate notify hook could not be spawned: {err}"),
351    }
352}
353
354/// Write `contents` to `path` atomically: write a temp file in the same
355/// directory, then rename over the target so readers never see a partial write.
356fn write_atomic(path: &Path, contents: &str) -> Result<(), GateError> {
357    if let Some(parent) = path.parent() {
358        crate::workflow::ensure_devflow_dir(parent)?;
359    }
360    let tmp = path.with_extension("tmp");
361    std::fs::write(&tmp, contents)?;
362    std::fs::rename(&tmp, path)?;
363    Ok(())
364}
365
366fn unix_now() -> String {
367    SystemTime::now()
368        .duration_since(UNIX_EPOCH)
369        .map(|d| d.as_secs().to_string())
370        .unwrap_or_else(|_| "0".to_string())
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use std::sync::Mutex;
377
378    /// Serializes tests that mutate process-global env vars (`set_var`/
379    /// `remove_var` are process-wide and `cargo test` runs in parallel by
380    /// default) so they don't race each other.
381    static ENV_MUTEX: Mutex<()> = Mutex::new(());
382
383    #[test]
384    fn gate_file_round_trips_through_serde() {
385        let gate = GateFile {
386            phase: PhaseId::new(11),
387            stage: Stage::Validate,
388            context: "review the validation".into(),
389            timestamp: "1750000000".into(),
390        };
391        let json = serde_json::to_string(&gate).unwrap();
392        let back: GateFile = serde_json::from_str(&json).unwrap();
393        assert_eq!(gate, back);
394    }
395
396    #[test]
397    fn write_gate_creates_file_with_correct_path() {
398        let dir = tempfile::tempdir().unwrap();
399        let path = Gates::write_gate(dir.path(), PhaseId::new(11), Stage::Validate, "ctx").unwrap();
400        assert!(path.ends_with(".devflow/gates/11-validate.json"));
401        assert!(path.exists());
402        let gate: GateFile =
403            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
404        assert_eq!(gate.phase, PhaseId::new(11));
405        assert_eq!(gate.stage, Stage::Validate);
406        assert_eq!(gate.context, "ctx");
407    }
408
409    #[test]
410    fn poll_response_returns_when_file_appears() {
411        let dir = tempfile::tempdir().unwrap();
412        let response = GateResponse {
413            approved: true,
414            note: None,
415            responded_by: Some("human".into()),
416        };
417        let path = Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate);
418        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
419        std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
420
421        let got = Gates::poll_response(dir.path(), PhaseId::new(11), Stage::Validate, 1).unwrap();
422        assert_eq!(got, response);
423    }
424
425    #[test]
426    fn poll_response_returns_immediately_at_full_timeout() {
427        const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
428
429        let dir = tempfile::tempdir().unwrap();
430        let response = GateResponse {
431            approved: true,
432            note: None,
433            responded_by: Some("human".into()),
434        };
435        let path = Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate);
436        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
437        std::fs::write(&path, serde_json::to_string(&response).unwrap()).unwrap();
438
439        let started = std::time::Instant::now();
440        let got = Gates::poll_response(dir.path(), PhaseId::new(11), Stage::Validate, SEVEN_DAYS)
441            .unwrap();
442
443        assert_eq!(got, response);
444        assert!(started.elapsed() < std::time::Duration::from_secs(5));
445    }
446
447    #[test]
448    fn poll_response_times_out_when_absent() {
449        let dir = tempfile::tempdir().unwrap();
450        assert!(Gates::poll_response(dir.path(), PhaseId::new(11), Stage::Ship, 0).is_none());
451    }
452
453    #[test]
454    fn ack_writes_received_true() {
455        let dir = tempfile::tempdir().unwrap();
456        let path = Gates::ack(dir.path(), PhaseId::new(11), Stage::Ship).unwrap();
457        let ack: GateAck = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
458        assert!(ack.received);
459    }
460
461    #[test]
462    fn cleanup_removes_all_three_files_idempotently() {
463        let dir = tempfile::tempdir().unwrap();
464        Gates::write_gate(dir.path(), PhaseId::new(11), Stage::Validate, "ctx").unwrap();
465        Gates::ack(dir.path(), PhaseId::new(11), Stage::Validate).unwrap();
466        std::fs::write(
467            Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate),
468            "{\"approved\":true}",
469        )
470        .unwrap();
471
472        Gates::cleanup(dir.path(), PhaseId::new(11), Stage::Validate).unwrap();
473        assert!(!Gates::gate_path(dir.path(), PhaseId::new(11), Stage::Validate).exists());
474        assert!(!Gates::response_path(dir.path(), PhaseId::new(11), Stage::Validate).exists());
475        assert!(!Gates::ack_path(dir.path(), PhaseId::new(11), Stage::Validate).exists());
476        // Idempotent: cleaning again with nothing present succeeds.
477        Gates::cleanup(dir.path(), PhaseId::new(11), Stage::Validate).unwrap();
478    }
479
480    /// 15a: `devflow gate list` — a gate is open until its response lands;
481    /// response/ack protocol files must never be mistaken for requests.
482    #[test]
483    fn list_open_shows_unanswered_gates_only() {
484        let dir = tempfile::tempdir().unwrap();
485        Gates::write_gate(dir.path(), PhaseId::new(7), Stage::Ship, "approve merge?").unwrap();
486        Gates::write_gate(dir.path(), PhaseId::new(8), Stage::Validate, "review gaps").unwrap();
487        // Phase 8's gate gets answered; its response/ack must hide it.
488        Gates::respond(
489            dir.path(),
490            PhaseId::new(8),
491            Stage::Validate,
492            &GateResponse {
493                approved: true,
494                note: None,
495                responded_by: Some("test".into()),
496            },
497        )
498        .unwrap();
499        Gates::ack(dir.path(), PhaseId::new(8), Stage::Validate).unwrap();
500        // Corrupt junk in the gates dir is skipped, not fatal.
501        std::fs::write(Gates::dir(dir.path()).join("junk.json"), "{nope").unwrap();
502
503        let open = Gates::list_open(dir.path());
504
505        assert_eq!(open.len(), 1);
506        assert_eq!(open[0].phase, PhaseId::new(7));
507        assert_eq!(open[0].stage, Stage::Ship);
508        assert_eq!(open[0].context, "approve merge?");
509    }
510
511    #[test]
512    fn list_open_is_empty_without_gates_dir() {
513        let dir = tempfile::tempdir().unwrap();
514        assert!(Gates::list_open(dir.path()).is_empty());
515    }
516
517    /// 15a: `respond` is the programmatic answer path — it must round-trip
518    /// through the same file `poll_response` reads.
519    #[test]
520    fn respond_writes_a_response_poll_response_consumes() {
521        let dir = tempfile::tempdir().unwrap();
522        Gates::write_gate(dir.path(), PhaseId::new(9), Stage::Ship, "ctx").unwrap();
523        let response = GateResponse {
524            approved: false,
525            note: Some("abort: nope".into()),
526            responded_by: Some("cli".into()),
527        };
528
529        Gates::respond(dir.path(), PhaseId::new(9), Stage::Ship, &response).unwrap();
530
531        let polled = Gates::poll_response(dir.path(), PhaseId::new(9), Stage::Ship, 1).unwrap();
532        assert_eq!(polled, response);
533        assert!(matches!(
534            GateAction::from_response(&polled),
535            GateAction::Abort(_)
536        ));
537    }
538
539    #[test]
540    fn respond_refuses_when_no_gate_is_open() {
541        let dir = tempfile::tempdir().unwrap();
542        let response = GateResponse {
543            approved: true,
544            note: None,
545            responded_by: None,
546        };
547        let err = Gates::respond(dir.path(), PhaseId::new(3), Stage::Ship, &response).unwrap_err();
548        assert!(matches!(err, GateError::NoOpenGate { phase, .. } if phase == PhaseId::new(3)));
549    }
550
551    #[test]
552    fn respond_refuses_to_clobber_unconsumed_response() {
553        let dir = tempfile::tempdir().unwrap();
554        Gates::write_gate(dir.path(), PhaseId::new(4), Stage::Validate, "ctx").unwrap();
555        let response = GateResponse {
556            approved: true,
557            note: None,
558            responded_by: None,
559        };
560        Gates::respond(dir.path(), PhaseId::new(4), Stage::Validate, &response).unwrap();
561
562        let err =
563            Gates::respond(dir.path(), PhaseId::new(4), Stage::Validate, &response).unwrap_err();
564        assert!(
565            matches!(err, GateError::AlreadyResponded { phase, .. } if phase == PhaseId::new(4))
566        );
567    }
568
569    #[test]
570    fn gate_action_advances_on_approval() {
571        let response = GateResponse {
572            approved: true,
573            note: None,
574            responded_by: None,
575        };
576        assert_eq!(GateAction::from_response(&response), GateAction::Advance);
577    }
578
579    #[test]
580    fn gate_action_loops_back_on_fixable_rejection() {
581        let response = GateResponse {
582            approved: false,
583            note: Some("fix the failing test".into()),
584            responded_by: None,
585        };
586        assert_eq!(
587            GateAction::from_response(&response),
588            GateAction::LoopBack(Stage::Code)
589        );
590    }
591
592    #[test]
593    fn gate_action_aborts_when_note_says_abort() {
594        let response = GateResponse {
595            approved: false,
596            note: Some("abort: requirements changed".into()),
597            responded_by: None,
598        };
599        assert!(matches!(
600            GateAction::from_response(&response),
601            GateAction::Abort(_)
602        ));
603    }
604
605    /// `run_notify_command` takes the command string as an argument (not an
606    /// env var), so this test needs no env mutation and cannot race other
607    /// tests.
608    #[test]
609    fn notify_hook_runs_configured_command() {
610        let dir = tempfile::tempdir().unwrap();
611        let sentinel = dir.path().join("sentinel");
612        let cmd = format!("touch {}", sentinel.display());
613        run_notify_command(&cmd, PhaseId::new(11), Stage::Ship, "ctx", false);
614        assert!(sentinel.exists());
615    }
616
617    #[test]
618    fn notify_hook_failure_is_fail_soft() {
619        // A command that always fails must not panic or otherwise abort the
620        // caller — fail-soft per T-13-02.
621        run_notify_command("exit 1", PhaseId::new(11), Stage::Ship, "ctx", false);
622    }
623
624    #[test]
625    fn notify_hook_sets_non_silent_flag() {
626        let dir = tempfile::tempdir().unwrap();
627
628        let sentinel_unexpected = dir.path().join("unexpected");
629        let cmd_unexpected = format!(
630            "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
631            sentinel_unexpected.display()
632        );
633        run_notify_command(&cmd_unexpected, PhaseId::new(11), Stage::Code, "ctx", true);
634        assert_eq!(std::fs::read_to_string(&sentinel_unexpected).unwrap(), "1");
635
636        let sentinel_expected = dir.path().join("expected");
637        let cmd_expected = format!(
638            "echo -n \"$DEVFLOW_NON_SILENT_GATE\" > {}",
639            sentinel_expected.display()
640        );
641        run_notify_command(&cmd_expected, PhaseId::new(11), Stage::Ship, "ctx", false);
642        assert_eq!(std::fs::read_to_string(&sentinel_expected).unwrap(), "0");
643    }
644
645    /// This test mutates process-global env, so it acquires `ENV_MUTEX` to
646    /// avoid racing any other env-touching test in this module.
647    #[test]
648    fn notify_hook_unset_is_noop() {
649        let _guard = ENV_MUTEX.lock().unwrap();
650        // SAFETY: serialized under ENV_MUTEX — no other thread in this
651        // process reads/writes DEVFLOW_GATE_NOTIFY_CMD concurrently.
652        unsafe {
653            std::env::remove_var("DEVFLOW_GATE_NOTIFY_CMD");
654        }
655        // Must return normally without touching the filesystem or panicking.
656        fire_gate_notify(PhaseId::new(11), Stage::Ship, "ctx", false);
657    }
658}