-
Notifications
You must be signed in to change notification settings - Fork 36
fix(browser): retire timed-out snippet sessions and return partial output #126
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
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 |
|---|---|---|
|
|
@@ -32,10 +32,13 @@ | |
| // | ||
| // Cancellation: JS Promises are not preemptively cancellable. A snippet | ||
| // without `await` yield-points (e.g. `for (let i = 0; i < 1e9; i++) {}`) | ||
| // runs to completion before our timeout fiber observes it. `Effect.timeoutOrElse` | ||
| // fails the surrounding fiber but the orphan Promise keeps running until it | ||
| // finishes. This matches the `uv run` subprocess case (SIGTERM only after | ||
| // the Python signal handler yields). Document, don't fix. | ||
| // runs to completion before our timeout fiber observes it. When a yielding | ||
| // snippet times out, its Promise keeps running as an orphan — so on timeout | ||
| // we retire the exact Session object the snippet received (rejects future | ||
| // connect/_call, closes the socket) and evict it from SessionStore. The | ||
| // orphan can finish local work but cannot keep driving the browser, and the | ||
| // next tool call gets a fresh Session instead of sharing a socket with it. | ||
| // The timeout error carries the console output captured so far. | ||
| // | ||
| // Level 1 per decisions.md §1c — substantial implementation lives here. The | ||
| // Level-2 hook in packages/opencode is a thin adapter. | ||
|
|
@@ -48,6 +51,18 @@ import { Skills } from "./skills" | |
|
|
||
| const DEFAULT_TIMEOUT_MS = 60 * 1000 | ||
| const MAX_TIMEOUT_MS = 10 * 60 * 1000 | ||
| const MAX_TIMEOUT_OUTPUT_BYTES = 8 * 1024 | ||
| const TIMEOUT_OUTPUT_TRUNCATED = "[partial console output truncated; showing final bytes]\n" | ||
|
|
||
| // Tail-cap the captured output for the timeout error: last 8 KiB, snapped | ||
| // forward to a UTF-8 sequence start so multibyte characters survive the cut. | ||
| const timeoutOutput = (output: string) => { | ||
| const bytes = Buffer.from(output, "utf8") | ||
| if (bytes.length <= MAX_TIMEOUT_OUTPUT_BYTES) return output | ||
| let start = bytes.length - (MAX_TIMEOUT_OUTPUT_BYTES - Buffer.byteLength(TIMEOUT_OUTPUT_TRUNCATED)) | ||
| while (start < bytes.length && (bytes[start]! & 0xc0) === 0x80) start++ | ||
| return TIMEOUT_OUTPUT_TRUNCATED + bytes.subarray(start).toString("utf8") | ||
| } | ||
|
|
||
| // Field order matters: providers stream tool-call args in schema-declared | ||
| // order, so the model commits to whichever field comes first. `code` is the | ||
|
|
@@ -157,20 +172,27 @@ const serialize = (v: unknown): string => { | |
| export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) { | ||
| const skillsDir = yield* Effect.promise(() => Skills.resolveSkillsDir(dataDir)) | ||
|
|
||
| // Effect values are re-runnable, so per-run state lives inside the suspend | ||
| // thunk: each run resolves its own Session (the timeout handler retires | ||
| // exactly that object) and its own capture buffer (a re-run after a timeout | ||
| // must not inherit a retired Session or a frozen capture). | ||
| const execute = (args: Parameters, ctx: ExecuteContext) => | ||
| Effect.gen(function* () { | ||
| const session = SessionStore.get(ctx.sessionID) | ||
| Effect.suspend(() => { | ||
| const session = SessionStore.get(ctx.sessionID) | ||
| const captured = { active: true, output: "" } | ||
| const timeout = Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS) | ||
| return Effect.gen(function* () { | ||
| yield* Effect.promise(() => fs.mkdir(ctx.workspaceDir, { recursive: true })) | ||
|
|
||
| const wrapped = yield* Effect.try({ | ||
| try: () => new AsyncFunction("session", "console", args.code), | ||
| catch: (err) => new Error(`syntax error in browser_execute snippet: ${err}`), | ||
| }) | ||
|
|
||
| let output = "" | ||
| const tee = (...a: unknown[]) => { | ||
| output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n" | ||
| if (ctx.onChunk) Effect.runFork(ctx.onChunk(output)) | ||
| if (!captured.active) return | ||
| captured.output += a.map((x) => (typeof x === "string" ? x : serialize(x))).join(" ") + "\n" | ||
|
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. P2: The 8 KiB limit is applied only after the entire console history has been retained. A yielding snippet that logs continuously can grow Prompt for AI agents
Member
Author
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. Declining as out of scope. The full buffer is the success path's contract ( |
||
| if (ctx.onChunk) Effect.runFork(ctx.onChunk(captured.output)) | ||
|
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. P2: Progress callbacks already forked before timeout can still publish output afterward when Prompt for AI agents
Member
Author
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. Declining. A late in-flight onChunk delivery carries the cumulative output that was true at timeout time — never post-timeout content, since the tee freezes the buffer before the error is built — and the tool's final result supersedes any progress update. Fiber tracking would add bookkeeping to suppress a benign, already-stale cosmetic update. |
||
| } | ||
| // Prototype-chain to the real `console` so uncommon methods (`debug`, | ||
| // `dir`, `trace`, `table`, `group`, …) don't throw when a snippet calls | ||
|
|
@@ -223,14 +245,34 @@ export const make = Effect.fn("BrowserExecute.make")(function* (dataDir: string) | |
| catch: (err) => new Error(`browser_execute snippet threw: ${err instanceof Error ? err.stack ?? err.message : String(err)}`), | ||
| }).pipe(Effect.ensuring(Effect.sync(() => unsubscribe()))) | ||
|
|
||
| return { output, result: serialize(ran), screenshots } satisfies ExecuteResult | ||
| return { output: captured.output, result: serialize(ran), screenshots } satisfies ExecuteResult | ||
| }).pipe( | ||
| Effect.scoped, | ||
| Effect.timeoutOrElse({ | ||
| duration: Math.min(args.timeout ?? DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS), | ||
| orElse: () => Effect.fail(new Error("browser_execute timed out")), | ||
| duration: timeout, | ||
| orElse: () => | ||
| Effect.suspend(() => { | ||
| captured.active = false | ||
| const output = timeoutOutput(captured.output) | ||
| const error = new Error( | ||
| [ | ||
| `browser_execute timed out after ${timeout} ms; CDP session was reset — reconnect in the next snippet`, | ||
| output.trim() ? `Partial console output before timeout:\n${output.trimEnd()}` : "", | ||
| ] | ||
| .filter(Boolean) | ||
| .join("\n\n"), | ||
| ) | ||
| // Always retires this snippet's Session; the identity check | ||
| // inside only guards the store delete, so a successor Session is | ||
| // never evicted. A concurrent same-sessionID call would share the | ||
| // retired object — acceptable for v1, opencode serializes tool | ||
| // calls within an assistant message. | ||
| SessionStore.invalidate(ctx.sessionID, session, error) | ||
| return Effect.fail(error) | ||
| }), | ||
| }), | ||
| ) | ||
| }) | ||
|
|
||
| return { parameters, execute, skillsDir } | ||
| }) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.