Skip to content

Controller-authorized continuation for /loop: gate on goal state, not a prompt note #226

Description

@justrach

Problem

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.sqlite thread_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.

1. Controller + outcome enum in src/repl_glue.zig (pure, unit-testable alongside goalSteeringNote):

pub const ContinuationOutcome = enum { accepted, exhausted, blocked, cancelled };

pub const ContinuationDecision = union(enum) {
    continue_turn,               // inject continuation note, run another turn
    stop: 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.
pub fn continuationDecision(
    goal_status: GoalStatus,        // from the structured-goal issue (#223) (mirrors ThreadGoalStatus)
    budget_remaining: ?u64,         // from the per-goal budget issue (#224); null = not configured
    todos_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 == .cancelled or goal_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.
pub fn continuationSteeringNote(arena: Allocator, todos_render: []const u8) ![]const u8 { ... }

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().
  • The named terminal outcome is surfaced in the transcript/trace so a durable ledger (Governed runs: durable attempt ledger, retry policy, and named terminal states #219) can record it later with no extra plumbing.

Scope & non-goals

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.

Non-goals:

Relationship to existing issues

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions