Skip to content

Output hooks never run when an agent completes outside finalizeAgent, losing the quota-burn dispatch record #3182

Description

@atomantic

Problem

processTaskOutput hooks (server/services/taskTypeHooks.js) run from exactly one place: dispatchTaskOutputHook, called by finalizeAgent (server/services/agentFinalization.js). Two live paths complete an agent without going through finalizeAgent, so no output hook ever runs for those runs:

  1. Orphan cleanupcleanupOrphanedAgents (server/services/agentManagement.js:599) calls completeAgent directly. Runs at boot and every 15 minutes.
  2. Post-restart recovery — the "Completing untracked agent from cos state" branch (server/services/agentLifecycle.js:774) also calls completeAgent directly.

This is now load-bearing for quota-burn (#3179). Its dispatch ledger is written by processTaskOutput (server/services/autonomousJobs/quotaBurnHooks.js), and the in-flight tally in getEffectiveQuotaBurnDispatches (server/services/quotaBurn.js) only covers tasks still in pending/in_progress. A run finalized through one of the two paths above records neither: the hook never fires, and handleOrphanedTask then settles the task out of pending/in_progress, dropping it from the tally too.

Failure scenario

Family grok configured with maxDispatchesPerWindow: 1. A quota-burn agent spawns and runs for 40 minutes, genuinely consuming provider quota. PortOS restarts (self-update, pm2 restart, or crash). At boot, orphan cleanup marks the agent complete and handleOrphanedTask settles the task to completed or blocked. The ledger is still empty and no in-flight task remains, so the next improvement tick computes an effective count of 0 < 1 and dispatches a second burn — two agents in a window capped at one.

This is a regression against the pre-#3179 behavior, where the ledger was written during generation and therefore counted the dispatch regardless of how the run ended.

Note: the user-terminate paths (terminateAgent / killAgent) are not affected — they call completeAgent early, but the process close handler still reaches finalizeAgent, so the hook fires.

Decision

Route both non-finalize completion paths through dispatchTaskOutputHook rather than adding another per-hook special case.

The alternative — teaching quota-burn specifically to recover its missed writes — is the wrong altitude and would be the third hand-patch of this kind: layered-intelligence already needed stampLiExecutionVerdict wired into the post-restart branch for the same structural reason (#2779). Every programmatic-I/O task type has the same gap, so fix it once at the chokepoint.

Scope notes for the implementer:

  • Extract the hook dispatch so orphan cleanup and post-restart recovery can call it with success: false and no .agent-done payload (the worktree is usually gone by then, so payload will be null — hooks must already tolerate that, and quota-burn's ignores payload entirely).
  • Keep the existing withOutputHookTimeout bound so a slow hook can't stall boot-time orphan cleanup.
  • These paths must not double-fire the hook for an agent that did go through finalizeAgent; gate on whether finalize already ran for that agent.
  • stampLiExecutionVerdict's existing hand-patch in the post-restart branch can likely be folded into the shared dispatch as a follow-up, but that is not required here.

Acceptance criteria

  • A quota-burn agent reaped by cleanupOrphanedAgents records its dispatch in data/cos/quota-burn-dispatches.json.
  • A quota-burn agent completed by the post-restart "untracked agent" branch records its dispatch.
  • An agent that completes normally through finalizeAgent records its dispatch exactly once (no double-count from the new call sites).
  • Tests cover both recovery paths for a task type registering processTaskOutput, plus the no-double-fire case.

Context

  • server/services/agentFinalization.js (dispatchTaskOutputHook, withOutputHookTimeout)
  • server/services/agentManagement.js (cleanupOrphanedAgents)
  • server/services/agentLifecycle.js (post-restart untracked-agent branch)
  • server/services/autonomousJobs/quotaBurnHooks.js, server/services/quotaBurn.js
  • Surfaced by the review gate on quota-burn dispatch ledger records before task creation is confirmed, undercounting the per-window budget #3179; deferred there because the fix touches shared finalize paths for every programmatic-I/O task type, not just quota-burn.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions