Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,5 @@ VOLUME /debug

# disable annoying feature of BUN that makes it so source lines show up in error output
ENV BUN_DISABLE_SOURCE_CODE_PREVIEW=1
# byte-level socket idle backstop; kept above the agent-loop idle timer (300s) so the agent-loop's non-throwing retry always fires first on stalled streams
ENV BUN_CONFIG_HTTP_IDLE_TIMEOUT=600

ENTRYPOINT ["bun", "/ci-agent/source/index.mts"]
4 changes: 2 additions & 2 deletions source/agent-loop.mts
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ function toWireTools(tools: readonly Tool[]): CompletionsRequest['tools'] {
})
}

export async function* agentLoop(dependencies: { fetch: Fetch }, model: string, messages: readonly CompletionsMessage[], tools: readonly Tool[], profile: ProviderProfile, signal?: AbortSignal, outputValidator?: OutputValidator, idleTimeoutMilliseconds: number = 300_000): AsyncGenerator<AgentLoopEvent, AgentLoopResult> {
// Must stay below BUN_CONFIG_HTTP_IDLE_TIMEOUT in Dockerfile so this non-throwing retry fires before Bun's socket timer throws a DOMException.
export async function* agentLoop(dependencies: { fetch: Fetch }, model: string, messages: readonly CompletionsMessage[], tools: readonly Tool[], profile: ProviderProfile, signal?: AbortSignal, outputValidator?: OutputValidator, idleTimeoutMilliseconds: number = 240_000): AsyncGenerator<AgentLoopEvent, AgentLoopResult> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-file invariant violation: the new default of 240,000ms is equal to MAX_SINGLE_WAIT_MILLISECONDS in source/ai.mts (line 48), breaking the documented constraint that it must "stay under" the idle timeout. Previously 240,000 < 300,000 gave a 60s safety margin; now there is 0s margin. A retry sleep at the max cap with high jitter can exceed the remaining idle timer, causing an abort during the retry sleep. Either idleTimeoutMilliseconds should remain above 240,000 (e.g., 300,000), or MAX_SINGLE_WAIT_MILLISECONDS should be reduced below 240,000 to restore the strict-less-than invariant. The comment in ai.mts also still references "300s" and is now stale.

// Must stay below Bun's socket idle timeout (hard-coded 300s on 1.3.12; tunable via BUN_CONFIG_HTTP_IDLE_TIMEOUT in a future Bun) so this non-throwing retry fires first on a stalled stream.
const toolMap = new Map(tools.map(tool => [tool.name, tool]))
const wireTools = toWireTools(tools)
const mutableMessages: CompletionsMessage[] = [...messages]
Expand Down
4 changes: 2 additions & 2 deletions source/ai.mts
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ export function parseAiConfiguration(environment: Record<string, string | undefi
const INITIAL_BACKOFF_MILLISECONDS = 1_000
const MAX_BACKOFF_MILLISECONDS = 30_000

// Stays under the agent-loop idle timeout (300s) so a long reset window can't trip the composite abort mid-sleep.
const MAX_SINGLE_WAIT_MILLISECONDS = 240_000
// Stays under the agent-loop idle timeout (240s) so a long reset window can't trip the composite abort mid-sleep.
const MAX_SINGLE_WAIT_MILLISECONDS = 180_000

const RETRY_DEADLINE_MILLISECONDS = 300_000

Expand Down
10 changes: 8 additions & 2 deletions source/typescript-helpers.mts
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,20 @@ export function deepMerge<T>(base: T, override: T): T {

// DOMException is instanceof Error but Bun's console renders it as a generic object dump, so rethrow error-likes as real Errors.
export async function normalizeFetchError<T>(promise: Promise<T>): Promise<T> {
// Native fetch DOMExceptions carry no JS stack and `await` unwinds the caller chain, so capture it here before suspending.
const entryStack = new Error().stack
try {
return await promise
} catch (error) {
if (error instanceof Error && !(error instanceof DOMException)) throw error
if (isRecord(error) && isString(error.message)) {
const name = isString(error.name) ? error.name : undefined
throw new Error(name !== undefined ? `${name}: ${error.message}` : error.message, { cause: error })
const normalized = new Error(name !== undefined ? `${name}: ${error.message}` : error.message, { cause: error })
if (isString(entryStack)) normalized.stack = `${normalized.toString()}\n${entryStack.slice(entryStack.indexOf('\n') + 1)}`
throw normalized
}
throw new Error(String(error), { cause: error })
const fallback = new Error(String(error), { cause: error })
if (isString(entryStack)) fallback.stack = `${fallback.toString()}\n${entryStack.slice(entryStack.indexOf('\n') + 1)}`
throw fallback
}
}
2 changes: 1 addition & 1 deletion tests/agent-loop.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -994,7 +994,7 @@ describe("agentLoop", () => {
})

describe("default idle timeout", () => {
it("defaults to 300000ms", async () => {
it("defaults to 240000ms", async () => {
const { fetch } = createFetchWithSignal(() =>
buildSse([chunk({ content: "ok" }), chunk({}, "stop")]),
)
Expand Down
2 changes: 1 addition & 1 deletion tests/ai.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,7 @@ describe("createFetch", () => {
const farFutureEpochSeconds = (BASE_TIME + 10 * 60 * 1000) / 1000
const { fetch, delays } = buildFetch([response(429, { "X-RateLimit-Reset": String(farFutureEpochSeconds) }), response(200)])
await fetch(new AbortController().signal, "body")
expect(delays()).toEqual([240_000])
expect(delays()).toEqual([180_000])
})

it("returns the 429 without sleeping when the deadline is already exhausted after a fetch", async () => {
Expand Down