Skip to main content

fno_agents/
loop_runtime.rs

1//! Unified loop runtime primitive for target, megawalk, and megatron drivers.
2//!
3//! This module is the generic "walk a queue of units, dispatch sessions,
4//! handle termination" engine. Drivers differ only in their Queue and
5//! Dispatcher implementations. The runtime itself has no opinion about what
6//! a "unit" is (backlog node, fleet project, ...) or how sessions are launched.
7//!
8//! ## Why journal write failure is fatal (contrast with loopcheck.rs)
9//!
10//! `loopcheck.rs` runs as a stop hook: it is a read-only observer that must
11//! never block the session from exiting. Journal writes there are best-effort
12//! (logged to stderr, never propagated as errors) because a failed write
13//! cannot undo a decision the runtime already made.
14//!
15//! Here the journal is the observability record of an *active walk*. If the
16//! runtime cannot record that it dispatched a session, an operator watching
17//! the log sees nothing and cannot tell whether work is happening. An
18//! unobservable walk that continues spending compute (and potentially money)
19//! is worse than stopping loudly. The invariant: "The system must handle
20//! journal write failure by stopping dispatch loudly; an unobservable walk
21//! must not continue spending."
22//!
23//! The global mirror (`~/.fno/events.jsonl`) is best-effort: a write
24//! failure there is logged to stderr but never fatal, because the project
25//! journal is the authoritative record.
26//!
27//! ## Source field
28//!
29//! Events written by this runtime carry `source: "loop"`, distinct from the
30//! `source: "hook"` that loopcheck uses. The two streams have different
31//! semantics: hook events are stop-hook decision records; loop events are
32//! walk-level orchestration records. Consumers that aggregate both streams
33//! can use the source field to distinguish them.
34//!
35//! ## Module naming
36//!
37//! The module name starts with `loop` so that the LOC-ratchet glob
38//! `crates/fno-agents/src/loop*` counts this file's LOC toward the ratchet
39//! budget deliberately (alongside loopcheck.rs).
40
41use crate::loopcheck::TerminationReason;
42use chrono::Utc;
43use serde_json::{json, Value};
44use std::fs;
45use std::io::{BufRead, BufReader, Write};
46use std::path::{Path, PathBuf};
47
48// ── newtype wrappers for Journal paths (F9) ───────────────────────────────────
49
50/// Newtype for the project-authoritative journal path. Writes are FATAL on failure.
51/// Using a distinct type prevents silent positional swap of the two same-type args.
52pub struct ProjectJournalPath(pub PathBuf);
53
54/// Newtype for the global mirror journal path (`~/.fno/events.jsonl`).
55/// Writes are best-effort only.
56pub struct GlobalJournalPath(pub PathBuf);
57
58// ── public error type ─────────────────────────────────────────────────────────
59
60/// Errors returned by the loop runtime.
61#[derive(Debug, thiserror::Error)]
62pub enum LoopError {
63    /// I/O failure (file open, read, write).
64    #[error("I/O error: {0}")]
65    Io(#[from] std::io::Error),
66
67    /// Journal write to the project events file failed (fatal per spec).
68    #[error("journal write failure (project): {0}")]
69    Journal(String),
70
71    /// Queue operation failed.
72    #[error("queue error: {0}")]
73    Queue(String),
74
75    /// Walk policy requests a pause. Not a true error: `run_loop` maps it to a
76    /// `walk_paused` journal event + `TerminationReason::NoProgress`. Kept
77    /// distinct from `Queue` so a real queue error whose message happens to
78    /// start with `pause:` can never be misrouted to the pause path. Structural
79    /// twin of `NextStep::Pause`, carrying the same typed `policy`/`detail`.
80    #[error("walk paused (policy={policy}): {detail}")]
81    Pause { policy: String, detail: String },
82
83    /// Dispatcher operation failed.
84    #[error("dispatch error: {0}")]
85    Dispatch(String),
86
87    /// Configuration error (e.g., invalid budget).
88    #[error("configuration error: {0}")]
89    Config(String),
90}
91
92// ── public types ──────────────────────────────────────────────────────────────
93
94/// A single unit of work to be dispatched. Drivers map their domain objects
95/// (backlog nodes, fleet projects, ...) to this common shape.
96pub struct Unit {
97    /// Stable identifier, e.g. `ab-XXXXXXXX` for a backlog node.
98    pub id: String,
99    /// Human-readable title for log output.
100    pub title: String,
101    /// Correlation key matched against termination events' `data.session_id`.
102    /// For target sessions this is the session identifier written into the
103    /// target-state.md manifest; for other drivers it is whatever key the
104    /// dispatcher embeds in its events.
105    pub session_key: String,
106    /// Optional plan path for context (not used by the runtime itself).
107    pub plan_path: Option<String>,
108    /// Driver-specific extra env vars to inject into the child process.
109    /// The runtime passes these through to the Dispatcher without inspecting
110    /// them. MegawalkQueue populates TARGET_MISSION_* for fleet nodes;
111    /// TargetQueue leaves this empty. Megatron will use this seam too.
112    pub extra_env: Vec<(String, String)>,
113}
114
115/// Evidence of termination extracted from the project journal.
116pub struct Evidence {
117    /// The parsed TerminationReason.
118    pub reason: TerminationReason,
119    /// Human-readable message from the event's `data.message` field.
120    pub message: String,
121}
122
123/// Outcome of queue.close() for a single unit.
124#[derive(Debug, PartialEq)]
125pub enum CloseOutcome {
126    /// Unit was closed successfully.
127    Closed,
128    /// Queue refused to close the unit (e.g. it was claimed by another walker).
129    Refused(String),
130    /// Unit was parked for later (e.g. dependents not yet resolved).
131    Parked(String),
132}
133
134/// Runtime-varying context passed to each Dispatcher::run call. Static
135/// configuration (project root, env vars, etc.) lives in the Dispatcher impl.
136pub struct DispatchCtx {
137    /// 1-based iteration counter across all units in this walk.
138    pub iteration: u64,
139}
140
141// ── NextStep ──────────────────────────────────────────────────────────────────
142
143/// The richer return type for Queue::next_step(). Used by policy-aware queues
144/// (MegawalkQueue) to signal a walk-level pause without returning an error.
145///
146/// - `Dispatch(Unit)`: there is work to do; dispatch this unit.
147/// - `Drained`: the queue is empty; the walk may complete with NoWork.
148/// - `Pause{policy, detail}`: walk policy says stop; run_loop maps this to
149///   walk_paused + loop_terminated{reason: NoProgress}.
150///
151/// Queues that do not implement walk policy (TargetQueue) return only
152/// Dispatch / Drained (i.e., they translate their Option<Unit> to these two).
153pub enum NextStep {
154    /// There is a unit ready to dispatch.
155    Dispatch(Unit),
156    /// The queue is empty; no more work.
157    Drained,
158    /// Walk policy requests a pause.
159    Pause {
160        /// Short tag: "consecutive_failures" | "p0_failed".
161        policy: String,
162        /// Human-readable detail (unit IDs involved, streak count, etc.).
163        detail: String,
164    },
165}
166
167// ── traits ────────────────────────────────────────────────────────────────────
168
169/// Source of work units. Each call to `next` either returns the next unit to
170/// dispatch or `None` to signal that the walk is complete.
171///
172/// ## Why `&mut self` (F8 rationale)
173///
174/// Group-2 megawalk Queue carries real cursor state (current position in the
175/// backlog, consecutive-failure counters, etc.). Using `&self` would force
176/// pointless `Mutex`-wrapping for state that is inherently sequential in the
177/// single-threaded walk loop. No threading requirement exists at this seam:
178/// `run_loop` is always called from a single thread. `&mut self` is the natural
179/// fit and avoids unnecessary interior-mutability noise.
180pub trait Queue {
181    /// Return the next unit, or `None` if the queue is empty.
182    ///
183    /// Queues that implement walk policy (e.g. MegawalkPolicyQueue) should
184    /// use `next_step()` instead. This default impl calls `next_step()` and
185    /// maps Dispatch->Some, Drained->None, Pause->Err(LoopError::Pause{..}).
186    fn next(&mut self) -> Result<Option<Unit>, LoopError> {
187        match self.next_step()? {
188            NextStep::Dispatch(u) => Ok(Some(u)),
189            NextStep::Drained => Ok(None),
190            NextStep::Pause { policy, detail } => Err(LoopError::Pause { policy, detail }),
191        }
192    }
193
194    /// Richer variant of `next()` that can signal a walk-level pause.
195    /// Policy-aware queues override this; simple queues (TargetQueue) can
196    /// leave the default which panics (they override `next()` directly instead).
197    ///
198    /// The default panics to make missing overrides detectable at test time.
199    fn next_step(&mut self) -> Result<NextStep, LoopError> {
200        // Default: not implemented. Queues override exactly one of next() or
201        // next_step(). TargetQueue overrides next() only (returns Option).
202        // MegawalkPolicyQueue overrides next_step() only (returns NextStep).
203        panic!("Queue::next_step() not implemented; override next() or next_step()");
204    }
205
206    /// Mark a unit as closed (done/parked/refused) given the termination
207    /// evidence extracted from the journal.
208    fn close(&mut self, unit: &Unit, evidence: &Evidence) -> Result<CloseOutcome, LoopError>;
209}
210
211/// A live session handle returned by a Dispatcher.
212pub trait Session {
213    /// Block until the session exits and return its exit code.
214    fn wait(&mut self) -> Result<i32, LoopError>;
215}
216
217/// Launches sessions for units. Stateless with respect to the walk loop;
218/// any per-dispatch state is internal to the impl.
219pub trait Dispatcher {
220    /// Launch a session for the given unit in the given walk context.
221    fn run(&self, unit: &Unit, ctx: &DispatchCtx) -> Result<Box<dyn Session>, LoopError>;
222}
223
224// ── budget ────────────────────────────────────────────────────────────────────
225
226/// Walk-level iteration budget. Prevents unbounded loops when sessions never
227/// produce termination events.
228pub struct LoopBudget {
229    max_iterations: u64,
230}
231
232impl LoopBudget {
233    /// Create a budget. Rejects `max_iterations = 0` because a budget of zero
234    /// would immediately terminate every walk at the pre-dispatch check,
235    /// which is never a useful configuration.
236    pub fn new(max_iterations: u64) -> Result<Self, LoopError> {
237        if max_iterations == 0 {
238            return Err(LoopError::Config("max_iterations must be > 0".to_string()));
239        }
240        Ok(Self { max_iterations })
241    }
242}
243
244// ── journal ───────────────────────────────────────────────────────────────────
245
246/// Append-only event log with a project-authoritative path and a best-effort
247/// global mirror.
248pub struct Journal {
249    /// Project-scoped events file. Writes here are FATAL on failure.
250    project_path: PathBuf,
251    /// Global mirror (`~/.fno/events.jsonl`). Writes here are best-effort.
252    global_path: PathBuf,
253}
254
255impl Journal {
256    /// Create a Journal. Paths are injected via newtypes (F9) to prevent silent
257    /// positional swap of the two same-type `PathBuf` arguments.
258    /// The runtime never hard-codes `~/.fno/`. The driver CLI wires the real
259    /// paths (task 1.2). Tests use tempdir paths.
260    pub fn new(project_path: ProjectJournalPath, global_path: GlobalJournalPath) -> Self {
261        Self {
262            project_path: project_path.0,
263            global_path: global_path.0,
264        }
265    }
266
267    /// Convenience constructor accepting plain `PathBuf`s directly. Intended for
268    /// tests where importing the newtype wrappers would add noise. Production
269    /// callers should prefer `Journal::new` (which enforces distinct types).
270    pub fn new_raw(project_path: PathBuf, global_path: PathBuf) -> Self {
271        Self {
272            project_path,
273            global_path,
274        }
275    }
276
277    /// Append one event line to the project journal (fatal on failure) and
278    /// mirror it to the global file (best-effort).
279    ///
280    /// The envelope shape is:
281    /// `{"ts":"YYYY-MM-DDTHH:MM:SSZ","type":"<kind>","source":"loop","data":{...}}`
282    ///
283    /// Method name is `append` (NOT `emit`/`emit_fields`) so the
284    /// daemon-stream parity scanner in lib.rs (which greps for `.emit(`)
285    /// does not require these kinds to be registered in KNOWN_EVENT_KINDS.
286    /// These are loop-stream events, not daemon-stream events.
287    pub fn append(&self, event_type: &str, data: Value) -> Result<(), LoopError> {
288        let ts = Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
289        let env = json!({
290            "ts": ts,
291            "type": event_type,
292            "source": "loop",
293            "data": data,
294        });
295        let mut line = serde_json::to_string(&env)
296            .map_err(|e| LoopError::Journal(format!("serialize {event_type}: {e}")))?;
297        line.push('\n');
298
299        // Write to project file - FATAL on failure.
300        self.append_to_file(&self.project_path, &line, true)?;
301
302        // Mirror to global file - best-effort (warn, never fatal).
303        if self.project_path != self.global_path {
304            if let Err(e) = self.append_to_file(&self.global_path, &line, false) {
305                eprintln!("loop-runtime: global mirror write failed (non-fatal): {e}");
306            }
307        }
308
309        Ok(())
310    }
311
312    /// Open `path` in append+create mode and write `line`. If `fatal` is true,
313    /// returns `Err(LoopError::Journal)` on failure; otherwise returns `Ok`.
314    fn append_to_file(&self, path: &Path, line: &str, fatal: bool) -> Result<(), LoopError> {
315        // Ensure parent directory exists.
316        if let Some(parent) = path.parent() {
317            if let Err(e) = fs::create_dir_all(parent) {
318                let msg = format!("create_dir_all {}: {e}", parent.display());
319                if fatal {
320                    return Err(LoopError::Journal(msg));
321                } else {
322                    return Err(LoopError::Io(e));
323                }
324            }
325        }
326
327        match fs::OpenOptions::new().create(true).append(true).open(path) {
328            Ok(mut f) => {
329                if let Err(e) = f.write_all(line.as_bytes()) {
330                    let msg = format!("write to {}: {e}", path.display());
331                    if fatal {
332                        return Err(LoopError::Journal(msg));
333                    } else {
334                        return Err(LoopError::Io(e));
335                    }
336                }
337                Ok(())
338            }
339            Err(e) => {
340                let msg = format!("open {}: {e}", path.display());
341                if fatal {
342                    Err(LoopError::Journal(msg))
343                } else {
344                    Err(LoopError::Io(e))
345                }
346            }
347        }
348    }
349
350    /// Scan journals for the LAST termination event matching `session_key`.
351    ///
352    /// ## Search order (ab-7303e5d7: cross-cwd delivery via global mirror)
353    ///
354    /// 1. Scan the project journal first (authoritative for single-cwd target walks).
355    /// 2. When no match is found AND the global path differs from the project path,
356    ///    scan the global journal (`~/.fno/events.jsonl`).
357    ///
358    /// Rationale: worker sessions dispatched by the megawalk walker run `/target`
359    /// in their OWN conductor worktrees, so their termination events land in the
360    /// WORKTREE's events.jsonl.  loopcheck's `emit_to_both` also mirrors them to
361    /// `~/.fno/events.jsonl` (the global path).  The walker's project journal
362    /// lives at the walker's cwd; only the global mirror is shared across cwds.
363    ///
364    /// Returns `None` if not found in either journal or on read errors (fail
365    /// tolerant: unreadable journal = no pre-existing termination).
366    ///
367    /// Uses `BufReader` + `.lines()` to stream files rather than loading them
368    /// entirely; the journal rotates at 8 MB but can grow to that before
369    /// rotation, so streaming avoids a large allocation on hot paths.
370    ///
371    /// Unknown reason strings are treated as no-match (warn to stderr).
372    /// Unreadable lines (I/O errors from `.lines()`) are skipped silently.
373    pub fn find_termination(&self, session_key: &str) -> Result<Option<Evidence>, LoopError> {
374        // Scan project journal first.
375        if let Some(ev) = Self::scan_journal(&self.project_path, session_key) {
376            return Ok(Some(ev));
377        }
378
379        // Fall back to global journal when it differs from the project journal
380        // and no match was found in the project journal.  This handles the
381        // megawalk case where worker termination events land in a different
382        // worktree's journal but are mirrored to the global file.
383        if self.project_path != self.global_path {
384            if let Some(ev) = Self::scan_journal(&self.global_path, session_key) {
385                return Ok(Some(ev));
386            }
387        }
388
389        Ok(None)
390    }
391
392    /// Scan a single journal file for the LAST termination event matching
393    /// `session_key`.  Returns `None` on missing file, read errors, or no
394    /// matching event (all fail-tolerant).
395    fn scan_journal(path: &Path, session_key: &str) -> Option<Evidence> {
396        if !path.exists() {
397            return None;
398        }
399
400        let file = match fs::File::open(path) {
401            Ok(f) => f,
402            Err(e) => {
403                eprintln!(
404                    "loop-runtime: could not read journal {}: {e}",
405                    path.display()
406                );
407                return None;
408            }
409        };
410
411        let mut last_match: Option<Evidence> = None;
412
413        for line_result in BufReader::new(file).lines() {
414            // Unreadable line (I/O error mid-file) -> skip silently.
415            let raw = match line_result {
416                Ok(l) => l,
417                Err(_) => continue,
418            };
419            let line = raw.trim();
420            if line.is_empty() {
421                continue;
422            }
423            // Skip unparseable lines silently.
424            let v: Value = match serde_json::from_str(line) {
425                Ok(v) => v,
426                Err(_) => continue,
427            };
428
429            // Match type == "termination" && data.session_id == session_key.
430            if v["type"].as_str() != Some("termination") {
431                continue;
432            }
433            if v["data"]["session_id"].as_str() != Some(session_key) {
434                continue;
435            }
436
437            let reason_str = match v["data"]["reason"].as_str() {
438                Some(s) => s,
439                None => {
440                    // F1: missing reason field is authoritative-record corruption; warn loudly.
441                    eprintln!(
442                        "loop-runtime: termination event for {session_key} missing 'reason' field, skipping"
443                    );
444                    continue;
445                }
446            };
447            let reason = match parse_termination_reason(reason_str) {
448                Some(r) => r,
449                None => {
450                    // Unknown reason: warn and skip (fail tolerant).
451                    eprintln!(
452                        "loop-runtime: unknown TerminationReason '{reason_str}' in journal, skipping"
453                    );
454                    continue;
455                }
456            };
457            let message = v["data"]["message"].as_str().unwrap_or("").to_string();
458
459            last_match = Some(Evidence { reason, message });
460        }
461
462        last_match
463    }
464}
465
466/// Parse a reason string into TerminationReason. Returns None for unknown values.
467///
468/// F7: uses serde round-trip instead of a hand-written match so that new
469/// variants added in group 2 are picked up automatically without a silent desync.
470fn parse_termination_reason(s: &str) -> Option<TerminationReason> {
471    serde_json::from_value(Value::String(s.to_string())).ok()
472}
473
474// ── outcome types ─────────────────────────────────────────────────────────────
475
476/// The result of closing a single unit (after its session produced a
477/// TerminationReason event).
478pub struct UnitResult {
479    /// The unit's identifier.
480    pub unit_id: String,
481    /// Termination evidence that drove the close decision.
482    pub evidence: Evidence,
483    /// What the queue did when asked to close the unit.
484    pub close: CloseOutcome,
485}
486
487/// The result of a complete walk.
488pub struct LoopOutcome {
489    /// Why the walk stopped (walk-level reason, not per-unit reason).
490    pub reason: TerminationReason,
491    /// Total iterations consumed across all units.
492    pub iterations_used: u64,
493    /// Per-unit results for every unit that reached the close step.
494    pub units: Vec<UnitResult>,
495}
496
497// ── main loop ─────────────────────────────────────────────────────────────────
498
499/// Run a walk over the queue until the queue is empty, the budget is exhausted,
500/// or the cancel sentinel fires.
501///
502/// ## Algorithm
503///
504/// ```text
505/// loop:
506///   check cancel -> Interrupted
507///   unit = queue.next()  -> None: NoWork
508///                        -> Err(LoopError::Pause{policy, detail}):
509///                             journal walk_paused + loop_terminated(NoProgress) -> NoProgress
510///   resume guard: if journal has a termination event for unit.session_key,
511///     close the unit without dispatching, journal node_closed, and continue.
512///   inner dispatch loop:
513///     check budget -> Budget
514///     check cancel -> Interrupted
515///     iterations_used += 1
516///     per_unit_cap check: if unit_dispatches >= cap, synthesize NoProgress park
517///     emit loop_unit_dispatched
518///     run session, wait
519///     if journal has termination event: close unit, journal node_closed, break
520///     else: emit node_failed, continue inner loop (re-dispatch)
521/// ```
522///
523/// ## Journal invariant
524///
525/// Every `journal.append` call for project events is fatal on failure.
526/// An unobservable walk must not continue spending.
527///
528/// ## per_unit_max_dispatches
529///
530/// When `Some(N)`, a unit that accumulates N dispatches without a termination
531/// event is synthesized a `NoProgress` Evidence and parked via `queue.close()`.
532/// The walk continues to the next unit. `None` means no per-unit cap (the
533/// walk-level budget is the only ceiling, as in the original degenerate policy).
534pub fn run_loop(
535    queue: &mut dyn Queue,
536    dispatcher: &dyn Dispatcher,
537    budget: &LoopBudget,
538    journal: &Journal,
539    cancel: &dyn Fn() -> bool,
540    per_unit_max_dispatches: Option<u64>,
541) -> Result<LoopOutcome, LoopError> {
542    let mut iterations_used: u64 = 0;
543    let mut units: Vec<UnitResult> = Vec::new();
544
545    loop {
546        // ── cancel check (outer loop top) ─────────────────────────────────
547        if cancel() {
548            journal.append(
549                "loop_terminated",
550                json!({
551                    "reason": "Interrupted",
552                    "iterations_used": iterations_used,
553                    "units_closed": units.len(),
554                }),
555            )?;
556            return Ok(LoopOutcome {
557                reason: TerminationReason::Interrupted,
558                iterations_used,
559                units,
560            });
561        }
562
563        // ── dequeue next unit ─────────────────────────────────────────────
564        // queue.next() may return:
565        //   Ok(None)  -> backlog empty -> NoWork
566        //   Ok(Some)  -> dispatch this unit
567        //   Err(LoopError::Pause{policy, detail}) -> walk policy pause -> NoProgress
568        //   Err(other) -> hard failure (a real LoopError::Queue now falls here)
569        let unit = match queue.next() {
570            Ok(None) => {
571                journal.append(
572                    "loop_terminated",
573                    json!({
574                        "reason": "NoWork",
575                        "iterations_used": iterations_used,
576                        "units_closed": units.len(),
577                    }),
578                )?;
579                return Ok(LoopOutcome {
580                    reason: TerminationReason::NoWork,
581                    iterations_used,
582                    units,
583                });
584            }
585            Ok(Some(u)) => u,
586            Err(LoopError::Pause { policy, detail }) => {
587                // Walk policy pause: the typed variant carries policy/detail
588                // directly, so there is nothing to parse. A real LoopError::Queue
589                // can no longer reach this arm (it hits the catch-all below).
590                journal.append(
591                    "walk_paused",
592                    json!({
593                        "policy": policy,
594                        "detail": detail,
595                        "iterations_used": iterations_used,
596                        "units_closed": units.len(),
597                    }),
598                )?;
599                journal.append(
600                    "loop_terminated",
601                    json!({
602                        "reason": "NoProgress",
603                        "iterations_used": iterations_used,
604                        "units_closed": units.len(),
605                    }),
606                )?;
607                return Ok(LoopOutcome {
608                    reason: TerminationReason::NoProgress,
609                    iterations_used,
610                    units,
611                });
612            }
613            Err(e) => return Err(e),
614        };
615
616        // ── resume guard (AC1-FR): check for pre-existing termination ─────
617        // If a prior session for this unit already terminated (e.g. the walk
618        // restarted mid-flight), close the unit without dispatching so work
619        // is not duplicated.
620        if let Some(evidence) = journal.find_termination(&unit.session_key)? {
621            let close = queue.close(&unit, &evidence)?;
622            // AC2-UI: journal node_closed for every close path.
623            journal_node_closed(journal, &unit, &evidence, &close, iterations_used)?;
624            units.push(UnitResult {
625                unit_id: unit.id.clone(),
626                evidence,
627                close,
628            });
629            // Do NOT increment iterations_used: no dispatch happened.
630            continue;
631        }
632
633        // ── inner dispatch loop ───────────────────────────────────────────
634        // Re-dispatch until a TerminationReason event appears, the per-unit
635        // cap is hit, or the walk-level budget is exhausted.
636        let mut unit_dispatches: u64 = 0;
637        loop {
638            // Budget check (inner loop top, before dispatch).
639            if iterations_used >= budget.max_iterations {
640                journal.append(
641                    "loop_terminated",
642                    json!({
643                        "reason": "Budget",
644                        "iterations_used": iterations_used,
645                        "units_closed": units.len(),
646                        "axis": "iterations",
647                    }),
648                )?;
649                return Ok(LoopOutcome {
650                    reason: TerminationReason::Budget,
651                    iterations_used,
652                    units,
653                });
654            }
655
656            // Cancel check (inner loop, before dispatch).
657            if cancel() {
658                journal.append(
659                    "loop_terminated",
660                    json!({
661                        "reason": "Interrupted",
662                        "iterations_used": iterations_used,
663                        "units_closed": units.len(),
664                    }),
665                )?;
666                return Ok(LoopOutcome {
667                    reason: TerminationReason::Interrupted,
668                    iterations_used,
669                    units,
670                });
671            }
672
673            // Per-unit dispatch cap: if this unit has been dispatched N times
674            // without a termination event, synthesize a NoProgress park and
675            // continue to the next unit.
676            if let Some(cap) = per_unit_max_dispatches {
677                if unit_dispatches >= cap {
678                    let evidence = Evidence {
679                        reason: TerminationReason::NoProgress,
680                        message: format!(
681                            "no termination event after {cap} dispatch(es); unit parked"
682                        ),
683                    };
684                    let close = queue.close(&unit, &evidence)?;
685                    journal_node_closed(journal, &unit, &evidence, &close, iterations_used)?;
686                    units.push(UnitResult {
687                        unit_id: unit.id.clone(),
688                        evidence,
689                        close,
690                    });
691                    break; // Break inner loop -> continue outer loop (next unit).
692                }
693            }
694
695            iterations_used += 1;
696            unit_dispatches += 1;
697
698            // Emit loop_unit_dispatched before running the session.
699            journal.append(
700                "loop_unit_dispatched",
701                json!({
702                    "unit_id": unit.id,
703                    "session_id": unit.session_key,
704                    "iteration": iterations_used,
705                    "title": unit.title,
706                }),
707            )?;
708
709            // Launch and wait for the session.
710            let mut session = dispatcher
711                .run(
712                    &unit,
713                    &DispatchCtx {
714                        iteration: iterations_used,
715                    },
716                )
717                .map_err(|e| LoopError::Dispatch(e.to_string()))?;
718            let exit_code = session.wait()?;
719
720            // Check whether the session produced a termination event.
721            if let Some(evidence) = journal.find_termination(&unit.session_key)? {
722                let close = queue.close(&unit, &evidence)?;
723                // AC2-UI: journal node_closed for every close path.
724                journal_node_closed(journal, &unit, &evidence, &close, iterations_used)?;
725                units.push(UnitResult {
726                    unit_id: unit.id.clone(),
727                    evidence,
728                    close,
729                });
730                break; // Break inner loop -> continue outer loop (next unit).
731            }
732
733            // No termination event: emit node_failed (watchdog synthesis) and
734            // re-dispatch in the next inner iteration.
735            journal.append(
736                "node_failed",
737                json!({
738                    "unit_id": unit.id,
739                    "session_id": unit.session_key,
740                    "iteration": iterations_used,
741                    "exit_code": exit_code,
742                }),
743            )?;
744        }
745    }
746}
747
748/// Journal a `node_closed` loop event after every queue.close() call.
749///
750/// Fields: unit_id, session_id, reason (evidence reason string), close
751/// ("closed"|"parked"|"refused"), detail (Parked/Refused string, "" for Closed),
752/// iterations_used (walk iteration count at close time).
753///
754/// The TUI and progress-line consumers read this event to track per-unit
755/// close outcomes. It is emitted on EVERY close path: resume guard, normal
756/// termination, per-unit cap park, and (in megawalk) consecutive-failure park.
757fn journal_node_closed(
758    journal: &Journal,
759    unit: &Unit,
760    evidence: &Evidence,
761    close: &CloseOutcome,
762    iterations_used: u64,
763) -> Result<(), LoopError> {
764    let (close_str, detail) = match close {
765        CloseOutcome::Closed => ("closed", String::new()),
766        CloseOutcome::Parked(s) => ("parked", s.clone()),
767        CloseOutcome::Refused(s) => ("refused", s.clone()),
768    };
769    let reason_str = format!("{:?}", evidence.reason);
770    journal.append(
771        "node_closed",
772        json!({
773            "unit_id": unit.id,
774            "session_id": unit.session_key,
775            "reason": reason_str,
776            "close": close_str,
777            "detail": detail,
778            "iterations_used": iterations_used,
779        }),
780    )
781}