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
//! The failure circuit-breaker (issue #521): a routine that fails every scheduled fire otherwise
//! keeps spawning a fresh agent session forever — on a `*/5` cron that is ~288 doomed sessions/day,
//! burning CPU, disk, and API spend with no automatic backstop. This module tracks each routine's
//! `Routine::consecutive_failures` and, once it reaches the routine's opt-in
//! `Routine::failure_threshold`, auto-disables it through the same `enabled = false` path a user
//! flipping the toggle off would use — so the very next crontab sync removes it, and no further
//! sessions spawn.
//!
//! # Hook point: the TTL-reap `persist` closure, not `svc_list_runs`
//!
//! A run's outcome becomes knowable in two places: the on-demand `svc_list_runs`
//! listing (`svc_list_runs`/`run_summary`), computed fresh on every `GET .../runs` call straight
//! from the workbench's `exit_code` file and tmux liveness; and the periodic TTL-reap sweep
//! ([`super::cleanup_expired_workbenches`]'s `persist` closure), which already durably records the
//! same outcome into `runs.log` right before the workbench is removed (see
//! [`super::super::run_history`]).
//!
//! `svc_list_runs` looks tempting for responsiveness — it observes a finished run immediately, not
//! after a TTL — but it is *pull-based*: it only ever runs when an API client happens to call it, so
//! a routine nobody is watching would never trip the breaker at all. The reap sweep, by contrast, is
//! a background task the daemon already drives on its own (every [`super::CLEANUP_INTERVAL`], 5
//! minutes) independent of anyone polling the API, which is what "auto-disables itself" requires.
//! It also comes with [`super::super::run_history::has_persisted_run`] already guarding against
//! recording (and so double-counting) the same finished run twice — reusing it here needs no new
//! dedup bookkeeping.
//!
//! The tradeoff is that this hook only fires once a workbench's *retention* has elapsed, not the
//! instant its session exits. In the worst case that sounds like it could be a long delay, but
//! retention is `min(MAX_TTL_SECS, cron interval)` ([`super::ttl`]) — capped at the routine's own
//! firing interval — so for exactly the high-frequency routines this feature is meant to protect
//! (e.g. the `*/5` example in #521) the delay before a finished run is counted is bounded by
//! [`super::CLEANUP_INTERVAL`] (a handful of minutes), not by the retention window itself. That is
//! responsive enough to stop the bleed within a few extra fires — a world away from "forever" — at
//! the cost of no new per-workbench marker files or a second background sweep.
use cratewrite_routine;
use crate;
use crateLockRecover;
/// Update routine `id`'s consecutive-failure streak for a run that just durably finished with
/// `status`, auto-disabling it once/if [`Routine::failure_threshold`](
/// crate::routines::model::Routine::failure_threshold) is reached.
///
/// `status` is never [`RunStatus::Running`] here (the caller only invokes this for a workbench
/// already confirmed finished — see this module's doc comment). [`RunStatus::Success`] resets the
/// streak to `0`; anything else (`Failed` or `Unknown`) increments it — `Unknown` counts too because
/// it covers a session that vanished with no exit code, including one the max-runtime watchdog just
/// force-killed for hanging, which is exactly the kind of never-succeeds loop this breaker exists to
/// stop. A missing `id` (routine deleted between the workbench's creation and this sweep) is a no-op.
///
/// Persists the routine unconditionally (mirroring how [`super::super::run_history`] persists every
/// outcome), but only re-syncs the crontab when this call is the one that actually flips `enabled`
/// to `false` — every other call (the common case: a success, or a failure short of the threshold)
/// changes nothing the crontab cares about, so it skips that round trip.
pub