-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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:
continueif the developer is making steady progress (writing code, running tests, moving forward)steerif the developer seems confused, is going in circles, or is heading in the wrong direction — include a specific, actionable suggestionreplaceonly 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_activityonly 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:
- Check if a model named
"steering"is defined in the user's agent config (theiropencode.personal.jsonor similar) - If yes, use that model
- 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:
continueresponse → task unchanged,last_steeringupdatedsteerresponse → message sent to developer session, comment addedreplaceresponse → developer killed, task reset toopen, comment added- Steering not triggered if less than 15 minutes since last steering
- Steering not triggered for tasks not in
in_progressstatus - 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.tswithmode: "subagent",hidden: true, cheapest model -
checkSteering()added to Pulse tick aftercheckTimeouts - Steering evaluated every 15 minutes per in-progress task (not per tick)
-
continueresponse → no action, timestamp updated -
steerresponse → guidance message sent to developer session, comment logged -
replaceresponse → session killed, task reset toopen, fresh worktree on next schedule -
checkTimeoutsimproved to check actual session message activity, not just Pulse timestamps - Steering model configurable; falls back to cheapest available if not configured
-
bun testpasses,bun run typecheckpasses with 0 errors