fix(background-agent): track session.next activity

Convert OpenCode v2 session.next stream events into the existing message part activity path so child sessions that are still producing text, reasoning, or tool output refresh lastUpdate before stale polling runs.

This keeps the timeout poller from cancelling active subagents and preserves tool-call progress for session.next.tool.called events.

Plan: .omo/plans/subagent-timeout-active-output.md
This commit is contained in:
YeonGyu-Kim
2026-05-21 15:23:26 +09:00
parent 94d6d5b495
commit b68af25e41
3 changed files with 293 additions and 65 deletions
+48 -65
View File
@@ -83,6 +83,12 @@ import {
verifySessionExists as verifySessionStillExists,
} from "./session-existence"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
import {
hasOutputSignalFromPart,
resolveMessagePartInfo,
resolveSessionNextPartInfo,
SESSION_NEXT_EVENT_PREFIX,
} from "./session-stream-activity"
import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier"
import { buildFallbackBody, FALLBACK_AGENT, isAgentNotFoundError } from "./spawner"
import {
@@ -144,15 +150,6 @@ const PARENT_WAKE_TOOL_CALL_DEFER_MAX_MS = 5_000
const PARENT_WAKE_USER_MESSAGE_IN_PROGRESS_WINDOW_MS = 2_000
const PARENT_WAKE_SESSION_ACTIVITY_IN_PROGRESS_WINDOW_MS = 2_000
interface MessagePartInfo {
id?: string
sessionID?: string
type?: string
tool?: string
input?: Record<string, unknown>
state?: { status?: string; input?: Record<string, unknown> }
}
interface EventProperties {
sessionID?: string
info?: { id?: string; sessionID?: string }
@@ -164,19 +161,6 @@ interface Event {
properties?: EventProperties
}
function resolveMessagePartInfo(properties: EventProperties | undefined): MessagePartInfo | undefined {
if (!properties || typeof properties !== "object") {
return undefined
}
const nestedPart = properties.part
if (nestedPart && typeof nestedPart === "object") {
return nestedPart as MessagePartInfo
}
return properties as MessagePartInfo
}
interface Todo {
content: string
status: string
@@ -1457,22 +1441,21 @@ The fallback retry session is now created and can be inspected directly.
this.observedIncompleteTodosBySession.delete(sessionID)
}
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
if (!partInfo) return false
if (!partInfo.sessionID && !sessionID) return false
if (partInfo.tool) return true
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
if (partInfo.type === "text" || partInfo.type === "reasoning") return true
const field = typeof (partInfo as { field?: unknown }).field === "string"
? (partInfo as { field?: string }).field
: undefined
return field === "text" || field === "reasoning"
}
handleEvent(event: Event): void {
const props = event.properties
if (event.type.startsWith(SESSION_NEXT_EVENT_PREFIX)) {
const sessionID = resolveSessionEventID(props)
const partInfo = resolveSessionNextPartInfo(event.type, props)
if (!sessionID || !partInfo) return
this.handleEvent({
type: "message.part.updated",
properties: { sessionID, part: partInfo },
})
return
}
if (event.type === "message.updated") {
const info = props?.info
if (!info || typeof info !== "object") return
@@ -1523,7 +1506,7 @@ The fallback retry session is now created and can be inspected directly.
const { task } = resolved
if (this.hasOutputSignalFromPart(partInfo, sessionID)) {
if (hasOutputSignalFromPart(partInfo, sessionID)) {
this.markSessionOutputObserved(sessionID)
}
@@ -1537,10 +1520,10 @@ The fallback retry session is now created and can be inspected directly.
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
lastUpdate: partInfo?.activityTime ?? new Date(),
}
}
task.progress.lastUpdate = new Date()
task.progress.lastUpdate = partInfo?.activityTime ?? new Date()
if (partInfo?.type === "tool" || partInfo?.tool) {
const countedToolPartIDs = task.progress.countedToolPartIDs ?? new Set<string>()
@@ -1560,34 +1543,34 @@ The fallback retry session is now created and can be inspected directly.
task.progress.toolCalls += 1
task.progress.lastTool = partInfo.tool
const circuitBreaker = this.cachedCircuitBreakerSettings ?? resolveCircuitBreakerSettings(this.config)
this.cachedCircuitBreakerSettings = circuitBreaker
if (partInfo.tool) {
const toolInput = partInfo.state?.input ?? partInfo.input
task.progress.toolCallWindow = recordToolCall(
task.progress.toolCallWindow,
partInfo.tool,
circuitBreaker,
toolInput
)
const circuitBreaker = this.cachedCircuitBreakerSettings ?? resolveCircuitBreakerSettings(this.config)
this.cachedCircuitBreakerSettings = circuitBreaker
if (partInfo.tool) {
const toolInput = partInfo.state?.input ?? partInfo.input
task.progress.toolCallWindow = recordToolCall(
task.progress.toolCallWindow,
partInfo.tool,
circuitBreaker,
toolInput
)
if (circuitBreaker.enabled) {
const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
if (loopDetection.triggered) {
log("[background-agent] Circuit breaker: consecutive tool usage detected", {
taskId: task.id,
agent: task.agent,
sessionID,
toolName: loopDetection.toolName,
repeatedCount: loopDetection.repeatedCount,
})
void this.cancelTask(task.id, {
source: "circuit-breaker",
reason: `Subagent called ${loopDetection.toolName} ${loopDetection.repeatedCount} consecutive times (threshold: ${circuitBreaker.consecutiveThreshold}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
})
return
}
}
if (circuitBreaker.enabled) {
const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
if (loopDetection.triggered) {
log("[background-agent] Circuit breaker: consecutive tool usage detected", {
taskId: task.id,
agent: task.agent,
sessionID,
toolName: loopDetection.toolName,
repeatedCount: loopDetection.repeatedCount,
})
void this.cancelTask(task.id, {
source: "circuit-breaker",
reason: `Subagent called ${loopDetection.toolName} ${loopDetection.repeatedCount} consecutive times (threshold: ${circuitBreaker.consecutiveThreshold}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
})
return
}
}
}
const maxToolCalls = circuitBreaker.maxToolCalls