返回 CodeWhale
goal.rs
根目录 / crates / tui / src / tools / goal.rs
1 //! Goal tools for the model-visible LLM-as-judge loop.
2 //!
3 //! The TUI already has a `/goal` command and passes its objective into the
4 //! engine prompt. This module keeps the runtime slice separate: a small
5 //! session-scoped state object plus tools the model can use to inspect and
6 //! close out that state.
7
8 use std::sync::{Arc, Mutex};
9 use std::time::Instant;
10
11 use async_trait::async_trait;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14 use sha2::{Digest, Sha256};
15
16 use crate::tools::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
18 };
19
20 /// Shared reference to the current runtime goal.
21 pub type SharedGoalState = Arc<Mutex<GoalState>>;
22
23 /// Create an empty shared goal state.
24 #[must_use]
25 pub fn new_shared_goal_state() -> SharedGoalState {
26 Arc::new(Mutex::new(GoalState::default()))
27 }
28
29 /// Create shared state seeded from the host goal surface with an explicit status.
30 #[must_use]
31 pub fn new_shared_goal_state_from_host_status(
32 objective: Option<String>,
33 token_budget: Option<u32>,
34 status: GoalStatus,
35 ) -> SharedGoalState {
36 let mut state = GoalState::default();
37 state.sync_from_host_status(objective.as_deref(), token_budget, status);
38 Arc::new(Mutex::new(state))
39 }
40
41 /// Restore the complete durable history; loading is not an explicit resume.
42 #[must_use]
43 pub fn new_shared_goal_state_from_snapshot(snapshot: &GoalSnapshot) -> SharedGoalState {
44 Arc::new(Mutex::new(GoalState::from_snapshot(snapshot)))
45 }
46
47 /// Runtime status for a goal.
48 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49 pub enum GoalStatus {
50 #[default]
51 Active,
52 Paused,
53 Complete,
54 Blocked,
55 }
56
57 impl GoalStatus {
58 #[must_use]
59 pub fn as_str(self) -> &'static str {
60 match self {
61 Self::Active => "active",
62 Self::Paused => "paused",
63 Self::Complete => "complete",
64 Self::Blocked => "blocked",
65 }
66 }
67 }
68
69 pub use codewhale_protocol::GoalPauseReason;
70
71 /// Whether a goal review is allowed to decide the judged contract.
72 ///
73 /// Critical reviews fail closed and may satisfy the completion gate. Advisory
74 /// reviews are append-only context: malformed or negative advice must never
75 /// pause, block, or complete the goal.
76 #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
77 #[serde(rename_all = "snake_case")]
78 pub enum GoalReviewRole {
79 #[default]
80 Critical,
81 Advisory,
82 }
83
84 /// Best-effort review context kept separate from the judged completion
85 /// contract. Notes are append-only for the lifetime of one objective.
86 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
87 pub struct GoalAdvisoryNote {
88 pub summary: String,
89 }
90
91 /// The model's own reported progress for the active goal: a coarse percent
92 /// plus what is happening now and what comes next. Runtime-only — the durable
93 /// record deliberately keeps no volatile progress projection. The percent is
94 /// the model's estimate, rendered as reported progress, never as a verified
95 /// fraction of the work.
96 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
97 pub struct GoalProgressReport {
98 pub percent: u8,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub now: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub next: Option<String>,
103 }
104
105 /// Session-local goal state. `Instant` stays runtime-only; snapshots expose
106 /// elapsed seconds so tool output remains serializable and stable.
107 #[derive(Debug, Clone, Default)]
108 pub struct GoalState {
109 goal_id: Option<String>,
110 objective: Option<String>,
111 token_budget: Option<u32>,
112 status: Option<GoalStatus>,
113 tokens_used: u64,
114 time_used_seconds: u64,
115 continuation_count: u32,
116 started_at: Option<Instant>,
117 finished_at: Option<Instant>,
118 evidence: Option<String>,
119 blocker: Option<String>,
120 pause_reason: Option<GoalPauseReason>,
121 completion_verification: Option<GoalCompletionVerification>,
122 advisories: Vec<GoalAdvisoryNote>,
123 last_gap_fingerprint: Option<String>,
124 repeated_gap_count: u32,
125 /// The continuation pass the repeated-gap counter last advanced on.
126 /// The bound is "equivalent gaps on consecutive PASSES", so a verifier
127 /// reporting the same gap several times inside one turn must not trip it
128 /// before any continuation has happened.
129 last_gap_pass: Option<u32>,
130 /// Latest reported progress, kept out of the stall accounting entirely.
131 progress: Option<GoalProgressReport>,
132 /// The current blocker was set by the runtime (a continuation turn that
133 /// failed, timed out or never started), not reported by the model or the
134 /// user. Such a stop is not a judgement about the work, so the user's next
135 /// message resumes the goal (see [`Self::resume_after_runtime_block`]).
136 /// Known limitation: session-local, like the rest of this state's
137 /// lifecycle detail; a restored Blocked goal needs `/goal resume`.
138 runtime_blocked: bool,
139 }
140
141 impl GoalState {
142 #[must_use]
143 pub fn objective(&self) -> Option<&str> {
144 self.objective.as_deref()
145 }
146
147 #[must_use]
148 pub fn token_budget(&self) -> Option<u32> {
149 self.token_budget
150 }
151
152 #[must_use]
153 pub fn is_active(&self) -> bool {
154 self.objective.is_some() && self.status == Some(GoalStatus::Active)
155 }
156
157 pub fn sync_from_host_status(
158 &mut self,
159 objective: Option<&str>,
160 token_budget: Option<u32>,
161 status: GoalStatus,
162 ) {
163 let objective = objective.map(str::trim).filter(|value| !value.is_empty());
164 match objective {
165 Some(objective) => {
166 let changed = self.objective.as_deref() != Some(objective);
167 let status_changed = self.status != Some(status);
168 let resumed = !changed
169 && status == GoalStatus::Active
170 && self
171 .status
172 .is_some_and(|previous| previous != GoalStatus::Active);
173 if changed {
174 self.goal_id = Some(uuid::Uuid::new_v4().to_string());
175 self.objective = Some(objective.to_string());
176 self.token_budget = token_budget;
177 self.tokens_used = 0;
178 self.time_used_seconds = 0;
179 self.continuation_count = 0;
180 self.started_at = Some(Instant::now());
181 self.evidence = None;
182 self.blocker = None;
183 self.pause_reason = None;
184 self.completion_verification = None;
185 self.advisories.clear();
186 self.last_gap_fingerprint = None;
187 self.repeated_gap_count = 0;
188 self.last_gap_pass = None;
189 self.progress = None;
190 } else if self.token_budget != token_budget {
191 self.token_budget = token_budget;
192 }
193
194 if resumed {
195 self.goal_id = Some(uuid::Uuid::new_v4().to_string());
196 self.evidence = None;
197 self.blocker = None;
198 self.pause_reason = None;
199 self.completion_verification = None;
200 self.last_gap_fingerprint = None;
201 self.repeated_gap_count = 0;
202 self.last_gap_pass = None;
203 self.progress = None;
204 }
205
206 if changed || status_changed || self.status.is_none() {
207 self.status = Some(status);
208 self.pause_reason = if status == GoalStatus::Paused {
209 Some(GoalPauseReason::User)
210 } else {
211 None
212 };
213 self.finished_at = if status == GoalStatus::Active {
214 None
215 } else {
216 Some(Instant::now())
217 };
218 }
219 }
220 None => self.clear(),
221 }
222 }
223
224 pub fn create(
225 &mut self,
226 objective: String,
227 token_budget: Option<u32>,
228 ) -> Result<(), &'static str> {
229 if self.objective.is_some() && self.status != Some(GoalStatus::Complete) {
230 return Err(
231 "An unfinished goal already exists. Complete or clear it before creating another.",
232 );
233 }
234 self.goal_id = Some(uuid::Uuid::new_v4().to_string());
235 self.objective = Some(objective);
236 self.token_budget = token_budget;
237 self.status = Some(GoalStatus::Active);
238 self.tokens_used = 0;
239 self.time_used_seconds = 0;
240 self.continuation_count = 0;
241 self.started_at = Some(Instant::now());
242 self.finished_at = None;
243 self.evidence = None;
244 self.blocker = None;
245 self.pause_reason = None;
246 self.completion_verification = None;
247 self.advisories.clear();
248 self.last_gap_fingerprint = None;
249 self.repeated_gap_count = 0;
250 self.last_gap_pass = None;
251 self.progress = None;
252 Ok(())
253 }
254
255 /// Restore goal state from a persisted runtime goal, keeping the
256 /// accumulated usage and continuation counters.
257 ///
258 /// Unlike [`Self::sync_from_host_status`], which resets the counters
259 /// whenever the objective changes, this constructor treats the persisted
260 /// values as the authoritative history: the durable store owns them and
261 /// the engine is rehydrating, not re-declaring, the goal. Evidence,
262 /// blockers, and review notes are runtime-only and start empty; the
263 /// durable loop re-derives them on the next pass.
264 ///
265 #[must_use]
266 pub fn from_persisted(
267 objective: &str,
268 token_budget: Option<u32>,
269 status: GoalStatus,
270 pause_reason: Option<GoalPauseReason>,
271 tokens_used: u64,
272 time_used_seconds: u64,
273 continuation_count: u32,
274 ) -> Self {
275 Self {
276 goal_id: None,
277 objective: Some(objective.to_string()),
278 token_budget,
279 status: Some(status),
280 tokens_used,
281 time_used_seconds,
282 continuation_count,
283 started_at: Some(Instant::now()),
284 finished_at: (status != GoalStatus::Active).then(Instant::now),
285 evidence: None,
286 blocker: None,
287 pause_reason,
288 completion_verification: None,
289 advisories: Vec::new(),
290 last_gap_fingerprint: None,
291 repeated_gap_count: 0,
292 last_gap_pass: None,
293 progress: None,
294 // The origin of a persisted blocker is not recorded; treat it as
295 // reported so only an explicit resume clears it.
296 runtime_blocked: false,
297 }
298 }
299
300 /// Keep the pre-pause review window on ordinary load. Invalid in-memory
301 /// input is held paused; durable stores reject it before this constructor.
302 #[must_use]
303 pub fn from_snapshot(snapshot: &GoalSnapshot) -> Self {
304 let Some(objective) = snapshot.objective.as_deref() else {
305 return Self::default();
306 };
307 let status = match snapshot.status.as_str() {
308 "active" => GoalStatus::Active,
309 "complete" => GoalStatus::Complete,
310 "blocked" => GoalStatus::Blocked,
311 _ => GoalStatus::Paused,
312 };
313 let mut state = Self::from_persisted(
314 objective,
315 snapshot.token_budget,
316 status,
317 snapshot.pause_reason,
318 snapshot.tokens_used,
319 snapshot.time_used_seconds,
320 snapshot.continuation_count,
321 );
322 state.goal_id.clone_from(&snapshot.goal_id);
323 state
324 .last_gap_fingerprint
325 .clone_from(&snapshot.last_gap_fingerprint);
326 state.repeated_gap_count = snapshot.repeated_gap_count;
327 state.last_gap_pass = snapshot.last_gap_pass;
328 state.progress = snapshot.progress.clone();
329 let now = Instant::now();
330 state.started_at = now
331 .checked_sub(std::time::Duration::from_secs(
332 snapshot
333 .elapsed_seconds
334 .unwrap_or(snapshot.time_used_seconds),
335 ))
336 .or(Some(now));
337 let stall_window_exhausted = state.status == Some(GoalStatus::Active)
338 && state.repeated_gap_count >= crate::goal_loop::MAX_REPEATED_GAP_PASSES;
339 if let Err(error) = snapshot.validate_stall_state() {
340 tracing::warn!("holding invalid restored goal paused: {error}");
341 state.status = Some(GoalStatus::Paused);
342 state.pause_reason = Some(GoalPauseReason::NoProgress);
343 state.finished_at = Some(now);
344 } else if stall_window_exhausted {
345 // The engine pauses NoProgress in the same mutation that fills
346 // the stall window, so a restored Active goal at the ceiling is
347 // corrupt; hold it paused rather than re-arming spent passes.
348 tracing::warn!("holding exhausted-stall-window restored goal paused");
349 state.status = Some(GoalStatus::Paused);
350 state.pause_reason = Some(GoalPauseReason::NoProgress);
351 state.finished_at = Some(now);
352 }
353 state
354 }
355
356 /// An accepted user resume is a new control revision, even when already
357 /// active. Cached loads never call this path.
358 pub fn resume(&mut self, goal_id: Option<String>) {
359 let objective = self.objective.clone();
360 self.sync_from_host_status(objective.as_deref(), self.token_budget, GoalStatus::Active);
361 if self.objective.is_some() {
362 self.goal_id = Some(goal_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()));
363 self.last_gap_fingerprint = None;
364 self.repeated_gap_count = 0;
365 self.last_gap_pass = None;
366 self.progress = None;
367 }
368 }
369
370 /// A new explicit declaration replaces the old revision, including when
371 /// the user repeats the same objective text.
372 pub fn replace(&mut self, objective: &str, token_budget: Option<u32>, goal_id: Option<String>) {
373 self.clear();
374 self.sync_from_host_status(Some(objective), token_budget, GoalStatus::Active);
375 if let Some(goal_id) = goal_id {
376 self.goal_id = Some(goal_id);
377 }
378 }
379
380 pub fn record_usage(&mut self, token_delta: u64, time_delta_seconds: u64) {
381 if self.is_active() {
382 self.tokens_used = self.tokens_used.saturating_add(token_delta);
383 self.time_used_seconds = self.time_used_seconds.saturating_add(time_delta_seconds);
384 }
385 }
386
387 pub fn record_continuation(&mut self) {
388 if self.is_active() {
389 self.continuation_count = self.continuation_count.saturating_add(1);
390 }
391 }
392
393 pub fn mark_complete(
394 &mut self,
395 evidence: String,
396 mut verification: GoalCompletionVerification,
397 ) -> Result<(), &'static str> {
398 if self.objective.is_none() {
399 return Err("No active goal exists to complete.");
400 }
401 if self.status == Some(GoalStatus::Complete) || self.completion_verification.is_some() {
402 return Err("The judged completion contract is already sealed and cannot be replaced.");
403 }
404 if verification.role != GoalReviewRole::Critical {
405 return Err("An advisory review cannot complete the judged goal contract.");
406 }
407 verification.contract_fingerprint = completion_contract_fingerprint(
408 self.objective.as_deref().unwrap_or_default(),
409 &verification,
410 );
411 self.status = Some(GoalStatus::Complete);
412 self.finished_at = Some(Instant::now());
413 self.evidence = Some(evidence);
414 self.blocker = None;
415 self.pause_reason = None;
416 self.completion_verification = Some(verification);
417 Ok(())
418 }
419
420 /// Replace the reported progress projection. This never touches the
421 /// stall window or lifecycle state; it is display context only.
422 pub fn record_progress(&mut self, progress: GoalProgressReport) {
423 if self.is_active() {
424 self.progress = Some(progress);
425 }
426 }
427
428 pub fn record_advisory(&mut self, summary: String) -> Result<(), &'static str> {
429 if !self.is_active() {
430 return Err("Advisory notes require an active goal.");
431 }
432 const MAX_ADVISORY_NOTES: usize = 16;
433 if self.advisories.len() == MAX_ADVISORY_NOTES {
434 self.advisories.remove(0);
435 }
436 self.advisories.push(GoalAdvisoryNote { summary });
437 Ok(())
438 }
439
440 pub fn record_not_achieved(
441 &mut self,
442 verification: GoalProgressVerification,
443 ) -> Result<(), &'static str> {
444 if !self.is_active() {
445 return Err("Verifier progress requires an active goal.");
446 }
447 if verification.role == GoalReviewRole::Advisory {
448 return self
449 .record_advisory(format!("{}: {}", verification.check, verification.summary));
450 }
451
452 let fingerprint = gap_fingerprint(&verification.gaps)
453 .ok_or("Critical not-achieved verification requires at least one concrete gap.")?;
454 // Advance at most once per continuation pass. `record_not_achieved`
455 // runs per `update_goal` tool call, so counting calls would let a
456 // verifier that reports one gap three times in a single turn pause the
457 // goal before a single continuation had been spent — stopping valid
458 // work rather than a stall.
459 let same_gap = self.last_gap_fingerprint.as_deref() == Some(&fingerprint);
460 let already_counted_this_pass = self.last_gap_pass == Some(self.continuation_count);
461 self.repeated_gap_count = if !same_gap {
462 1
463 } else if already_counted_this_pass {
464 self.repeated_gap_count
465 } else {
466 self.repeated_gap_count.saturating_add(1)
467 };
468 self.last_gap_pass = Some(self.continuation_count);
469 self.last_gap_fingerprint = Some(fingerprint);
470
471 // The stall bound the continuation prompt promises. Pausing *is* the
472 // stop: both continuation dispatchers refuse to re-dispatch a goal
473 // whose snapshot is not "active", and the runtime host mirrors a
474 // non-limit pause into the durable `ThreadGoalStatus::Paused`, so this
475 // needs no second gate in `decide_continuation` and survives a restart
476 // until someone explicitly resumes.
477 if self.repeated_gap_count >= crate::goal_loop::MAX_REPEATED_GAP_PASSES {
478 tracing::warn!(
479 repeated_gap_count = self.repeated_gap_count,
480 max_repeated_gap_passes = crate::goal_loop::MAX_REPEATED_GAP_PASSES,
481 "goal stall pause: critical verifier reported an equivalent gap set on \
482 consecutive passes; pausing for inspection instead of spending further"
483 );
484 self.mark_paused(GoalPauseReason::NoProgress)?;
485 }
486
487 Ok(())
488 }
489
490 /// Block on a runtime stop rather than a reported blocker; see
491 /// [`Self::runtime_blocked`].
492 pub fn mark_runtime_blocked(&mut self, blocker: String) -> Result<(), &'static str> {
493 self.mark_blocked(blocker)?;
494 self.runtime_blocked = true;
495 Ok(())
496 }
497
498 /// Resume a goal whose only blocker was a runtime stop, as a new control
499 /// revision. Returns false, changing nothing, for any other state: a
500 /// reported blocker stays until an explicit resume.
501 pub fn resume_after_runtime_block(&mut self) -> bool {
502 if !(self.runtime_blocked && self.status == Some(GoalStatus::Blocked)) {
503 return false;
504 }
505 self.resume(None);
506 self.runtime_blocked = false;
507 true
508 }
509
510 /// Whether a judged completion has sealed this goal. A sealed goal is
511 /// terminal: blocking or pausing it would overwrite the verified
512 /// completion, so only an explicit resume or a new goal moves it on.
513 fn completion_sealed(&self) -> bool {
514 self.status == Some(GoalStatus::Complete) || self.completion_verification.is_some()
515 }
516
517 pub fn mark_blocked(&mut self, blocker: String) -> Result<(), &'static str> {
518 if self.objective.is_none() {
519 return Err("No active goal exists to block.");
520 }
521 if self.completion_sealed() {
522 return Err(
523 "The goal is already complete with a judged verification; it cannot be blocked.",
524 );
525 }
526 self.runtime_blocked = false;
527 self.status = Some(GoalStatus::Blocked);
528 self.finished_at = Some(Instant::now());
529 self.blocker = Some(blocker);
530 self.evidence = None;
531 self.pause_reason = None;
532 self.completion_verification = None;
533 Ok(())
534 }
535
536 pub fn mark_paused(&mut self, reason: GoalPauseReason) -> Result<(), &'static str> {
537 if self.objective.is_none() {
538 return Err("No active goal exists to pause.");
539 }
540 if self.completion_sealed() {
541 return Err(
542 "The goal is already complete with a judged verification; it cannot be paused.",
543 );
544 }
545 self.status = Some(GoalStatus::Paused);
546 self.finished_at = Some(Instant::now());
547 self.pause_reason = Some(reason);
548 self.evidence = None;
549 self.blocker = None;
550 self.completion_verification = None;
551 Ok(())
552 }
553
554 pub fn clear(&mut self) {
555 *self = Self::default();
556 }
557
558 #[must_use]
559 pub fn snapshot(&self) -> GoalSnapshot {
560 // Once the goal is terminal, freeze elapsed at the finish time so the
561 // sidebar timer (and any tool snapshot) stops growing after completion.
562 let elapsed_seconds = match (self.started_at, self.finished_at) {
563 (Some(started), Some(finished)) => {
564 Some(finished.saturating_duration_since(started).as_secs())
565 }
566 (Some(started), None) => Some(started.elapsed().as_secs()),
567 (None, _) => None,
568 };
569 GoalSnapshot {
570 goal_id: self.goal_id.clone(),
571 objective: self.objective.clone(),
572 status: self
573 .status
574 .map(GoalStatus::as_str)
575 .unwrap_or("none")
576 .to_string(),
577 token_budget: self.token_budget,
578 tokens_used: self.tokens_used,
579 time_used_seconds: self.time_used_seconds,
580 continuation_count: self.continuation_count,
581 elapsed_seconds,
582 evidence: self.evidence.clone(),
583 blocker: self.blocker.clone(),
584 pause_reason: self.pause_reason,
585 completion_verification: self.completion_verification.clone(),
586 advisories: self.advisories.clone(),
587 last_gap_fingerprint: self.last_gap_fingerprint.clone(),
588 repeated_gap_count: self.repeated_gap_count,
589 last_gap_pass: self.last_gap_pass,
590 progress: self.progress.clone(),
591 }
592 }
593 }
594
595 /// Serializable tool output and prompt input for the current goal.
596 #[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
597 pub struct GoalSnapshot {
598 pub goal_id: Option<String>,
599 pub objective: Option<String>,
600 pub status: String,
601 pub token_budget: Option<u32>,
602 pub tokens_used: u64,
603 pub time_used_seconds: u64,
604 pub continuation_count: u32,
605 pub elapsed_seconds: Option<u64>,
606 pub evidence: Option<String>,
607 pub blocker: Option<String>,
608 pub pause_reason: Option<GoalPauseReason>,
609 pub completion_verification: Option<GoalCompletionVerification>,
610 pub advisories: Vec<GoalAdvisoryNote>,
611 pub last_gap_fingerprint: Option<String>,
612 pub repeated_gap_count: u32,
613 pub last_gap_pass: Option<u32>,
614 /// Latest reported progress. Skipped when absent so tool output and the
615 /// continuation prompt stay stable for goals that never report one.
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub progress: Option<GoalProgressReport>,
618 }
619
620 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
621 pub struct GoalCompletionVerification {
622 pub status: String,
623 pub check: String,
624 pub summary: String,
625 #[serde(default)]
626 pub role: GoalReviewRole,
627 #[serde(default)]
628 pub contract_fingerprint: String,
629 }
630
631 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
632 pub struct GoalProgressVerification {
633 pub status: String,
634 pub check: String,
635 pub summary: String,
636 #[serde(default)]
637 pub role: GoalReviewRole,
638 #[serde(default)]
639 pub gaps: Vec<String>,
640 }
641
642 fn completion_contract_fingerprint(
643 objective: &str,
644 verification: &GoalCompletionVerification,
645 ) -> String {
646 let mut hasher = Sha256::new();
647 for field in [
648 objective.trim(),
649 verification.status.trim(),
650 verification.check.trim(),
651 verification.summary.trim(),
652 ] {
653 hasher.update(field.as_bytes());
654 hasher.update([0]);
655 }
656 hasher
657 .finalize()
658 .iter()
659 .map(|byte| format!("{byte:02x}"))
660 .collect()
661 }
662
663 fn gap_fingerprint(gaps: &[String]) -> Option<String> {
664 let mut normalized = gaps
665 .iter()
666 .map(|gap| {
667 gap.split_whitespace()
668 .collect::<Vec<_>>()
669 .join(" ")
670 .to_lowercase()
671 })
672 .filter(|gap| !gap.is_empty())
673 .collect::<Vec<_>>();
674 normalized.sort_unstable();
675 normalized.dedup();
676 if normalized.is_empty() {
677 return None;
678 }
679
680 let mut hasher = Sha256::new();
681 hasher.update(b"codewhale-goal-gaps-v1\0");
682 for gap in normalized {
683 hasher.update(gap.as_bytes());
684 hasher.update([0]);
685 }
686 Some(
687 hasher
688 .finalize()
689 .iter()
690 .map(|byte| format!("{byte:02x}"))
691 .collect(),
692 )
693 }
694
695 impl GoalSnapshot {
696 #[must_use]
697 pub fn is_active(&self) -> bool {
698 self.objective.is_some() && self.status == GoalStatus::Active.as_str()
699 }
700
701 pub fn validate_stall_state(&self) -> Result<(), &'static str> {
702 codewhale_protocol::validate_goal_stall_state(
703 self.last_gap_fingerprint.as_deref(),
704 self.repeated_gap_count,
705 self.last_gap_pass,
706 self.continuation_count,
707 )
708 }
709
710 #[must_use]
711 pub fn from_thread_goal(goal: &codewhale_protocol::ThreadGoal) -> Self {
712 let (status, pause_reason) = thread_goal_status_projection(goal.status.clone());
713 Self {
714 goal_id: Some(goal.goal_id.clone()),
715 objective: Some(goal.objective.clone()),
716 status: status.as_str().to_string(),
717 token_budget: goal
718 .token_budget
719 .and_then(|value| u32::try_from(value.max(0)).ok()),
720 tokens_used: u64::try_from(goal.tokens_used.max(0)).unwrap_or(u64::MAX),
721 time_used_seconds: u64::try_from(goal.time_used_seconds.max(0)).unwrap_or(u64::MAX),
722 continuation_count: u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX),
723 elapsed_seconds: None,
724 evidence: None,
725 blocker: None,
726 pause_reason: goal.pause_reason.or(pause_reason),
727 completion_verification: None,
728 advisories: Vec::new(),
729 last_gap_fingerprint: goal.last_gap_fingerprint.clone(),
730 repeated_gap_count: goal.repeated_gap_count,
731 last_gap_pass: goal.last_gap_pass,
732 progress: None,
733 }
734 }
735 }
736
737 #[must_use]
738 pub fn thread_goal_status_projection(
739 status: codewhale_protocol::ThreadGoalStatus,
740 ) -> (GoalStatus, Option<GoalPauseReason>) {
741 match status {
742 codewhale_protocol::ThreadGoalStatus::Active => (GoalStatus::Active, None),
743 codewhale_protocol::ThreadGoalStatus::Paused => {
744 (GoalStatus::Paused, Some(GoalPauseReason::User))
745 }
746 codewhale_protocol::ThreadGoalStatus::Complete => (GoalStatus::Complete, None),
747 codewhale_protocol::ThreadGoalStatus::Blocked => (GoalStatus::Blocked, None),
748 codewhale_protocol::ThreadGoalStatus::UsageLimited => {
749 (GoalStatus::Paused, Some(GoalPauseReason::UsageLimit))
750 }
751 codewhale_protocol::ThreadGoalStatus::BudgetLimited => {
752 (GoalStatus::Paused, Some(GoalPauseReason::BudgetLimit))
753 }
754 }
755 }
756
757 /// Render the continuation prompt injected when a goal is still active after a
758 /// turn. This shows progress and lets the circuit breaker remain an
759 /// implementation detail rather than encouraging the model to spend the cap.
760 #[must_use]
761 pub fn render_continuation_prompt(snapshot: &GoalSnapshot, continuation_index: u32) -> String {
762 let goal_json = serde_json::to_string_pretty(snapshot).unwrap_or_else(|_| "{}".to_string());
763 format!(
764 "{}\n\n## Active Goal State\n\n```json\n{}\n```\n\nContinuation pass #{}.\nIf a critical verifier finds remaining work, call `update_goal` with `status: \"not_achieved\"` and its concrete `verification.gaps`; {} equivalent gap sets in a row pause this goal (`no progress`) for inspection instead of spending indefinitely, so report what actually still fails rather than restating the previous pass. If the goal is complete, first run or cite a concrete verifier/check when one applies, then call `update_goal` with `status: \"complete\"`, concrete evidence, and `verification: {{\"status\":\"passed\",\"check\":\"...\",\"summary\":\"...\"}}`. For non-verifiable work (docs, research, writing), use `verification: {{\"status\":\"not_applicable\",\"check\":\"...\",\"summary\":\"...\"}}` with a clear rationale instead of fabricating a verifier receipt. If it is blocked, call `update_goal` with `status: \"blocked\"` and the blocker. Otherwise continue making progress toward the objective.",
765 crate::prompts::GOAL_CONTINUATION_PROMPT.trim(),
766 goal_json,
767 continuation_index,
768 crate::goal_loop::MAX_REPEATED_GAP_PASSES,
769 )
770 }
771
772 /// Render the reported-progress bar used by the transcript receipt and the
773 /// metrics line: eight cells, filled in proportion to the percent. The bar
774 /// visualizes a model-reported estimate; it is not a verified fraction.
775 #[must_use]
776 pub fn goal_progress_bar(percent: u8) -> String {
777 const CELLS: usize = 8;
778 let filled = (usize::from(percent.min(100)) * CELLS + 50) / 100;
779 let mut bar = String::with_capacity(CELLS * 3);
780 bar.push_str(&"▓".repeat(filled));
781 bar.push_str(&"░".repeat(CELLS - filled));
782 bar
783 }
784
785 fn lock_goal_state(
786 state: &SharedGoalState,
787 ) -> Result<std::sync::MutexGuard<'_, GoalState>, ToolError> {
788 state
789 .lock()
790 .map_err(|_| ToolError::execution_failed("goal state lock poisoned"))
791 }
792
793 fn parse_token_budget(input: &Value) -> Result<Option<u32>, ToolError> {
794 let Some(raw) = input.get("token_budget") else {
795 return Ok(None);
796 };
797 if raw.is_null() {
798 return Ok(None);
799 }
800 let Some(value) = raw.as_u64() else {
801 return Err(ToolError::invalid_input(
802 "token_budget must be a non-negative integer",
803 ));
804 };
805 u32::try_from(value)
806 .map(Some)
807 .map_err(|_| ToolError::invalid_input("token_budget is too large"))
808 }
809
810 fn parse_completion_verification(input: &Value) -> Result<GoalCompletionVerification, ToolError> {
811 let Some(raw) = input.get("verification") else {
812 return Err(ToolError::invalid_input(
813 "verification is required when status is complete; run a verifier/check and pass verification: {status, check, summary}",
814 ));
815 };
816 let verification: GoalCompletionVerification = serde_json::from_value(raw.clone())
817 .map_err(|err| ToolError::invalid_input(format!("invalid verification: {err}")))?;
818 let status = verification.status.trim();
819 let normalized_status = match status {
820 "passed" | "not_applicable" => status,
821 other => {
822 return Err(ToolError::invalid_input(format!(
823 "verification.status must be 'passed' or 'not_applicable' before update_goal can mark a goal complete; got '{other}'"
824 )));
825 }
826 };
827 if verification.check.trim().is_empty() {
828 return Err(ToolError::invalid_input("verification.check is required"));
829 }
830 if verification.summary.trim().is_empty() {
831 return Err(ToolError::invalid_input("verification.summary is required"));
832 }
833 Ok(GoalCompletionVerification {
834 status: normalized_status.to_string(),
835 check: verification.check.trim().to_string(),
836 summary: verification.summary.trim().to_string(),
837 role: verification.role,
838 contract_fingerprint: String::new(),
839 })
840 }
841
842 fn parse_progress_verification(input: &Value) -> Result<GoalProgressVerification, ToolError> {
843 let Some(raw) = input.get("verification") else {
844 return Err(ToolError::invalid_input(
845 "verification is required when status is not_achieved",
846 ));
847 };
848 let mut verification: GoalProgressVerification = serde_json::from_value(raw.clone())
849 .map_err(|err| ToolError::invalid_input(format!("invalid verification: {err}")))?;
850 if verification.status.trim() != "not_achieved" {
851 return Err(ToolError::invalid_input(
852 "verification.status must be 'not_achieved' for progress review",
853 ));
854 }
855 verification.check = verification.check.trim().to_string();
856 verification.summary = verification.summary.trim().to_string();
857 if verification.check.is_empty() {
858 return Err(ToolError::invalid_input("verification.check is required"));
859 }
860 if verification.summary.is_empty() {
861 return Err(ToolError::invalid_input("verification.summary is required"));
862 }
863 Ok(verification)
864 }
865
866 fn parse_progress_report(input: &Value) -> Result<Option<GoalProgressReport>, ToolError> {
867 let Some(raw) = input.get("progress") else {
868 return Ok(None);
869 };
870 if raw.is_null() {
871 return Ok(None);
872 }
873 let percent = raw.get("percent").and_then(Value::as_u64).ok_or_else(|| {
874 ToolError::invalid_input("progress.percent must be an integer from 0 to 100")
875 })?;
876 let percent = u8::try_from(percent)
877 .ok()
878 .filter(|percent| *percent <= 100)
879 .ok_or_else(|| {
880 ToolError::invalid_input("progress.percent must be an integer from 0 to 100")
881 })?;
882 let note = |key: &str| -> Option<String> {
883 raw.get(key)
884 .and_then(Value::as_str)
885 .map(str::trim)
886 .filter(|value| !value.is_empty())
887 .map(|value| value.chars().take(160).collect())
888 };
889 Ok(Some(GoalProgressReport {
890 percent,
891 now: note("now"),
892 next: note("next"),
893 }))
894 }
895
896 fn json_result(snapshot: &GoalSnapshot) -> Result<ToolResult, ToolError> {
897 ToolResult::json(snapshot).map_err(|err| ToolError::execution_failed(err.to_string()))
898 }
899
900 fn require_root_goal_mutation(context: &ToolContext) -> Result<(), ToolError> {
901 if context.owner_agent_id.is_some() {
902 return Err(ToolError::invalid_input(
903 "Goal lifecycle mutation is root-agent only; sub-agents may inspect the parent goal with get_goal.",
904 ));
905 }
906 Ok(())
907 }
908
909 pub struct CreateGoalTool {
910 goal_state: SharedGoalState,
911 }
912
913 impl CreateGoalTool {
914 #[must_use]
915 pub fn new(goal_state: SharedGoalState) -> Self {
916 Self { goal_state }
917 }
918 }
919
920 #[async_trait]
921 impl ToolSpec for CreateGoalTool {
922 fn name(&self) -> &'static str {
923 "create_goal"
924 }
925
926 fn description(&self) -> &'static str {
927 "Create the session's one persistent goal: a completion objective Codewhale keeps working toward across turns until it is verified complete, blocked, or the user stops it. You decide when a request is a durable objective worth carrying across turns — a multi-step outcome the user will want continued and verified. Do not create a goal for a question, a greeting, a one-shot edit, or a conversational probe; those are ordinary turns. When the user explicitly asks to use `/goal` or asks you to make something the goal, call `create_goal` before doing the rest of the work; acknowledging it in prose is not sufficient. Keep the user's full objective, not a shortened one-turn version. Set token_budget only when the user explicitly provides one. Creating a goal shows the user a one-line receipt (they can /goal pause or /goal clear); do not also ask for confirmation. Only one unfinished goal exists at a time: complete or clear it before creating another."
928 }
929
930 fn input_schema(&self) -> Value {
931 json!({
932 "type": "object",
933 "properties": {
934 "objective": {
935 "type": "string",
936 "description": "The full objective to pursue. Keep the complete user goal, not a shortened one-turn version."
937 },
938 "token_budget": {
939 "type": "integer",
940 "minimum": 0,
941 "description": "Optional soft token budget for the goal."
942 }
943 },
944 "required": ["objective"],
945 "additionalProperties": false
946 })
947 }
948
949 fn capabilities(&self) -> Vec<ToolCapability> {
950 Vec::new()
951 }
952
953 fn approval_requirement(&self) -> ApprovalRequirement {
954 ApprovalRequirement::Auto
955 }
956
957 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
958 require_root_goal_mutation(context)?;
959 let objective = required_str(&input, "objective")?.trim().to_string();
960 if objective.is_empty() {
961 return Err(ToolError::invalid_input("objective cannot be empty"));
962 }
963 let token_budget = parse_token_budget(&input)?;
964 let snapshot = {
965 let mut state = lock_goal_state(&self.goal_state)?;
966 state
967 .create(objective, token_budget)
968 .map_err(ToolError::invalid_input)?;
969 state.snapshot()
970 };
971 json_result(&snapshot)
972 }
973 }
974
975 pub struct GetGoalTool {
976 goal_state: SharedGoalState,
977 }
978
979 impl GetGoalTool {
980 #[must_use]
981 pub fn new(goal_state: SharedGoalState) -> Self {
982 Self { goal_state }
983 }
984 }
985
986 #[async_trait]
987 impl ToolSpec for GetGoalTool {
988 fn name(&self) -> &'static str {
989 "get_goal"
990 }
991
992 fn description(&self) -> &'static str {
993 "Inspect the current runtime goal state, including objective, status, token budget, elapsed time, evidence, and blocker."
994 }
995
996 fn input_schema(&self) -> Value {
997 json!({
998 "type": "object",
999 "properties": {},
1000 "additionalProperties": false
1001 })
1002 }
1003
1004 fn capabilities(&self) -> Vec<ToolCapability> {
1005 vec![ToolCapability::ReadOnly]
1006 }
1007
1008 fn approval_requirement(&self) -> ApprovalRequirement {
1009 ApprovalRequirement::Auto
1010 }
1011
1012 fn supports_parallel(&self) -> bool {
1013 true
1014 }
1015
1016 async fn execute(
1017 &self,
1018 _input: Value,
1019 _context: &ToolContext,
1020 ) -> Result<ToolResult, ToolError> {
1021 let snapshot = {
1022 let state = lock_goal_state(&self.goal_state)?;
1023 state.snapshot()
1024 };
1025 json_result(&snapshot)
1026 }
1027 }
1028
1029 pub struct UpdateGoalTool {
1030 goal_state: SharedGoalState,
1031 }
1032
1033 impl UpdateGoalTool {
1034 #[must_use]
1035 pub fn new(goal_state: SharedGoalState) -> Self {
1036 Self { goal_state }
1037 }
1038 }
1039
1040 #[async_trait]
1041 impl ToolSpec for UpdateGoalTool {
1042 fn name(&self) -> &'static str {
1043 "update_goal"
1044 }
1045
1046 fn description(&self) -> &'static str {
1047 "Update the runtime goal completion gate by calling this tool; a prose status in your answer does not change the goal or stop continuation. Critical verification may seal one immutable completion contract. Advisory review is append-only context and never completes, blocks, or pauses the goal. Mark blocked when progress requires user input."
1048 }
1049
1050 fn input_schema(&self) -> Value {
1051 json!({
1052 "type": "object",
1053 "properties": {
1054 "status": {
1055 "type": "string",
1056 "enum": ["complete", "blocked", "not_achieved", "advisory"],
1057 "description": "Use complete only when a critical verifier proves the goal; not_achieved to record verifier gaps; blocked when meaningful progress cannot continue; advisory to append best-effort context without changing lifecycle state."
1058 },
1059 "evidence": {
1060 "type": "string",
1061 "description": "Required when status is complete. Briefly cite the proof that the goal is done."
1062 },
1063 "verification": {
1064 "type": "object",
1065 "description": "Required when status is complete or not_achieved. A verifier-as-judge receipt from a concrete check, such as Run action=\"verifiers\" or an equivalent project-specific gate.",
1066 "properties": {
1067 "status": {
1068 "type": "string",
1069 "enum": ["passed", "not_applicable", "not_achieved"],
1070 "description": "Use passed when a concrete verifier/check succeeded; not_applicable when no automated verifier applies; not_achieved when the verifier found concrete remaining gaps."
1071 },
1072 "check": {
1073 "type": "string",
1074 "description": "The verifier/check that passed."
1075 },
1076 "summary": {
1077 "type": "string",
1078 "description": "Brief result summary from the verifier/check."
1079 },
1080 "role": {
1081 "type": "string",
1082 "enum": ["critical", "advisory"],
1083 "description": "Critical reviews may satisfy the judged completion contract. Advisory reviews are fail-open and cannot complete it. Defaults to critical for compatibility."
1084 },
1085 "gaps": {
1086 "type": "array",
1087 "items": {"type": "string"},
1088 "description": "Concrete remaining gaps. Required for critical not_achieved reviews; order and duplicate wording do not affect the stall fingerprint."
1089 }
1090 },
1091 "required": ["status", "check", "summary"],
1092 "additionalProperties": false
1093 },
1094 "blocker": {
1095 "type": "string",
1096 "description": "Required when status is blocked. Explain the condition preventing progress."
1097 },
1098 "advisory": {
1099 "type": "string",
1100 "description": "Required when status is advisory. Appended separately from the judged completion contract."
1101 },
1102 "progress": {
1103 "type": "object",
1104 "description": "Optional with not_achieved or advisory: your current best estimate of overall completion, shown to the user as reported progress. Keep percent honest — it is an estimate, never a verified fraction.",
1105 "properties": {
1106 "percent": {
1107 "type": "integer",
1108 "minimum": 0,
1109 "maximum": 100,
1110 "description": "Estimated percent complete, 0-100."
1111 },
1112 "now": {
1113 "type": "string",
1114 "description": "One short line: what is being worked on right now."
1115 },
1116 "next": {
1117 "type": "string",
1118 "description": "One short line: what comes next."
1119 }
1120 },
1121 "required": ["percent"],
1122 "additionalProperties": false
1123 }
1124 },
1125 "required": ["status"],
1126 "additionalProperties": false
1127 })
1128 }
1129
1130 fn capabilities(&self) -> Vec<ToolCapability> {
1131 Vec::new()
1132 }
1133
1134 fn approval_requirement(&self) -> ApprovalRequirement {
1135 ApprovalRequirement::Auto
1136 }
1137
1138 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
1139 require_root_goal_mutation(context)?;
1140 // #5123-class: `objective` used to be accepted and silently ignored
1141 // with a success receipt. The objective is immutable after
1142 // create_goal; fail fast and name the corrective path.
1143 if input
1144 .get("objective")
1145 .and_then(Value::as_str)
1146 .is_some_and(|value| !value.trim().is_empty())
1147 {
1148 return Err(ToolError::invalid_input(
1149 "update_goal cannot change the objective — it is immutable after create_goal. \
1150 Mark the current goal complete or blocked, then create_goal with the new objective",
1151 ));
1152 }
1153 let status = required_str(&input, "status")?.trim().to_ascii_lowercase();
1154 let progress = parse_progress_report(&input)?;
1155 if progress.is_some() && !matches!(status.as_str(), "not_achieved" | "advisory") {
1156 return Err(ToolError::invalid_input(
1157 "progress is only accepted with status not_achieved or advisory",
1158 ));
1159 }
1160 let snapshot = {
1161 let mut state = lock_goal_state(&self.goal_state)?;
1162 // #6542: with no goal there is nothing to update. Say so as a
1163 // successful no-op rather than an error the model retries.
1164 if state.objective.is_none() {
1165 return Ok(ToolResult::success(format!(
1166 "No goal is set, so update_goal(status: {status}) changed nothing. \
1167 Continue the user's request directly; create_goal only if the user \
1168 asked for a tracked goal."
1169 )));
1170 }
1171 match status.as_str() {
1172 "complete" => {
1173 let evidence = input
1174 .get("evidence")
1175 .and_then(Value::as_str)
1176 .map(str::trim)
1177 .unwrap_or_default()
1178 .to_string();
1179 if evidence.is_empty() {
1180 return Err(ToolError::invalid_input(
1181 "evidence is required when status is complete",
1182 ));
1183 }
1184 let verification = parse_completion_verification(&input)?;
1185 state
1186 .mark_complete(evidence, verification)
1187 .map_err(ToolError::invalid_input)?;
1188 }
1189 "blocked" => {
1190 let blocker = input
1191 .get("blocker")
1192 .and_then(Value::as_str)
1193 .map(str::trim)
1194 .unwrap_or_default()
1195 .to_string();
1196 if blocker.is_empty() {
1197 return Err(ToolError::invalid_input(
1198 "blocker is required when status is blocked",
1199 ));
1200 }
1201 state
1202 .mark_blocked(blocker)
1203 .map_err(ToolError::invalid_input)?;
1204 }
1205 "not_achieved" => {
1206 let verification = parse_progress_verification(&input)?;
1207 state
1208 .record_not_achieved(verification)
1209 .map_err(ToolError::invalid_input)?;
1210 if let Some(progress) = progress {
1211 state.record_progress(progress);
1212 }
1213 }
1214 "advisory" => {
1215 let advisory = input
1216 .get("advisory")
1217 .and_then(Value::as_str)
1218 .map(str::trim)
1219 .unwrap_or_default()
1220 .to_string();
1221 if advisory.is_empty() {
1222 return Err(ToolError::invalid_input(
1223 "advisory is required when status is advisory",
1224 ));
1225 }
1226 state
1227 .record_advisory(advisory)
1228 .map_err(ToolError::invalid_input)?;
1229 if let Some(progress) = progress {
1230 state.record_progress(progress);
1231 }
1232 }
1233 other => {
1234 return Err(ToolError::invalid_input(format!(
1235 "unsupported goal status '{other}'; update_goal can only mark complete or blocked, record not_achieved verifier gaps, or append advisory context"
1236 )));
1237 }
1238 }
1239 state.snapshot()
1240 };
1241 json_result(&snapshot)
1242 }
1243 }
1244
1245 #[cfg(test)]
1246 mod tests {
1247 use serde_json::{Value, json};
1248
1249 use super::*;
1250
1251 #[tokio::test]
1252 async fn update_goal_rejects_objective_knob_instead_of_ignoring_it() {
1253 // #5123-class: `objective` used to return a success receipt with no
1254 // behavior. It is immutable after create_goal; the knob is gone from
1255 // the schema and supplying it fails fast with the corrective path.
1256 let state = new_shared_goal_state();
1257 let ctx = ToolContext::new(".");
1258 let create = CreateGoalTool::new(state.clone());
1259 create
1260 .execute(json!({"objective": "ship the runtime slice"}), &ctx)
1261 .await
1262 .expect("create goal");
1263
1264 let update = UpdateGoalTool::new(state.clone());
1265 let schema = update.input_schema();
1266 assert!(
1267 schema["properties"].get("objective").is_none(),
1268 "ignored objective knob must not be advertised: {schema}"
1269 );
1270
1271 let err = update
1272 .execute(
1273 json!({"status": "blocked", "blocker": "x", "objective": "different goal"}),
1274 &ctx,
1275 )
1276 .await
1277 .expect_err("objective must not be silently ignored");
1278 let message = format!("{err}");
1279 assert!(message.contains("immutable"), "{message}");
1280 assert!(message.contains("create_goal"), "{message}");
1281 // The rejected call must not have mutated goal state.
1282 assert!(state.lock().expect("goal lock").is_active());
1283 }
1284
1285 #[tokio::test]
1286 async fn update_goal_without_a_goal_is_a_clear_no_op() {
1287 let state = new_shared_goal_state();
1288 let update = UpdateGoalTool::new(state.clone());
1289 for input in [
1290 json!({"status": "complete", "evidence": "done"}),
1291 json!({"status": "blocked", "blocker": "x"}),
1292 json!({"status": "advisory", "advisory": "note"}),
1293 ] {
1294 let result = update
1295 .execute(input, &ToolContext::new("."))
1296 .await
1297 .expect("no goal is a no-op, not an error");
1298 assert!(result.success);
1299 assert!(
1300 result.content.contains("No goal is set"),
1301 "{}",
1302 result.content
1303 );
1304 }
1305 assert!(state.lock().expect("goal lock").objective.is_none());
1306 }
1307
1308 #[tokio::test]
1309 async fn create_get_and_complete_goal() {
1310 let state = new_shared_goal_state();
1311 let ctx = ToolContext::new(".");
1312
1313 let create = CreateGoalTool::new(state.clone());
1314 let created = create
1315 .execute(
1316 json!({
1317 "objective": "ship the runtime slice",
1318 "token_budget": 1200
1319 }),
1320 &ctx,
1321 )
1322 .await
1323 .expect("create goal");
1324 assert!(created.success);
1325 let created_json: Value = serde_json::from_str(&created.content).expect("created json");
1326 assert_eq!(
1327 created_json.get("status").and_then(Value::as_str),
1328 Some("active")
1329 );
1330
1331 let get = GetGoalTool::new(state.clone());
1332 let current = get.execute(json!({}), &ctx).await.expect("get goal");
1333 assert!(current.content.contains("ship the runtime slice"));
1334 let current_json: Value = serde_json::from_str(&current.content).expect("current json");
1335 assert_eq!(
1336 current_json.get("token_budget").and_then(Value::as_u64),
1337 Some(1200)
1338 );
1339
1340 let update = UpdateGoalTool::new(state.clone());
1341 let completed = update
1342 .execute(
1343 json!({
1344 "status": "complete",
1345 "evidence": "focused tests passed",
1346 "verification": {
1347 "status": "passed",
1348 "check": "cargo test -p codewhale-tui goal_loop",
1349 "summary": "focused tests passed"
1350 }
1351 }),
1352 &ctx,
1353 )
1354 .await
1355 .expect("complete goal");
1356 let completed_json: Value =
1357 serde_json::from_str(&completed.content).expect("completed json");
1358 assert_eq!(
1359 completed_json.get("status").and_then(Value::as_str),
1360 Some("complete")
1361 );
1362 assert!(completed.content.contains("focused tests passed"));
1363 assert!(!state.lock().expect("goal lock").is_active());
1364 }
1365
1366 #[test]
1367 fn unfinished_goal_replacement_fails_closed_without_mutating_state() {
1368 for status in [GoalStatus::Active, GoalStatus::Paused, GoalStatus::Blocked] {
1369 let mut state = GoalState::default();
1370 state.sync_from_host_status(
1371 Some("preserve the current objective"),
1372 Some(1_200),
1373 status,
1374 );
1375 state.record_usage(300, 12);
1376 state.record_continuation();
1377 let before = state.snapshot();
1378
1379 let error = state
1380 .create("replace it silently".to_string(), Some(99))
1381 .expect_err("unfinished goal replacement must fail");
1382
1383 assert!(
1384 error.contains("unfinished goal"),
1385 "status {status:?}: {error}"
1386 );
1387 assert_eq!(
1388 state.snapshot(),
1389 before,
1390 "status {status:?} must preserve the entire goal snapshot"
1391 );
1392 }
1393 }
1394
1395 #[test]
1396 fn same_objective_goal_host_resume_clears_terminal_payloads_and_preserves_progress() {
1397 let mut blocked = GoalState::default();
1398 blocked
1399 .create("resume the release goal".to_string(), Some(4_000))
1400 .expect("create blocked fixture");
1401 blocked.record_usage(750, 44);
1402 blocked.record_continuation();
1403 blocked
1404 .mark_blocked("provider failed".to_string())
1405 .expect("block goal");
1406
1407 blocked.sync_from_host_status(
1408 Some("resume the release goal"),
1409 Some(4_000),
1410 GoalStatus::Active,
1411 );
1412
1413 let resumed = blocked.snapshot();
1414 assert_eq!(resumed.status, "active");
1415 assert_eq!(resumed.tokens_used, 750);
1416 assert_eq!(resumed.time_used_seconds, 44);
1417 assert_eq!(resumed.continuation_count, 1);
1418 assert_eq!(resumed.evidence, None);
1419 assert_eq!(resumed.blocker, None);
1420 assert_eq!(resumed.completion_verification, None);
1421 let prompt = render_continuation_prompt(&resumed, resumed.continuation_count);
1422 assert!(prompt.contains("\"blocker\": null"), "{prompt}");
1423
1424 let mut completed = GoalState::default();
1425 completed
1426 .create("resume verified work".to_string(), None)
1427 .expect("create completed fixture");
1428 completed
1429 .mark_complete(
1430 "focused tests passed".to_string(),
1431 GoalCompletionVerification {
1432 status: "passed".to_string(),
1433 check: "cargo test".to_string(),
1434 summary: "goal tests passed".to_string(),
1435 ..Default::default()
1436 },
1437 )
1438 .expect("complete goal");
1439
1440 completed.sync_from_host_status(Some("resume verified work"), None, GoalStatus::Active);
1441 let resumed = completed.snapshot();
1442 assert_eq!(resumed.status, "active");
1443 assert_eq!(resumed.evidence, None);
1444 assert_eq!(resumed.blocker, None);
1445 assert_eq!(resumed.completion_verification, None);
1446 }
1447
1448 /// #6561 D04-12: blocking or pausing used to overwrite a sealed, judged
1449 /// completion and clear its verification.
1450 #[test]
1451 fn sealed_completion_cannot_be_blocked_or_paused() {
1452 let mut state = GoalState::default();
1453 state
1454 .create("ship the verified change".to_string(), None)
1455 .expect("create goal");
1456 state
1457 .mark_complete(
1458 "focused tests passed".to_string(),
1459 GoalCompletionVerification {
1460 status: "passed".to_string(),
1461 check: "cargo test".to_string(),
1462 summary: "goal tests passed".to_string(),
1463 ..Default::default()
1464 },
1465 )
1466 .expect("complete goal");
1467 let sealed = state.snapshot();
1468
1469 assert!(state.mark_blocked("late blocker".to_string()).is_err());
1470 assert!(
1471 state
1472 .mark_runtime_blocked("runtime stop".to_string())
1473 .is_err()
1474 );
1475 assert!(state.mark_paused(GoalPauseReason::NoProgress).is_err());
1476
1477 let after = state.snapshot();
1478 assert_eq!(after.status, "complete");
1479 assert_eq!(after.evidence, sealed.evidence);
1480 assert_eq!(after.blocker, None);
1481 assert!(after.completion_verification.is_some());
1482 assert_eq!(
1483 after.completion_verification,
1484 sealed.completion_verification
1485 );
1486 }
1487
1488 #[test]
1489 fn completed_goal_can_be_replaced_with_fresh_accounting() {
1490 let mut state = GoalState::default();
1491 state
1492 .create("finish the first objective".to_string(), Some(1_200))
1493 .expect("create first goal");
1494 state.record_usage(300, 12);
1495 state.record_continuation();
1496 state
1497 .mark_complete(
1498 "focused tests passed".to_string(),
1499 GoalCompletionVerification {
1500 status: "passed".to_string(),
1501 check: "cargo test".to_string(),
1502 summary: "goal tests passed".to_string(),
1503 ..Default::default()
1504 },
1505 )
1506 .expect("complete first goal");
1507
1508 state
1509 .create("start the next objective".to_string(), Some(2_400))
1510 .expect("completed goal may be replaced");
1511
1512 let snapshot = state.snapshot();
1513 assert_eq!(
1514 snapshot.objective.as_deref(),
1515 Some("start the next objective")
1516 );
1517 assert_eq!(snapshot.status, "active");
1518 assert_eq!(snapshot.token_budget, Some(2_400));
1519 assert_eq!(snapshot.tokens_used, 0);
1520 assert_eq!(snapshot.time_used_seconds, 0);
1521 assert_eq!(snapshot.continuation_count, 0);
1522 assert_eq!(snapshot.evidence, None);
1523 assert_eq!(snapshot.blocker, None);
1524 assert_eq!(snapshot.completion_verification, None);
1525 }
1526
1527 #[tokio::test]
1528 async fn subagent_context_cannot_mutate_parent_goal() {
1529 let state = new_shared_goal_state_from_host_status(
1530 Some("keep root lifecycle authority".to_string()),
1531 Some(1_200),
1532 GoalStatus::Active,
1533 );
1534 let before = state.lock().expect("goal lock").snapshot();
1535 let child_context = ToolContext::new(".").with_owner_agent("agent_child", "child verifier");
1536
1537 let create_error = CreateGoalTool::new(state.clone())
1538 .execute(
1539 json!({"objective": "replace the parent goal"}),
1540 &child_context,
1541 )
1542 .await
1543 .expect_err("child create_goal must fail");
1544 assert!(create_error.to_string().contains("root-agent only"));
1545
1546 let update_error = UpdateGoalTool::new(state.clone())
1547 .execute(
1548 json!({"status": "blocked", "blocker": "child decided to stop"}),
1549 &child_context,
1550 )
1551 .await
1552 .expect_err("child update_goal must fail");
1553 assert!(update_error.to_string().contains("root-agent only"));
1554
1555 assert_eq!(
1556 state.lock().expect("goal lock").snapshot(),
1557 before,
1558 "rejected child mutations must leave the parent goal unchanged"
1559 );
1560 }
1561
1562 #[tokio::test]
1563 async fn update_goal_requires_completion_evidence() {
1564 let state = new_shared_goal_state_from_host_status(
1565 Some("prove completion".to_string()),
1566 None,
1567 GoalStatus::Active,
1568 );
1569 let update = UpdateGoalTool::new(state);
1570 let err = update
1571 .execute(json!({"status": "complete"}), &ToolContext::new("."))
1572 .await
1573 .expect_err("missing evidence should fail");
1574
1575 assert!(err.to_string().contains("evidence is required"));
1576 }
1577
1578 #[tokio::test]
1579 async fn update_goal_accepts_not_applicable_verification_for_non_verifiable_goals() {
1580 let state = new_shared_goal_state_from_host_status(
1581 Some("write the release notes".to_string()),
1582 None,
1583 GoalStatus::Active,
1584 );
1585 let update = UpdateGoalTool::new(state.clone());
1586 let completed = update
1587 .execute(
1588 json!({
1589 "status": "complete",
1590 "evidence": "release notes drafted and reviewed in thread",
1591 "verification": {
1592 "status": "not_applicable",
1593 "check": "no automated verifier applies",
1594 "summary": "writing task completed with evidence in thread"
1595 }
1596 }),
1597 &ToolContext::new("."),
1598 )
1599 .await
1600 .expect("non-verifiable goal should complete");
1601
1602 let completed_json: Value =
1603 serde_json::from_str(&completed.content).expect("completed json");
1604 assert_eq!(
1605 completed_json.get("status").and_then(Value::as_str),
1606 Some("complete")
1607 );
1608 assert_eq!(
1609 completed_json
1610 .get("completion_verification")
1611 .and_then(|verification| verification.get("status"))
1612 .and_then(Value::as_str),
1613 Some("not_applicable")
1614 );
1615 assert!(!state.lock().expect("goal lock").is_active());
1616 }
1617
1618 #[tokio::test]
1619 async fn update_goal_requires_passed_verification_to_complete() {
1620 let state = new_shared_goal_state_from_host_status(
1621 Some("prove completion".to_string()),
1622 None,
1623 GoalStatus::Active,
1624 );
1625 let update = UpdateGoalTool::new(state.clone());
1626 let err = update
1627 .execute(
1628 json!({
1629 "status": "complete",
1630 "evidence": "all checks look good"
1631 }),
1632 &ToolContext::new("."),
1633 )
1634 .await
1635 .expect_err("missing verifier gate should fail");
1636
1637 assert!(err.to_string().contains("verification is required"));
1638 assert!(state.lock().expect("goal lock").is_active());
1639 }
1640
1641 #[tokio::test]
1642 async fn advisory_review_is_append_only_and_fail_open() {
1643 let state = new_shared_goal_state_from_host_status(
1644 Some("keep the judged contract authoritative".to_string()),
1645 None,
1646 GoalStatus::Active,
1647 );
1648 let update = UpdateGoalTool::new(state.clone());
1649 update
1650 .execute(
1651 json!({
1652 "status": "advisory",
1653 "advisory": "Consider a narrower compatibility test."
1654 }),
1655 &ToolContext::new("."),
1656 )
1657 .await
1658 .expect("advisory note");
1659 let result = state.lock().expect("goal lock").snapshot();
1660
1661 assert_eq!(result.status, "active");
1662 assert_eq!(result.advisories.len(), 1);
1663 assert_eq!(
1664 result.advisories[0].summary,
1665 "Consider a narrower compatibility test."
1666 );
1667 assert!(result.completion_verification.is_none());
1668 }
1669
1670 #[tokio::test]
1671 async fn advisory_verification_cannot_complete_goal() {
1672 let state = new_shared_goal_state_from_host_status(
1673 Some("require a critical judge".to_string()),
1674 None,
1675 GoalStatus::Active,
1676 );
1677 let err = UpdateGoalTool::new(state.clone())
1678 .execute(
1679 json!({
1680 "status": "complete",
1681 "evidence": "an advisor liked it",
1682 "verification": {
1683 "status": "passed",
1684 "check": "advisory review",
1685 "summary": "looks reasonable",
1686 "role": "advisory"
1687 }
1688 }),
1689 &ToolContext::new("."),
1690 )
1691 .await
1692 .expect_err("advisory completion must fail closed");
1693
1694 assert!(err.to_string().contains("advisory review cannot complete"));
1695 assert!(state.lock().expect("goal lock").is_active());
1696 }
1697
1698 #[test]
1699 fn judged_completion_contract_is_fingerprinted_and_immutable() {
1700 let mut state = GoalState::default();
1701 state
1702 .create("seal the release candidate".to_string(), None)
1703 .expect("create goal");
1704 state
1705 .mark_complete(
1706 "locked tests passed".to_string(),
1707 GoalCompletionVerification {
1708 status: "passed".to_string(),
1709 check: "cargo test --locked".to_string(),
1710 summary: "all required tests passed".to_string(),
1711 ..Default::default()
1712 },
1713 )
1714 .expect("seal judged contract");
1715 let sealed = state.snapshot();
1716 let fingerprint = &sealed
1717 .completion_verification
1718 .as_ref()
1719 .expect("completion contract")
1720 .contract_fingerprint;
1721 assert_eq!(fingerprint.len(), 64);
1722
1723 let err = state
1724 .mark_complete(
1725 "replace the evidence".to_string(),
1726 GoalCompletionVerification {
1727 status: "passed".to_string(),
1728 check: "different check".to_string(),
1729 summary: "different result".to_string(),
1730 ..Default::default()
1731 },
1732 )
1733 .expect_err("sealed contract must be immutable");
1734 assert!(err.contains("already sealed"));
1735 assert_eq!(state.snapshot(), sealed);
1736 }
1737
1738 fn not_achieved_review(role: GoalReviewRole, gaps: &[&str]) -> GoalProgressVerification {
1739 GoalProgressVerification {
1740 status: "not_achieved".to_string(),
1741 check: "critical verifier".to_string(),
1742 summary: "remaining work found".to_string(),
1743 role,
1744 gaps: gaps.iter().map(|gap| (*gap).to_string()).collect(),
1745 }
1746 }
1747
1748 #[test]
1749 fn equivalent_gap_sets_have_one_stable_fingerprint() {
1750 let first = gap_fingerprint(&[
1751 " Add a regression test ".to_string(),
1752 "Fix provider copy".to_string(),
1753 ]);
1754 let reordered = gap_fingerprint(&[
1755 "fix PROVIDER copy".to_string(),
1756 "add a regression test".to_string(),
1757 "Add a regression test".to_string(),
1758 ]);
1759 assert_eq!(first, reordered);
1760 assert_eq!(first.expect("fingerprint").len(), 64);
1761 }
1762
1763 #[test]
1764 fn changed_gaps_reset_stall_counter_and_advice_never_advances_it() {
1765 let mut state = GoalState::default();
1766 state
1767 .create("keep making measurable progress".to_string(), None)
1768 .expect("create goal");
1769 state
1770 .record_not_achieved(not_achieved_review(
1771 GoalReviewRole::Critical,
1772 &["first gap"],
1773 ))
1774 .expect("first critical review");
1775 // Two reports of one gap only count twice when they land on separate
1776 // continuation passes; several inside one turn are one pass.
1777 state.record_continuation();
1778 state
1779 .record_not_achieved(not_achieved_review(
1780 GoalReviewRole::Critical,
1781 &["first gap"],
1782 ))
1783 .expect("repeat critical review");
1784 assert_eq!(state.snapshot().repeated_gap_count, 2);
1785
1786 state
1787 .record_not_achieved(not_achieved_review(
1788 GoalReviewRole::Advisory,
1789 &["advisor-only concern"],
1790 ))
1791 .expect("advisory review is fail-open");
1792 let after_advice = state.snapshot();
1793 assert_eq!(after_advice.repeated_gap_count, 2);
1794 assert_eq!(after_advice.advisories.len(), 1);
1795 assert_eq!(after_advice.status, "active");
1796
1797 state
1798 .record_not_achieved(not_achieved_review(
1799 GoalReviewRole::Critical,
1800 &["a different remaining gap"],
1801 ))
1802 .expect("changed critical review");
1803 let progressed = state.snapshot();
1804 assert_eq!(progressed.repeated_gap_count, 1);
1805 assert_eq!(progressed.status, "active");
1806 }
1807
1808 #[test]
1809 fn repeated_equivalent_gap_sets_pause_the_loop_for_no_progress() {
1810 // The continuation prompt promises this stop, and until it existed the
1811 // default Operate goal had none: `DEFAULT_MAX_GOAL_CONTINUATIONS` is 0,
1812 // so only the model volunteering complete/blocked ended a run.
1813 let mut state = GoalState::default();
1814 state
1815 .create("stall on purpose".to_string(), None)
1816 .expect("create goal");
1817
1818 for pass in 1..crate::goal_loop::MAX_REPEATED_GAP_PASSES {
1819 state
1820 .record_not_achieved(not_achieved_review(
1821 GoalReviewRole::Critical,
1822 &["provider copy still wrong", " Regression test MISSING "],
1823 ))
1824 .expect("critical review below the stall bound");
1825 let snapshot = state.snapshot();
1826 assert_eq!(snapshot.repeated_gap_count, pass);
1827 assert!(
1828 snapshot.is_active(),
1829 "pass {pass} is under the bound and must keep working",
1830 );
1831 // The bound counts continuation PASSES, so each iteration has to
1832 // actually be one. Without this the loop would be several reports
1833 // inside a single turn, which deliberately no longer advances it.
1834 state.record_continuation();
1835 }
1836
1837 // Reordered and re-cased wording is the same gap set, so restating the
1838 // previous pass cannot buy another pass.
1839 state
1840 .record_not_achieved(not_achieved_review(
1841 GoalReviewRole::Critical,
1842 &["Regression test missing", "PROVIDER copy still wrong"],
1843 ))
1844 .expect("stall review is recorded, not rejected");
1845
1846 let stalled = state.snapshot();
1847 assert_eq!(
1848 stalled.repeated_gap_count,
1849 crate::goal_loop::MAX_REPEATED_GAP_PASSES
1850 );
1851 assert_eq!(stalled.status, "paused");
1852 assert_eq!(stalled.pause_reason, Some(GoalPauseReason::NoProgress));
1853 assert!(
1854 !stalled.is_active(),
1855 "an inactive goal is what stops both continuation dispatchers",
1856 );
1857
1858 // The pause holds: a stalled goal cannot keep reporting gaps at itself.
1859 let err = state
1860 .record_not_achieved(not_achieved_review(
1861 GoalReviewRole::Critical,
1862 &["provider copy still wrong"],
1863 ))
1864 .expect_err("a paused goal takes no further verifier progress");
1865 assert!(err.contains("active goal"));
1866 }
1867
1868 #[test]
1869 fn repeating_one_gap_inside_a_single_turn_does_not_trip_the_stall_bound() {
1870 // `record_not_achieved` runs per `update_goal` tool call. Counting
1871 // calls rather than passes meant a verifier that restated the same gap
1872 // three times in ONE turn paused the goal before a single continuation
1873 // had been spent — stopping valid work and calling it a stall.
1874 let mut state = GoalState::default();
1875 state
1876 .create("one turn, several reports".to_string(), None)
1877 .expect("create goal");
1878
1879 for _ in 0..(crate::goal_loop::MAX_REPEATED_GAP_PASSES + 2) {
1880 state
1881 .record_not_achieved(not_achieved_review(
1882 GoalReviewRole::Critical,
1883 &["provider copy still wrong"],
1884 ))
1885 .expect("repeated reports inside one turn are recorded");
1886 }
1887
1888 let snapshot = state.snapshot();
1889 assert_eq!(
1890 snapshot.repeated_gap_count, 1,
1891 "many reports in one turn are still one pass",
1892 );
1893 assert!(
1894 snapshot.is_active(),
1895 "no continuation was spent, so there is no stall to pause on",
1896 );
1897 }
1898
1899 #[tokio::test]
1900 async fn update_goal_rejects_model_resume() {
1901 let state = new_shared_goal_state_from_host_status(
1902 Some("pause remains host controlled".to_string()),
1903 None,
1904 GoalStatus::Paused,
1905 );
1906 let update = UpdateGoalTool::new(state);
1907 let err = update
1908 .execute(json!({"status": "active"}), &ToolContext::new("."))
1909 .await
1910 .expect_err("model resume should fail");
1911
1912 assert!(err.to_string().contains("complete or blocked"));
1913 }
1914
1915 #[test]
1916 fn paused_host_goal_is_not_active() {
1917 let state = new_shared_goal_state_from_host_status(
1918 Some("wait for user".to_string()),
1919 Some(42),
1920 GoalStatus::Paused,
1921 );
1922 let snapshot = state.lock().expect("goal lock").snapshot();
1923
1924 assert_eq!(snapshot.status, "paused");
1925 assert_eq!(snapshot.token_budget, Some(42));
1926 assert_eq!(snapshot.pause_reason, Some(GoalPauseReason::User));
1927 assert!(!snapshot.is_active());
1928 }
1929
1930 #[test]
1931 fn goal_state_projects_usage_and_continuations() {
1932 let state = new_shared_goal_state_from_host_status(
1933 Some("persist accounting".to_string()),
1934 Some(1_000),
1935 GoalStatus::Active,
1936 );
1937 {
1938 let mut goal = state.lock().expect("goal lock");
1939 goal.record_usage(300, 12);
1940 goal.record_continuation();
1941 }
1942
1943 let snapshot = state.lock().expect("goal lock").snapshot();
1944 assert_eq!(snapshot.tokens_used, 300);
1945 assert_eq!(snapshot.time_used_seconds, 12);
1946 assert_eq!(snapshot.continuation_count, 1);
1947 }
1948
1949 #[test]
1950 fn completed_goal_snapshot_freezes_elapsed() {
1951 // Regression: a completed goal's snapshot elapsed_seconds must not keep
1952 // growing. Before the fix, snapshot() always used started_at.elapsed(),
1953 // so a finished goal's elapsed kept ticking in the sidebar/tool output.
1954 let state = new_shared_goal_state_from_host_status(
1955 Some("freeze on completion".to_string()),
1956 None,
1957 GoalStatus::Active,
1958 );
1959 let first = {
1960 let mut goal = state.lock().expect("goal lock");
1961 goal.mark_complete(
1962 "evidence".to_string(),
1963 GoalCompletionVerification {
1964 status: "passed".to_string(),
1965 check: "cargo test".to_string(),
1966 summary: "ok".to_string(),
1967 ..Default::default()
1968 },
1969 )
1970 .expect("mark complete");
1971 goal.snapshot()
1972 };
1973 let elapsed_at_completion = first.elapsed_seconds.expect("elapsed present");
1974
1975 // Sleep past a whole-second boundary. Under the old (buggy) code,
1976 // snapshot() returned started_at.elapsed().as_secs(), so this would
1977 // tick up by at least one second and the assertion below would fail.
1978 // With the freeze, the completed snapshot stays at the captured value.
1979 std::thread::sleep(std::time::Duration::from_millis(1_100));
1980 let second = state.lock().expect("goal lock").snapshot();
1981 assert_eq!(second.status, "complete");
1982 assert_eq!(
1983 second.elapsed_seconds,
1984 Some(elapsed_at_completion),
1985 "completed goal elapsed must be frozen, not keep ticking"
1986 );
1987 }
1988
1989 #[test]
1990 fn protocol_thread_goal_converts_to_runtime_snapshot() {
1991 let snapshot = GoalSnapshot::from_thread_goal(&codewhale_protocol::ThreadGoal {
1992 thread_id: "thread-1".to_string(),
1993 goal_id: "goal-1".to_string(),
1994 objective: "Bridge the goal models".to_string(),
1995 status: codewhale_protocol::ThreadGoalStatus::Active,
1996 token_budget: Some(2_000),
1997 tokens_used: 750,
1998 time_used_seconds: 44,
1999 continuation_count: 3,
2000 last_gap_fingerprint: None,
2001 repeated_gap_count: 0,
2002 last_gap_pass: None,
2003 pause_reason: None,
2004 created_at: 1,
2005 updated_at: 2,
2006 });
2007
2008 assert_eq!(
2009 snapshot.objective.as_deref(),
2010 Some("Bridge the goal models")
2011 );
2012 assert_eq!(snapshot.status, "active");
2013 assert_eq!(snapshot.token_budget, Some(2_000));
2014 assert_eq!(snapshot.tokens_used, 750);
2015 assert_eq!(snapshot.time_used_seconds, 44);
2016 assert_eq!(snapshot.continuation_count, 3);
2017 }
2018
2019 #[test]
2020 fn protocol_limit_statuses_keep_distinct_pause_reasons() {
2021 for (status, reason) in [
2022 (
2023 codewhale_protocol::ThreadGoalStatus::UsageLimited,
2024 GoalPauseReason::UsageLimit,
2025 ),
2026 (
2027 codewhale_protocol::ThreadGoalStatus::BudgetLimited,
2028 GoalPauseReason::BudgetLimit,
2029 ),
2030 ] {
2031 let (projected, projected_reason) = thread_goal_status_projection(status);
2032 assert_eq!(projected, GoalStatus::Paused);
2033 assert_eq!(projected_reason, Some(reason));
2034 }
2035 }
2036
2037 #[test]
2038 fn continuation_prompt_includes_bound_and_goal_state() {
2039 let snapshot = GoalSnapshot {
2040 objective: Some("finish issue 2199".to_string()),
2041 status: "active".to_string(),
2042 token_budget: None,
2043 tokens_used: 0,
2044 time_used_seconds: 0,
2045 continuation_count: 0,
2046 elapsed_seconds: Some(5),
2047 evidence: None,
2048 blocker: None,
2049 pause_reason: None,
2050 completion_verification: None,
2051 ..Default::default()
2052 };
2053
2054 let prompt = render_continuation_prompt(&snapshot, 2);
2055 assert!(prompt.contains("Goal Continuation"));
2056 assert!(prompt.contains("finish issue 2199"));
2057 assert!(prompt.contains("Continuation pass #2"));
2058 // The named bound has to be the one the state machine actually
2059 // enforces; the prompt used to promise a stall stop that nothing
2060 // implemented.
2061 assert!(
2062 prompt.contains(&format!(
2063 "{} equivalent gap sets in a row",
2064 crate::goal_loop::MAX_REPEATED_GAP_PASSES
2065 )),
2066 "{prompt}"
2067 );
2068 }
2069
2070 #[test]
2071 fn update_goal_contract_treats_required_user_input_as_blocking() {
2072 let update = UpdateGoalTool::new(new_shared_goal_state());
2073 assert!(update.description().contains("requires user input"));
2074 }
2075
2076 #[test]
2077 fn goal_progress_bar_fills_in_proportion() {
2078 assert_eq!(goal_progress_bar(0), "░░░░░░░░");
2079 assert_eq!(goal_progress_bar(50), "▓▓▓▓░░░░");
2080 assert_eq!(goal_progress_bar(100), "▓▓▓▓▓▓▓▓");
2081 assert_eq!(goal_progress_bar(200), "▓▓▓▓▓▓▓▓");
2082 }
2083
2084 #[tokio::test]
2085 async fn update_goal_records_progress_with_not_achieved_and_advisory() {
2086 let state = new_shared_goal_state();
2087 {
2088 let mut guard = state.lock().expect("goal lock");
2089 guard
2090 .create("ship the release".to_string(), None)
2091 .expect("create");
2092 }
2093 let tool = UpdateGoalTool::new(state.clone());
2094 let context = ToolContext::new(".");
2095 let result = tool
2096 .execute(
2097 json!({
2098 "status": "not_achieved",
2099 "verification": {
2100 "status": "not_achieved",
2101 "check": "cargo test",
2102 "summary": "two failures remain",
2103 "gaps": ["picker test", "pricing test"]
2104 },
2105 "progress": {"percent": 40, "now": "fixing the picker", "next": "rerun gates"}
2106 }),
2107 &context,
2108 )
2109 .await
2110 .expect("not_achieved accepted");
2111 let snapshot: Value = serde_json::from_str(&result.content).expect("snapshot json");
2112 let progress = snapshot.get("progress").expect("progress recorded");
2113 assert_eq!(progress.get("percent").and_then(Value::as_u64), Some(40));
2114 assert_eq!(
2115 progress.get("now").and_then(Value::as_str),
2116 Some("fixing the picker")
2117 );
2118 assert_eq!(
2119 progress.get("next").and_then(Value::as_str),
2120 Some("rerun gates")
2121 );
2122
2123 let result = tool
2124 .execute(
2125 json!({
2126 "status": "advisory",
2127 "advisory": "cache eviction is likely",
2128 "progress": {"percent": 55}
2129 }),
2130 &context,
2131 )
2132 .await
2133 .expect("advisory accepted");
2134 let snapshot: Value = serde_json::from_str(&result.content).expect("snapshot json");
2135 assert_eq!(
2136 snapshot
2137 .get("progress")
2138 .and_then(|progress| progress.get("percent"))
2139 .and_then(Value::as_u64),
2140 Some(55)
2141 );
2142 }
2143
2144 #[tokio::test]
2145 async fn update_goal_rejects_progress_on_terminal_status_and_bad_percent() {
2146 let state = new_shared_goal_state();
2147 {
2148 let mut guard = state.lock().expect("goal lock");
2149 guard
2150 .create("ship the release".to_string(), None)
2151 .expect("create");
2152 }
2153 let tool = UpdateGoalTool::new(state.clone());
2154 let context = ToolContext::new(".");
2155 let err = tool
2156 .execute(
2157 json!({
2158 "status": "complete",
2159 "evidence": "all gates pass",
2160 "verification": {"status": "passed", "check": "cargo test", "summary": "ok"},
2161 "progress": {"percent": 100}
2162 }),
2163 &context,
2164 )
2165 .await
2166 .expect_err("progress is not terminal evidence");
2167 assert!(
2168 err.to_string().contains("not_achieved or advisory"),
2169 "{err}"
2170 );
2171
2172 let err = tool
2173 .execute(
2174 json!({
2175 "status": "advisory",
2176 "advisory": "note",
2177 "progress": {"percent": 140}
2178 }),
2179 &context,
2180 )
2181 .await
2182 .expect_err("percent above 100 must fail");
2183 assert!(err.to_string().contains("0 to 100"), "{err}");
2184 }
2185
2186 #[test]
2187 fn from_snapshot_holds_exhausted_stall_window_paused() {
2188 // A restored snapshot that is still Active with a full stall window
2189 // is corrupt: the engine pauses NoProgress in the same mutation that
2190 // reaches the ceiling. The rehydrated state must stay paused instead
2191 // of arming another pass.
2192 let snapshot = GoalSnapshot {
2193 goal_id: Some("goal-stall".to_string()),
2194 objective: Some("finish issue 2199".to_string()),
2195 status: "active".to_string(),
2196 continuation_count: 3,
2197 last_gap_fingerprint: Some("b".repeat(64)),
2198 repeated_gap_count: crate::goal_loop::MAX_REPEATED_GAP_PASSES,
2199 last_gap_pass: Some(3),
2200 ..Default::default()
2201 };
2202 snapshot.validate_stall_state().expect("structurally valid");
2203 let state = GoalState::from_snapshot(&snapshot);
2204 assert_eq!(state.status, Some(GoalStatus::Paused));
2205 assert_eq!(state.pause_reason, Some(GoalPauseReason::NoProgress));
2206 assert!(!state.is_active());
2207
2208 // One below the ceiling restores as ordinary Active state.
2209 let below = GoalSnapshot {
2210 repeated_gap_count: crate::goal_loop::MAX_REPEATED_GAP_PASSES - 1,
2211 ..snapshot
2212 };
2213 let state = GoalState::from_snapshot(&below);
2214 assert_eq!(state.status, Some(GoalStatus::Active));
2215 assert!(state.is_active());
2216 }
2217
2218 #[test]
2219 fn from_persisted_keeps_counters_that_sync_from_host_status_resets() {
2220 // Rehydration treats the durable record as history: the counters
2221 // survive, evidence starts empty, and only a terminal status carries
2222 // a finish time.
2223 let restored = GoalState::from_persisted(
2224 "ship the goal loop",
2225 Some(50_000),
2226 GoalStatus::Active,
2227 None,
2228 1_234,
2229 56,
2230 3,
2231 );
2232 assert_eq!(restored.objective.as_deref(), Some("ship the goal loop"));
2233 assert_eq!(restored.token_budget, Some(50_000));
2234 assert_eq!(restored.status, Some(GoalStatus::Active));
2235 assert_eq!(restored.tokens_used, 1_234);
2236 assert_eq!(restored.time_used_seconds, 56);
2237 assert_eq!(restored.continuation_count, 3);
2238 assert!(restored.started_at.is_some());
2239 assert!(restored.finished_at.is_none());
2240 assert!(restored.evidence.is_none());
2241 assert!(restored.blocker.is_none());
2242 assert!(restored.advisories.is_empty());
2243
2244 let blocked = GoalState::from_persisted(
2245 "ship the goal loop",
2246 None,
2247 GoalStatus::Blocked,
2248 None,
2249 0,
2250 0,
2251 0,
2252 );
2253 assert!(blocked.finished_at.is_some());
2254
2255 // The same objective through the host-status path keeps the counters
2256 // (it is not a re-declaration)…
2257 let mut state = restored;
2258 state.sync_from_host_status(Some("ship the goal loop"), Some(50_000), GoalStatus::Active);
2259 assert_eq!(state.tokens_used, 1_234);
2260 assert_eq!(state.continuation_count, 3);
2261
2262 // …while a changed objective resets them — the contrast that makes
2263 // `from_persisted` necessary for rehydration.
2264 state.sync_from_host_status(Some("a different objective"), None, GoalStatus::Active);
2265 assert_eq!(state.tokens_used, 0);
2266 assert_eq!(state.time_used_seconds, 0);
2267 assert_eq!(state.continuation_count, 0);
2268 }
2269 }
2270
2270 lines RUST