Repository navigation
fix(goal): let the model hand a goal back at a milestone - #10
SparkofSpike wants to merge 2 commits into
Conversation
A goal continues until it is complete or blocked. Operate tells the model to create one for every substantive request, so a request with natural stages has no way to stop between them: the intra-turn continuation re-dispatches on any answer that leaves the goal Active, and a completed turn re-arms another one. That leaves "verified complete" and "blocked" as the only model-side exits — and a blocked goal stays blocked until an explicit `/goal resume`, so reporting a milestone that way strands the work instead of continuing it. `update_goal` gains `status: "yield"`: the model finished a stage and the next step is the user's call. The goal stops being Active — which is exactly what both continuation dispatchers test before re-arming — while staying unfinished, and the user's next message resumes it. That is the same shape as a runtime stop, which is already resumable by writing to it. A blocker the model reported and a pause the user asked for still need an explicit resume. The continuation prompt and the tool description name the new exit, so the model knows it may hand back at a milestone rather than grinding to a terminal status. Tests: `a_yield_stops_continuation_and_the_users_message_resumes_it`, `a_user_pause_is_not_resumed_by_a_yield_resume`, and `update_goal_yield_pauses_without_completing_or_blocking`. The existing `update_goal_rejects_model_resume` keeps proving that the model cannot resume itself. `cargo test -p codewhale-tui --lib goal` -> 143 passed; 0 failed.
Adversarial review — PR #10
|
The adversarial review blocked the first version; the independent review found the same host-side gap. Three fixes: - `mark_yielded` refuses any goal that is not active. Without that guard the tool could overwrite a NoProgress stall, a BudgetLimit pause, a user pause, or a blocker into a user-resumable hand-back — widening the resume contract this state exists to keep narrow. The guard is what makes "two states, two meanings" true at the tool boundary rather than only at resume time. - `GoalPauseReason::Unrecognized` with `#[serde(other)]`. The durable host stores the reason as JSON (`crates/state/src/lib.rs`), so a new variant with no fallback made a record written by this build fail an older build's goal load. Nothing constructs the variant; the resume path reads it as "not a hand-back". - `thread_goal_status_projection` takes the record's own pause reason, and `sync_from_host_status_with_reason` carries it through. Mapping every Paused status to `GoalPauseReason::User` erased a hand-back on any host sync between the yield and the user's reply, leaving the goal silently paused. The tests the review called unpinned are now pinned at the gate the loop consults: `a_yielded_goal_is_not_rearmed_by_the_continuation_gate`, with an active-goal control proving the gate is not dead under test conditions. Known and recorded in the PR body rather than closed here: a model can yield repeatedly without advancing the goal, and the stall counter only moves on `record_not_achieved`, so the NoProgress net does not catch that pattern. `cargo test -p codewhale-tui --lib goal` -> 147 passed; 0 failed.
Verdict: APPROVE — the follow-up clears my blockThe three material findings from the first pass are each fixed soundly, and the Finding 1 — state guard on
|
Summary
An operator running Operate reports that an agent can no longer stop at a
milestone. It used to do a stage, stop, and ask what to do next; now it runs
until the goal terminates, which costs a lot of rework when the direction was
wrong.
Both halves of that are real, and they compound:
create_goalfor every substantive request(
crates/tui/src/runtime_handoff.rs, the operate contract). Before that textlanded, the contract claimed the host created the goal — which the same
change's own commit message records as "the host never created one, contrary
to the old text". So goals were rare in practice and the continuation loop
rarely armed.
re-dispatches on any answer that leaves the goal Active
(
crates/tui/src/core/engine/turn_loop/continuation.rs), a completed turnre-arms another one (
crates/tui/src/core/engine.rs,schedule_goal_continuation), anddecide_continuation's default path isContinue(crates/runtime/src/goal_loop.rs). The model-side exits arecompleteandblockedonly.blockedis not a usable milestone exit: a blocker the model reported staysuntil an explicit
/goal resume(GoalState::resume_after_runtime_blockonlyresumes the runtime-set flag), so reporting a milestone that way strands the
work. And the tool text says to use it when progress cannot continue — not
when a stage is finished.
Changes
update_goalgainsstatus: "yield"(crates/tui/src/tools/goal.rs).The model finished a stage and the next step is the user's call. It pauses the
goal, so neither continuation dispatcher re-arms it, while leaving it
unfinished.
crates/tui/src/core/engine.rs,resume_yielded_goal). This is the same shape as a runtime stop, which isalready resumable by writing to it: the stop is not a judgement about the
work. A reported blocker and a user-requested pause still require an explicit
resume, and automated inputs still resume nothing.
GoalPauseReason::Yielded(crates/protocol/src/lib.rs), so the pausenames itself in the UI instead of reading as a user pause or a stall.
crates/tui/src/prompts/text.rs),and the tool description says what a yield means.
Why not just soften
blockedMaking a model-reported blocker resumable by the next message would erase the
distinction the runtime already draws: "this is blocked on a decision" versus
"the runtime stopped this". The first should keep needing an explicit resume —
it is the model telling the user something is wrong. A yield is the opposite:
nothing is wrong, the model is handing the turn back. Two states, two meanings.
After review
The adversarial review blocked the first version and the independent review
found the same host-side gap. Three fixes landed on top:
yieldis refused unless the goal is active (mark_yielded). Withoutthat guard the tool could overwrite a
NoProgressstall, aBudgetLimitpause, a user pause, or a blocker into a user-resumable hand-back — widening
the resume contract this state exists to keep narrow. The guard is what makes
"two states, two meanings" true at the tool boundary, not just at resume time.
GoalPauseReason::Unrecognized(crates/protocol/src/lib.rs) with#[serde(other)]. The durable host stores the reason as JSON(
crates/state/src/lib.rs), and a new variant with no fallback made a recordwritten by this build fail an older build's goal load. Nothing constructs the
variant; the resume path reads it as "not a hand-back".
thread_goal_status_projectionmapped everyPausedstatus toGoalPauseReason::User, andsync_from_host_statushard-coded the same, so ahost sync between a yield and the user's reply erased the hand-back and the
goal silently stayed paused. Both now carry the record's own reason
(
sync_from_host_status_with_reason, and the projection takes it as anargument).
Tests gained:
a_yield_cannot_overwrite_a_pause_reason_someone_else_set,a_pause_reason_written_by_a_newer_build_still_reads, and two gate-level tests(
a_yielded_goal_is_not_rearmed_by_the_continuation_gateplus its active-goalcontrol, which proves the gate is not simply dead under test conditions — the
review's point that the earlier unit tests pinned
GoalStaterather than thedispatcher).
Known limitations this does not close
A model can yield repeatedly without advancing the goal.
record_not_achievedis the only place the stall counter moves, and a yieldends the turn before it runs, so the
NoProgressnet does not catch thatpattern — the user notices instead. Bounding it (for example, counting
consecutive yields that moved nothing) is a separate change.
The host-managed runtime may not persist the resumption in one pass. The
independent review traced this:
resume_after_yieldcallsresume(None), whichmints a new
goal_id, while the durable write path is revision-fenced on theid the host bound at admission — so the settlement write can be refused and the
record stays at the paused revision until a later pass re-syncs it. This is
deliberately not changed here, because it is not specific to a yield: the
existing
resume_after_runtime_blockuses the identicalresume(None)shape andis subject to the same fencing. Making a hand-back persist in one pass means
teaching the fence about a resumed revision, which changes a contract every goal
transition shares; that belongs in its own change with its own review, not
folded into this one. The engine-local (TUI) path, where the goal state gates
the loop directly, is unaffected.
What this does not do
It does not make a goal stop on its own. A goal still runs to completion unless
the model yields or the user intervenes —
[goal] max_continuations,[goal] max_steps, and/goal pauseremain the operator controls. WhetherOperate should create a goal for every substantive request is a product
question this change deliberately leaves alone; it only makes the milestone
that the prompt already asks the model to report a thing the model can actually
do.
Testing
cargo test -p codewhale-tui --lib goal— 147 passed; 0 failed(14,663 filtered out). New:
a_yield_stops_continuation_and_the_users_message_resumes_it— a yielded goalis
PausedwithGoalPauseReason::Yielded, is not active, andresume_after_yieldbrings it back.a_user_pause_is_not_resumed_by_a_yield_resume— the resume refuses anypause that is not a yield.
a_yield_cannot_overwrite_a_pause_reason_someone_else_set— a user pause, abudget stop, a usage stop, a stall, and a blocker all survive a rejected
yield.
a_pause_reason_written_by_a_newer_build_still_reads— the durable store'sforward compatibility, plus the
yieldedround-trip.a_yielded_goal_is_not_rearmed_by_the_continuation_gateandan_active_goal_is_still_rearmed_by_the_continuation_gate— the cross-turngate itself, with the control that keeps the first from passing vacuously.
update_goal_yield_pauses_without_completing_or_blocking— the tool pathpauses the goal, reports no blocker, and claims no completion.
update_goal_rejects_model_resumestill passes (its expected message text wasupdated for the new status list), so the model still cannot resume itself.
cargo fmt --all -- --checkis clean.Type of Change
Checklist
Related Issues
No-Issue: operator report on 0.10.1; no upstream issue filed for it.
Attribution
🤖 Generated by SpikeBot 000(CodeWhale-LOCAL)