You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Today /loop autonomy is a one-shot prompt string, not a controller. In src/mainloop.zig L441-445 a /loop turn gets a single appended harness note:
[harness note: /loop was used. Work autonomously until the prompt is satisfied ... only stop when you can report completion or you need a required human decision ...]
That note runs inside one Agent.runTurn(). When the model emits its final text the turn ends and control falls straight back to the readline prompt (top of the loop at src/mainloop.zig L125; the continue at L427). Nothing between the turn ending and the next line being read ever re-reads the goal to decide whether work should continue. So:
The model's own decision to stop IS the completion decision. L444 literally asks the model to "only stop when you can report completion." If the model stops early, gives up, or hallucinates that it's done, the harness accepts that silently. There is no controller state that says "the objective is still Active, keep going" or "we are out of budget, stop."
Stopping has no named outcome. A /loop that finishes and a /loop that quit because it got stuck look identical to the harness: both just return to the prompt. There is no accepted / exhausted / blocked / cancelled distinction to log, act on, or later persist.
Goal state is inert during /loop.root.goal is only a steering string (repl_glue.goalSteeringNote, src/repl_glue.zig L97) folded into the message at src/mainloop.zig L434. It never gates continuation. /loop bare is still just a usage stub (src/commands_session.zig L139-142).
This is exactly the failure mode #216 named: continuation is authorized by a prompt string ("keep going"), not by controller state.
How codex handles this
codex's GoalExtension (codex-rs/ext/goal, behind Feature::Goals) makes continuation a controller decision, not a prompt:
When the thread goes idle (on_thread_idle), if the goal's status is Active, codex injects a continuation_steering_item to nudge the model to keep going, starting a fresh turn only if no user work is queued.
The authorization comes from persisted controller state, not the prompt: the current ThreadGoal { objective, status, token_budget, tokens_used, time_used_seconds } is read from the goals_1.sqlitethread_goals table. Continuation happens because a goal row is Active, not because some string said "continue."
Status is a typed enum ThreadGoalStatus { Active, Paused, Blocked, Complete, BudgetLimited, UsageLimited }, so "stop" is always a named state. When a goal exceeds token_budget it becomes BudgetLimited and codex injects a budget_limit_steering_item instead of continuing.
The model asserts completion by calling the model-facing update_goal tool (GoalToolExecutor) to move the goal to Complete. Note: this is model-asserted complete, not independently verified (relevant to the non-goal below).
The transferable idea, minimal: after an autonomous turn, a small controller reads persisted goal status (+ remaining budget) and either injects a continuation item or stops with a typed status. (codex has no scheduler / deferred run-at-T and does not auto-terminate a goal on wall-clock time — its clock.sleep only pauses the current turn; this issue mirrors only the idle-continuation gate.)
Proposed codegraff shape
A tight, turn-driven continuation gate for the /loop path only. No daemon, no timer, no scheduler — each continuation is a synchronous Agent.runTurn() call.
pubconstContinuationOutcome=enum { accepted, exhausted, blocked, cancelled };
pubconstContinuationDecision=union(enum) {
continue_turn, // inject continuation note, run another turnstop: ContinuationOutcome, // typed terminal outcome, return to prompt
};
/// Controller-authorized continuation, mirroring codex's continuation_steering_item/// gate: continue only while goal state authorizes it; otherwise name the outcome.pubfncontinuationDecision(
goal_status: GoalStatus, // from the structured-goal issue (#223) (mirrors ThreadGoalStatus)budget_remaining: ?u64, // from the per-goal budget issue (#224); null = not configuredtodos_all_completed: bool, // observable controller state (ctx.root.todos)iters_left: u32, // hard bound so a stuck model can't spin forever
) ContinuationDecision {
if (todos_all_completed) return .{ .stop=.accepted };
if (goal_status==.cancelledorgoal_status==.paused) return .{ .stop=.cancelled };
if (goal_status==.blocked) return .{ .stop=.blocked };
if (budget_remaining) |r|if (r==0) return .{ .stop=.exhausted };
if (iters_left==0) return .{ .stop=.exhausted };
if (goal_status==.active) return.continue_turn;
return .{ .stop=.accepted };
}
/// Analog of codex's continuation_steering_item: the note injected to keep going.pubfncontinuationSteeringNote(arena: Allocator, todos_render: []constu8) ![]constu8 { ... }
The completion signal is observable controller state, not the model going quiet: reuse the existing goal-tracking checklist that goalSteeringNote already instructs the model to keep (ctx.root.todos, rendered at src/mainloop.zig L433). "All todos completed" -> accepted. This is the honest minimal analog of codex reading Complete from the goal row.
2. Wire the post-turn check in src/mainloop.zig at the /loop turn-run site (near L441-445). Instead of unconditionally returning to readline: when the turn was a /loop turn, call continuationDecision. If continue_turn, append continuationSteeringNote and run the next Agent.runTurn()without reading a new user line; loop until a stop. On stop, print the named outcome and fall back to the readline prompt.
3. Preserve the single-turn default. The gate engages only for the /loop path. Plain /goal <objective> keeps its existing behavior (src/mainloop.zig L136-140 sets the standing goal and runs exactly one turn, then returns to the prompt) — the goal supplies status/budget for the authorization, but auto-continuation stays opt-in behind /loop, which is codegraff's existing autonomous mode.
Acceptance criteria
ContinuationOutcome (accepted / exhausted / blocked / cancelled) and a pure continuationDecision(...) live in src/repl_glue.zig, with a unit test per branch (accepted, exhausted-by-budget, exhausted-by-iteration-bound, blocked, cancelled/paused, continue).
After a /loop turn (src/mainloop.zig, the turn-run site near L441), the harness calls continuationDecision instead of unconditionally returning to the readline prompt at L427/L125.
On continue_turn, the harness injects continuationSteeringNote (the continuation_steering_item analog) and runs the next turn without reading a new user line.
On stop, the harness prints the named outcome and returns control to the readline prompt.
Continuation is gated on controller state, never on the model merely stopping: an active status plus remaining budget authorize continuation; null/paused/blocked/cancelled status, zero remaining budget, or all-todos-completed each yield the corresponding named terminal outcome.
Plain /goal <objective> remains single-turn (regression test): it runs exactly one turn and returns to the prompt; the continuation loop engages only under /loop.
A hard per-/loop iteration bound exists so a model that never signals completion cannot spin forever when no budget is configured; hitting it reports exhausted. This bound is separate from Add harness-level max tool-call and duplicate-suppression controls #61's session-wide --max-tool-calls.
The loop is fully turn-driven: no background thread, daemon, or timer is introduced; each continuation is a synchronous Agent.runTurn().
In scope: the /loop continuation gate, the ContinuationOutcome enum, continuationSteeringNote, and the post-turn controller wiring. One focused PR.
Depends on: the structured-goal issue (#223) (provides Goal.status, a Zig enum mirroring codex ThreadGoalStatus) and the per-goal budget issue (#224) (provides budget_remaining). If both land first this is a small wiring PR; if not, it may introduce a minimal GoalStatus stub but MUST NOT re-implement budget accounting here.
No scheduler / deferred run-at-T. codex has no such mechanism (its clock.sleep only pauses the current turn, max 12h, interruptible) and does not auto-terminate on wall-clock time (time is accounting-only in codex); this gate adds neither and does not touch the existing harness-level ScheduleWakeup.
No /goal pause/resume/edit — those belong to the structured-goal work.
Problem
Today
/loopautonomy is a one-shot prompt string, not a controller. Insrc/mainloop.zigL441-445 a/loopturn gets a single appended harness note:That note runs inside one
Agent.runTurn(). When the model emits its final text the turn ends and control falls straight back to the readline prompt (top of the loop atsrc/mainloop.zigL125; thecontinueat L427). Nothing between the turn ending and the next line being read ever re-reads the goal to decide whether work should continue. So:/loopthat finishes and a/loopthat quit because it got stuck look identical to the harness: both just return to the prompt. There is noaccepted/exhausted/blocked/cancelleddistinction to log, act on, or later persist./loop.root.goalis only a steering string (repl_glue.goalSteeringNote,src/repl_glue.zigL97) folded into the message atsrc/mainloop.zigL434. It never gates continuation./loopbare is still just a usage stub (src/commands_session.zigL139-142).This is exactly the failure mode #216 named: continuation is authorized by a prompt string ("keep going"), not by controller state.
How codex handles this
codex's
GoalExtension(codex-rs/ext/goal, behindFeature::Goals) makes continuation a controller decision, not a prompt:on_thread_idle), if the goal's status isActive, codex injects acontinuation_steering_itemto nudge the model to keep going, starting a fresh turn only if no user work is queued.ThreadGoal { objective, status, token_budget, tokens_used, time_used_seconds }is read from thegoals_1.sqlitethread_goalstable. Continuation happens because a goal row isActive, not because some string said "continue."ThreadGoalStatus { Active, Paused, Blocked, Complete, BudgetLimited, UsageLimited }, so "stop" is always a named state. When a goal exceedstoken_budgetit becomesBudgetLimitedand codex injects abudget_limit_steering_iteminstead of continuing.update_goaltool (GoalToolExecutor) to move the goal toComplete. Note: this is model-asserted complete, not independently verified (relevant to the non-goal below).The transferable idea, minimal: after an autonomous turn, a small controller reads persisted goal status (+ remaining budget) and either injects a continuation item or stops with a typed status. (codex has no scheduler / deferred run-at-T and does not auto-terminate a goal on wall-clock time — its
clock.sleeponly pauses the current turn; this issue mirrors only the idle-continuation gate.)Proposed codegraff shape
A tight, turn-driven continuation gate for the
/looppath only. No daemon, no timer, no scheduler — each continuation is a synchronousAgent.runTurn()call.1. Controller + outcome enum in
src/repl_glue.zig(pure, unit-testable alongsidegoalSteeringNote):The completion signal is observable controller state, not the model going quiet: reuse the existing goal-tracking checklist that
goalSteeringNotealready instructs the model to keep (ctx.root.todos, rendered atsrc/mainloop.zigL433). "All todos completed" ->accepted. This is the honest minimal analog of codex readingCompletefrom the goal row.2. Wire the post-turn check in
src/mainloop.zigat the/loopturn-run site (near L441-445). Instead of unconditionally returning to readline: when the turn was a/loopturn, callcontinuationDecision. Ifcontinue_turn, appendcontinuationSteeringNoteand run the nextAgent.runTurn()without reading a new user line; loop until astop. Onstop, print the named outcome and fall back to the readline prompt.3. Preserve the single-turn default. The gate engages only for the
/looppath. Plain/goal <objective>keeps its existing behavior (src/mainloop.zigL136-140 sets the standing goal and runs exactly one turn, then returns to the prompt) — the goal supplies status/budget for the authorization, but auto-continuation stays opt-in behind/loop, which is codegraff's existing autonomous mode.Acceptance criteria
ContinuationOutcome(accepted/exhausted/blocked/cancelled) and a purecontinuationDecision(...)live insrc/repl_glue.zig, with a unit test per branch (accepted, exhausted-by-budget, exhausted-by-iteration-bound, blocked, cancelled/paused, continue)./loopturn (src/mainloop.zig, the turn-run site near L441), the harness callscontinuationDecisioninstead of unconditionally returning to the readline prompt at L427/L125.continue_turn, the harness injectscontinuationSteeringNote(thecontinuation_steering_itemanalog) and runs the next turn without reading a new user line.stop, the harness prints the named outcome and returns control to the readline prompt.activestatus plus remaining budget authorize continuation;null/paused/blocked/cancelledstatus, zero remaining budget, or all-todos-completed each yield the corresponding named terminal outcome./goal <objective>remains single-turn (regression test): it runs exactly one turn and returns to the prompt; the continuation loop engages only under/loop./loopiteration bound exists so a model that never signals completion cannot spin forever when no budget is configured; hitting it reportsexhausted. This bound is separate from Add harness-level max tool-call and duplicate-suppression controls #61's session-wide--max-tool-calls.Agent.runTurn().Scope & non-goals
In scope: the
/loopcontinuation gate, theContinuationOutcomeenum,continuationSteeringNote, and the post-turn controller wiring. One focused PR.Depends on: the structured-goal issue (#223) (provides
Goal.status, a Zig enum mirroring codexThreadGoalStatus) and the per-goal budget issue (#224) (providesbudget_remaining). If both land first this is a small wiring PR; if not, it may introduce a minimalGoalStatusstub but MUST NOT re-implement budget accounting here.Non-goals:
acceptedmeans model-asserted complete (all todos marked done), exactly as codex'supdate_goal->Completeis model-asserted. codex does NOT solve generic "did it really succeed" — this issue does not either.clock.sleeponly pauses the current turn, max 12h, interruptible) and does not auto-terminate on wall-clock time (time is accounting-only in codex); this gate adds neither and does not touch the existing harness-levelScheduleWakeup./goal pause/resume/edit— those belong to the structured-goal work.Relationship to existing issues
continuation_steering_item.budget_remaining).accepted/exhausted/blocked/cancelleddecision; Governed runs: durable attempt ledger, retry policy, and named terminal states #219 would durably record it.--max-tool-callsis a session-wide tool-call cap; this is per-/loopcontinuation authorization) or codex/gpt-5.x: a single-turn tool-output burst overflows the context window before auto-compaction fires (no pre-send local-estimate gate) #193 (context-window estimation/compaction, not goal budget). Does not touch Worktree isolation for parallel agents/sessions (no file collisions) #114 (worktree isolation); interacts with Define trace/trajectory file ownership and preserve JSONL integrity across concurrent processes #185 (trace integrity) only by surfacing the outcome to the trace.