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