fix(task): prevent subagent silent failures in session resolution and polling lifecycle (#903)

* fix(task): prevent subagent silent failures in session resolution and polling lifecycle

Two failure points fixed:

1. Fallback session resolution window too narrow (3s):
   - resolveFallbackTaskSessionId now accepts hasRetried boolean
   - First attempt uses 3s window (avoids binding wrong sessions)
   - Subsequent attempts widen to 8s (handles late-appearing child sessions)
   - Uses useState + useEffect instead of side effects in Zustand selector

2. Polling stops before child results are captured:
   - When child session goes idle before parent sees it active, polling
     would stop without fetching results
   - Added final-fetch-before-stop: a one-shot delayed fetch that runs
     after the settle grace period, ensuring child results are captured
   - Uses taskFinalFetchDoneRef to guarantee exactly one final fetch
   - Preserves existing happy path (active child → normal settle timer)

* fix(task): serialize final fetch after polling stops

Move the subagent final-fetch into a dedicated effect that runs only after
polling has stopped, avoiding races between polling writes and final-fetch
writes to the child sync store.

Also retry safely on final-fetch failure by reopening polling instead of
marking the final fetch as done before the request succeeds.

* feat(task): distinguish child session errors from normal idle

When a subagent terminates with an error, abort, timeout, or failure,
the parent session could not tell it apart from a normal completion.

Changes:
- event-reducer.ts: session.error now stores { type: 'error' } instead of
  { type: 'idle' }, so consumers can distinguish failed from completed sessions
- useSessionActivity.ts: add 'error' phase to SessionActivityPhase and
  isError flag to SessionActivityResult; error phase is non-active (like idle)
  but distinguishable via isError
- ToolPart.tsx: pass childSessionError to TaskToolSummary and show
  'Subagent session ended with an error.' instead of the generic
  'No subagent session id on task metadata.' when the child errored

Other consumers of session_status that only check 'busy'/'idle' are
unaffected — 'error' falls through to existing idle-like behavior.

* Revert "feat(task): distinguish child session errors from normal idle"

This reverts commit b3cc749bde16ed8fc55e4f304dcc455484335fba.

* fix(task): delay fallback retry window widening

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-14 20:17:52 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 43a4c0c874
commit bb1d522838
2 changed files with 159 additions and 13 deletions
@@ -161,6 +161,7 @@ const TASK_TOOL_ACTIVE_FETCH_LIMIT = 160;
const TASK_TOOL_IDLE_FETCH_LIMIT = 80;
const TASK_TOOL_NO_CHANGE_BACKOFF_AFTER_POLLS = 3;
const TASK_TOOL_SETTLE_GRACE_MS = 2500;
const TASK_TOOL_FALLBACK_RETRY_MS = 3000;
const GIT_REFRESH_MUTATING_TOOLS = new Set([
'bash',
'edit',
@@ -1880,6 +1881,10 @@ const ToolPart: React.FC<ToolPartProps> = ({
return parseTaskMetadataBlock(taskOutputString);
}, [taskOutputString]);
// Track whether fallback session resolution has failed at least once.
// When true, resolveFallbackTaskSessionId widens its time window (3s → 8s).
const [taskFallbackRetried, setTaskFallbackRetried] = React.useState(false);
const explicitTaskSessionId = React.useMemo<string | undefined>(() => {
if (!isTaskTool) {
return undefined;
@@ -1914,8 +1919,9 @@ const ToolPart: React.FC<ToolPartProps> = ({
isTaskFinalized: isFinalized,
sessions: storeState.session,
sessionStatusMap: storeState.session_status,
hasRetried: taskFallbackRetried,
});
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized]),
}, [explicitTaskSessionId, isTaskTool, currentSessionId, taskSessionResolutionStart, isFinalized, taskFallbackRetried]),
currentDirectory,
);
@@ -1979,17 +1985,55 @@ const ToolPart: React.FC<ToolPartProps> = ({
const childSessionActivity = useSessionActivity(taskSessionId, currentDirectory);
const [taskChildSeenActive, setTaskChildSeenActive] = React.useState(false);
const [taskChildPollingStopped, setTaskChildPollingStopped] = React.useState(false);
const [taskPendingFinalFetch, setTaskPendingFinalFetch] = React.useState(false);
const taskPollNoChangeCountRef = React.useRef(0);
const taskPollLastSignatureRef = React.useRef<string>('');
const taskFinalFetchDoneRef = React.useRef(false);
React.useEffect(() => {
setTaskChildSeenActive(false);
setTaskChildPollingStopped(false);
setTaskPendingFinalFetch(false);
taskPollNoChangeCountRef.current = 0;
taskPollLastSignatureRef.current = '';
taskFinalFetchDoneRef.current = false;
setTaskFallbackRetried(false);
}, [taskSessionId]);
// Widen fallback resolution window only after a real retry boundary.
React.useEffect(() => {
if (!isTaskTool || taskFallbackRetried || explicitTaskSessionId != null || taskSessionId != null || isFinalized) {
return;
}
const sinceStart =
typeof taskSessionResolutionStart === 'number'
? Date.now() - taskSessionResolutionStart
: 0;
const delay = Math.max(0, TASK_TOOL_FALLBACK_RETRY_MS - sinceStart);
if (typeof window === 'undefined') {
setTaskFallbackRetried(true);
return;
}
const timer = window.setTimeout(() => {
setTaskFallbackRetried(true);
}, delay);
return () => {
window.clearTimeout(timer);
};
}, [
explicitTaskSessionId,
isFinalized,
isTaskTool,
taskFallbackRetried,
taskSessionId,
taskSessionResolutionStart,
]);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId) {
return;
@@ -2007,37 +2051,129 @@ const ToolPart: React.FC<ToolPartProps> = ({
if (taskChildPollingStopped) {
setTaskChildPollingStopped(false);
}
if (taskPendingFinalFetch) {
setTaskPendingFinalFetch(false);
}
return;
}
if (!taskChildSeenActive || taskChildPollingStopped || childSessionTaskSummaryEntries.length === 0) {
// Always stop polling if already done.
if (taskChildPollingStopped && taskFinalFetchDoneRef.current) {
return;
}
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
// Normal settle path: child went idle after we saw it active, and we have entries.
// Schedule a grace period before marking polling as stopped.
if (taskChildSeenActive && childSessionTaskSummaryEntries.length > 0 && !taskChildPollingStopped) {
if (typeof window === 'undefined') {
setTaskChildPollingStopped(true);
return;
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
const timer = window.setTimeout(() => {
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
// Final-fetch path: child went idle before parent saw it active, or we have no
// entries yet. First stop polling after the settle grace period. A separate
// effect performs the final fetch once polling has fully stopped, avoiding
// races with any in-flight polling response.
if (!taskChildPollingStopped && !taskFinalFetchDoneRef.current) {
if (typeof window === 'undefined') {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
return;
}
return () => {
window.clearTimeout(timer);
};
const timer = window.setTimeout(() => {
setTaskPendingFinalFetch(true);
setTaskChildPollingStopped(true);
}, TASK_TOOL_SETTLE_GRACE_MS);
return () => {
window.clearTimeout(timer);
};
}
}, [
childSessionActivity.phase,
childSessionHasInFlightTools,
childSessionTaskSummaryEntries.length,
currentDirectory,
activeLatched,
isFinalized,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskChildSeenActive,
taskSessionId,
]);
React.useEffect(() => {
if (!isTaskTool || !taskSessionId || !taskChildPollingStopped || !taskPendingFinalFetch || taskFinalFetchDoneRef.current) {
return;
}
let cancelled = false;
const capturedSessionId = taskSessionId;
const runFinalFetch = async () => {
try {
const scopedClient = opencodeClient.getScopedSdkClient(currentDirectory);
const response = await scopedClient.session.messages({
sessionID: capturedSessionId,
limit: TASK_TOOL_INITIAL_FETCH_LIMIT,
});
if (cancelled) {
return;
}
const messages = response.data ?? [];
if (Array.isArray(messages) && messages.length > 0) {
const childStores = getSyncChildStores();
childStores.update(currentDirectory, (prev) => {
const records = messages as SessionMessageWithParts[];
const partPatch: Record<string, import('@opencode-ai/sdk/v2').Part[]> = { ...prev.part };
for (const rec of records) {
partPatch[rec.info.id] = rec.parts;
}
return {
message: { ...prev.message, [capturedSessionId]: records.map((r) => r.info) as import('@opencode-ai/sdk/v2').Message[] },
part: partPatch,
};
});
}
taskFinalFetchDoneRef.current = true;
setTaskPendingFinalFetch(false);
} catch {
if (cancelled) {
return;
}
setTaskPendingFinalFetch(false);
setTaskChildPollingStopped(false);
}
};
void runFinalFetch();
return () => {
cancelled = true;
};
}, [
currentDirectory,
isTaskTool,
taskChildPollingStopped,
taskPendingFinalFetch,
taskSessionId,
]);
React.useEffect(() => {
if (typeof time?.end === 'number' || typeof pinnedTime.end === 'number') {
setLocalFinalizedAt(undefined);
@@ -2080,7 +2216,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
const shouldPoll =
!taskChildPollingStopped
&& (childSessionHasInFlightTools || childSessionActive || childSessionTaskSummaryEntries.length === 0);
const shouldFetchSnapshot = childSessionTaskSummaryEntries.length === 0 || shouldPoll;
const shouldFetchSnapshot = !taskPendingFinalFetch && (childSessionTaskSummaryEntries.length === 0 || shouldPoll);
if (!shouldFetchSnapshot) {
return;
}
@@ -2180,6 +2316,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
currentDirectory,
isActive,
isTaskTool,
taskPendingFinalFetch,
taskChildPollingStopped,
taskSessionId,
]);
@@ -13,7 +13,12 @@ import type { Session, SessionStatus } from '@opencode-ai/sdk/v2/client';
* task started are eligible. This avoids binding to earlier or later sibling
* subagent sessions when explicit task metadata is delayed.
*/
/**
* Narrow initial window avoids binding to wrong sessions on first attempt.
* Wide window on retry handles late-appearing child sessions under load.
*/
const TASK_SESSION_MATCH_WINDOW_MS = 3000;
const TASK_SESSION_MATCH_WINDOW_WIDE_MS = 8000;
const LIVE_STATUSES = new Set<string>(['busy', 'retry']);
@@ -30,6 +35,8 @@ export interface ResolveFallbackParams {
sessions: Session[];
/** Session status map from the sync store */
sessionStatusMap?: Record<string, SessionStatus>;
/** True when a previous resolution attempt has already failed (enables wider window) */
hasRetried?: boolean;
}
/**
@@ -50,13 +57,15 @@ export function resolveFallbackTaskSessionId(params: ResolveFallbackParams): str
isTaskFinalized = false,
sessions,
sessionStatusMap,
hasRetried = false,
} = params;
if (!isTaskTool || isTaskFinalized || !parentSessionId || typeof taskStartTime !== 'number') {
return undefined;
}
const latestAllowed = taskStartTime + TASK_SESSION_MATCH_WINDOW_MS;
const windowMs = hasRetried ? TASK_SESSION_MATCH_WINDOW_WIDE_MS : TASK_SESSION_MATCH_WINDOW_MS;
const latestAllowed = taskStartTime + windowMs;
// Filter candidate sessions: parentID matches and created shortly after task start.
const candidates = sessions.filter((session) => {