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
//! Goal loop orchestrator — the persistent-objective control layer (#3215, and
//! its lineage #891 / #1976 / #2058 / #2029).
//!
//! This is the **WhaleFlow goal layer**: the decision core that turns a one-shot
//! `/goal` into a persistent work loop. Given the durable goal status, the
//! accumulated usage (from the per-goal accounting wired in `crates/state`
//! `record_thread_goal_usage`), and a budget, it decides whether to **continue**
//! (re-dispatch another worker turn toward the objective) or **stop** with a
//! terminal status. It is the orchestrator in the WhaleFlow≈ultracode mapping —
//! the loop that fans work out to workers (`worker_profile`) and verifies before
//! committing.
//!
//! Scope: **decision logic + types**. The engine (`core/engine.rs`) reads the
//! `SharedGoalState` snapshot after each turn and calls `decide_continuation`
//! to decide whether to re-dispatch. There is **no continuation cap** — a goal
//! runs until the model self-reports complete/blocked, the user pauses or
//! clears, or an optional token/time budget is exhausted. This matches how a
//! persistent objective should feel: "until done," not "until N turns."
/// Terminal or active state of a persistent goal.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GoalRunStatus {
/// Still working toward the objective.
Active,
/// The objective was achieved (the model self-reported done and, ideally, a
/// verifier confirmed — see `GoalGate`).
Completed,
/// The model reported it is blocked and needs the user.
#[allow(dead_code)]
Blocked,
}
/// Why the loop stopped, for a terminal decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
/// Objective achieved.
Completed,
/// Model reported blocked.
#[allow(dead_code)]
Blocked,
/// Token budget exhausted.
TokenBudget,
/// Wall-clock budget exhausted.
TimeBudget,
/// Continuation circuit-breaker tripped (too many continuations without a
/// terminal signal). Retained for API completeness; the current loop has no
/// continuation cap, so this variant is not constructed by
/// `decide_continuation`.
#[allow(dead_code)]
ContinuationLimit,
}
/// Accumulated, durable progress for a goal run. Mirrors the fields wired by
/// `crates/state` `record_thread_goal_usage` (tokens_used / time_used_seconds)
/// plus a continuation counter the loop maintains.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct GoalProgress {
pub tokens_used: u64,
pub time_used_seconds: u64,
pub continuations: u32,
}
/// The bound on a goal run. `None` fields mean unbounded. There is **no
/// continuation cap** — the loop runs until the model self-reports
/// complete/blocked, the user pauses/clears, or an optional budget is
/// exhausted. This is deliberate: a goal is "until done," not "until N turns."
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GoalBudget {
pub token_budget: Option<u64>,
pub time_budget_seconds: Option<u64>,
}
impl GoalBudget {
/// Fully unbounded — no token or time cap. The only stops are a terminal
/// model status (complete/blocked) or an explicit user pause/clear.
#[allow(dead_code)]
pub const fn unbounded() -> Self {
Self {
token_budget: None,
time_budget_seconds: None,
}
}
/// A token budget only — the loop runs until the model is done or the
/// token budget is exhausted.
#[allow(dead_code)]
pub const fn with_token_budget(token_budget: u64) -> Self {
Self {
token_budget: Some(token_budget),
time_budget_seconds: None,
}
}
}
/// The decision the loop makes after each worker turn.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContinuationDecision {
/// Re-dispatch another turn toward the objective.
Continue,
/// Stop; the goal run is terminal.
Stop(StopReason),
}
/// Decide whether a persistent goal run should continue after a turn.
///
/// Precedence (most authoritative first):
/// 1. A terminal model status (Completed / Blocked) ends the run.
/// 2. An optional token or time budget, if exhausted, ends the run.
/// 3. Otherwise continue.
///
/// There is **no continuation cap**. A goal runs until the model reports
/// done/blocked, the user pauses or clears, or an optional budget is spent.
#[must_use]
pub fn decide_continuation(
status: GoalRunStatus,
progress: GoalProgress,
budget: GoalBudget,
) -> ContinuationDecision {
// 1. Terminal model signal wins.
match status {
GoalRunStatus::Completed => return ContinuationDecision::Stop(StopReason::Completed),
GoalRunStatus::Blocked => return ContinuationDecision::Stop(StopReason::Blocked),
GoalRunStatus::Active => {}
}
// 2. Optional budget. No continuation cap — "until done."
if let Some(tokens) = budget.token_budget
&& progress.tokens_used >= tokens
{
return ContinuationDecision::Stop(StopReason::TokenBudget);
}
if let Some(secs) = budget.time_budget_seconds
&& progress.time_used_seconds >= secs
{
return ContinuationDecision::Stop(StopReason::TimeBudget);
}
// 3. Keep going.
ContinuationDecision::Continue
}
/// Whether a stop reason represents success (Completed) vs. an early/forced exit.
/// Useful for the UI/status projection (#2666 token/time visibility).
#[must_use]
#[allow(dead_code)]
pub fn is_success(reason: StopReason) -> bool {
matches!(reason, StopReason::Completed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completed_status_stops_with_success() {
let d = decide_continuation(
GoalRunStatus::Completed,
GoalProgress::default(),
GoalBudget::unbounded(),
);
assert_eq!(d, ContinuationDecision::Stop(StopReason::Completed));
assert!(is_success(StopReason::Completed));
}
#[test]
fn blocked_status_stops_without_success() {
let d = decide_continuation(
GoalRunStatus::Blocked,
GoalProgress::default(),
GoalBudget::unbounded(),
);
assert_eq!(d, ContinuationDecision::Stop(StopReason::Blocked));
assert!(!is_success(StopReason::Blocked));
}
#[test]
fn active_under_budget_continues() {
let progress = GoalProgress {
tokens_used: 10,
time_used_seconds: 5,
continuations: 2,
};
let budget = GoalBudget {
token_budget: Some(1000),
time_budget_seconds: Some(600),
};
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Continue
);
}
#[test]
fn active_with_no_budget_continues_indefinitely() {
// No continuation cap: a high continuation count with no token/time
// budget must still Continue. The loop is "until done," not "until N."
let progress = GoalProgress {
continuations: 1_000_000,
..GoalProgress::default()
};
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, GoalBudget::unbounded()),
ContinuationDecision::Continue
);
}
#[test]
fn token_budget_exhaustion_stops() {
let progress = GoalProgress {
tokens_used: 1000,
continuations: 1,
..GoalProgress::default()
};
let budget = GoalBudget::with_token_budget(1000);
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Stop(StopReason::TokenBudget)
);
}
#[test]
fn time_budget_exhaustion_stops() {
let progress = GoalProgress {
time_used_seconds: 601,
continuations: 1,
..GoalProgress::default()
};
let budget = GoalBudget {
token_budget: None,
time_budget_seconds: Some(600),
};
assert_eq!(
decide_continuation(GoalRunStatus::Active, progress, budget),
ContinuationDecision::Stop(StopReason::TimeBudget)
);
}
#[test]
fn terminal_status_outranks_remaining_budget() {
// Completed wins even if there is plenty of budget left.
let progress = GoalProgress::default();
let budget = GoalBudget {
token_budget: Some(1_000_000),
time_budget_seconds: Some(86_400),
};
assert_eq!(
decide_continuation(GoalRunStatus::Completed, progress, budget),
ContinuationDecision::Stop(StopReason::Completed)
);
}
}