返回 CodeWhale
goal_loop.rs
根目录 / crates / tui / src / goal_loop.rs
1 //! Goal loop orchestrator — the persistent-objective control layer (#3215, and
2 //! its lineage #891 / #1976 / #2058 / #2029).
3 //!
4 //! This is the **Workflow goal layer**: the decision core that turns a one-shot
5 //! `/goal` into a persistent work loop. Given the durable goal status, the
6 //! accumulated usage (from the per-goal accounting wired in `crates/state`
7 //! `record_thread_goal_usage`), and a budget, it decides whether to **continue**
8 //! (re-dispatch another worker turn toward the objective) or **stop** with a
9 //! terminal status. It is the orchestrator in the Workflow≈ultracode mapping —
10 //! the loop that fans work out to workers (`worker_profile`) and verifies before
11 //! committing.
12 //!
13 //! Scope: **decision logic + types**. The engine (`core/engine.rs`) reads the
14 //! `SharedGoalState` snapshot after each turn and calls `decide_continuation`
15 //! to decide whether to re-dispatch. For operate-mode goals the only terminal
16 //! stops are a verified completion, a blocked report, or the continuation
17 //! backstop (`[goal] max_continuations`); token/time accounting stays visible
18 //! as telemetry but does not gate continuation — the run is unbounded like
19 //! grokbuild (`DEFAULT_AGENT_BUDGET` as call cap) and kimicode swarm
20 //! (`turnBudget` per-task, resumable after budget-reached). Log when the
21 //! backstop fires.
22
23 /// Default safety backstop on automatic cross-turn continuation passes for one
24 /// goal run (#5052).
25 ///
26 /// This is deliberately generous: the completion gate is the real terminal
27 /// stop, and the backstop only exists to halt a pathological loop that never
28 /// emits a terminal signal. Override with `[goal] max_continuations` in
29 /// config.toml; `0` disables the backstop entirely so only terminal status
30 /// ends the run.
31 pub const DEFAULT_MAX_GOAL_CONTINUATIONS: u32 = 100;
32
33 /// Terminal or active state of a persistent goal.
34 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
35 pub enum GoalRunStatus {
36 /// Still working toward the objective.
37 Active,
38 /// The objective was achieved (the model self-reported done and, ideally, a
39 /// verifier confirmed — see `GoalGate`).
40 Completed,
41 /// The model reported it is blocked and needs the user.
42 #[allow(dead_code)]
43 Blocked,
44 }
45
46 /// Why the loop stopped, for a terminal decision.
47 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
48 pub enum StopReason {
49 /// Objective achieved.
50 Completed,
51 /// Model reported blocked.
52 #[allow(dead_code)]
53 Blocked,
54 /// Continuation circuit-breaker tripped (too many continuations without a
55 /// terminal signal).
56 ContinuationLimit,
57 }
58
59 /// Accumulated, durable progress for a goal run. Mirrors the fields wired by
60 /// `crates/state` `record_thread_goal_usage` (tokens_used / time_used_seconds)
61 /// plus a continuation counter the loop maintains.
62 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
63 pub struct GoalProgress {
64 pub tokens_used: u64,
65 pub time_used_seconds: u64,
66 pub continuations: u32,
67 }
68
69 /// The optional token/time bounds on a goal run. `None` fields mean unbounded
70 /// for that resource; the continuation backstop (`max_continuations`) still
71 /// applies unless configured to `0`.
72 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
73 pub struct GoalBudget {
74 pub token_budget: Option<u64>,
75 pub time_budget_seconds: Option<u64>,
76 /// Safety backstop on automatic continuation passes (#5052). `0` disables
77 /// the backstop: only terminal status stops the run.
78 pub max_continuations: u32,
79 }
80
81 impl GoalBudget {
82 /// No token or time cap. Terminal status, user control, and the default
83 /// continuation backstop still stop the run.
84 #[allow(dead_code)]
85 pub const fn unbounded() -> Self {
86 Self {
87 token_budget: None,
88 time_budget_seconds: None,
89 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
90 }
91 }
92
93 /// A token budget for telemetry/UI. It never pauses an unbounded goal.
94 #[allow(dead_code)]
95 pub const fn with_token_budget(token_budget: u64) -> Self {
96 Self {
97 token_budget: Some(token_budget),
98 time_budget_seconds: None,
99 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
100 }
101 }
102
103 /// Override the continuation backstop (`0` = unlimited until terminal
104 /// status).
105 #[allow(dead_code)]
106 #[must_use]
107 pub const fn with_max_continuations(mut self, max_continuations: u32) -> Self {
108 self.max_continuations = max_continuations;
109 self
110 }
111 }
112
113 /// The decision the loop makes after each worker turn.
114 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
115 pub enum ContinuationDecision {
116 /// Re-dispatch another turn toward the objective.
117 Continue,
118 /// Stop; the goal run is terminal.
119 Stop(StopReason),
120 }
121
122 /// Decide whether a persistent goal run should continue after a turn.
123 ///
124 /// Precedence (most authoritative first):
125 /// 1. A terminal model status (Completed / Blocked) ends the run.
126 /// 2. The configurable continuation backstop stops a pathological loop
127 /// (skipped entirely when configured to `0`).
128 /// 3. Otherwise continue — the loop runs to the completion gate, not to a
129 /// fixed pass count (#5052). Token/time budgets are advisory telemetry;
130 /// they are surfaced in the UI but do not stop the run (unbounded).
131 #[must_use]
132 pub fn decide_continuation(
133 status: GoalRunStatus,
134 progress: GoalProgress,
135 budget: GoalBudget,
136 ) -> ContinuationDecision {
137 // 1. Terminal model signal wins.
138 match status {
139 GoalRunStatus::Completed => return ContinuationDecision::Stop(StopReason::Completed),
140 GoalRunStatus::Blocked => return ContinuationDecision::Stop(StopReason::Blocked),
141 GoalRunStatus::Active => {}
142 }
143
144 // 2. Token/time budgets are advisory only (unbounded). They are
145 // visible in the Goal chip + /cost but never pause the loop — like
146 // grokbuild's agent-call budget and kimicode swarm's per-task
147 // turnBudget with resume. Log if we are over budget, then continue.
148 if budget
149 .token_budget
150 .is_some_and(|limit| progress.tokens_used >= limit)
151 {
152 tracing::debug!(
153 tokens_used = progress.tokens_used,
154 token_budget = ?budget.token_budget,
155 "goal over token budget but continuing (unbounded)"
156 );
157 }
158 if let Some(secs) = budget.time_budget_seconds
159 && progress.time_used_seconds >= secs
160 {
161 tracing::debug!(
162 time_used_seconds = progress.time_used_seconds,
163 time_budget_seconds = secs,
164 "goal over time budget but continuing (unbounded)"
165 );
166 }
167
168 // 3. Runaway-cost backstop. This deliberately uses the already-durable
169 // continuation counter instead of adding verifier fingerprints or another
170 // orchestration subsystem. `0` disables it — budget/terminal stops only.
171 if budget.max_continuations > 0 && progress.continuations >= budget.max_continuations {
172 tracing::warn!(
173 continuations = progress.continuations,
174 max_continuations = budget.max_continuations,
175 "goal continuation backstop fired: no terminal signal after the configured \
176 continuation limit ([goal] max_continuations)"
177 );
178 return ContinuationDecision::Stop(StopReason::ContinuationLimit);
179 }
180
181 // 4. Keep going.
182 ContinuationDecision::Continue
183 }
184
185 /// Whether the durable token usage has reached the active goal's budget.
186 ///
187 /// Budgets are telemetry-only in unbounded goal mode. Keeping this shared
188 /// predicate false ensures preview and the live continuation loop agree that
189 /// crossing a token budget does not close the outbound gate.
190 #[must_use]
191 pub const fn token_budget_exhausted(_progress: GoalProgress, _budget: GoalBudget) -> bool {
192 false
193 }
194
195 /// Whether a stop reason represents success (Completed) vs. an early/forced exit.
196 /// Useful for the UI/status projection (#2666 token/time visibility).
197 #[must_use]
198 #[allow(dead_code)]
199 pub fn is_success(reason: StopReason) -> bool {
200 matches!(reason, StopReason::Completed)
201 }
202
203 #[cfg(test)]
204 mod tests {
205 use super::*;
206
207 #[test]
208 fn completed_status_stops_with_success() {
209 let d = decide_continuation(
210 GoalRunStatus::Completed,
211 GoalProgress::default(),
212 GoalBudget::unbounded(),
213 );
214 assert_eq!(d, ContinuationDecision::Stop(StopReason::Completed));
215 assert!(is_success(StopReason::Completed));
216 }
217
218 #[test]
219 fn blocked_status_stops_without_success() {
220 let d = decide_continuation(
221 GoalRunStatus::Blocked,
222 GoalProgress::default(),
223 GoalBudget::unbounded(),
224 );
225 assert_eq!(d, ContinuationDecision::Stop(StopReason::Blocked));
226 assert!(!is_success(StopReason::Blocked));
227 }
228
229 #[test]
230 fn active_under_budget_continues() {
231 let progress = GoalProgress {
232 tokens_used: 10,
233 time_used_seconds: 5,
234 continuations: 2,
235 };
236 let budget = GoalBudget {
237 token_budget: Some(1000),
238 time_budget_seconds: Some(600),
239 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
240 };
241 assert_eq!(
242 decide_continuation(GoalRunStatus::Active, progress, budget),
243 ContinuationDecision::Continue
244 );
245 }
246
247 #[test]
248 fn active_under_continuation_limit_without_budget_continues() {
249 let progress = GoalProgress {
250 continuations: DEFAULT_MAX_GOAL_CONTINUATIONS - 1,
251 ..GoalProgress::default()
252 };
253 assert_eq!(
254 decide_continuation(GoalRunStatus::Active, progress, GoalBudget::unbounded()),
255 ContinuationDecision::Continue
256 );
257 }
258
259 #[test]
260 fn continuation_limit_stops_unbounded_run() {
261 let progress = GoalProgress {
262 continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
263 ..GoalProgress::default()
264 };
265 assert_eq!(
266 decide_continuation(GoalRunStatus::Active, progress, GoalBudget::unbounded()),
267 ContinuationDecision::Stop(StopReason::ContinuationLimit)
268 );
269 }
270
271 #[test]
272 fn operate_goal_continues_past_ten_when_budget_remains() {
273 // #5052 regression: the old hardcoded cap of 10 must not be a terminal
274 // stop. With budget remaining and no terminal signal, pass 10, 11, and
275 // far beyond keep continuing under the default backstop.
276 for continuations in [10, 11, DEFAULT_MAX_GOAL_CONTINUATIONS - 1] {
277 let progress = GoalProgress {
278 tokens_used: 5_000,
279 time_used_seconds: 300,
280 continuations,
281 };
282 let budget = GoalBudget::with_token_budget(1_000_000);
283 assert_eq!(
284 decide_continuation(GoalRunStatus::Active, progress, budget),
285 ContinuationDecision::Continue,
286 "pass {continuations} must continue toward the completion gate",
287 );
288 }
289 }
290
291 #[test]
292 fn configured_backstop_halts_pathological_loop() {
293 let backstop = 25;
294 let progress = GoalProgress {
295 continuations: backstop,
296 ..GoalProgress::default()
297 };
298 let budget = GoalBudget::unbounded().with_max_continuations(backstop);
299 assert_eq!(
300 decide_continuation(GoalRunStatus::Active, progress, budget),
301 ContinuationDecision::Stop(StopReason::ContinuationLimit)
302 );
303 }
304
305 #[test]
306 fn zero_backstop_is_unlimited_and_budget_advisory() {
307 // 0 = unlimited-with-budget-stops: no continuation count ends the run…
308 let progress = GoalProgress {
309 continuations: 10_000,
310 ..GoalProgress::default()
311 };
312 let budget = GoalBudget::unbounded().with_max_continuations(0);
313 assert_eq!(
314 decide_continuation(GoalRunStatus::Active, progress, budget),
315 ContinuationDecision::Continue
316 );
317
318 // exceeded token budget is advisory — must still continue (unbounded)
319 let progress = GoalProgress {
320 tokens_used: 1_000,
321 continuations: 10_000,
322 ..GoalProgress::default()
323 };
324 let budget = GoalBudget::with_token_budget(1_000).with_max_continuations(0);
325 assert_eq!(
326 decide_continuation(GoalRunStatus::Active, progress, budget),
327 ContinuationDecision::Continue,
328 "budget advisory — must continue even when over budget"
329 );
330 }
331
332 #[test]
333 fn token_budget_is_advisory_not_terminal() {
334 let progress = GoalProgress {
335 tokens_used: 1000,
336 continuations: 1,
337 ..GoalProgress::default()
338 };
339 let budget = GoalBudget::with_token_budget(1000);
340 assert_eq!(
341 decide_continuation(GoalRunStatus::Active, progress, budget),
342 ContinuationDecision::Continue,
343 "token budget is advisory — unbounded run must continue"
344 );
345 }
346
347 #[test]
348 fn time_budget_is_advisory_not_terminal() {
349 let progress = GoalProgress {
350 time_used_seconds: 601,
351 continuations: 1,
352 ..GoalProgress::default()
353 };
354 let budget = GoalBudget {
355 token_budget: None,
356 time_budget_seconds: Some(600),
357 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
358 };
359 assert_eq!(
360 decide_continuation(GoalRunStatus::Active, progress, budget),
361 ContinuationDecision::Continue,
362 "time budget is advisory — unbounded run must continue"
363 );
364 }
365
366 #[test]
367 fn terminal_status_outranks_remaining_budget() {
368 // Completed wins even if there is plenty of budget left.
369 let progress = GoalProgress::default();
370 let budget = GoalBudget {
371 token_budget: Some(1_000_000),
372 time_budget_seconds: Some(86_400),
373 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
374 };
375 assert_eq!(
376 decide_continuation(GoalRunStatus::Completed, progress, budget),
377 ContinuationDecision::Stop(StopReason::Completed)
378 );
379 }
380 }
381
381 lines RUST