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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user