#[allow(unused_imports)]
use crate::sync_util::LockExt;
use std::sync::{Arc, Mutex};
use super::message::{LoopMessage, UserMessage};
pub const STALL_TAG: &str = "[stall]";
pub const BUDGET_TAG: &str = "[budget]";
const MAX_STALL_NUDGES: u8 = 2;
const BUDGET_MARKS: &[(usize, usize)] = &[(60, 100), (85, 100)];
pub const PROLOGUE_TAG: &str = "[prologue]";
const MAX_PROLOGUE_NUDGES: u8 = 1;
pub const DEFAULT_PROLOGUE_CAP: usize = 24;
const PROLOGUE_TOOL_MULTIPLE: usize = 4;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct ProgressSnapshot {
pub todos_unfinished: usize,
pub files_touched: usize,
pub verified_green: bool,
pub tool_calls: usize,
}
#[derive(Debug)]
pub struct ProgressTracker {
inner: Mutex<Inner>,
stall_threshold: usize,
prologue_cap: usize,
}
#[derive(Debug, Default)]
struct Inner {
last: ProgressSnapshot,
armed: bool,
barren_turns: usize,
stall_nudges: u8,
budget_marks_fired: usize,
prologue_boundaries: usize,
prologue_tool_calls: usize,
prologue_nudges: u8,
}
impl ProgressTracker {
pub fn new(stall_threshold: usize, prologue_cap: usize) -> Arc<Self> {
Arc::new(Self {
inner: Mutex::new(Inner::default()),
stall_threshold: stall_threshold.max(2),
prologue_cap: prologue_cap.max(1),
})
}
pub fn record_turn(&self, snap: ProgressSnapshot) -> Option<LoopMessage> {
let mut inner = self.inner.lock_ignore_poison();
let progressed = snap.todos_unfinished < inner.last.todos_unfinished
|| snap.files_touched > inner.last.files_touched
|| (snap.verified_green && !inner.last.verified_green);
let tool_delta = snap.tool_calls.saturating_sub(inner.last.tool_calls);
inner.last = snap;
if progressed {
inner.armed = true;
inner.barren_turns = 0;
inner.prologue_boundaries = 0;
inner.prologue_tool_calls = 0;
return None;
}
if !inner.armed {
inner.prologue_boundaries += 1;
inner.prologue_tool_calls += tool_delta;
if inner.prologue_nudges >= MAX_PROLOGUE_NUDGES {
return None;
}
let by_boundaries = inner.prologue_boundaries >= self.prologue_cap;
let by_tool_calls =
inner.prologue_tool_calls >= self.prologue_cap * PROLOGUE_TOOL_MULTIPLE;
if !by_boundaries && !by_tool_calls {
return None;
}
inner.prologue_nudges += 1;
inner.prologue_boundaries = 0;
inner.prologue_tool_calls = 0;
return Some(prologue_message());
}
inner.barren_turns += 1;
if inner.barren_turns < self.stall_threshold || inner.stall_nudges >= MAX_STALL_NUDGES {
return None;
}
inner.stall_nudges += 1;
inner.barren_turns = 0;
Some(stall_message(self.stall_threshold))
}
pub fn poll_budget(&self, turns_used: usize, max_turns: usize) -> Option<LoopMessage> {
if max_turns == 0 {
return None;
}
let mut inner = self.inner.lock_ignore_poison();
let (num, den) = *BUDGET_MARKS.get(inner.budget_marks_fired)?;
if turns_used * den < max_turns * num {
return None;
}
inner.budget_marks_fired += 1;
Some(budget_message(turns_used, max_turns))
}
}
fn stall_message(threshold: usize) -> LoopMessage {
LoopMessage::User(UserMessage::text(format!(
"{STALL_TAG} {threshold} turns have passed without finishing a task item, touching a new \
file, or getting a green check. The calls are succeeding but the work isn't converging. \
Before another one: state in one line what is actually blocking progress, then either \
change approach or cut scope — if part of this can't be done, say which part and why, \
and finish the rest. Continuing the same way is the one option that isn't working."
)))
}
fn budget_message(turns_used: usize, max_turns: usize) -> LoopMessage {
let remaining = max_turns.saturating_sub(turns_used);
LoopMessage::User(UserMessage::text(format!(
"{BUDGET_TAG} You've used {turns_used} of {max_turns} turns; {remaining} remain, and the \
run stops when they're gone. Check what's left against that: finish the highest-value \
work first, and drop or hand off anything that won't fit rather than being cut off \
mid-way. If everything left fits comfortably, ignore this."
)))
}
fn prologue_message() -> LoopMessage {
LoopMessage::User(UserMessage::text(format!(
"{PROLOGUE_TAG} You've been reading and calling tools for a while without writing a \
file, closing a task, or getting a green check. At this point more analysis is the \
failure mode, not the way out of it. Pick the smallest piece of the goal and put it \
on disk now — a stub, a first test, anything concrete — then iterate. You can refine \
what's written; you can't refine what isn't. If you genuinely can't start because \
something is missing, say what it is and stop rather than reading further."
)))
}
pub fn is_prologue_checkpoint(msg: &LoopMessage) -> bool {
matches!(msg, LoopMessage::User(u) if u.text_joined().starts_with(PROLOGUE_TAG))
}
#[cfg(test)]
mod tests {
use super::*;
fn snap(todos: usize, files: usize, green: bool) -> ProgressSnapshot {
snap_tools(todos, files, green, 0)
}
fn snap_tools(todos: usize, files: usize, green: bool, tool_calls: usize) -> ProgressSnapshot {
ProgressSnapshot {
todos_unfinished: todos,
files_touched: files,
verified_green: green,
tool_calls,
}
}
fn text(msg: LoopMessage) -> String {
match msg {
LoopMessage::User(u) => u.text_joined(),
_ => panic!("expected a user message"),
}
}
#[test]
fn exploration_prologue_does_not_stall_below_the_cap() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
for _ in 0..(DEFAULT_PROLOGUE_CAP - 1) {
assert!(t.record_turn(snap(0, 0, false)).is_none());
}
}
#[test]
fn prologue_fires_at_the_cap_and_not_before() {
let t = ProgressTracker::new(3, 5);
for i in 1..5 {
assert!(
t.record_turn(snap(0, 0, false)).is_none(),
"barren boundary {i} is still under the cap"
);
}
let msg = t
.record_turn(snap(0, 0, false))
.expect("the 5th barren boundary hits the cap");
assert!(is_prologue_checkpoint(&msg), "must carry the prologue tag");
}
#[test]
fn produced_then_stalled_is_a_stall_not_a_prologue() {
let t = ProgressTracker::new(2, 5);
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(0, 1, false)).is_none(), "1 barren");
let msg = t
.record_turn(snap(0, 1, false))
.expect("2 barren turns hits the stall threshold");
assert!(
!is_prologue_checkpoint(&msg),
"an armed run stalls; it is not in the prologue"
);
match &msg {
LoopMessage::User(u) => assert!(u.text_joined().starts_with(STALL_TAG)),
_ => panic!("expected a user message"),
}
}
#[test]
fn prologue_trips_on_batched_tool_calls_within_few_boundaries() {
let t = ProgressTracker::new(3, 10);
assert!(
t.record_turn(snap_tools(0, 0, false, 20)).is_none(),
"20 calls is under 10*{PROLOGUE_TOOL_MULTIPLE}"
);
let msg = t
.record_turn(snap_tools(0, 0, false, 40))
.expect("40 barren tool calls crosses the tool-call arm");
assert!(is_prologue_checkpoint(&msg));
}
#[test]
fn prologue_is_bounded_per_run() {
let t = ProgressTracker::new(3, 2);
let mut fired = 0;
for _ in 0..40 {
if let Some(m) = t.record_turn(snap(0, 0, false))
&& is_prologue_checkpoint(&m)
{
fired += 1;
}
}
assert_eq!(
fired, MAX_PROLOGUE_NUDGES as usize,
"prologue checkpoints must be bounded"
);
}
#[test]
fn producing_ends_the_prologue() {
let t = ProgressTracker::new(3, 4);
assert!(t.record_turn(snap(0, 0, false)).is_none());
assert!(t.record_turn(snap(0, 0, false)).is_none());
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
for _ in 0..12 {
if let Some(m) = t.record_turn(snap(0, 1, false)) {
assert!(
!is_prologue_checkpoint(&m),
"a run that produced can never be told it produced nothing"
);
}
}
}
#[test]
fn stall_fires_at_threshold_after_arming() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(t.record_turn(snap(0, 1, false)).is_none(), "1 barren");
assert!(t.record_turn(snap(0, 1, false)).is_none(), "2 barren");
let msg = t
.record_turn(snap(0, 1, false))
.expect("3 barren turns hits the threshold");
let body = text(msg);
assert!(body.contains(STALL_TAG), "carries the tag: {body}");
assert!(body.contains("blocking"), "asks for a diagnosis: {body}");
}
#[test]
fn any_progress_event_resets_the_counter() {
for (label, progressed) in [
("new file", snap(0, 2, false)),
("went green", snap(0, 1, true)),
] {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(
t.record_turn(progressed).is_none(),
"{label} must reset, not fire"
);
assert!(t.record_turn(progressed).is_none(), "{label} +1");
assert!(t.record_turn(progressed).is_none(), "{label} +2");
}
}
#[test]
fn closing_a_todo_is_progress() {
let t = ProgressTracker::new(2, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(3, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(3, 1, false)).is_none(), "1 barren");
assert!(
t.record_turn(snap(2, 1, false)).is_none(),
"a closed item resets the counter"
);
assert!(
t.record_turn(snap(2, 1, false)).is_none(),
"counter restarted"
);
}
#[test]
fn adding_todos_is_not_progress() {
let t = ProgressTracker::new(2, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(1, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(5, 1, false)).is_none(), "1 barren");
assert!(
t.record_turn(snap(9, 1, false)).is_some(),
"growing the board doesn't count as getting work done"
);
}
#[test]
fn re_editing_one_file_is_not_progress() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(
t.record_turn(snap(0, 1, false)).is_some(),
"same file count across turns is a stall"
);
}
#[test]
fn green_suite_thrash_on_one_file_still_stalls() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(2, 1, true)).is_none(), "arm");
assert!(t.record_turn(snap(2, 1, true)).is_none(), "1 barren");
assert!(t.record_turn(snap(2, 1, true)).is_none(), "2 barren");
assert!(
t.record_turn(snap(2, 1, true)).is_some(),
"green-but-not-converging must still stall"
);
}
#[test]
fn staying_green_is_not_repeated_progress() {
let t = ProgressTracker::new(2, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, true)).is_none(), "arm + green");
assert!(t.record_turn(snap(0, 1, true)).is_none(), "1 barren");
assert!(
t.record_turn(snap(0, 1, true)).is_some(),
"still green isn't new progress"
);
}
#[test]
fn stall_is_bounded_and_re_arms() {
let t = ProgressTracker::new(2, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(t.record_turn(snap(0, 1, false)).is_some(), "first");
assert!(t.record_turn(snap(0, 1, false)).is_none(), "re-arm gap");
assert!(t.record_turn(snap(0, 1, false)).is_some(), "second");
for _ in 0..10 {
assert!(t.record_turn(snap(0, 1, false)).is_none(), "bounded");
}
}
#[test]
fn threshold_is_clamped_to_two() {
let t = ProgressTracker::new(0, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(0, 1, false)).is_none(), "1 barren");
assert!(t.record_turn(snap(0, 1, false)).is_some(), "2 barren");
}
#[test]
fn budget_marks_fire_once_each_in_order() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.poll_budget(50, 100).is_none(), "below the first mark");
let first = text(t.poll_budget(60, 100).expect("60% mark"));
assert!(first.contains(BUDGET_TAG), "carries the tag: {first}");
assert!(first.contains("60 of 100"), "states position: {first}");
assert!(first.contains("40 remain"), "states remaining: {first}");
assert!(t.poll_budget(70, 100).is_none());
assert!(t.poll_budget(84, 100).is_none());
let second = text(t.poll_budget(85, 100).expect("85% mark"));
assert!(second.contains("85 of 100"), "{second}");
assert!(t.poll_budget(99, 100).is_none());
}
#[test]
fn budget_marks_never_double_fire_in_one_poll() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.poll_budget(90, 100).is_some(), "first mark");
assert!(t.poll_budget(90, 100).is_some(), "then the second");
assert!(t.poll_budget(90, 100).is_none(), "and no more");
}
#[test]
fn budget_marks_do_not_re_arm_after_a_turn_counter_reset() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.poll_budget(60, 100).is_some(), "60% mark");
assert!(t.poll_budget(85, 100).is_some(), "85% mark");
for used in [0, 60, 85, 99] {
assert!(
t.poll_budget(used, 100).is_none(),
"spent marks stay spent across a reset (used={used})"
);
}
}
#[test]
fn budget_silent_without_a_cap() {
let t = ProgressTracker::new(3, DEFAULT_PROLOGUE_CAP);
assert!(t.poll_budget(1000, 0).is_none());
}
#[test]
fn stall_and_budget_budgets_are_independent() {
let t = ProgressTracker::new(2, DEFAULT_PROLOGUE_CAP);
assert!(t.record_turn(snap(0, 1, false)).is_none(), "arm");
assert!(t.record_turn(snap(0, 1, false)).is_none());
assert!(t.record_turn(snap(0, 1, false)).is_some(), "stall fired");
assert!(t.poll_budget(60, 100).is_some(), "budget still available");
}
}