From 3130ab05ae478058638b6f5da0926ac9a2a19419 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 12:19:41 -0400 Subject: [PATCH 1/4] feat(opencode): expose OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS Mirror the bash timeout pattern: surface the env var through RuntimeFlags so the Task tool can use it as the default subagent timeout when callers do not pass an explicit value. Co-Authored-By: Claude --- packages/opencode/src/effect/runtime-flags.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 65e02f076360..fbada71b349f 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -51,6 +51,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), + taskDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), From f56cf0b4f721d9612d9463a362fccdefbb4e6a86 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 12:20:05 -0400 Subject: [PATCH 2/4] feat(tool/task): add configurable timeout to the foreground task Awaiting a subagent with no timeout meant a provider hang, SSE keepalives with no content, or a stalled download would hang the parent session with it. Adds an optional 'timeout' (ms) parameter to the Task tool, mirroring the shell tool's pattern. When the timeout fires, the subagent is cancelled and the tool returns a block that includes the task_id so the caller can retry with a larger timeout, narrow scope, or fall back. Default is 10 minutes; overridable via OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS. Timeout only applies to the foreground path -- background tasks already return immediately. Co-Authored-By: Claude --- packages/opencode/src/tool/task.ts | 56 ++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 1384e5d19725..d9cdb8fbc630 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -59,6 +59,10 @@ export const Parameters = Schema.Struct({ description: "Run the agent in the background. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress", }), + timeout: Schema.optional(Schema.Number).annotate({ + description: + "Optional timeout in milliseconds for the foreground subagent. If the subagent does not complete within this duration it is cancelled and the tool returns a block so the calling agent can retry with a larger timeout, narrow the scope, or fall back. Defaults to the value of OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS, or 10 minutes when unset.", + }), }) function renderOutput(input: { @@ -78,6 +82,8 @@ function renderOutput(input: { ].join("\n") } +const TASK_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000 + export const TaskTool = Tool.define( id, Effect.gen(function* () { @@ -314,24 +320,54 @@ export const TaskTool = Tool.define( runCancel.fork(cancel) } + const taskTimeoutMs = params.timeout ?? flags.taskDefaultTimeoutMs ?? TASK_DEFAULT_TIMEOUT_MS + if (params.timeout !== undefined && params.timeout < 0) { + return yield* Effect.fail( + new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number of milliseconds.`), + ) + } + return yield* Effect.acquireUseRelease( Effect.sync(() => { ctx.abort.addEventListener("abort", onAbort) }), () => Effect.gen(function* () { - const result = yield* Effect.raceFirst( - background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), - background.waitForPromotion(nextSession.id), + const inner = Effect.gen(function* () { + const result = yield* Effect.raceFirst( + background.wait({ id: nextSession.id }).pipe(Effect.map((waited) => waited.info)), + background.waitForPromotion(nextSession.id), + ) + if (result?.metadata?.background === true) return backgroundResult() + if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) + if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) + return { + title: params.description, + metadata, + output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), + } + }) + const outcome = yield* Effect.raceFirst( + inner.pipe(Effect.map((output) => ({ kind: "result" as const, output }))), + Effect.sleep(`${taskTimeoutMs} millis`).pipe(Effect.as({ kind: "timeout" as const })), ) - if (result?.metadata?.background === true) return backgroundResult() - if (result?.status === "error") return yield* Effect.fail(new Error(result.error ?? "Task failed")) - if (result?.status === "cancelled") return yield* Effect.fail(new Error("Task cancelled")) - return { - title: params.description, - metadata, - output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), + if (outcome.kind === "timeout") { + yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) + return { + title: params.description, + metadata, + output: [ + `task_id: ${nextSession.id} (for resuming if the timeout was premature)`, + "", + "", + `Subagent "${params.subagent_type}" did not complete within ${taskTimeoutMs}ms and was cancelled.`, + `If this task legitimately needs more time, retry with a larger 'timeout' value (in milliseconds) or narrow the task scope.`, + `If the subagent appears stalled (provider or network hang), do not retry indefinitely; report what you have to the user.`, + "", + ].join("\n"), + } } + return outcome.output }), (_, exit) => Effect.gen(function* () { From d2e98658b0f005d61e3e873b16784a672faf1979 Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 12:20:09 -0400 Subject: [PATCH 3/4] test(tool/task): cover timeout cancellation and validation Adds two tests: - timeout fires for a stalled subagent and returns a block that carries the task_id, with the subagent session cancelled - a negative timeout fails the execute with an error rather than hanging Co-Authored-By: Claude --- packages/opencode/test/tool/task.test.ts | 73 ++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 2bcf05a2a49b..0ea56c9940f0 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -982,4 +982,77 @@ describe("tool.task", () => { expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled") }), ) + + it.instance("execute returns task_error and cancels the subagent when the timeout fires", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const cancelled = defer() + const promptOps: TaskPromptOps = { + cancel: (sessionID) => + Effect.sync(() => { + cancelled.resolve(sessionID) + }), + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: () => Effect.never, + } + + const result = yield* def.execute( + { + description: "stalls forever", + prompt: "hang please", + subagent_type: "general", + timeout: 50, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + const cancelledId = yield* Effect.promise(() => cancelled.promise) + expect(result.output).toContain("") + expect(result.output).toContain("did not complete within 50ms") + expect(result.output).toContain(cancelledId) + expect(result.output).not.toContain("") + }), + ) + + it.instance("execute fails when given a negative timeout", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + + const exit = yield* def + .execute( + { + description: "bad timeout", + prompt: "irrelevant", + subagent_type: "general", + timeout: -1, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps: stubOps() }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) }) From c9e69e4db3a1754d573dc0ea79a1775f6dcb736b Mon Sep 17 00:00:00 2001 From: george larson Date: Thu, 20 Aug 2026 12:34:49 -0400 Subject: [PATCH 4/4] feat(tool/task): let timeout 0 wait indefinitely The default-10min shape had no opt-out: params.timeout ?? default meant every foreground call was capped and 'wait forever' was unexpressible. 0 now disables the race (param and OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS alike, the latter via a nonNegativeInteger flag), the validation message and guidance say so, and a 100ms-delayed prompt proves a 0 timeout does not fire. Validation also moved ahead of the acquireUseRelease setup it short-circuits. --- packages/opencode/src/effect/runtime-flags.ts | 8 ++++- packages/opencode/src/tool/task.ts | 33 ++++++++++------- packages/opencode/test/tool/task.test.ts | 36 +++++++++++++++++++ 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index fbada71b349f..e07c5b2155ad 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -7,6 +7,12 @@ const positiveInteger = (name: string) => Config.map((value) => (Number.isInteger(value) && value > 0 ? value : undefined)), Config.orElse(() => Config.succeed(undefined)), ) +// 0 is meaningful for timeouts (wait indefinitely), so they can't use positiveInteger. +const nonNegativeInteger = (name: string) => + Config.number(name).pipe( + Config.map((value) => (Number.isInteger(value) && value >= 0 ? value : undefined)), + Config.orElse(() => Config.succeed(undefined)), + ) const experimental = bool("OPENCODE_EXPERIMENTAL") const enabledByExperimental = (name: string) => Config.all({ experimental, enabled: Config.boolean(name).pipe(Config.option) }).pipe( @@ -51,7 +57,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"), outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), - taskDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS"), + taskDefaultTimeoutMs: nonNegativeInteger("OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), experimentalWebSockets: bool("OPENCODE_EXPERIMENTAL_WEBSOCKETS"), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index d9cdb8fbc630..3eb57dad32f3 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -61,7 +61,7 @@ export const Parameters = Schema.Struct({ }), timeout: Schema.optional(Schema.Number).annotate({ description: - "Optional timeout in milliseconds for the foreground subagent. If the subagent does not complete within this duration it is cancelled and the tool returns a block so the calling agent can retry with a larger timeout, narrow the scope, or fall back. Defaults to the value of OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS, or 10 minutes when unset.", + "Optional timeout in milliseconds for the foreground subagent. If the subagent does not complete within this duration it is cancelled and the tool returns a block so the calling agent can retry with a larger timeout, narrow the scope, or fall back. Set to 0 to wait indefinitely. Defaults to the value of OPENCODE_EXPERIMENTAL_TASK_DEFAULT_TIMEOUT_MS, or 10 minutes when unset.", }), }) @@ -313,6 +313,15 @@ export const TaskTool = Tool.define( return backgroundResult() } + if (params.timeout !== undefined && params.timeout < 0) { + return yield* Effect.fail( + new Error( + `Invalid timeout value: ${params.timeout}. Timeout must be a non-negative number of milliseconds (0 waits indefinitely).`, + ), + ) + } + const taskTimeoutMs = params.timeout ?? flags.taskDefaultTimeoutMs ?? TASK_DEFAULT_TIMEOUT_MS + const runCancel = yield* EffectBridge.make() const cancel = ops.cancel(nextSession.id) @@ -320,13 +329,6 @@ export const TaskTool = Tool.define( runCancel.fork(cancel) } - const taskTimeoutMs = params.timeout ?? flags.taskDefaultTimeoutMs ?? TASK_DEFAULT_TIMEOUT_MS - if (params.timeout !== undefined && params.timeout < 0) { - return yield* Effect.fail( - new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number of milliseconds.`), - ) - } - return yield* Effect.acquireUseRelease( Effect.sync(() => { ctx.abort.addEventListener("abort", onAbort) @@ -347,10 +349,15 @@ export const TaskTool = Tool.define( output: renderOutput({ sessionID: nextSession.id, state: "completed", text: result?.output ?? "" }), } }) - const outcome = yield* Effect.raceFirst( - inner.pipe(Effect.map((output) => ({ kind: "result" as const, output }))), - Effect.sleep(`${taskTimeoutMs} millis`).pipe(Effect.as({ kind: "timeout" as const })), - ) + const raced = inner.pipe(Effect.map((output) => ({ kind: "result" as const, output }))) + // 0 means wait indefinitely: no timeout branch enters the race. + const outcome = + taskTimeoutMs === 0 + ? yield* raced + : yield* Effect.raceFirst( + raced, + Effect.sleep(`${taskTimeoutMs} millis`).pipe(Effect.as({ kind: "timeout" as const })), + ) if (outcome.kind === "timeout") { yield* Effect.all([cancel, background.cancel(nextSession.id)], { discard: true }) return { @@ -361,7 +368,7 @@ export const TaskTool = Tool.define( "", "", `Subagent "${params.subagent_type}" did not complete within ${taskTimeoutMs}ms and was cancelled.`, - `If this task legitimately needs more time, retry with a larger 'timeout' value (in milliseconds) or narrow the task scope.`, + `If this task legitimately needs more time, retry with a larger 'timeout' value (in milliseconds), set 'timeout' to 0 to wait indefinitely, or narrow the task scope.`, `If the subagent appears stalled (provider or network hang), do not retry indefinitely; report what you have to the user.`, "", ].join("\n"), diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index 0ea56c9940f0..757e1de38fa5 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -1055,4 +1055,40 @@ describe("tool.task", () => { expect(Exit.isFailure(exit)).toBe(true) }), ) + + it.instance("timeout 0 waits indefinitely for completion", () => + Effect.gen(function* () { + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const ops = stubOps() + const promptOps: TaskPromptOps = { + ...ops, + prompt: (input) => + Effect.sleep("100 millis").pipe(Effect.flatMap(() => Effect.sync(() => reply(input, "slow but done")))), + } + + const result = yield* def.execute( + { + description: "waits past any would-be timeout", + prompt: "finish normally", + subagent_type: "general", + timeout: 0, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + + expect(result.output).not.toContain("") + expect(result.output).toContain("") + }), + ) })