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