fno-agents 0.3.1

PTY supervisor substrate for persistent, attachable multi-CLI coding agents (codex, gemini, claude)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
//! Target driver: TargetQueue + the `loop run` CLI verb.
//!
//! ## Degenerate walk (one unit, re-dispatch until terminal event)
//!
//! A target session is a single unit of work: the active `target-state.md`
//! manifest identifies the session. The walk loop is degenerate:
//!
//! ```text
//! queue.next() -> Some(unit for session_id)
//! inner dispatch loop:
//!   run driver_invoke, wait
//!   if termination event in journal -> close unit, break
//!   else -> node_failed, re-dispatch
//! queue.next() -> None (closed) -> NoWork -> exit 0
//! ```
//!
//! The outer loop terminates with `NoWork` after the single unit is closed.
//! The CLI maps `NoWork` from a single-unit walk to exit 0 and reports the
//! unit's own termination reason as the headline (it is the news; the walk-level
//! `NoWork` is plumbing).
//!
//! ## Why TargetQueue::close() is inert
//!
//! The target session's own loop-check stop hook already emitted the
//! `termination` event before `close()` is called. The manifest is immutable
//! (invariant from ab-d0337fbc). Closing the backlog graph node and stamping the
//! plan belong to `reconcile` and `stamp-plan` respectively; calling them here
//! would duplicate work and couple the loop runtime to concerns it must not own.
//! The active-backlog daemon (the keep-set) is where `fno backlog done` runs.

use crate::loop_dispatch::{
    driver_default_max, preflight, resolve_driver_binary, ShelloutDispatcher,
};
use crate::loop_runtime::{
    run_loop, CloseOutcome, Evidence, GlobalJournalPath, Journal, LoopBudget, LoopError,
    ProjectJournalPath, Queue, Unit,
};
use crate::loopcheck::TerminationReason;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};

// ── SIGINT handler ────────────────────────────────────────────────────────────

pub(crate) static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);

// SAFETY: signal handler touches only an AtomicBool. No allocations, no locks.
extern "C" fn handle_sigint(_: libc::c_int) {
    SIGINT_RECEIVED.store(true, Ordering::SeqCst);
}

/// Install the SIGINT handler. The child process group receives SIGINT
/// naturally (foreground process group); after the child exits, `cancel()`
/// returns true and the loop terminates with Interrupted.
pub(crate) fn install_sigint_handler() {
    // SAFETY: handle_sigint is async-signal-safe (one atomic store).
    unsafe {
        libc::signal(
            libc::SIGINT,
            handle_sigint as *const () as libc::sighandler_t,
        );
    }
}

// ── TargetQueue ───────────────────────────────────────────────────────────────

/// Parsed fields from target-state.md frontmatter needed by the loop runtime.
pub(crate) struct TargetManifest {
    pub(crate) session_id: String,
    pub(crate) input: String,
    pub(crate) harness_session_id: Option<String>,
    plan_path: String,
}

/// Parse the minimal frontmatter fields needed by TargetQueue.
/// Style mirrors loopcheck.rs `parse_manifest` but is a local copy per the spec:
/// "write your own tiny local parser - do NOT modify loopcheck.rs".
pub(crate) fn parse_target_manifest(content: &str) -> Option<TargetManifest> {
    let content = content.trim_start();
    if !content.starts_with("---") {
        return None;
    }
    let after_first = &content[3..];
    let end = after_first.find("\n---")?;
    let body = &after_first[..end];

    // fno_id is canonical; session_id is the one-release legacy fallback.
    let mut fno_id = String::new();
    let mut session_id = String::new();
    let mut input = String::new();
    let mut harness_session_id = String::new();
    let mut plan_path = String::new();

    for line in body.lines() {
        let line = line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        if let Some((k, v)) = line.split_once(':') {
            let k = k.trim();
            // Strip surrounding quotes from values.
            let v = v.trim().trim_matches(|c: char| c == '"' || c == '\'');
            match k {
                "fno_id" => fno_id = v.to_string(),
                "session_id" => session_id = v.to_string(),
                "input" => input = v.to_string(),
                "harness_session_id" => harness_session_id = v.to_string(),
                "plan_path" => plan_path = v.to_string(),
                _ => {}
            }
        }
    }

    let session_id = if fno_id.is_empty() {
        session_id
    } else {
        fno_id
    };
    if session_id.is_empty() {
        return None;
    }
    Some(TargetManifest {
        session_id,
        input,
        harness_session_id: (!harness_session_id.is_empty() && harness_session_id != "null")
            .then_some(harness_session_id),
        plan_path,
    })
}

/// A degenerate queue containing exactly one unit: the active target session.
///
/// `next()` returns the unit on the first call. After `close()` is called, or
/// after the unit has been returned, subsequent `next()` calls return `None`.
///
/// `close()` is intentionally inert: the session's own stop hook already
/// emitted the termination event; the manifest is immutable; graph-node closing
/// and plan-stamping belong to `reconcile` / `stamp-plan`, not the loop runtime.
/// The active-backlog daemon is where `fno backlog done` runs.
/// See module documentation for why `close()` is inert.
///
/// ## Why Option<Unit> (not Mutex) (F8)
///
/// Queue::next/close take `&mut self` so no Mutex is needed here. The walk
/// loop is single-threaded; interior mutability would add noise without benefit.
pub struct TargetQueue {
    unit: Option<Unit>,
}

impl TargetQueue {
    /// Read `.fno/target-state.md` from `repo_root` and construct the queue.
    pub fn from_manifest(repo_root: &Path) -> Result<Self, LoopError> {
        let manifest_path = repo_root.join(".fno").join("target-state.md");
        if !manifest_path.exists() {
            return Err(LoopError::Queue(format!(
                "No state file found at .fno/target-state.md - run /target first to initialize (looked in: {})",
                manifest_path.display()
            )));
        }
        let content = fs::read_to_string(&manifest_path).map_err(LoopError::Io)?;
        let manifest = parse_target_manifest(&content).ok_or_else(|| {
            LoopError::Queue(
                "No state file found at .fno/target-state.md - run /target first to initialize (manifest missing required fields)".to_string()
            )
        })?;

        let unit = Unit {
            id: manifest.session_id.clone(),
            title: manifest.input.clone(),
            session_key: manifest.session_id,
            plan_path: if manifest.plan_path.is_empty() {
                None
            } else {
                Some(manifest.plan_path)
            },
            extra_env: vec![],
        };
        Ok(Self { unit: Some(unit) })
    }
}

impl Queue for TargetQueue {
    fn next(&mut self) -> Result<Option<Unit>, LoopError> {
        Ok(self.unit.take())
    }

    /// Inert close: see module doc for why this does nothing.
    ///
    /// The session's loop-check stop hook already emitted the termination event.
    /// The manifest is immutable (ab-d0337fbc invariant). Graph-node closing and
    /// plan-stamping belong to reconcile / stamp-plan, not the loop runtime.
    /// The active-backlog daemon is where `fno backlog done` runs.
    fn close(&mut self, _unit: &Unit, _evidence: &Evidence) -> Result<CloseOutcome, LoopError> {
        Ok(CloseOutcome::Closed)
    }
}

// ── exit-code mapping ─────────────────────────────────────────────────────────

/// Map a LoopOutcome walk reason to a process exit code.
///
/// DonePRGreen | DoneAdvisory | DoneDelivery | NoWork -> 0  (success)
/// Budget | NoProgress | Aborted       -> 1  (failed / budget)
/// Interrupted                         -> 130 (SIGINT convention)
///
/// For the degenerate single-unit walk, NoWork is reported after the unit closes
/// with a terminal reason. The headline exit code is derived from the unit's OWN
/// evidence reason, not the walk-level NoWork, so the caller sees the actual
/// outcome (DonePRGreen -> 0, Budget -> 1, etc.).
pub(crate) fn exit_code_for_reason(reason: &TerminationReason) -> i32 {
    match reason {
        TerminationReason::DonePRGreen
        | TerminationReason::DoneAdvisory
        | TerminationReason::DoneDelivery
        | TerminationReason::DoneBatched
        // DoneAwaitingMerge: work complete, human-merge-gated past proven main
        // red - a clean stop like the other Done* terminals. The reason string
        // (not the exit code) is what a wrapper reads to distinguish it.
        | TerminationReason::DoneAwaitingMerge
        // DonePlanned: a plan-only thread finished cleanly. Not a delivery, but a
        // clean stop (exit 0); the reason string distinguishes it from a ship.
        | TerminationReason::DonePlanned
        | TerminationReason::NoWork => 0,
        TerminationReason::Budget | TerminationReason::NoProgress | TerminationReason::Aborted => 1,
        TerminationReason::Interrupted => 130,
    }
}

// ── CLI verb ──────────────────────────────────────────────────────────────────

/// Entry point for `fno-agents loop run ...`.
///
/// Usage:
/// ```text
/// fno-agents loop run
///   --driver target
///   [--dispatcher claude-code|hermes|openclaw]
///   [--max-iterations N]
///   [--max-turns N]
///   [--budget N]
///   [--model NAME]
///   [--prompt-file PATH]
///   [--cli claude|opencode]
///   [--driver-lib-dir DIR]
///   [--cwd DIR]
/// ```
///
/// Exit codes:
/// - 0: DonePRGreen | DoneAdvisory | DoneDelivery | NoWork (unit terminated successfully)
/// - 1: Budget | NoProgress | Aborted (walk failed or hit ceiling)
/// - 2: usage error / internal error
/// - 77: driver binary missing from PATH (preflight failure)
/// - 130: Interrupted (SIGINT)
pub fn run_loop_verb(args: &[String]) -> i32 {
    match run_loop_verb_inner(args) {
        Ok(code) => code,
        Err(e) => {
            eprintln!("fno-agents loop: {e}");
            2
        }
    }
}

fn run_loop_verb_inner(args: &[String]) -> Result<i32, Box<dyn std::error::Error>> {
    // ── subcommand check ──────────────────────────────────────────────────────
    let subcommand = args.first().map(|s| s.as_str()).unwrap_or("");
    if subcommand != "run" {
        eprintln!("fno-agents loop: expected subcommand 'run', got '{subcommand}'");
        eprintln!("Usage: fno-agents loop run --driver <name> [options]");
        return Ok(2);
    }
    let args = &args[1..]; // skip "run"

    // ── flag parsing ──────────────────────────────────────────────────────────
    let mut driver: Option<String> = None;
    let mut dispatcher_name = "claude-code".to_string();
    let mut max_iterations: Option<u64> = None;
    let mut max_turns: u64 = 15;
    let mut budget_usd: f64 = 25.0;
    let mut model: Option<String> = None;
    let mut prompt_file: Option<String> = None;
    let mut cli_alias: Option<String> = None;
    let mut driver_lib_dir: Option<PathBuf> = None;
    let mut cwd: PathBuf = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));

    // Helper: advance i and return the next argument, or emit a "missing value"
    // usage error (exit 2) if the flag is trailing with no following value.
    // Using a macro (not a closure) to allow `return Ok(2)` from the outer fn.
    macro_rules! require_value {
        ($flag:expr, $args:expr, $i:expr) => {{
            $i += 1;
            match $args.get($i) {
                Some(v) => v.as_str(),
                None => {
                    eprintln!("fno-agents loop run: {}: missing value", $flag);
                    return Ok(2);
                }
            }
        }};
    }

    let mut i = 0;
    while i < args.len() {
        let flag = args[i].as_str();
        match flag {
            "--driver" => {
                driver = Some(require_value!("--driver", args, i).to_string());
            }
            "--dispatcher" => {
                dispatcher_name = require_value!("--dispatcher", args, i).to_string();
            }
            "--max-iterations" => {
                let v = require_value!("--max-iterations", args, i);
                max_iterations = Some(
                    v.parse::<u64>()
                        .map_err(|_| format!("--max-iterations: expected integer, got '{v}'"))?,
                );
            }
            "--max-turns" => {
                let v = require_value!("--max-turns", args, i);
                max_turns = v
                    .parse::<u64>()
                    .map_err(|_| format!("--max-turns: expected integer, got '{v}'"))?;
            }
            "--budget" => {
                let v = require_value!("--budget", args, i);
                let parsed = v
                    .parse::<f64>()
                    .map_err(|_| format!("--budget: expected number, got '{v}'"))?;
                // F3: reject zero/negative/NaN budget per plan Failure Mode.
                if !parsed.is_finite() || parsed <= 0.0 {
                    eprintln!(
                        "fno-agents loop run: --budget must be a positive number, got '{v}' ({parsed})"
                    );
                    return Ok(2);
                }
                budget_usd = parsed;
            }
            "--model" => {
                model = Some(require_value!("--model", args, i).to_string());
            }
            "--prompt-file" => {
                prompt_file = Some(require_value!("--prompt-file", args, i).to_string());
            }
            "--cli" => {
                cli_alias = Some(require_value!("--cli", args, i).to_string());
            }
            "--driver-lib-dir" => {
                driver_lib_dir = Some(PathBuf::from(require_value!("--driver-lib-dir", args, i)));
            }
            "--cwd" => {
                cwd = PathBuf::from(require_value!("--cwd", args, i));
            }
            _ => {
                eprintln!("fno-agents loop run: unknown flag '{flag}'");
                return Ok(2);
            }
        }
        i += 1;
    }

    // ── driver validation ─────────────────────────────────────────────────────
    match driver.as_deref() {
        None => {
            eprintln!("fno-agents loop run: --driver is required");
            eprintln!("Usage: fno-agents loop run --driver target [options]");
            return Ok(2);
        }
        Some("target") => {}
        Some(other) => {
            eprintln!("fno-agents loop run: unknown --driver '{other}'; supported: 'target'");
            return Ok(2);
        }
    }

    // ── resolve driver-lib-dir ────────────────────────────────────────────────
    let lib_dir = match driver_lib_dir {
        Some(d) => d,
        None => {
            // Try FNO_DRIVER_LIB_DIR env, then <cwd>/scripts/lib.
            if let Ok(env_dir) = std::env::var("FNO_DRIVER_LIB_DIR") {
                PathBuf::from(env_dir)
            } else {
                let candidate = cwd.join("scripts").join("lib");
                if candidate.is_dir() {
                    candidate
                } else {
                    eprintln!(
                        "fno-agents loop run: cannot resolve driver lib directory. \
                         Pass --driver-lib-dir <path> (the fno plugin's \
                         scripts/lib directory) or set FNO_DRIVER_LIB_DIR env."
                    );
                    return Ok(2);
                }
            }
        }
    };

    // ── preflight (all before any dispatch) ───────────────────────────────────
    // 1. Manifest exists (exit 1 on missing).
    let mut queue = match TargetQueue::from_manifest(&cwd) {
        Ok(q) => q,
        Err(e) => {
            eprintln!("fno-agents loop run: {e}");
            return Ok(1);
        }
    };

    // 2. Driver whitelist + lib file + binary (exit 77 on missing binary).
    // F2: pass cli_alias so preflight checks the same binary the dispatcher will use.
    let lib_path = match preflight(&dispatcher_name, &lib_dir, cli_alias.as_deref()) {
        Ok(p) => p,
        Err(LoopError::Dispatch(msg)) => {
            // Binary missing.
            eprintln!("fno-agents loop run: {msg}");
            return Ok(77);
        }
        Err(e) => {
            eprintln!("fno-agents loop run: {e}");
            return Ok(2);
        }
    };

    // ── resolve max_iterations ────────────────────────────────────────────────
    let max_iters = match max_iterations {
        Some(n) => n,
        None => match driver_default_max(&lib_path) {
            Ok(n) => n,
            Err(e) => {
                eprintln!(
                    "fno-agents loop run: could not query driver_default_max: {e}; \
                     pass --max-iterations explicitly"
                );
                return Ok(2);
            }
        },
    };

    // ── build static env for the dispatcher ──────────────────────────────────
    // Mirrors run-target-loop.sh:36-40.
    let fno_dir = cwd.join(".fno");
    let output_file = fno_dir.join("target-last-output.txt");
    let history_file = fno_dir.join("target-history.txt");
    let signal_file = fno_dir.join("target-promise.signal");

    let mut env: Vec<(String, String)> = vec![
        (
            "OUTPUT_FILE".to_string(),
            output_file.to_str().unwrap_or("").to_string(),
        ),
        (
            "HISTORY_FILE".to_string(),
            history_file.to_str().unwrap_or("").to_string(),
        ),
        (
            "SIGNAL_FILE".to_string(),
            signal_file.to_str().unwrap_or("").to_string(),
        ),
        ("MAX_TURNS".to_string(), max_turns.to_string()),
        ("BUDGET_USD".to_string(), format!("{budget_usd}")),
        (
            "CONTINUE_PROMPT".to_string(),
            "/target --resume".to_string(),
        ),
    ];

    if let Some(m) = &model {
        env.push(("MODEL_FLAG".to_string(), format!("--model {m}")));
    } else {
        env.push(("MODEL_FLAG".to_string(), String::new()));
    }

    if let Some(pf) = &prompt_file {
        env.push(("PROMPT_FILE".to_string(), pf.clone()));
    }

    if let Some(cli) = &cli_alias {
        env.push(("CLI".to_string(), cli.clone()));
    }

    // Pass FNO_CWD so driver stubs and real drivers can resolve paths.
    env.push((
        "FNO_CWD".to_string(),
        cwd.to_str().unwrap_or(".").to_string(),
    ));

    // ── SIGINT handler ────────────────────────────────────────────────────────
    install_sigint_handler();

    // ── build journal ─────────────────────────────────────────────────────────
    let project_events = fno_dir.join("events.jsonl");
    let home_dir = std::env::var("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from("/tmp"));
    let global_events = home_dir.join(".fno").join("events.jsonl");
    let journal = Journal::new(
        ProjectJournalPath(project_events),
        GlobalJournalPath(global_events),
    );

    // ── peek at the first unit for the header (F6: no TOCTOU re-read) ────────
    // Read session_id/input from the already-constructed queue instead of
    // re-reading the manifest (which avoids the TOCTOU double-read and the
    // .unwrap().unwrap() panic path).
    let (session_id_display, input_display) = {
        // Peek without consuming: TargetQueue stores Option<Unit>, so we look
        // at the inner unit via as_ref without taking it.
        match queue.unit.as_ref() {
            Some(u) => (u.id.clone(), u.title.clone()),
            None => ("(none)".to_string(), "(none)".to_string()),
        }
    };

    // Print header. resolve_driver_binary now reflects the cli_alias (F2).
    let binary_name = resolve_driver_binary(&dispatcher_name, cli_alias.as_deref());
    println!("fno-agents loop run");
    println!("  driver:     target");
    println!("  dispatcher: {dispatcher_name} (binary: {binary_name})");
    println!("  session:    {session_id_display}");
    println!("  input:      {input_display}");
    println!("  iterations: {max_iters} max");
    println!("  budget:     ${budget_usd} USD");

    // ── build dispatcher ──────────────────────────────────────────────────────
    let dispatcher = ShelloutDispatcher::new(lib_path, env, cwd.clone());

    // ── build budget ──────────────────────────────────────────────────────────
    let budget = match LoopBudget::new(max_iters) {
        Ok(b) => b,
        Err(e) => {
            eprintln!("fno-agents loop run: {e}");
            return Ok(2);
        }
    };

    // ── cancel closure ────────────────────────────────────────────────────────
    let cancel_file = cwd.join(".fno").join(".target-cancelled");
    let cancel = move || SIGINT_RECEIVED.load(Ordering::SeqCst) || cancel_file.exists();

    // ── run the loop ──────────────────────────────────────────────────────────
    let outcome = match run_loop(&mut queue, &dispatcher, &budget, &journal, &cancel, None) {
        Ok(o) => o,
        Err(e) => {
            eprintln!("fno-agents loop run: fatal loop error: {e}");
            return Ok(2);
        }
    };

    // ── report outcome ────────────────────────────────────────────────────────
    // For the degenerate single-unit walk, report the unit's evidence reason as
    // the headline; the walk-level NoWork is plumbing, not news.
    let (headline_reason, exit_code) = if let Some(unit_result) = outcome.units.first() {
        let r = &unit_result.evidence.reason;
        let code = exit_code_for_reason(r);
        (format!("{r:?}"), code)
    } else {
        // No units closed (Budget/Interrupted at walk level before close).
        let code = exit_code_for_reason(&outcome.reason);
        (format!("{:?}", outcome.reason), code)
    };

    println!(
        "loop: {} ({} iterations used)",
        headline_reason, outcome.iterations_used
    );

    // Emit a summary line for each unit.
    for unit_result in &outcome.units {
        println!(
            "  unit {}: {:?} ({:?})",
            unit_result.unit_id, unit_result.evidence.reason, unit_result.close
        );
    }

    Ok(exit_code)
}