返回 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 terminal stops
16 //! are a verified completion, a blocked report, the stall pause that fires
17 //! when a critical verifier reports the same gap set
18 //! [`MAX_REPEATED_GAP_PASSES`] times running (enforced in
19 //! `GoalState::record_not_achieved`), and the opt-in continuation backstop
20 //! (`[goal] max_continuations`); token/time accounting stays visible as
21 //! telemetry but does not gate continuation — spend is bounded by stalling,
22 //! not by a pass count, like grokbuild (`DEFAULT_AGENT_BUDGET` as call cap)
23 //! and kimicode swarm (`turnBudget` per-task, resumable after
24 //! budget-reached). Log when the backstop fires.
25
26 use std::time::Duration;
27
28 /// Default automatic cross-turn continuation policy for one goal run (#5052).
29 ///
30 /// Goals are unlimited by default: completion, blocked status, or explicit
31 /// user control ends the run. Operators who want a circuit breaker can opt in
32 /// with `[goal] max_continuations`; `0` keeps the default unlimited behavior.
33 pub const DEFAULT_MAX_GOAL_CONTINUATIONS: u32 = 0;
34
35 /// Default per-engine-turn step allowance while a goal is active (#5994).
36 /// Deliberate maintainer policy, not a measured number: five times the
37 /// ordinary interactive allowance (200), so intentional goal work has room
38 /// while every provider turn stays finite. The count of continuation passes
39 /// remains governed separately (`[goal] max_continuations`).
40 pub const DEFAULT_GOAL_MAX_STEPS: u32 = 1_000;
41
42 /// How many consecutive critical `not_achieved` reviews naming the *same*
43 /// normalized gap set a goal may accumulate before it pauses itself with
44 /// `GoalPauseReason::NoProgress`.
45 ///
46 /// This is the bound the continuation prompt promises the model, and it is the
47 /// only default stop on a goal run: `DEFAULT_MAX_GOAL_CONTINUATIONS` is `0`, so
48 /// without it the loop runs until the model volunteers a terminal status.
49 /// Enforcement lives in `GoalState::record_not_achieved`, and it reuses the
50 /// existing pause authority rather than adding a second one — both continuation
51 /// dispatchers already refuse to re-dispatch a non-active goal, and
52 /// `RuntimeThreadManager` already mirrors a non-limit pause into the durable
53 /// `ThreadGoalStatus::Paused`, so the stop survives a restart and needs an
54 /// explicit resume.
55 ///
56 /// The counter is `1` on the first report of a gap set, so `3` means the
57 /// verifier named identical remaining work three times running: two whole
58 /// continuation passes that moved nothing the verifier can see. Two is too
59 /// eager — one pass legitimately fails to land a fix and retries — and anything
60 /// larger just buys more identical passes.
61 pub const MAX_REPEATED_GAP_PASSES: u32 = 3;
62
63 /// Upper bound for one between-turn quiet period. A day is long enough for
64 /// coordinator cadences while preventing an accidental giant integer from
65 /// becoming a practically uninterruptible-looking schedule receipt.
66 pub const MAX_GOAL_CONTINUATION_DELAY_SECONDS: u64 = 24 * 60 * 60;
67
68 /// Terminal or active state of a persistent goal.
69 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
70 pub enum GoalRunStatus {
71 /// Still working toward the objective.
72 Active,
73 /// The objective was achieved (the model self-reported done and, ideally, a
74 /// verifier confirmed — see `GoalGate`).
75 Completed,
76 /// The model reported it is blocked and needs the user.
77 #[cfg_attr(not(test), expect(dead_code))]
78 Blocked,
79 }
80
81 /// Why the loop stopped, for a terminal decision.
82 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 pub enum StopReason {
84 /// Objective achieved.
85 Completed,
86 /// Model reported blocked.
87 Blocked,
88 /// Continuation circuit-breaker tripped (too many continuations without a
89 /// terminal signal).
90 ContinuationLimit,
91 /// The goal's token budget was reached while `[goal] enforce_token_budget`
92 /// opted the budget into a hard stop (#6013). Default-off: without the
93 /// opt-in the budget stays advisory telemetry and never produces this.
94 BudgetLimit,
95 }
96
97 /// Accumulated, durable progress for a goal run. Mirrors the fields wired by
98 /// `crates/state` `record_thread_goal_usage` (tokens_used / time_used_seconds)
99 /// plus a continuation counter the loop maintains.
100 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
101 pub struct GoalProgress {
102 pub tokens_used: u64,
103 pub time_used_seconds: u64,
104 pub continuations: u32,
105 }
106
107 /// The optional token/time bounds on a goal run. `None` fields mean unbounded
108 /// for that resource; the continuation backstop (`max_continuations`) still
109 /// applies unless configured to `0`.
110 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
111 pub struct GoalBudget {
112 pub token_budget: Option<u64>,
113 pub time_budget_seconds: Option<u64>,
114 /// Whether `token_budget` stops the run when reached (#6013). `false`
115 /// keeps the default advisory behavior: crossing the budget logs and
116 /// continues. There is no time-budget enforcement because goals carry no
117 /// `time_budget` field — only `token_budget` can gate.
118 pub enforce_token_budget: bool,
119 /// Safety backstop on automatic continuation passes (#5052). `0` disables
120 /// the backstop: only terminal status stops the run.
121 pub max_continuations: u32,
122 }
123
124 impl GoalBudget {
125 /// No token or time cap. Terminal status, user control, and the default
126 /// continuation backstop still stop the run.
127 pub const fn unbounded() -> Self {
128 Self {
129 token_budget: None,
130 time_budget_seconds: None,
131 enforce_token_budget: false,
132 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
133 }
134 }
135
136 /// A token budget for telemetry/UI. It never pauses an unbounded goal.
137 #[cfg_attr(not(test), expect(dead_code))]
138 pub const fn with_token_budget(token_budget: u64) -> Self {
139 Self {
140 token_budget: Some(token_budget),
141 time_budget_seconds: None,
142 enforce_token_budget: false,
143 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
144 }
145 }
146
147 /// Opt the token budget into a hard stop (`[goal] enforce_token_budget`).
148 /// With no `token_budget` set this is a no-op by construction — there is
149 /// no ceiling to reach.
150 #[must_use]
151 pub const fn with_enforced_token_budget(mut self, enforce: bool) -> Self {
152 self.enforce_token_budget = enforce;
153 self
154 }
155
156 /// Override the continuation backstop (`0` = unlimited until terminal
157 /// status).
158 #[must_use]
159 pub const fn with_max_continuations(mut self, max_continuations: u32) -> Self {
160 self.max_continuations = max_continuations;
161 self
162 }
163 }
164
165 /// The decision the loop makes after each worker turn.
166 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
167 pub enum ContinuationDecision {
168 /// Re-dispatch another turn toward the objective.
169 Continue,
170 /// Stop; the goal run is terminal.
171 Stop(StopReason),
172 }
173
174 /// Decide whether a persistent goal run should continue after a turn.
175 ///
176 /// Precedence (most authoritative first):
177 /// 1. A terminal model status (Completed / Blocked) ends the run.
178 /// 2. The configurable continuation backstop stops a pathological loop
179 /// (skipped entirely when configured to `0`).
180 /// 3. Otherwise continue — the loop runs to the completion gate, not to a
181 /// fixed pass count (#5052). Token/time budgets are advisory telemetry;
182 /// they are surfaced in the UI but do not stop the run (unbounded).
183 #[must_use]
184 pub fn decide_continuation(
185 status: GoalRunStatus,
186 progress: GoalProgress,
187 budget: GoalBudget,
188 ) -> ContinuationDecision {
189 // 1. Terminal model signal wins.
190 match status {
191 GoalRunStatus::Completed => return ContinuationDecision::Stop(StopReason::Completed),
192 GoalRunStatus::Blocked => return ContinuationDecision::Stop(StopReason::Blocked),
193 GoalRunStatus::Active => {}
194 }
195
196 // 2. Token budget: advisory telemetry by default, or a hard stop when
197 // `[goal] enforce_token_budget` opts in (#6013). Time stays advisory —
198 // goals carry no time budget to enforce against.
199 if budget
200 .token_budget
201 .is_some_and(|limit| progress.tokens_used >= limit)
202 {
203 if budget.enforce_token_budget {
204 tracing::info!(
205 tokens_used = progress.tokens_used,
206 token_budget = ?budget.token_budget,
207 "goal token budget reached; stopping ([goal] enforce_token_budget)"
208 );
209 return ContinuationDecision::Stop(StopReason::BudgetLimit);
210 }
211 tracing::debug!(
212 tokens_used = progress.tokens_used,
213 token_budget = ?budget.token_budget,
214 "goal over token budget but continuing (unbounded)"
215 );
216 }
217 if let Some(secs) = budget.time_budget_seconds
218 && progress.time_used_seconds >= secs
219 {
220 tracing::debug!(
221 time_used_seconds = progress.time_used_seconds,
222 time_budget_seconds = secs,
223 "goal over time budget but continuing (unbounded)"
224 );
225 }
226
227 // 3. Runaway-cost backstop. This deliberately uses the already-durable
228 // continuation counter instead of adding verifier fingerprints or another
229 // orchestration subsystem. `0` disables it — budget/terminal stops only.
230 if budget.max_continuations > 0 && progress.continuations >= budget.max_continuations {
231 tracing::warn!(
232 continuations = progress.continuations,
233 max_continuations = budget.max_continuations,
234 "goal continuation backstop fired: no terminal signal after the configured \
235 continuation limit ([goal] max_continuations)"
236 );
237 return ContinuationDecision::Stop(StopReason::ContinuationLimit);
238 }
239
240 // 4. Keep going.
241 ContinuationDecision::Continue
242 }
243
244 /// Outcome of waiting out the between-continuation quiet period.
245 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
246 pub enum ContinuationWaitOutcome {
247 /// The quiet period elapsed — dispatch the continuation.
248 Elapsed,
249 /// Cancelled during the quiet period — never dispatch.
250 Cancelled,
251 }
252
253 /// Compute the quiet-period wait for a configured between-continuation delay.
254 /// `None` continues immediately (unset or zero delay); a positive delay
255 /// returns the capped wait shared by every dispatch path so no caller can
256 /// construct an effectively uninterruptible schedule receipt.
257 #[must_use]
258 pub const fn continuation_wait(delay_seconds: u64) -> Option<Duration> {
259 if delay_seconds == 0 {
260 None
261 } else {
262 Some(Duration::from_secs(
263 if delay_seconds > MAX_GOAL_CONTINUATION_DELAY_SECONDS {
264 MAX_GOAL_CONTINUATION_DELAY_SECONDS
265 } else {
266 delay_seconds
267 },
268 ))
269 }
270 }
271
272 /// Wait out the between-continuation quiet period, honoring cancellation.
273 /// `None` resolves to `Elapsed` immediately so callers have a single dispatch
274 /// gate. Cancellation is biased and always wins over a racing expiry — the
275 /// same semantics as the interactive cadence (#5508). Two dispatchers await
276 /// this gate: the turn loop's intra-turn passes (every session), and the
277 /// runtime host's cross-turn re-arm for host-managed engines
278 /// (`RuntimeThreadManager::spawn_goal_continuation`), which never
279 /// self-continue.
280 pub async fn await_continuation_wait(
281 wait: Option<Duration>,
282 cancel_token: &tokio_util::sync::CancellationToken,
283 ) -> ContinuationWaitOutcome {
284 let Some(wait) = wait else {
285 return ContinuationWaitOutcome::Elapsed;
286 };
287 tokio::select! {
288 biased;
289 () = cancel_token.cancelled() => ContinuationWaitOutcome::Cancelled,
290 () = tokio::time::sleep(wait) => ContinuationWaitOutcome::Elapsed,
291 }
292 }
293
294 /// Whether the durable token usage has reached the active goal's budget.
295 ///
296 /// Budgets are telemetry-only in unbounded goal mode. Keeping this shared
297 /// predicate false ensures preview and the live continuation loop agree that
298 /// crossing a token budget does not close the outbound gate.
299 #[must_use]
300 pub const fn token_budget_exhausted(_progress: GoalProgress, _budget: GoalBudget) -> bool {
301 false
302 }
303
304 /// Whether a stop reason represents success (Completed) vs. an early/forced exit.
305 /// Useful for the UI/status projection (#2666 token/time visibility).
306 #[must_use]
307 #[cfg_attr(not(test), expect(dead_code))]
308 pub fn is_success(reason: StopReason) -> bool {
309 matches!(reason, StopReason::Completed)
310 }
311
312 #[cfg(test)]
313 mod tests {
314 use super::*;
315
316 #[test]
317 fn completed_status_stops_with_success() {
318 let d = decide_continuation(
319 GoalRunStatus::Completed,
320 GoalProgress::default(),
321 GoalBudget::unbounded(),
322 );
323 assert_eq!(d, ContinuationDecision::Stop(StopReason::Completed));
324 assert!(is_success(StopReason::Completed));
325 }
326
327 #[test]
328 fn blocked_status_stops_without_success() {
329 let d = decide_continuation(
330 GoalRunStatus::Blocked,
331 GoalProgress::default(),
332 GoalBudget::unbounded(),
333 );
334 assert_eq!(d, ContinuationDecision::Stop(StopReason::Blocked));
335 assert!(!is_success(StopReason::Blocked));
336 }
337
338 #[test]
339 fn active_under_budget_continues() {
340 let progress = GoalProgress {
341 tokens_used: 10,
342 time_used_seconds: 5,
343 continuations: 2,
344 };
345 let budget = GoalBudget {
346 token_budget: Some(1000),
347 time_budget_seconds: Some(600),
348 enforce_token_budget: false,
349 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
350 };
351 assert_eq!(
352 decide_continuation(GoalRunStatus::Active, progress, budget),
353 ContinuationDecision::Continue
354 );
355 }
356
357 #[test]
358 fn default_goal_has_no_continuation_limit() {
359 let progress = GoalProgress {
360 continuations: 10_000,
361 ..GoalProgress::default()
362 };
363 assert_eq!(
364 decide_continuation(GoalRunStatus::Active, progress, GoalBudget::unbounded()),
365 ContinuationDecision::Continue
366 );
367 }
368
369 #[test]
370 fn explicit_continuation_limit_stops_run() {
371 let configured_limit = 100;
372 let progress = GoalProgress {
373 continuations: configured_limit,
374 ..GoalProgress::default()
375 };
376 let budget = GoalBudget::unbounded().with_max_continuations(configured_limit);
377 assert_eq!(
378 decide_continuation(GoalRunStatus::Active, progress, budget),
379 ContinuationDecision::Stop(StopReason::ContinuationLimit)
380 );
381 }
382
383 #[test]
384 fn operate_goal_continues_past_ten_when_budget_remains() {
385 // #5052 regression: the old hardcoded cap of 10 must not be a terminal
386 // stop. With no terminal signal, pass 10, 11, and far beyond keep
387 // continuing because the default has no hidden ceiling.
388 for continuations in [10, 11, 100, 10_000] {
389 let progress = GoalProgress {
390 tokens_used: 5_000,
391 time_used_seconds: 300,
392 continuations,
393 };
394 let budget = GoalBudget::with_token_budget(1_000_000);
395 assert_eq!(
396 decide_continuation(GoalRunStatus::Active, progress, budget),
397 ContinuationDecision::Continue,
398 "pass {continuations} must continue toward the completion gate",
399 );
400 }
401 }
402
403 #[test]
404 fn configured_backstop_halts_pathological_loop() {
405 let backstop = 25;
406 let progress = GoalProgress {
407 continuations: backstop,
408 ..GoalProgress::default()
409 };
410 let budget = GoalBudget::unbounded().with_max_continuations(backstop);
411 assert_eq!(
412 decide_continuation(GoalRunStatus::Active, progress, budget),
413 ContinuationDecision::Stop(StopReason::ContinuationLimit)
414 );
415 }
416
417 #[test]
418 fn zero_backstop_is_unlimited_and_budget_advisory() {
419 // 0 = unlimited-with-budget-stops: no continuation count ends the run…
420 let progress = GoalProgress {
421 continuations: 10_000,
422 ..GoalProgress::default()
423 };
424 let budget = GoalBudget::unbounded().with_max_continuations(0);
425 assert_eq!(
426 decide_continuation(GoalRunStatus::Active, progress, budget),
427 ContinuationDecision::Continue
428 );
429
430 // exceeded token budget is advisory — must still continue (unbounded)
431 let progress = GoalProgress {
432 tokens_used: 1_000,
433 continuations: 10_000,
434 ..GoalProgress::default()
435 };
436 let budget = GoalBudget::with_token_budget(1_000).with_max_continuations(0);
437 assert_eq!(
438 decide_continuation(GoalRunStatus::Active, progress, budget),
439 ContinuationDecision::Continue,
440 "budget advisory — must continue even when over budget"
441 );
442 }
443
444 #[test]
445 fn enforced_token_budget_stops_the_run() {
446 let progress = GoalProgress {
447 tokens_used: 1000,
448 continuations: 1,
449 ..GoalProgress::default()
450 };
451 let budget = GoalBudget::with_token_budget(1000).with_enforced_token_budget(true);
452 assert_eq!(
453 decide_continuation(GoalRunStatus::Active, progress, budget),
454 ContinuationDecision::Stop(StopReason::BudgetLimit),
455 "enforce_token_budget makes an exhausted token budget terminal"
456 );
457 assert!(!is_success(StopReason::BudgetLimit));
458 }
459
460 #[test]
461 fn enforce_flag_without_a_token_budget_cannot_stop() {
462 // A goal created without `token_budget` has no ceiling to reach — the
463 // flag must not invent one.
464 let progress = GoalProgress {
465 tokens_used: u64::MAX / 2,
466 continuations: 1,
467 ..GoalProgress::default()
468 };
469 let budget = GoalBudget::unbounded().with_enforced_token_budget(true);
470 assert_eq!(
471 decide_continuation(GoalRunStatus::Active, progress, budget),
472 ContinuationDecision::Continue
473 );
474 }
475
476 #[test]
477 fn enforced_budget_still_yields_to_a_terminal_status() {
478 let budget = GoalBudget::with_token_budget(1000).with_enforced_token_budget(true);
479 assert_eq!(
480 decide_continuation(GoalRunStatus::Completed, GoalProgress::default(), budget),
481 ContinuationDecision::Stop(StopReason::Completed),
482 "a clean completion outranks the budget gate"
483 );
484 }
485
486 #[test]
487 fn token_budget_is_advisory_not_terminal() {
488 let progress = GoalProgress {
489 tokens_used: 1000,
490 continuations: 1,
491 ..GoalProgress::default()
492 };
493 let budget = GoalBudget::with_token_budget(1000);
494 assert_eq!(
495 decide_continuation(GoalRunStatus::Active, progress, budget),
496 ContinuationDecision::Continue,
497 "token budget is advisory — unbounded run must continue"
498 );
499 }
500
501 #[test]
502 fn time_budget_is_advisory_not_terminal() {
503 let progress = GoalProgress {
504 time_used_seconds: 601,
505 continuations: 1,
506 ..GoalProgress::default()
507 };
508 let budget = GoalBudget {
509 token_budget: None,
510 time_budget_seconds: Some(600),
511 enforce_token_budget: false,
512 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
513 };
514 assert_eq!(
515 decide_continuation(GoalRunStatus::Active, progress, budget),
516 ContinuationDecision::Continue,
517 "time budget is advisory — unbounded run must continue"
518 );
519 }
520
521 #[test]
522 fn terminal_status_outranks_remaining_budget() {
523 // Completed wins even if there is plenty of budget left.
524 let progress = GoalProgress::default();
525 let budget = GoalBudget {
526 token_budget: Some(1_000_000),
527 time_budget_seconds: Some(86_400),
528 enforce_token_budget: false,
529 max_continuations: DEFAULT_MAX_GOAL_CONTINUATIONS,
530 };
531 assert_eq!(
532 decide_continuation(GoalRunStatus::Completed, progress, budget),
533 ContinuationDecision::Stop(StopReason::Completed)
534 );
535 }
536
537 #[test]
538 fn continuation_wait_honors_configured_delay() {
539 assert_eq!(
540 continuation_wait(300),
541 Some(Duration::from_secs(300)),
542 "a positive configured delay must become the quiet-period wait"
543 );
544 assert_eq!(
545 continuation_wait(MAX_GOAL_CONTINUATION_DELAY_SECONDS + 1),
546 Some(Duration::from_secs(MAX_GOAL_CONTINUATION_DELAY_SECONDS)),
547 "the shared cap must bound an oversized configured delay"
548 );
549 }
550
551 #[test]
552 fn zero_delay_continues_immediately() {
553 assert_eq!(
554 continuation_wait(0),
555 None,
556 "an unset or zero delay must dispatch immediately"
557 );
558 }
559
560 #[tokio::test]
561 async fn cancellation_wins_over_pending_quiet_period() {
562 let cancel_token = tokio_util::sync::CancellationToken::new();
563 let canceller = cancel_token.clone();
564 tokio::spawn(async move {
565 tokio::time::sleep(Duration::from_millis(10)).await;
566 canceller.cancel();
567 });
568 assert_eq!(
569 await_continuation_wait(
570 continuation_wait(MAX_GOAL_CONTINUATION_DELAY_SECONDS),
571 &cancel_token,
572 )
573 .await,
574 ContinuationWaitOutcome::Cancelled,
575 "an explicit cancel during the quiet period must win and never dispatch"
576 );
577 }
578
579 #[tokio::test]
580 async fn elapsed_quiet_period_dispatches() {
581 let cancel_token = tokio_util::sync::CancellationToken::new();
582 assert_eq!(
583 await_continuation_wait(None, &cancel_token).await,
584 ContinuationWaitOutcome::Elapsed,
585 "an unset wait must gate the dispatch through immediately"
586 );
587 assert_eq!(
588 await_continuation_wait(Some(Duration::from_millis(1)), &cancel_token).await,
589 ContinuationWaitOutcome::Elapsed,
590 "an expired quiet period must dispatch"
591 );
592 }
593 }
594
594 lines RUST