Skip to content

feat(taskctl): Phase 4 — Steering agent + timeout recovery #207

@randomm

Description

@randomm

Context

Part of the taskctl epic (#201). Depends on Phase 3b (#205) and Phase 3c (#206).

What you're building: The self-healing layer. A lightweight steering agent that Pulse spawns every 15 minutes to assess whether a developer is making meaningful progress or is stuck, and true timeout detection based on session-level heartbeating. Together these allow the pipeline to handle stuck agents without PM involvement.

Background reading

Read lievo/plan-v2.md sections: "Steering Agent" under The Six Agents, and "Phase 4" under Implementation Phases.

Also read packages/opencode/src/session/async-tasks.ts to understand how session heartbeating works today.

Files to create/modify

packages/opencode/src/agent/agent.ts      ← add Steering agent definition
packages/opencode/src/tasks/pulse.ts      ← add checkSteering() and improve checkTimeouts()

The Steering agent definition

Add to packages/opencode/src/agent/agent.ts following the same pattern as the Composer:

{
  name: "steering",
  mode: "subagent",
  hidden: true,
  model: "...",    // use the cheapest available model in config — haiku or equivalent
  prompt: `...`   // see below
}

Steering agent system prompt — keep it short and focused:

The steering agent receives (in its user prompt):

  • The task title, description, and acceptance criteria
  • The last 10 assistant turns from the developer's session (formatted as a list)

It should respond with exactly this JSON and nothing else:

{ "action": "continue", "message": null }

or:

{ "action": "steer", "message": "You seem to be going in circles with the auth module. Try focusing on the minimal change needed: just add the null check at line 42 in src/api.ts" }

or:

{ "action": "replace", "message": "Developer appears completely stuck after 15 minutes of no meaningful progress" }

Guidelines for the steering agent to follow:

  • continue if the developer is making steady progress (writing code, running tests, moving forward)
  • steer if the developer seems confused, is going in circles, or is heading in the wrong direction — include a specific, actionable suggestion
  • replace only if the developer has made no meaningful progress for the entire session or is clearly broken (e.g., repeatedly running the same failing command)

checkSteering in Pulse

Add checkSteering(jobId, projectId) to the Pulse tick (after checkTimeouts, before checkCompletion):

async function checkSteering(jobId: string, projectId: string) {
  const tasks = await getTasksForJob(jobId, projectId)
  const now = new Date()

  for (const task of tasks) {
    if (task.status !== "in_progress") continue

    // Only evaluate every 15 minutes per task
    const lastSteering = task.pipeline.last_steering
      ? new Date(task.pipeline.last_steering)
      : new Date(task.created_at)
    
    const minutesSince = (now.getTime() - lastSteering.getTime()) / 60_000
    if (minutesSince < 15) continue

    // Get session history — last 10 assistant turns
    const session = await Session.get(task.assignee)
    if (!session) continue
    
    const history = await getLastNAssistantTurns(session, 10)
    
    // Spawn steering agent (one-shot, sync)
    const result = await spawnSteeringAgent(task, history)
    
    // Update steering timestamp regardless of outcome
    task.pipeline.last_steering = now.toISOString()
    await writeTask(task, projectId)

    if (result.action === "continue") {
      // Nothing to do
    } else if (result.action === "steer") {
      // Send message to developer session
      await SessionPrompt.prompt({
        sessionID: task.assignee,
        // ... pass result.message as a user message
      })
      await writeTaskComment(task, projectId, `Steering guidance sent: ${result.message}`)
    } else if (result.action === "replace") {
      // Kill developer, reset task, reschedule
      await killSession(task.assignee)
      await removeWorktree(task.worktree)
      task.status = "open"
      task.assignee = null
      task.worktree = null
      task.pipeline.stage = "idle"
      await writeTask(task, projectId)
      await writeTaskComment(task, projectId, `Developer replaced by steering: ${result.message}`)
    }
  }
}

You'll need to figure out how to get the last N assistant turns from a session — look at how session messages are stored and retrieved in packages/opencode/src/session/index.ts. There may be a way to read message history.

Improving checkTimeouts

Phase 3a's checkTimeouts just checks whether pipeline.last_activity (a Pulse-written timestamp) is stale. That's a coarse check — it just means "Pulse hasn't touched this task in 30 minutes."

In Phase 4, improve this to also check developer session activity:

  • Look for a way to check when the developer session last had LLM activity (look at session message timestamps)
  • If the session exists but hasn't had any activity (no new messages) in 30 minutes → that's a genuine zombie
  • Update pipeline.last_activity only when you confirm the developer session produced new output since the last tick

This gives more accurate timeout detection. The 30-minute threshold stays the same.

Configuring the steering model

The steering agent should use the cheapest capable model. Add a way to configure this:

  1. Check if a model named "steering" is defined in the user's agent config (their opencode.personal.json or similar)
  2. If yes, use that model
  3. If no, fall back to whatever the cheapest model in their configured providers is

Document this in a code comment so it's clear how to configure it.

Testing

packages/opencode/test/tasks/steering.test.ts

Mock the steering agent response and test:

  • continue response → task unchanged, last_steering updated
  • steer response → message sent to developer session, comment added
  • replace response → developer killed, task reset to open, comment added
  • Steering not triggered if less than 15 minutes since last steering
  • Steering not triggered for tasks not in in_progress status
  • Improved timeout detection: session with no new messages in 30 min → timed out

Run bun test and bun run typecheck — both must pass.

Acceptance criteria

  • Steering agent defined in agent/agent.ts with mode: "subagent", hidden: true, cheapest model
  • checkSteering() added to Pulse tick after checkTimeouts
  • Steering evaluated every 15 minutes per in-progress task (not per tick)
  • continue response → no action, timestamp updated
  • steer response → guidance message sent to developer session, comment logged
  • replace response → session killed, task reset to open, fresh worktree on next schedule
  • checkTimeouts improved to check actual session message activity, not just Pulse timestamps
  • Steering model configurable; falls back to cheapest available if not configured
  • bun test passes, bun run typecheck passes with 0 errors

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions