use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const BIN: &str = env!("CARGO_BIN_EXE_fno-agents");
struct Env {
_tmp: TempDir,
cwd: PathBuf,
pypath: PathBuf,
state: PathBuf,
events: PathBuf,
global_events: PathBuf,
handoffs: PathBuf,
postmortems: PathBuf,
calls_log: PathBuf,
}
fn setup(session_id: &str, register_fails: bool) -> Env {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let cwd = root.join("proj");
let pypath = root.join("pypath");
let handoffs = root.join("handoffs");
let postmortems = root.join("postmortems");
fs::create_dir_all(cwd.join(".fno")).unwrap();
fs::create_dir_all(pypath.join("fno/plan")).unwrap();
fs::create_dir_all(pypath.join("fno/cost")).unwrap();
fs::create_dir_all(&handoffs).unwrap();
let calls_log = cwd.join("calls.log");
let state = cwd.join(".fno/target-state.md");
fs::write(
&state,
format!(
"---\n\
session_id: {session_id}\n\
created_at: 2026-06-07T00:00:00Z\n\
input: \"ab-test feature\"\n\
plan_path: \"plan.md\"\n\
provider: claude\n\
claude_transcript_id: tid-{session_id}\n\
---\n\
# Target Session State\n\
graph_node_id: ab-testnode\n"
),
)
.unwrap();
fs::write(cwd.join("plan.md"), "---\nstatus: ready\n---\n").unwrap();
fs::write(pypath.join("fno/__init__.py"), "").unwrap();
fs::write(pypath.join("fno/plan/__init__.py"), "").unwrap();
fs::write(pypath.join("fno/cost/__init__.py"), "").unwrap();
fs::write(
pypath.join("fno/cost/_session_cost.py"),
"import sys, json, os\n\
open('calls.log','a').write('session-cost\\n')\n\
print(json.dumps({'cost_usd': 1.23, 'tokens': {'total': 100, 'cache_read': 10}, 'duration_minutes': 5.0, 'primary_model': 'claude-opus', 'compactions': 0}))\n",
)
.unwrap();
let reg = if register_fails {
"import sys\n\
open('calls.log','a').write('register-task FAIL\\n')\n\
sys.exit(1)\n"
} else {
"import sys\n\
tr = ''\n\
if '--termination-reason' in sys.argv:\n\
\x20 tr = sys.argv[sys.argv.index('--termination-reason')+1]\n\
cj = '--cost-json' in sys.argv\n\
open('calls.log','a').write('register-task reason=%s costjson=%s\\n' % (tr, cj))\n"
};
fs::write(pypath.join("fno/cost/_register.py"), reg).unwrap();
fs::write(
pypath.join("fno/plan/_stamp.py"),
"import sys\n\
sub = sys.argv[1] if len(sys.argv) > 1 else '?'\n\
open('calls.log','a').write('stamp-plan %s\\n' % sub)\n",
)
.unwrap();
fs::write(
pypath.join("fno/verify_advise.py"),
"import sys\n\
open('calls.log','a').write('verify-advise %s\\n' % ' '.join(sys.argv[1:]))\n",
)
.unwrap();
Env {
_tmp: tmp,
cwd,
pypath,
state,
events: root.join("proj/.fno/events.jsonl"),
global_events: root.join("global-events.jsonl"),
handoffs,
postmortems,
calls_log,
}
}
fn run_finalize(env: &Env, reason: &str) -> std::process::Output {
Command::new(BIN)
.arg("finalize")
.arg("--state")
.arg(&env.state)
.arg("--cwd")
.arg(&env.cwd)
.arg("--reason")
.arg(reason)
.arg("--events")
.arg(&env.events)
.arg("--global-events")
.arg(&env.global_events)
.arg("--handoffs-dir")
.arg(&env.handoffs)
.arg("--postmortems-dir")
.arg(&env.postmortems)
.env("PYTHONPATH", &env.pypath)
.current_dir(&env.cwd)
.output()
.expect("run finalize")
}
fn calls(env: &Env) -> String {
fs::read_to_string(&env.calls_log).unwrap_or_default()
}
fn events_text(p: &Path) -> String {
fs::read_to_string(p).unwrap_or_default()
}
fn count_event(p: &Path, kind: &str, session_id: &str) -> usize {
events_text(p)
.lines()
.filter(|l| {
serde_json::from_str::<serde_json::Value>(l)
.ok()
.map(|v| {
v.get("type").and_then(|t| t.as_str()) == Some(kind)
&& v.pointer("/data/session_id").and_then(|s| s.as_str())
== Some(session_id)
})
.unwrap_or(false)
})
.count()
}
fn handoff_files(env: &Env) -> Vec<PathBuf> {
fs::read_dir(&env.handoffs)
.map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
.unwrap_or_default()
}
fn postmortem_files(env: &Env) -> Vec<PathBuf> {
fs::read_dir(&env.postmortems)
.map(|rd| rd.filter_map(|e| e.ok().map(|e| e.path())).collect())
.unwrap_or_default()
}
#[test]
fn finalize_ledger_every_exit() {
let env = setup("S-budget", false);
let out = run_finalize(&env, "Budget");
assert!(out.status.success(), "finalize must exit 0");
let c = calls(&env);
assert!(
c.contains("register-task reason=Budget"),
"ledger record must fire: {c}"
);
assert!(
!c.contains("stamp-plan"),
"non-ship reason must NOT stamp: {c}"
);
assert!(
!c.contains("verify-advise"),
"non-ship reason must NOT run the verifier advisory: {c}"
);
assert!(
handoff_files(&env).is_empty(),
"non-ship reason must NOT write a handoff"
);
let pms = postmortem_files(&env);
assert_eq!(pms.len(), 1, "Budget terminal must write one postmortem");
let pm = fs::read_to_string(&pms[0]).unwrap();
assert!(
pm.contains("termination: **Budget**"),
"postmortem names the reason: {pm}"
);
assert!(pm.contains("ab-testnode"), "postmortem names the node");
assert_eq!(count_event(&env.events, "session_finalized", "S-budget"), 1);
assert_eq!(
count_event(&env.global_events, "session_finalized", "S-budget"),
1
);
}
#[test]
fn finalize_no_postmortem_on_ship_or_benign() {
let ship = setup("S-noprm-ship", false);
assert!(run_finalize(&ship, "DonePRGreen").status.success());
assert!(
postmortem_files(&ship).is_empty(),
"ship reason must NOT write a postmortem"
);
let benign = setup("S-noprm-nowork", false);
assert!(run_finalize(&benign, "NoWork").status.success());
assert!(
postmortem_files(&benign).is_empty(),
"NoWork is benign, must NOT write a postmortem"
);
}
#[test]
fn finalize_postmortem_on_interrupted_or_aborted() {
for (sid, reason) in [("S-interrupted", "Interrupted"), ("S-aborted", "Aborted")] {
let env = setup(sid, false);
assert!(run_finalize(&env, reason).status.success());
let pms = postmortem_files(&env);
assert_eq!(pms.len(), 1, "{reason} terminal must write one postmortem");
let pm = fs::read_to_string(&pms[0]).unwrap();
assert!(
pm.contains(&format!("termination: **{reason}**")),
"postmortem names the reason: {pm}"
);
}
}
#[test]
fn finalize_ship_gated() {
let env = setup("S-ship", false);
let out = run_finalize(&env, "DonePRGreen");
assert!(out.status.success());
let c = calls(&env);
assert!(
c.contains("register-task reason=DonePRGreen"),
"ledger: {c}"
);
assert!(c.contains("stamp-plan stamp"), "stamp must fire: {c}");
assert!(
!c.contains("stamp-plan graduate"),
"graduate must NOT fire at ship (done = merged): {c}"
);
let adv = c
.lines()
.find(|l| l.starts_with("verify-advise"))
.expect("ship fire runs verify_advise");
for want in [
"--node-id ab-testnode",
"--session-id S-ship",
"--reason DonePRGreen",
"--plan-path plan.md",
"--events",
"--global-events",
] {
assert!(
adv.contains(want),
"verify-advise argv missing {want}: {adv}"
);
}
assert_eq!(handoff_files(&env).len(), 1, "exactly one handoff artifact");
let handoff = fs::read_to_string(&handoff_files(&env)[0]).unwrap();
assert!(
handoff.contains("ab-testnode"),
"handoff names the node: {handoff}"
);
assert!(handoff.contains("S-ship"), "handoff names the session");
assert_eq!(count_event(&env.events, "session_finalized", "S-ship"), 1);
}
#[test]
fn finalize_advisory_ship_graduates() {
let env = setup("S-adv", false);
let out = run_finalize(&env, "DoneAdvisory");
assert!(out.status.success());
let c = calls(&env);
assert!(c.contains("stamp-plan stamp"), "advisory ship stamps: {c}");
assert!(
c.contains("stamp-plan graduate"),
"advisory ship graduates to done (no merge event to flip it): {c}"
);
}
#[test]
fn finalize_idempotent_across_refires() {
let env = setup("S-idem", false);
for _ in 0..4 {
let out = run_finalize(&env, "DonePRGreen");
assert!(out.status.success());
}
let c = calls(&env);
assert_eq!(
c.matches("register-task").count(),
1,
"exactly one ledger call: {c}"
);
assert_eq!(
c.matches("stamp-plan stamp").count(),
1,
"exactly one stamp: {c}"
);
assert_eq!(handoff_files(&env).len(), 1, "exactly one handoff");
assert_eq!(count_event(&env.events, "session_finalized", "S-idem"), 1);
}
#[test]
fn finalize_nonfatal_partial_failure() {
let env = setup("S-fail", true); let out = run_finalize(&env, "DonePRGreen");
assert!(
out.status.success(),
"side-effect failure must NOT raise exit code"
);
let c = calls(&env);
assert!(
c.contains("register-task FAIL"),
"ledger was attempted: {c}"
);
assert!(
c.contains("stamp-plan stamp"),
"stamp still runs after ledger failure: {c}"
);
assert_eq!(
count_event(&env.events, "session_finalize_failed", "S-fail"),
1,
"a failure event is emitted"
);
assert_eq!(
count_event(&env.events, "session_finalized", "S-fail"),
0,
"session_finalized NOT emitted on partial failure (so a re-fire retries)"
);
let txt = events_text(&env.events);
assert!(
txt.contains("\"ledger\""),
"failed_steps names ledger: {txt}"
);
}
#[test]
fn finalize_missing_manifest_is_noop() {
let env = setup("S-gone", false);
fs::remove_file(&env.state).unwrap();
let out = run_finalize(&env, "DonePRGreen");
assert!(out.status.success());
assert!(
!env.calls_log.exists() || calls(&env).is_empty(),
"no scripts run"
);
assert!(
events_text(&env.events).is_empty(),
"no events on missing manifest"
);
}
#[test]
fn finalize_three_sessions_one_node() {
for (sid, reason) in [
("node-shipped", "DonePRGreen"),
("node-delegated", "delegated"),
("node-budget", "Budget"),
] {
let env = setup(sid, false);
let out = run_finalize(&env, reason);
assert!(out.status.success());
let c = calls(&env);
assert!(
c.contains(&format!("register-task reason={reason}")),
"session {sid} records reason={reason}: {c}"
);
if reason == "DonePRGreen" {
assert!(c.contains("stamp-plan stamp"), "shipped session stamps");
} else {
assert!(!c.contains("stamp-plan"), "{sid} ({reason}) must not stamp");
}
}
}
#[test]
fn finalize_delegated_is_ledger_only() {
let env = setup("S-deleg", false);
let out = run_finalize(&env, "delegated");
assert!(out.status.success());
let c = calls(&env);
assert!(
c.contains("register-task reason=delegated"),
"ledger row: {c}"
);
assert!(
!c.contains("stamp-plan"),
"delegated must not stamp/graduate: {c}"
);
assert!(
handoff_files(&env).is_empty(),
"delegated must not write a handoff"
);
assert_eq!(count_event(&env.events, "session_finalized", "S-deleg"), 1);
}
#[test]
fn finalize_nonship_then_ship_runs_ship_sideeffects() {
let env = setup("S-recover", false);
assert!(run_finalize(&env, "Budget").status.success()); assert!(run_finalize(&env, "DonePRGreen").status.success()); let c = calls(&env);
assert_eq!(
c.matches("register-task").count(),
1,
"ledger written once: {c}"
);
assert!(
c.contains("stamp-plan stamp"),
"ship fire must stamp after a non-ship terminal: {c}"
);
assert!(
!c.contains("stamp-plan graduate"),
"ship fire stamps only; done = merged, no graduate (x-f34f): {c}"
);
assert_eq!(handoff_files(&env).len(), 1, "ship fire writes the handoff");
assert_eq!(
count_event(&env.events, "session_finalized", "S-recover"),
2,
"two finalized events: ship:false then ship:true"
);
assert!(run_finalize(&env, "DonePRGreen").status.success());
assert_eq!(
calls(&env).matches("stamp-plan stamp").count(),
1,
"stamp ran exactly once across all fires"
);
}
#[test]
fn finalize_verify_advise_failure_never_wedges() {
let env = setup("S-advfail", false);
fs::write(
env.pypath.join("fno/verify_advise.py"),
"import sys\n\
open('calls.log','a').write('verify-advise FAIL\\n')\n\
sys.stderr.write('advisory exploded')\n\
sys.exit(1)\n",
)
.unwrap();
let out = run_finalize(&env, "DonePRGreen");
assert!(out.status.success(), "advisory failure must not raise exit");
assert!(calls(&env).contains("verify-advise FAIL"), "advisory ran");
assert_eq!(
count_event(&env.events, "session_finalized", "S-advfail"),
1,
"session_finalized still emitted despite the advisory failure"
);
assert_eq!(
count_event(&env.events, "session_finalize_failed", "S-advfail"),
0,
"advisory failure never lands in failed_steps"
);
}
#[test]
fn finalize_doneadvisory_ships() {
let env = setup("S-adv", false);
assert!(run_finalize(&env, "DoneAdvisory").status.success());
let c = calls(&env);
assert!(
c.contains("register-task reason=DoneAdvisory"),
"ledger: {c}"
);
assert!(
c.contains("stamp-plan stamp"),
"DoneAdvisory is a ship reason: {c}"
);
assert_eq!(handoff_files(&env).len(), 1);
}
#[test]
fn finalize_cost_null_when_no_transcript() {
let env = setup("S-nocost", false);
fs::write(
&env.state,
"---\nsession_id: S-nocost\ncreated_at: 2026-06-07T00:00:00Z\ninput: \"x\"\nplan_path: \"plan.md\"\nprovider: claude\n---\n# Target Session State\ngraph_node_id: ab-testnode\n",
)
.unwrap();
assert!(run_finalize(&env, "Budget").status.success());
let c = calls(&env);
assert!(
c.contains("register-task reason=Budget costjson=False"),
"no transcript -> ledger row without --cost-json (cost=null): {c}"
);
assert!(
!c.contains("session-cost"),
"session-cost.py skipped when no transcript uuid: {c}"
);
}
fn write_shim(dir: &Path, name: &str, body: &str) {
let p = dir.join(name);
fs::write(&p, body).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&p, fs::Permissions::from_mode(0o755)).unwrap();
}
}
fn run_finalize_shimmed(env: &Env, reason: &str, gh_body: &str) -> std::process::Output {
let bin = env.cwd.join("shimbin");
fs::create_dir_all(&bin).unwrap();
write_shim(&bin, "gh", gh_body);
write_shim(&bin, "fno", "#!/bin/sh\necho \"fno $*\" >> calls.log\n");
let path = format!(
"{}:{}",
bin.display(),
std::env::var("PATH").unwrap_or_default()
);
Command::new(BIN)
.arg("finalize")
.arg("--state")
.arg(&env.state)
.arg("--cwd")
.arg(&env.cwd)
.arg("--reason")
.arg(reason)
.arg("--events")
.arg(&env.events)
.arg("--global-events")
.arg(&env.global_events)
.arg("--handoffs-dir")
.arg(&env.handoffs)
.arg("--postmortems-dir")
.arg(&env.postmortems)
.env("PYTHONPATH", &env.pypath)
.env("PATH", path)
.current_dir(&env.cwd)
.output()
.expect("run finalize")
}
const GH_PR_358: &str =
"#!/bin/sh\necho '{\"number\": 358, \"url\": \"https://github.com/o/r/pull/358\"}'\n";
#[test]
fn finalize_stamps_pr_number_on_nonship() {
let env = setup("S-stamp", false);
let out = run_finalize_shimmed(&env, "DoneAwaitingMerge", GH_PR_358);
assert!(out.status.success(), "stamp path must exit 0");
let c = calls(&env);
assert!(
c.contains(
"fno backlog update ab-testnode --pr-number 358 --pr-url https://github.com/o/r/pull/358"
),
"finalize must stamp pr_number on a non-ship terminal with an open PR: {c}"
);
}
#[test]
fn finalize_skips_stamp_when_no_pr() {
let env = setup("S-nopr", false);
let out = run_finalize_shimmed(&env, "Budget", "#!/bin/sh\nexit 1\n");
assert!(out.status.success(), "no-PR skip must exit 0");
assert!(
!calls(&env).contains("fno backlog update"),
"no open PR -> no pr_number stamp call"
);
}
#[test]
fn finalize_skips_stamp_when_no_node() {
let env = setup("S-nonode", false);
fs::write(
&env.state,
"---\nsession_id: S-nonode\ncreated_at: 2026-06-07T00:00:00Z\ninput: \"x\"\nplan_path: \"plan.md\"\nprovider: claude\n---\n# Target Session State\n",
)
.unwrap();
let out = run_finalize_shimmed(&env, "Budget", GH_PR_358);
assert!(out.status.success());
assert!(
!calls(&env).contains("fno backlog update"),
"no node id -> no pr_number stamp call"
);
}