Files
orca/src/main/opencode/status-plugin-ownership-source.ts
T
Neil 8ddad49ae5 fix(opencode): retire a subagent's blocker when the root turn ends (#22604)
A descendant's question is stored under its own sourceSessionID but displayed on
the root session it rolls up to. clearAttentionForSession matches only the
source, so the root's own idle could never retire it: the blocker outlived the
turn that raised it and pinned the pane on "needs input" with nothing left to
answer. The lead agent truthfully reports no pending question, because the
blocker belongs to a child it does not know about (#22371).

Add clearAttentionForTurnEnd, matching the source id or the rolled-up root, and
use it only from the idle branch. Scoped to turn end deliberately: a live
blocker must still outrank the root going Busy, since a subagent can be waiting
on the user while the root keeps working — widening the existing clear broke
exactly that test, which is what surfaced the right scope.

Shared with the OpenCode family, so mimo-code gets the same fix; both pinned
plugin digests move.

New test fails without the change: after the root turn ends the last post is
still AskUserQuestion instead of SessionIdle.
2026-09-23 23:45:50 -07:00

169 lines
7.1 KiB
TypeScript

export function getStatusPluginOwnershipSource(): string[] {
return [
'',
'function currentAttention() {',
' let latestQuestion = null;',
' for (const attention of pendingAttentionByKey.values()) {',
' if (attention.hookEventName === "PermissionRequest") return attention;',
' latestQuestion = attention;',
' }',
' return latestQuestion;',
'}',
'',
'function attentionKey(factoryID, hookEventName, requestID, sourceSessionID) {',
' // Why: custom plugins may reuse request IDs across sessions or factories;',
' // JSON tuple identity prevents one owner from hiding another blocker.',
' return JSON.stringify([factoryID, hookEventName, requestID, sourceSessionID || ""]);',
'}',
'',
'function clearAttentionForSession(sessionID, factoryID) {',
' let rootSessionID = null;',
' for (const [key, attention] of pendingAttentionByKey) {',
' if (attention.sourceSessionID === sessionID && attention.factoryID === factoryID) {',
' pendingAttentionByKey.delete(key);',
' rootSessionID = attention.properties?.sessionID || sessionID;',
' }',
' }',
' return rootSessionID;',
'}',
'',
'// Why a second, wider clear: a descendant blocker is stored under its own',
'// sourceSessionID but displayed on the root it rolled up to, so the root turn',
'// ending can never retire it through the source match alone — a subagent question',
'// outlives the turn that raised it and pins the pane (#22371). Scoped to turn end',
'// on purpose: a live blocker must still outrank the root going Busy, because a',
'// subagent can be waiting on the user while the root keeps working.',
'function clearAttentionForTurnEnd(sessionID, factoryID) {',
' let rootSessionID = null;',
' for (const [key, attention] of pendingAttentionByKey) {',
' const ownsAttention =',
' attention.sourceSessionID === sessionID ||',
' attention.properties?.sessionID === sessionID;',
' if (ownsAttention && attention.factoryID === factoryID) {',
' pendingAttentionByKey.delete(key);',
' rootSessionID = attention.properties?.sessionID || sessionID;',
' }',
' }',
' return rootSessionID;',
'}',
'',
'function clearQuestionForToolPart(part, sessionID, factoryID) {',
' if (',
' part?.type !== "tool" ||',
' part.tool !== "question" ||',
' (part.state?.status !== "completed" && part.state?.status !== "error")',
' ) return null;',
' let rootSessionID = null;',
' for (const [key, attention] of pendingAttentionByKey) {',
' const tool = attention.properties?.tool;',
' if (',
' attention.hookEventName === "AskUserQuestion" &&',
' attention.sourceSessionID === sessionID &&',
' attention.factoryID === factoryID &&',
' tool?.messageID === part.messageID &&',
' tool?.callID === part.callID',
' ) {',
' pendingAttentionByKey.delete(key);',
' rootSessionID = attention.properties?.sessionID || sessionID;',
' }',
' }',
' return rootSessionID;',
'}',
'',
'function clearAttentionForResolution(event, sessionID, factoryID) {',
' const hookEventName =',
' event.type === "permission.replied" ? "PermissionRequest" : "AskUserQuestion";',
' const requestID = event.properties?.requestID || "";',
' const key = attentionKey(factoryID, hookEventName, requestID, sessionID);',
' const attention = pendingAttentionByKey.get(key);',
' if (!attention || attention.sourceSessionID !== sessionID) return null;',
' pendingAttentionByKey.delete(key);',
' return attention.properties?.sessionID || sessionID;',
'}',
'',
'function provisionalBusyKey(factoryID, sessionID) {',
' return JSON.stringify([factoryID, sessionID || ""]);',
'}',
'',
'function rememberProvisionalBusy(sessionID, factoryID) {',
' const key = provisionalBusyKey(factoryID, sessionID);',
' // Why: active ownership cannot be LRU-evicted without allowing false',
' // Idle; exact matching Idle or factory disposal lifecycle-bounds it.',
' provisionalBusyByKey.delete(key);',
' provisionalBusyByKey.set(key, { sessionID, factoryID });',
'}',
'',
'function clearProvisionalBusy(sessionID, factoryID) {',
' return provisionalBusyByKey.delete(provisionalBusyKey(factoryID, sessionID));',
'}',
'',
'function busyChildKey(factoryID, sessionID) {',
' return JSON.stringify([factoryID, sessionID || ""]);',
'}',
'',
'function rememberBusyChild(sessionID, rootSessionID, factoryID) {',
' const key = busyChildKey(factoryID, sessionID);',
' // Why: repeated Busy for the same child is not a state change; report one',
' // only when the pane actually gains a running descendant.',
' if (busyChildRootByKey.get(key)?.sessionID === rootSessionID) return false;',
' busyChildRootByKey.delete(key);',
' busyChildRootByKey.set(key, { sessionID: rootSessionID, factoryID });',
' return true;',
'}',
'',
'function clearBusyChild(sessionID, factoryID) {',
' return busyChildRootByKey.delete(busyChildKey(factoryID, sessionID));',
'}',
'',
'function clearKnownBusyRoot(sessionID, factoryID) {',
' if (busyRootOwnerBySessionID.get(sessionID) !== factoryID) return false;',
' busyRootOwnerBySessionID.delete(sessionID);',
' return true;',
'}',
'',
'function latestBusyOwner() {',
' let latest = null;',
' // Why: a running descendant is reported under its ROOT id, and ranks',
' // below a directly busy root so an ongoing turn keeps labelling the pane.',
' for (const busyChild of busyChildRootByKey.values()) {',
' latest = busyChild;',
' }',
' for (const [sessionID, factoryID] of busyRootOwnerBySessionID) {',
' latest = { sessionID, factoryID };',
' }',
' for (const provisional of provisionalBusyByKey.values()) {',
' latest = provisional;',
' }',
' return latest;',
'}',
'',
'async function publishAggregateStatus(fallbackFactoryID, preferredSessionID) {',
' const attention = currentAttention();',
' if (attention) {',
' await setAttention(',
' attention.hookEventName,',
' attention.properties,',
' attention.factoryID,',
' attention.sourceSessionID',
' );',
' return;',
' }',
' const busyOwner = latestBusyOwner();',
' if (busyOwner) {',
' await setStatus("busy", { sessionID: busyOwner.sessionID }, busyOwner.factoryID);',
' return;',
' }',
' await setStatus("idle", { sessionID: preferredSessionID }, fallbackFactoryID);',
'}',
'',
'async function publishOwnershipChange(fallbackFactoryID, preferredSessionID) {',
' stateArrivalRevision += 1;',
' // Why: exact blocker/Busy retirement is authoritative even if ancestry',
' // lookup failed; every older preview must settle before its replacement.',
' await flushPendingAssistantPart(true);',
' await publishAggregateStatus(fallbackFactoryID, preferredSessionID);',
'}',
''
]
}