Repository navigation
fix(opencode): OpenCode 1.x background subagents no longer show as done at launch #36523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
02c2976
d9046a3
197f784
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -391,6 +391,8 @@ interface OpenCodeSubagentContext { | |
| childProviderThreadId: OrchestrationV2ProviderThread["id"] | null; | ||
| model: string | null; | ||
| result: string | null; | ||
| // A background task call returns at launch; held here until its child session stops. | ||
| launch: ToolPart | null; | ||
| } | ||
|
|
||
| interface OpenCodeThreadState { | ||
|
|
@@ -1039,6 +1041,10 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| const relatedSessionOwners = new Map<string, OpenCodeThreadState>(); | ||
| // Busy children outlive parent settlement; keep their server open. | ||
| const busySessionIds = new Set<string>(); | ||
| // A background task returns before its new child session reports busy. | ||
| const unstartedSessionIds = new Set<string>(); | ||
| // A child can error before any turn of it is projected; its next idle settles the launch. | ||
| const sessionErrorOutcomes = new Map<string, TerminalTurnStatus>(); | ||
| // Remember settled requests across routing retries to prevent resurrection; only recent IDs matter. | ||
| const settledNativeRequestIds = new Set<string>(); | ||
| const pendingChildRequestRoutes = new Set<string>(); | ||
|
|
@@ -1359,6 +1365,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| state: OpenCodeThreadState, | ||
| turn: ActiveOpenCodeTurn, | ||
| part: ToolPart, | ||
| childOutcome?: TerminalTurnStatus, | ||
| ) { | ||
| const now = yield* DateTime.now; | ||
| const nativeItemRef = providerRef(part.id); | ||
|
|
@@ -1389,6 +1396,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| childProviderThreadId: null, | ||
| model: null, | ||
| result: null, | ||
| launch: null, | ||
| }; | ||
| subagentsByNativeItemId.set(part.id, context); | ||
| } | ||
|
|
@@ -1500,8 +1508,27 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| } | ||
| const output = toolOutput(part); | ||
| if (part.state.status === "completed" && output !== undefined) context.result = output; | ||
| const status = toolStatus(part); | ||
| const completedAt = toolCompletedAt(part, now); | ||
| const launchStatus = toolStatus(part); | ||
| context.launch = | ||
| childOutcome === undefined && | ||
| launchStatus.item === "completed" && | ||
| context.childSessionId !== null && | ||
| (busySessionIds.has(context.childSessionId) || | ||
| unstartedSessionIds.has(context.childSessionId)) | ||
|
Comment on lines
+1515
to
+1517
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Track resumed background children until they report busy. A Track a resumed child as pending launch, as a newly created child is tracked. Clear that marker on busy, idle, or deletion, as the existing paths do. Add a test where the resume's completed tool update precedes the child's busy event.
|
||
| ? part | ||
| : null; | ||
| const status = | ||
| context.launch !== null | ||
| ? ({ node: "running", item: "running" } as const) | ||
| : childOutcome === undefined | ||
| ? launchStatus | ||
| : { node: childOutcome, item: childOutcome }; | ||
| const completedAt = | ||
| context.launch !== null | ||
| ? null | ||
| : childOutcome === undefined | ||
| ? toolCompletedAt(part, now) | ||
| : now; | ||
| const subagentStatus: OrchestrationV2Subagent["status"] = status.item; | ||
| const subagent: OrchestrationV2Subagent = { | ||
| id: nodeId, | ||
|
|
@@ -1580,6 +1607,49 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| }); | ||
| }); | ||
|
|
||
| const settleLaunchedSubagents = Effect.fnUntraced(function* ( | ||
| childSessionId: string, | ||
| outcome?: TerminalTurnStatus, | ||
| ) { | ||
| const lastTurn = Array.from(threads.get(childSessionId)?.providerTurns.values() ?? []).at( | ||
| -1, | ||
| )?.status; | ||
| const errorOutcome = sessionErrorOutcomes.get(childSessionId); | ||
| sessionErrorOutcomes.delete(childSessionId); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep a child's error outcome when it settles before the task completes. When a child records an error and idles before the parent's completed task part is handled, Retain the terminal outcome on the subagent context when settlement finds no launch. Apply that outcome when the completed task part is processed. Clear the retained outcome when the child is launched again, so a resumed child does not inherit an earlier outcome.
|
||
| const childOutcome = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium A launched child that errors before its first turn is projected is reported as 🤖 Copy this AI Prompt to have your agent fix this: |
||
| outcome ?? | ||
| errorOutcome ?? | ||
| (lastTurn === "interrupted" || lastTurn === "failed" || lastTurn === "cancelled" | ||
| ? lastTurn | ||
| : "completed"); | ||
|
Comment on lines
+1587
to
+1589
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Record child session errors that arrive before the first turn.
This can occur if OpenCode emits a child Keep the terminal error for a child session when no turn is active. Make
|
||
| for (const context of subagentsByNativeItemId.values()) { | ||
| if (context.childSessionId === childSessionId && context.launch !== null) { | ||
| yield* emitSubagent( | ||
| context.parentState, | ||
| context.parentTurn, | ||
| context.launch, | ||
| childOutcome, | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| // No idle event settles a launched child that Stop aborts before it reports busy. | ||
| const interruptLaunchedSubagents = Effect.fnUntraced(function* (rootSessionId: string) { | ||
| for (const context of subagentsByNativeItemId.values()) { | ||
| let owner = context.parentState; | ||
| while (owner.parentSubagent !== null) owner = owner.parentSubagent.parentState; | ||
| if (owner.nativeSessionId === rootSessionId && context.launch !== null) { | ||
| yield* emitSubagent( | ||
| context.parentState, | ||
| context.parentTurn, | ||
| context.launch, | ||
| "interrupted", | ||
| ); | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const emitToolPart = Effect.fnUntraced(function* ( | ||
| state: OpenCodeThreadState, | ||
| turn: ActiveOpenCodeTurn, | ||
|
|
@@ -2676,6 +2746,9 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| case "session.created": | ||
| case "session.updated": { | ||
| const info = event.properties.info; | ||
| if (event.type === "session.created" && info.parentID !== undefined) { | ||
| unstartedSessionIds.add(info.id); | ||
| } | ||
| if (info.parentID !== undefined && !threads.has(info.id)) { | ||
| const owner = threads.get(info.parentID) ?? relatedSessionOwners.get(info.parentID); | ||
| if (owner !== undefined) { | ||
|
|
@@ -2687,9 +2760,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| case "session.deleted": | ||
| relatedSessionOwners.delete(event.properties.info.id); | ||
| busySessionIds.delete(event.properties.info.id); | ||
| unstartedSessionIds.delete(event.properties.info.id); | ||
| yield* settleLaunchedSubagents(event.properties.info.id, "interrupted"); | ||
| return; | ||
| case "session.status": { | ||
| const sessionId = event.properties.sessionID; | ||
| unstartedSessionIds.delete(sessionId); | ||
| switch (event.properties.status.type) { | ||
| case "busy": | ||
| case "retry": | ||
|
|
@@ -2718,10 +2794,14 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| state.activeTurn.interrupted ? "interrupted" : "completed", | ||
| ); | ||
| } | ||
| if (event.properties.status.type === "idle") { | ||
| yield* settleLaunchedSubagents(sessionId); | ||
| } | ||
| return; | ||
| } | ||
| case "session.idle": { | ||
| busySessionIds.delete(event.properties.sessionID); | ||
| unstartedSessionIds.delete(event.properties.sessionID); | ||
| const state = threads.get(event.properties.sessionID); | ||
| if (state?.activeTurn !== null && state?.activeTurn !== undefined) { | ||
| if (state.activeTurn.admissionPending) { | ||
|
|
@@ -2734,6 +2814,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| state.activeTurn.interrupted ? "interrupted" : "completed", | ||
| ); | ||
| } | ||
| yield* settleLaunchedSubagents(event.properties.sessionID); | ||
| return; | ||
| } | ||
| case "session.error": { | ||
|
|
@@ -2751,6 +2832,12 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| }); | ||
| return; | ||
| } | ||
| if (event.properties.sessionID !== undefined) { | ||
| sessionErrorOutcomes.set( | ||
| event.properties.sessionID, | ||
| isMessageAbortedError(event) ? "interrupted" : "failed", | ||
| ); | ||
| } | ||
| const states = | ||
| event.properties.sessionID === undefined | ||
| ? Array.from(threads.values()).filter((state) => state.activeTurn !== null) | ||
|
|
@@ -2815,6 +2902,8 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| Effect.flatMap((exit) => | ||
| Effect.gen(function* () { | ||
| busySessionIds.clear(); | ||
| unstartedSessionIds.clear(); | ||
| sessionErrorOutcomes.clear(); | ||
| if (closing || abortController.signal.aborted) return; | ||
| const detail = Exit.isSuccess(exit) | ||
| ? "OpenCode event stream ended unexpectedly." | ||
|
|
@@ -2830,6 +2919,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| failure: makeProviderFailure({ message: detail, class: "transport_error" }), | ||
| threadDisposition: "broken", | ||
| }); | ||
| yield* settleLaunchedSubagents(state.nativeSessionId, "failed"); | ||
| } | ||
| yield* Queue.end(events); | ||
| }), | ||
|
|
@@ -3584,6 +3674,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| for (const controller of commandControllers.get(sessionId) ?? []) | ||
| controller.abort(); | ||
| yield* abortDescendants(sessionId); | ||
| yield* interruptLaunchedSubagents(sessionId); | ||
| return; | ||
| } | ||
| if ( | ||
|
|
@@ -3622,6 +3713,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function | |
| ); | ||
| // Root abort leaves children running; report incomplete cleanup even after root termination. | ||
| yield* abortDescendants(sessionId); | ||
| yield* interruptLaunchedSubagents(sessionId); | ||
| }, | ||
| (effect, interruptInput) => | ||
| effect.pipe( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Include unstarted launched children in the 1.x pending-work signal.
The 1.x runtime reports pending background work only for busy child sessions. A launched child that has not reported busy yet is still retained as
running, but it does not pin idle release. Idle release can then close the provider scope. That closes the event stream and discards the closure-local launch state, so the retained subagent can stay running with no session to stop it.Count unstarted children as pending work, consistent with the retention check at lines 1484-1485. The existing
maxIdlePinMsbound still applies. The assertion atadapter.test.ts:1476that expectsfalseforstarts=falsemust change with this edit.Suggested fix