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}