Background agent completion notifications can be delivered long after they stop being useful — after the final answer, for agents whose results were already read with get_subagent_result. Each one costs a turn to answer "already incorporated".
Why the existing suppression misses it. The notification is sent with deliverAs: "followUp", which pi delivers only once the agent has no more tool calls. Emitting one mid-run parks it in pi's follow-up queue until the run ends, and a parked message can't be withdrawn. emitIndividualNudge does re-check record.resultConsumed, but that check runs when the message is enqueued — 200ms after completion (NUDGE_HOLD_MS) — while the orchestrator typically joins the agent minutes later. So a 200ms cancellation window is guarding a queue with multi-minute latency.
With pi's default followUpMode: "one-at-a-time" the parked notifications then drain one turn each.
Repro: spawn a background agent during a long run, join it with get_subagent_result a few minutes later, keep working. Its notification still arrives after the final answer.
From a real session (pi 0.82.1, extension 0.14.3, macOS):
22:09:39 agent spawned (run_in_background: true)
22:10:42 agent completes
22:13:55 get_subagent_result -> resultConsumed = true
22:23:40 final answer
22:23:40 notification delivered (13 min late)
22:23:44 extra turn: "Already incorporated; no further action needed."
A worse one in the same setup: 7 agents that had finished up to 1h41m earlier were flushed in a 54-second burst after the final answer — 7 wasted turns, and the burst pushed the session into a compaction.
Suggested fix: hold due notifications in-process rather than in pi's queue, and flush them at agent_settled — the first point where pi won't continue on its own. agent_end and turn_end both still have retries, compaction, or further tool-calling turns ahead, so emitting there parks them exactly as before. Since each send closure already re-checks resultConsumed, deferring the call defers the check, and a joined agent simply never notifies.
I have this implemented with tests and would like to submit it — opening a PR right after this so there's something concrete to react to. Happy to rework it if you'd rather solve it a different way.
Background agent completion notifications can be delivered long after they stop being useful — after the final answer, for agents whose results were already read with
get_subagent_result. Each one costs a turn to answer "already incorporated".Why the existing suppression misses it. The notification is sent with
deliverAs: "followUp", which pi delivers only once the agent has no more tool calls. Emitting one mid-run parks it in pi's follow-up queue until the run ends, and a parked message can't be withdrawn.emitIndividualNudgedoes re-checkrecord.resultConsumed, but that check runs when the message is enqueued — 200ms after completion (NUDGE_HOLD_MS) — while the orchestrator typically joins the agent minutes later. So a 200ms cancellation window is guarding a queue with multi-minute latency.With pi's default
followUpMode: "one-at-a-time"the parked notifications then drain one turn each.Repro: spawn a background agent during a long run, join it with
get_subagent_resulta few minutes later, keep working. Its notification still arrives after the final answer.From a real session (pi 0.82.1, extension 0.14.3, macOS):
A worse one in the same setup: 7 agents that had finished up to 1h41m earlier were flushed in a 54-second burst after the final answer — 7 wasted turns, and the burst pushed the session into a compaction.
Suggested fix: hold due notifications in-process rather than in pi's queue, and flush them at
agent_settled— the first point where pi won't continue on its own.agent_endandturn_endboth still have retries, compaction, or further tool-calling turns ahead, so emitting there parks them exactly as before. Since each send closure already re-checksresultConsumed, deferring the call defers the check, and a joined agent simply never notifies.I have this implemented with tests and would like to submit it — opening a PR right after this so there's something concrete to react to. Happy to rework it if you'd rather solve it a different way.