You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Orphan cleanup — cleanupOrphanedAgents (server/services/agentManagement.js:599) calls completeAgent directly. Runs at boot and every 15 minutes.
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.
Problem
processTaskOutputhooks (server/services/taskTypeHooks.js) run from exactly one place:dispatchTaskOutputHook, called byfinalizeAgent(server/services/agentFinalization.js). Two live paths complete an agent without going throughfinalizeAgent, so no output hook ever runs for those runs:cleanupOrphanedAgents(server/services/agentManagement.js:599) callscompleteAgentdirectly. Runs at boot and every 15 minutes.server/services/agentLifecycle.js:774) also callscompleteAgentdirectly.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 ingetEffectiveQuotaBurnDispatches(server/services/quotaBurn.js) only covers tasks still inpending/in_progress. A run finalized through one of the two paths above records neither: the hook never fires, andhandleOrphanedTaskthen settles the task out ofpending/in_progress, dropping it from the tally too.Failure scenario
Family
grokconfigured withmaxDispatchesPerWindow: 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 andhandleOrphanedTasksettles the task tocompletedorblocked. The ledger is still empty and no in-flight task remains, so the next improvement tick computes an effective count of0 < 1and 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 callcompleteAgentearly, but the process close handler still reachesfinalizeAgent, so the hook fires.Decision
Route both non-finalize completion paths through
dispatchTaskOutputHookrather 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
stampLiExecutionVerdictwired 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:
success: falseand no.agent-donepayload (the worktree is usually gone by then, sopayloadwill benull— hooks must already tolerate that, and quota-burn's ignorespayloadentirely).withOutputHookTimeoutbound so a slow hook can't stall boot-time orphan cleanup.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
cleanupOrphanedAgentsrecords its dispatch indata/cos/quota-burn-dispatches.json.finalizeAgentrecords its dispatch exactly once (no double-count from the new call sites).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