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
//! Dead-row garbage collection decision (x-b1aa).
//!
//! Finished agent-view rows accumulate "like browser tabs": the daemon retires a
//! worker *process* on idle and reconcile flips its status to `exited`, but the
//! *row* lingers until someone `rm`s it. `config.post_merge.self_reap` only fires
//! via the `/pr merged` ritual and reaps by tearing the session down -- unusable
//! for a bg session a human is attached to. This module is the pure decision
//! function both the automatic daemon GC sweep and the manual `fno agents reap`
//! verb call (Locked Decision #2: one decision, two triggers). All I/O -- the
//! liveness re-check, the worktree-cleanliness probe, and the clock -- is done by
//! the caller and passed in, so the policy is unit-testable in isolation.
use crate::AgentStatus;
/// What the GC sweep should do with one registry row this tick.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GcAction {
/// Remove the row now: terminal/dead, strictly past the grace window, and
/// (for a worktree-owning row) the worktree is clean.
Reap,
/// First tick we observe this row dead: stamp `exited_at` to start the grace
/// clock. The row stays visible for the whole grace window after this.
StampExit,
/// Leave the row untouched: still live, still coming up (mid-spawn), inside
/// the grace window, worktree dirty, or the cleanliness probe failed.
Keep,
}
/// The probed facts about one registry row the [`gc_action`] policy needs.
#[derive(Debug, Clone, Copy)]
pub struct GcRow {
/// Registry status (denormalized projection of `state.status`).
pub status: AgentStatus,
/// Liveness RE-CHECKED at decision time (AC1-FR): a reachable worker socket
/// OR a `pid` whose start time still matches what we recorded. A live row is
/// never touched, so a worker that re-registered during the grace window is
/// never swept on a stale `exited`.
pub is_live: bool,
/// A recorded `pid` is present but is confirmed NOT ours (ESRCH or a recycled
/// pid whose start time no longer matches): the process is gone even if the
/// status has not yet been flipped to `Exited`. Lets GC reap a dead row the
/// reconcile sweep has not visited yet.
pub pid_confirmed_dead: bool,
/// A one-shot `ask` row (empty short_id + no pid): it owns no worktree, so the
/// dirty-worktree guard does not apply -- it is reaped on terminal + grace
/// alone.
pub is_ask: bool,
/// `exited_at` parsed to epoch seconds; `None` when the row is not yet
/// stamped (never observed dead before).
pub exited_at: Option<i64>,
/// Worktree cleanliness for a worktree-owning row: `Some(true)` clean,
/// `Some(false)` dirty (uncommitted changes -> keep), `None` the probe could
/// not determine it (fail closed -> keep). Ignored for `is_ask` rows.
pub worktree_clean: Option<bool>,
}
/// Decide the GC action for one row. Pure: no clock, no I/O.
///
/// The reap condition is all three of: (1) terminal status OR pid confirmed dead
/// (with liveness re-checked, never trusting a stale `exited`), (2) strictly past
/// `grace_secs` since `exited_at`, (3) the worktree is clean (or the row owns
/// none). A row seen dead for the first time is `StampExit`ed rather than reaped,
/// so a just-finished row stays visible for the whole grace window.
pub fn gc_action(row: &GcRow, now: i64, grace_secs: i64) -> GcAction {
// (AC1-FR) A live worker -- re-checked -- is never touched. The caller clears
// any stale `exited_at` on such a row separately.
if row.is_live {
return GcAction::Keep;
}
// Reap condition #1: terminal status OR a confirmed-dead pid. A non-terminal
// row with no confirmed-dead pid (e.g. `Spawning` with no pid recorded yet)
// is NOT eligible -- never reap something still coming up.
let terminal_or_dead = matches!(row.status, AgentStatus::Exited | AgentStatus::PermanentDead)
|| row.pid_confirmed_dead;
if !terminal_or_dead {
return GcAction::Keep;
}
match row.exited_at {
// First observation of a dead row: start the grace clock, do not reap yet.
None => GcAction::StampExit,
Some(exited) => {
// Boundary: keep until STRICTLY past the grace window. A row that
// exited exactly `grace_secs` ago is still kept.
if now.saturating_sub(exited) <= grace_secs {
return GcAction::Keep;
}
if row.is_ask {
// No worktree to protect.
return GcAction::Reap;
}
match row.worktree_clean {
Some(true) => GcAction::Reap,
// Dirty worktree kept (AC1-EDGE); probe failure fails closed.
Some(false) | None => GcAction::Keep,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const GRACE: i64 = 3600; // 1h
const NOW: i64 = 1_000_000;
/// A dead, terminal, clean, past-grace worktree row: the AC1-HP base case.
fn reapable() -> GcRow {
GcRow {
status: AgentStatus::Exited,
is_live: false,
pid_confirmed_dead: false,
is_ask: false,
exited_at: Some(NOW - GRACE - 1),
worktree_clean: Some(true),
}
}
#[test]
fn ac1_hp_exited_past_grace_clean_is_reaped() {
assert_eq!(gc_action(&reapable(), NOW, GRACE), GcAction::Reap);
}
#[test]
fn first_dead_observation_stamps_not_reaps() {
let row = GcRow {
exited_at: None,
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::StampExit);
}
#[test]
fn ac1_fr_live_row_is_kept_even_if_stale_exited() {
// Re-registered worker: status still says exited but liveness re-check
// reports it live. Never swept.
let row = GcRow {
is_live: true,
exited_at: Some(NOW - GRACE - 999),
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Keep);
}
#[test]
fn ac1_edge_dirty_worktree_is_kept() {
let row = GcRow {
worktree_clean: Some(false),
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Keep);
}
#[test]
fn probe_failure_fails_closed_kept() {
let row = GcRow {
worktree_clean: None,
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Keep);
}
#[test]
fn within_grace_is_kept() {
let row = GcRow {
exited_at: Some(NOW - GRACE + 10),
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Keep);
}
#[test]
fn exactly_at_grace_boundary_is_kept() {
// Boundary invariant: kept until STRICTLY past grace.
let row = GcRow {
exited_at: Some(NOW - GRACE),
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Keep);
}
#[test]
fn one_second_past_grace_is_reaped() {
let row = GcRow {
exited_at: Some(NOW - GRACE - 1),
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Reap);
}
#[test]
fn ask_row_ignores_worktree_probe() {
// An ask row owns no worktree: a dirty/unknown cwd (the user's repo) must
// not pin it forever.
let row = GcRow {
is_ask: true,
worktree_clean: Some(false),
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Reap);
}
#[test]
fn non_terminal_pid_none_row_is_kept() {
// Mid-spawn: Spawning, no pid yet, not live. Must not be reaped/stamped.
let row = GcRow {
status: AgentStatus::Spawning,
pid_confirmed_dead: false,
exited_at: None,
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Keep);
}
#[test]
fn pid_confirmed_dead_non_exited_status_is_eligible() {
// Process gone before reconcile flipped the status: still eligible.
let row = GcRow {
status: AgentStatus::Live,
is_live: false,
pid_confirmed_dead: true,
exited_at: None,
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::StampExit);
}
#[test]
fn permanent_dead_is_terminal() {
let row = GcRow {
status: AgentStatus::PermanentDead,
..reapable()
};
assert_eq!(gc_action(&row, NOW, GRACE), GcAction::Reap);
}
}