Skip to content

Structured goal state + status lifecycle (/goal pause, /goal resume, /goal status) #223

Description

@justrach

Problem

Today a standing goal is a single bare string. Agent.goal is ?[]const u8 (src/agent.zig:138), and the whole /goal surface in src/commands_session.zig tryHandle (L123-138) only knows three moves: bare /goal shows it, /goal <text> sets it, /goal clear//goal off nulls it.

The concrete thing that breaks: there is no way to temporarily suspend a goal without destroying it. The per-turn steering note fires unconditionally the moment the objective is non-null. goalSteeringNote returns "" only when goal == null (src/repl_glue.zig:98, const g = goal orelse return ""), and mainloop appends it every turn whenever ctx.root.goal is set (src/mainloop.zig:434-438). The REPL path likewise folds the objective straight into the system prompt whenever params.goal.len > 0 (src/repl_glue.zig:182-185). So the only lever a user has to stop goal steering for a quick side task is /goal clear, which deletes the objective text (src/commands_session.zig:128). To come back they must retype the whole objective from memory.

There is also nothing to represent "this goal exists but is on hold." Persistence stores only the string: saveSession writes "goal" as a scalar (src/session.zig:231-232) and loadSession reads it back as a bare string or null (src/session.zig:265, :293). Even if we wanted a pause, there is no status to persist, so resume could never bring a goal back in a suspended state.

How codex handles this

codex does not model a goal as a string. ThreadGoal is a record { objective, status, token_budget, tokens_used, time_used_seconds }, and status is the ThreadGoalStatus enum { Active, Paused, Blocked, Complete, BudgetLimited, UsageLimited }. The GoalExtension (codex-rs/ext/goal, behind Feature::Goals) exposes /goal <objective>, /goal clear, /goal edit, /goal pause, and /goal resume.

Two properties are the ones worth copying here:

  1. Status is durable controller state, not a prompt. Goal rows live in a dedicated store (goals_1.sqlite, table thread_goals, columns thread_id, goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms) and survive across separate CLI processes. On resume, GoalExtension.on_thread_resume -> runtime.restore_after_resume() -> get_thread_goal re-marks an Active goal active in accounting, but a Paused goal stays Paused (test thread_resume_keeps_paused_goal_paused).

  2. Steering is gated on that status. on_turn_start reads the current ThreadGoal and injects steering items only for an appropriate status (e.g. continuation_steering_item is authorized by an Active goal in the DB, not by a prompt string that merely says "keep going"). A paused goal injects nothing.

Proposed codegraff shape

Promote the bare string into a small structured record, persisted in the existing session JSON (codegraff has no separate goal DB, and this issue does not add one; the session file is the store, mirroring codex's thread_goals columns as JSON fields).

src/agent.zig (replacing the field at src/agent.zig:138):

pub const GoalStatus = enum { active, paused, blocked, complete };

pub const Goal = struct {
    objective: []const u8,
    status: GoalStatus = .active,
    created_ms: i64,
    updated_ms: i64,
};

// on Agent:
goal: ?Goal = null, // persistent objective + status (/goal)

(Subset of codex's enum: Active/Paused/Blocked/Complete. BudgetLimited/UsageLimited are deliberately left out because there is no budget in this issue.)

src/commands_session.zig tryHandle (extend the L123-138 block):

  • /goal <text> sets objective, status = .active, stamps created_ms/updated_ms, saves. The mainloop first-turn behavior (src/mainloop.zig:136-140) is preserved: setting an active goal still runs it as the first turn.
  • /goal pause sets status = .paused, updates updated_ms, saves, prints confirmation. Objective is kept.
  • /goal resume sets status = .active, updates updated_ms, saves, prints confirmation. Re-arms steering for subsequent turns; it does not force an autonomous turn (see non-goals).
  • /goal status prints objective + status + created/updated. Bare /goal also shows status.
  • /goal clear//goal off still nulls the whole record.

Gate the steering on status == .active:

  • src/mainloop.zig:434-438: pass the objective to goalSteeringNote only when the goal is present and active; a paused goal yields no note. (Either add a status check at the call site or thread the status into goalSteeringNote.)
  • src/repl_glue.zig:182-185: fold the objective into the system prompt only for an active goal.

Persistence, src/session.zig:

  • saveSession (src/session.zig:231-232): write "goal" as a nested object { objective, status, created_ms, updated_ms } instead of a scalar.
  • loadSession (src/session.zig:265, :293): accept both the new object form and the legacy bare-string form (a plain string -> { objective: s, status: .active, timestamps: now }), so old sessions keep resuming.
  • hasMeaningfulState (src/session.zig:199-200): still treat any non-null goal as meaningful; a paused goal is still worth persisting.
  • Invariant to mirror: a session saved with a paused goal reloads paused and does not steer (the codegraff analog of thread_resume_keeps_paused_goal_paused).

Everything stays turn-driven: no daemon, no background loop. Status is read at turn assembly time exactly where goalSteeringNote is already called.

Acceptance criteria

  • Agent.goal is a structured record (objective + GoalStatus enum {active,paused,blocked,complete} + created_ms + updated_ms), replacing the bare ?[]const u8 at src/agent.zig:138, and all existing call sites compile against it.
  • /goal pause sets status to paused, updates updated_ms, persists, and prints a confirmation; the objective is retained (not cleared).
  • /goal resume sets status to active, updates updated_ms, persists, and prints a confirmation.
  • /goal status (and bare /goal) prints objective, current status, and created/updated timestamps.
  • Per-turn steering is suppressed for a non-active goal: with a paused goal, goalSteeringNote at src/mainloop.zig:434 produces no note and replTurnCb at src/repl_glue.zig:182 does not fold the objective into the system prompt.
  • /goal <text> still sets an active goal and runs it as the first turn (src/mainloop.zig:136-140 behavior preserved; interactive single-turn /goal remains the default).
  • saveSession writes the structured goal object; loadSession accepts both the new object form and the legacy bare-string form (bare string loads as active).
  • Unit test: a session persisted with a paused goal reloads with status paused and yields no steering note (codegraff analog of codex thread_resume_keeps_paused_goal_paused).
  • Unit test: goalSteeringNote (or the gate at its call site) returns empty for a paused goal and non-empty for an active goal (extends the existing test at src/repl_glue.zig:149).
  • /clear and /new still drop the whole goal via resetConversationSteering (src/commands_session.zig:59-62); the existing ultracode codeword is scanned on the assembled message (goal note included) and goal/ultracode steering survives /clear — banner fires on prompts that never typed it #178 test still passes.
  • /goal pause//goal resume//goal status with no goal set print a friendly "no active goal" message rather than erroring.

Scope & 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