fix(web): repair subagent streaming pipeline

- Handle ev.data.text fallback when delta is absent in assistant events
  (both active-runs and subagent-runs)
- Defer subagent finalizeRun until subscribe process closes so buffered
  events in readline are still delivered to SSE subscribers
- Register subagents from sessions_spawn tool results in active-runs so
  hasRunningSubagentsForParent works without opening SubagentPanel first
- Add disk registry fallback in hasRunningSubagentsForParent for cases
  where in-memory parentIndex has no entries
- Fix pre-commit hook: tolerate oxfmt exit 2 when all files are ignored

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kumarabhirup 2026-02-22 00:06:31 -08:00
parent 15063e85a2
commit b29b2e39a3
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167
3 changed files with 110 additions and 19 deletions

View File

@ -31,6 +31,7 @@ import {
} from "./agent-runner";
import {
hasRunningSubagentsForParent,
registerSubagent,
} from "./subagent-runs";
// ── Types ──
@ -592,7 +593,12 @@ function wireChildProcess(run: ActiveRun): void {
typeof ev.data?.delta === "string"
? ev.data.delta
: undefined;
if (delta) {
const textFallback =
!delta && typeof ev.data?.text === "string"
? ev.data.text
: undefined;
const chunk = delta ?? textFallback;
if (chunk) {
closeReasoning();
if (!textStarted) {
currentTextId = nextId("text");
@ -600,8 +606,8 @@ function wireChildProcess(run: ActiveRun): void {
textStarted = true;
}
everSentText = true;
emit({ type: "text-delta", id: currentTextId, delta });
accAppendText(delta);
emit({ type: "text-delta", id: currentTextId, delta: chunk });
accAppendText(chunk);
}
// Media URLs
const mediaUrls = ev.data?.mediaUrls;
@ -709,6 +715,25 @@ function wireChildProcess(run: ActiveRun): void {
}
}
}
if (toolName === "sessions_spawn" && !isError) {
const childSessionKey =
result?.details?.sessionKey as string | undefined;
const childRunId =
result?.details?.runId as string | undefined;
const spawnTask =
result?.details?.task as string | undefined;
const spawnLabel =
result?.details?.label as string | undefined;
if (childSessionKey && childRunId) {
registerSubagent(run.sessionId, {
sessionKey: childSessionKey,
runId: childRunId,
task: spawnTask ?? "Subagent task",
label: spawnLabel,
});
}
}
}
}

View File

@ -45,6 +45,10 @@ type SubagentRun = SubagentInfo & {
_state: TransformState;
_subscribeProcess: ChildProcess | null;
_cleanupTimer: ReturnType<typeof setTimeout> | null;
/** Set when lifecycle/end is received; actual finalization deferred to subscribe close. */
_lifecycleEnded: boolean;
/** Safety timer to finalize if subscribe process hangs after lifecycle/end. */
_finalizeTimer: ReturnType<typeof setTimeout> | null;
/** Last globalSeq seen from the gateway event stream for replay cursor. */
lastGlobalSeq: number;
};
@ -233,6 +237,8 @@ export function registerSubagent(
_state: createTransformState(),
_subscribeProcess: null,
_cleanupTimer: null,
_lifecycleEnded: false,
_finalizeTimer: null,
lastGlobalSeq: 0,
};
@ -357,19 +363,49 @@ export function isSubagentRunning(sessionKey: string): boolean {
export function hasRunningSubagentsForParent(parentWebSessionId: string): boolean {
const reg = getRegistry();
const keys = reg.parentIndex.get(parentWebSessionId);
if (!keys) {return false;}
let anyRunning = false;
for (const key of keys) {
const run = reg.runs.get(key);
if (run?.status !== "running") {continue;}
const diskStatus = readDiskStatus(key);
if (diskStatus !== "running") {
finalizeRun(run, diskStatus === "error" ? "error" : "completed");
continue;
if (keys && keys.size > 0) {
let anyRunning = false;
for (const key of keys) {
const run = reg.runs.get(key);
if (run?.status !== "running") {continue;}
const diskStatus = readDiskStatus(key);
if (diskStatus !== "running") {
finalizeRun(run, diskStatus === "error" ? "error" : "completed");
continue;
}
anyRunning = true;
}
anyRunning = true;
if (anyRunning) {return true;}
}
return anyRunning;
// Fallback: check the gateway disk registry for running subagents
// that may not have been registered in-memory yet.
return checkDiskRegistryForRunningSubagents(parentWebSessionId);
}
function checkDiskRegistryForRunningSubagents(parentWebSessionId: string): boolean {
const registryPath = join(resolveOpenClawStateDir(), "subagents", "runs.json");
if (!existsSync(registryPath)) {return false;}
try {
const raw = JSON.parse(readFileSync(registryPath, "utf-8")) as {
runs?: Record<string, Record<string, unknown>>;
};
const runs = raw?.runs;
if (!runs) {return false;}
const parentKeyPattern = `:web:${parentWebSessionId}`;
for (const entry of Object.values(runs)) {
const requester = typeof entry.requesterSessionKey === "string"
? entry.requesterSessionKey
: "";
if (!requester.endsWith(parentKeyPattern)) {continue;}
if (typeof entry.endedAt === "number") {continue;}
return true;
}
} catch {
// ignore read errors
}
return false;
}
/** Return session keys of all currently running subagents. */
@ -657,7 +693,10 @@ function handleAgentEvent(run: SubagentRun, evt: AgentEvent): void {
// Assistant text
if (stream === "assistant") {
const delta = typeof data.delta === "string" ? data.delta : undefined;
if (delta) {
const textFallback =
!delta && typeof data.text === "string" ? data.text : undefined;
const chunk = delta ?? textFallback;
if (chunk) {
closeReasoning();
if (!st.textStarted) {
st.currentTextId = nextId("text");
@ -665,7 +704,7 @@ function handleAgentEvent(run: SubagentRun, evt: AgentEvent): void {
st.textStarted = true;
}
st.everSentText = true;
emit({ type: "text-delta", id: st.currentTextId, delta });
emit({ type: "text-delta", id: st.currentTextId, delta: chunk });
}
// Inline error
if (
@ -728,11 +767,19 @@ function handleAgentEvent(run: SubagentRun, evt: AgentEvent): void {
}
}
// Lifecycle end → mark run completed
// Lifecycle end → defer finalization until subscribe process closes
// so any remaining events in the readline buffer are still delivered.
if (stream === "lifecycle" && data.phase === "end") {
closeReasoning();
closeText();
finalizeRun(run, "completed");
run._lifecycleEnded = true;
if (run._finalizeTimer) {clearTimeout(run._finalizeTimer);}
run._finalizeTimer = setTimeout(() => {
run._finalizeTimer = null;
if (run.status === "running") {
finalizeRun(run, "completed");
}
}, 5_000);
}
// Lifecycle error
@ -746,6 +793,11 @@ function handleAgentEvent(run: SubagentRun, evt: AgentEvent): void {
function finalizeRun(run: SubagentRun, status: "completed" | "error"): void {
if (run.status !== "running") {return;}
if (run._finalizeTimer) {
clearTimeout(run._finalizeTimer);
run._finalizeTimer = null;
}
run.status = status;
run.endedAt = Date.now();
@ -821,6 +873,14 @@ function startSubagentSubscribeStream(run: SubagentRun): void {
run._subscribeProcess = null;
}
if (run.status !== "running") {return;}
if (run._lifecycleEnded) {
if (run._finalizeTimer) {
clearTimeout(run._finalizeTimer);
run._finalizeTimer = null;
}
finalizeRun(run, "completed");
return;
}
setTimeout(() => {
if (run.status === "running" && !run._subscribeProcess) {
startSubagentSubscribeStream(run);

View File

@ -43,7 +43,13 @@ if [ "${#lint_files[@]}" -gt 0 ]; then
fi
if [ "${#format_files[@]}" -gt 0 ]; then
"$RUN_NODE_TOOL" oxfmt --write -- "${format_files[@]}"
"$RUN_NODE_TOOL" oxfmt --write -- "${format_files[@]}" || {
rc=$?
# oxfmt exits 2 when all target files are in ignorePatterns; treat as success.
if [ $rc -ne 2 ]; then
exit $rc
fi
}
fi
git add -- "${files[@]}"