Repository navigation
AI-3201: fork takes tool_timeout_ms, 30 s default documented - #2004
Conversation
…efault ForkSessionRequest gains tool_timeout_ms. A fork keeps a running parent's timeout, also when it names another config, and the field wins over both. tool_timeout_ms on createSession now says its default is 30 seconds and that a tool's own timeout_ms replaces it. Clients regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Go gains ForkOptions.ToolTimeout; JavaScript's fork() sends the generated tool_timeout_ms. Both document the router's 30 s default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/jeffery review |
|
Thanks for asking Jeffery for review. I'll stay on this PR until I can approve it. How it works
Commands (each as a whole comment)
|
There was a problem hiding this comment.
✅ Approved 425f5337048e after 1 round
Left open, your call:
- consider: Fork doc omits that an explicit zero resets to 30 s (
acceleration/internal/api/sessions.go:1506) - consider: Sub-millisecond ToolTimeout is sent as zero (
sdks/go/client/sessions.go:599)
Not covered: Kotlin CreateSessionRequest.kt and swift generated.json hunks were not opened; compare their descriptions with openapi.yaml line 4302; Overflow of time.Duration for a huge tool_timeout_ms (about 9.2e12 ms and above) at session/manager.go:703, and whether huma or JSON decoding caps the value first; this already applies to createSession.
Resolves the Swift client's generated.json by regenerating it from the merged spec. Every other client regenerated without a change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Withdrawing approval until f376644 is reviewed.
The Go client sent a ToolTimeout under a millisecond as zero, which the router reads as 30 seconds. The fork's tool_timeout_ms doc now says zero is 30 seconds; clients regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/jeffery review |
There was a problem hiding this comment.
✅ Approved 5defd05a5612 after 2 rounds · 2 fixed
Left open, your call:
- consider: SDK note says Go sends the fork timeout "above zero" (
.claude/skills/sdk/SKILL.md:872)
Not covered: Swift generated.json spec_sha256/client_sha256 recomputation against head openapi.yaml (python3 sdks/swift/generate.py); Merged-in Twilio/Telnyx router changes from the accelerate merge commit f376644 were not reviewed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/jeffery review |
Withdrawing approval until 274e8af is reviewed.
There was a problem hiding this comment.
✅ Approved 274e8af62170 after 3 rounds · 3 fixed
Left open, your call:
- consider: SDK note omits what leaving the fork timeout out does (
.claude/skills/sdk/SKILL.md:872)
Not covered: Per-SDK status claims in the rest of the SKILL.md paragraph (JavaScript, Python, Swift, .NET, Ruby, Rust, PHP, Kotlin, Dart) were unchanged this round and not re-checked; grepping each SDK's fork model for tool_timeout_ms would settle them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/jeffery review |
Withdrawing approval until 7dfbfb0 is reviewed.
There was a problem hiding this comment.
✅ Approved 7dfbfb018f5a after 4 rounds · 4 fixed
Not covered: Claims about the JavaScript, Python, Swift, .NET, Ruby, Rust, PHP, Kotlin and Dart fork clients in the same SKILL.md paragraph were not checked; grep tool_timeout_ms under /work/sdks/* for each fork call.
## Why Production showed a picture takes 60 to 132 s at FAL whatever the router does: 4 `image-fast` requests on 2026-11-10, two of them run with nothing else in flight (data in #2004). Waiting for that inside a turn is what made the customer's tool time out. Laurent chose async delivery: `generate_image` answers at once, the picture is drawn in the background and posted into the conversation when ready (AI-3201). Stacked on #2002 (base `laurentschaffner/ai-3200-image-generation-let-an-agent-draw`). ## Merge and deploy - **#2002 and this merge and deploy together. #2002 must never deploy alone**: on its own it runs every picture inside the turn, with the 6-minute message-hook wait this PR reverts. - Merge order: #2002, this, #2004, #2003. Regenerate the clients after each merge: #2002, this and #2004 all change `openapi.yaml` and every generated client. - Migrations are strict (`goose.Up`): `20261229090000` (#2002) and `20261230090000` (this) must be newer than anything on `accelerate` when they merge. Checked 2026-11-13: `accelerate`'s newest is `20261226120000`, and no open PR adds a newer one. Check again right before marking ready. - `20261230090000` was reshaped in place while this PR was a draft (last: the `released` column). No shared database ran an earlier draft (read-only checks, 2026-11-13): every prod router in us-east4, us-west1 and europe-west4 logged `goose: no migrations to run. current version: 20261226120000` at start; the highest version goose logged in the last 30 days is `20261226120000` for `accelerate-staging` (still logging today) and `20261202135000` for `accelerate-benchmark`, and no log line in either names `20261229090000`, `20261230090000`, `image_jobs` or `agent_config_image`; CI's integration databases are per-job `postgres:16-alpine` service containers. A developer's local database that ran a draft needs recreating. ## How it works ``` turn: generate_image(prompt, aspect_ratio in 1:1 4:3 3:4 16:9 9:16) agent.generateImage -> session drawer -> imagejobs.Queue INSERT image_jobs, unique request_key = digest(session, turn, call, prompt, shape): a call run again in its turn queues nothing <- "being drawn, posted here when ready" turn ends, model replies worker (every router; claims SKIP LOCKED, lease 5 min, attempts + 1) draw slots: 2 (FAL's per process) post slots: 16 undecided job decided job (claimed without bytes) ├─ draws >= 2 -> decide failure ├─ picture kept? else read and upload ├─ count the draw, Generate (240 s, one │ transient (5xx, 408, 429, already │ usage id per job, money held 240 s) │ written) -> back off ~2.5 h │ ├─ never left FAL's slot wait │ refused for good -> decide "could │ │ (ErrNotStarted) -> give back, │ not be posted here" │ │ uncounted, for up to 30 min ├─ post decided message (id: job / │ ├─ sent and failed -> decide why │ job-failed); transient -> back off │ └─ drawn (even as Close comes) -> keep │ refused -> give up └─ deliver ─────────────────────────────────> └─ done (picture) or failed (why not) every write after the claim: WHERE id, state queued, attempts = this claim ``` A message is decided and kept on the row before it is posted, so a post tried again posts the same message, and a person shown a failure never later gets a picture drawn and billed behind it. ## Mechanism: Postgres, not asynq `asynq` is in go.mod, used only for knowledge ingest. Every delivery-style job here (`chat_inbound`, scheduled tasks, event forwarding) is a Postgres table claimed with a lease, and this follows that. Prod Redis is unreplicated and treated as optional (chart README), and a job has to keep durable state anyway: who to post to, what to bill, the picture once drawn. No new library. ## Restart story | Event | What happens | Test | | --- | --- | --- | | Deploy with `shutdown.drain` | Stop signal stops claims; the drain counts drawing and posting jobs and waits for them | `TestTheDrainWaitsForAPictureBeingDrawn` (cmd/router, runs `stopServing`) | | Stopped without a drain (Close) | Job given back at once, its cut draw uncounted; a kept picture is never redrawn | `TestARouterThatStopsMidDrawGivesThePictureBackAtOnce` | | Killed mid-draw | Next router claims it after the 5-min lease and draws again, at most 2 draws | `TestARouterKilledMidDrawLeavesThePictureToTheNextOnceItsLeaseRunsOut`, `TestAJobDrawnAsOftenAsItMayBePostsItsFailure` | | Killed after posting, before recording | Next claim posts the decided message under the same id: found written | `TestAPostWhoseAnswerWasLostIsNotPostedTwice`, `TestAFailurePostedButNotRecordedIsNotFollowedByAPaidDraw` | | Stream Chat down | Upload or post backs off over about 2.5 h; draw claims do not count against it | `TestAnUploadStreamChatFailedIsTriedAgainWithoutDrawing`, `TestAFailureWhosePostFailedIsPostedLater`, `TestAPostIsTriedAgainOverHoursBeforeItIsGivenUp` | | Stream Chat refuses for good (4xx) | Upload: post that the picture could not be posted. Post: record failed | `TestAnUploadStreamChatRefusedForGoodPostsThatItCouldNotBePosted`, `TestAPostThatNeverGetsThroughIsGivenUpOnceItsWaitsRanOut` | | 4 pictures in one reply | Drawn two at a time; a draw that waited out its deadline for a slot is given back uncounted | `TestFourPicturesAtOnceAreAllDrawnTwoAtATime`, `TestADrawNoProviderTookInTimeIsTriedAgainUncounted` | | A job waiting minutes for a FAL slot | Each give-back counts in `released`, not against the 20-claim cap (`attempts - released`) | `TestADrawGivenBackMoreTimesThanTheClaimCapIsStillDrawn` | | Two routers | Each picture drawn and posted once | `TestTwoRoutersDrawEachPictureOnce`, `TestASlowClaimCannotFinishAnImageJobAnotherClaimTook` | **Billing: the customer is billed once, we may pay FAL twice.** Every draw of a job carries one usage id, `routing.UsageID("image_job", job.ID)`, into `routing.Meter.UsageID`. Usage rows are unique on `usage_id` (`requests_usage_id_idx`, inserted `ON CONFLICT (usage_id) DO NOTHING`, `store/store.go:192`, `:312`), and a reservation made again under an id already settled is only checked, not held twice (`policy/holds.go:304-320`). So in the one window where a picture is drawn again (a router killed in the milliseconds between the draw returning and the picture being kept, or Postgres unreachable for the whole 30 s the worker tries to keep it) the second draw settles on the first one's row: the customer is billed once, while Stream may pay FAL for both draws. Every other path keeps the picture or was never billed. `TestAGenerationDrawnTwiceUnderOneUsageIDIsBilledOnce` (imagerouter), `TestADrawIsBilledUnderTheSessionThatAskedWhenItIsDrawn` (the row carries the job's usage id). Requests to `POST /v1/image/generations` share FAL's two slots per process with the worker. A job that waits out its deadline for a slot (`imagegen.ErrNotStarted`, set only by that wait) is given back, not billed and not counted, and tried again for up to 30 minutes, then posts that it took too long. A draw that was sent and then timed out (Gemini's 120 s call, a FAL submit or poll after FAL took the job) is counted and posts that it took too long: the vendor may have drawn and billed it. While a job waits for a slot it holds its money reservation, as an endpoint request does; with at most two draws per router, at most two such holds wait. ## What stays and what goes from #2002 | | Decision | Why | | --- | --- | --- | | 240 s deadline in `imagerouter.Generate`, money held 240 s | Stays | The background draw still needs a bound, and a hold that outlasts it | | Message-hook wait of 2 min + 240 s, lease renewal | Goes (revert of 7772caf) | The turn no longer waits for the picture | | `chatinbound.Lease` 9 min | Back to 5 min | Same; 9 min only slowed crash redelivery for every queued message | | `generate_image` attaching files to the reply | Goes | The picture is its own message | ## Changes | Change | Test | | --- | --- | | `image_jobs` (`20261230090000`), store claim/keep/decide/save/retry/release/finish/prune | store: `TestAnImageJobUnderItsLeaseIsNotClaimedAgain`, `TestAnImageJobWithItsMessageDecidedIsClaimedOnlyToPost`, `TestPostedImageJobsAreDeletedAfterADayAndFailedOnesAfter30Days` | | `internal/imagejobs` worker | the restart table, plus `TestAPictureIsPostedIntoTheConversationOnceDrawn` (read back in history), `TestAPictureTheProviderFailedPostsWhyAndCostsNothing` (row: not a success, cost 0), `TestAPictureTheSafetyFilterRefusedSaysSo`, `TestACallRunAgainQueuesNoSecondPicture`, `TestTheSameCallIdInAnotherTurnIsAnotherPicture`, `TestTheSameCallAskingForAnotherPictureIsAnotherJob`, `TestADrawSentThatTimedOutIsCountedAndSaysSo`, `TestADrawNeverStartedForHalfAnHourPostsThatItTookTooLong`, `TestADrawThatFinishesAsTheRouterStopsIsKeptNotDrawnAgain`, `TestAStreamChatRequestTimeoutIsTriedAgain`, `TestOnlyAStreamChatRefusalALaterTryGetsTooIsPermanent` | | FAL marks only its slot wait as never started | `TestADrawThatWaitedOutItsDeadlineForASlotWasNeverStarted`, `TestADrawThatTimedOutOnceSubmittedWasStarted` | | A claim of decided jobs loads no picture bytes | `TestAnImageJobWithItsMessageDecidedIsClaimedOnlyToPost` | | Usage row under the session's attribution, when drawn | `TestADrawIsBilledUnderTheSessionThatAskedWhenItIsDrawn` (row cost 40,000 micros) | | `generate_image` queues and answers at once; `aspect_ratio` an enum checked in the turn; plain words when it cannot queue | `TestAPictureIsQueuedAndTheTurnIsAnsweredAtOnce`, `TestAShapeNoModelDrawsIsRefusedInTheTurn`, `TestAPictureThatCouldNotBeQueuedIsToldInPlainWords` | | An `image` target nothing answers to is refused on config save (400); a lookup that failed stays a server error | `TestAnImageTargetNothingDrawsWithIsRefused`, `TestOnlyAnImageTargetNothingAnswersToIsRefused` | | FAL `queued_ms` / `drawn_ms` on the usage row | `TestAJobsTimeIsSplitIntoFALsQueueAndTheDrawing`, `TestAJobFirstSeenFinishedLeavesItsQueueAndDrawingUnknown` | ## Notes for review - A session still open keeps its in-memory model history; the posted picture is in it only once the conversation is read again from the channel (next reopen). The model was told the picture is on its way. - Thread channels (Slack, SMS): `Post` hands the picture to `OnFinishedReply` with no command id. By reading, `channelbridge.Bridge.Reply` then sends it like any reply, claimed by message id. No test covers it: unverified. - Failure texts are written to the person, since the model is gone by the time the worker knows. - `image_jobs` rows hold prompts: done rows are deleted after a day, failed after 30 days. - After #2002 squash-merges, this branch still carries #2002's commits; it will be brought up to date by merging `origin/accelerate` (no rebase, no force-push) and regenerating the clients. ## Validation **CI: `accelerate` is red on two jobs this PR does not touch.** `integration (internal/memory/mem0)` fails on the shared mem0 account's monthly retrieval quota (`429 ... Monthly retrieval limit for your Pro plan reached`, 50,000 used of 50,000, resets 2026-11-13 21:24 UTC), and `integration (internal/api)` fails `TestMemorySuite` at `memory_recall_test.go:218`. Both fail the same way on `accelerate`'s own last two runs (43444872895, 43440533515). Rerunning before the quota resets would fail again. - `go build ./... && go vet ./...`: clean. - Unit, `go test ./internal/{agent,session,api,imagejobs,imagegen/...,imagerouter,store,conversation,chatinbound} ./cmd/router`: all ok. - Integration (`-tags integration -p 1`), by hand: `./internal/imagejobs` (24 integration tests and 2 unit) ok, `./internal/imagerouter` ok (CI skips it), `./internal/chatinbound` ok, `./internal/conversation` ok, `./internal/agent` ok (771 s), store image job tests ok, `./cmd/router -run TestDrainSuite` ok, `./internal/api -run 'TestAppHooksSuite|TestThreadHooks|TestChatInbound|TestConfigsSuite|TestSync|TestMessageHooksSuite|TestImages|TestSessionConnectorsSuite|TestSessionCreateSuite'` ok. - `sdks/go`: `go test ./...` ok. The JS and Python loader fixes for `image:` are on #2002 and come in by merge: JS `npm test` 601 passed, Python `test_config.py` and `test_folder.py` 210 passed, `test_config.py` 65 passed after the bare-`image:` fix, mypy and ruff 0.15.20 clean on the changed files. - An earlier full `./internal/store` run failed once in `TestProviderAppLocksOnOneAppNeverOverlapAcrossRouters`, a lock test this change does not touch; it passed 3 of 3 runs on its own. - `dev.py check` not run: no synced Python env in the worktree. Mutation checks, each made the named test fail and was reverted: | Mutation | Caught by | | --- | --- | | 16 draws at once instead of 2 | `TestFourPicturesAtOnceAreAllDrawnTwoAtATime` | | A draw no provider took treated as a failure | `TestADrawNoProviderTookInTimeIsTriedAgainUncounted` | | A permanent upload refusal retried | `TestAnUploadStreamChatRefusedForGood...` | | Every Stream error treated as permanent | `TestAnUploadStreamChatFailedIsTriedAgain...` | | A failure posted without being decided first | `TestAFailurePostedButNotRecorded...` | | A drawn picture not kept | `TestAPostWhoseAnswerWasLost...` | | Post waits that never run out | `TestAPostThatNeverGetsThrough...` | | Close keeping the lease | `TestARouterThatStopsMidDraw...` | | The 2-draw limit ignored | `TestAJobDrawnAsOften...` | | The claim dropped from the conditional update | `TestASlowClaimCannotFinish...` | | Drawings left out of the drain count | `TestTheDrainWaitsForAPictureBeingDrawn` | | An unroutable `image` target accepted | `TestAnImageTargetNothingDrawsWithIsRefused` | | Queue and draw durations swapped | `TestAJobsTimeIsSplit...` | | Earlier: a fresh message id per post, no reclaim after the lease | `TestAPostWhoseAnswerWasLost...`, `TestARouterKilledMidDraw...` | | 408 treated as permanent | `TestAStreamChatRequestTimeoutIsTriedAgain` | | An already-written 400 treated as permanent | `TestOnlyAStreamChatRefusalALaterTryGetsTooIsPermanent` | | Untaken read from the timeout code, not the sentinel | `TestADrawSentThatTimedOutIsCountedAndSaysSo` | | No 30-minute cutoff for untaken draws | `TestADrawNeverStartedForHalfAnHour...` | | Request key of session and call only | `TestTheSameCallIdInAnotherTurn...`, `TestTheSameCallAskingForAnotherPicture...` | | A draw that finished as Close came released and uncounted | `TestADrawThatFinishesAsTheRouterStops...` | | Usage id dropped from the meter, or not passed by the job | `TestAGenerationDrawnTwiceUnderOneUsageIDIsBilledOnce`, `TestADrawIsBilled...` | | FAL's slot wait not marked never started | `TestADrawThatWaitedOutItsDeadlineForASlot...` | | A failed lookup of the image target answered as a 400 | `TestOnlyAnImageTargetNothingAnswersToIsRefused` | | Picture bytes returned by the claim | `TestAnImageJobWithItsMessageDecidedIsClaimedOnlyToPost` | | The turn id not passed by the agent | `TestAPictureIsQueuedAndTheTurnIsAnsweredAtOnce` | | A bare `image:` read as "turn drawing off" in Python (on #2002) | `test_a_bare_image_leaves_the_stored_one` | 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Swift's generated.json is regenerated from the merged spec; every other client regenerated without a change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/jeffery review |
Withdrawing approval until 02ff422 is reviewed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
## Why
`POST /v1/image/generations` is served (`alibaba/qwen-image-3` behind
`image-fast`) but documented nowhere, and its shortcuts are missing from
the routes table. From the JavaScript SDK, listing image routes meant
writing `client.get("/v1/{modality}/routes", ...)` by hand, which throws
`needs a modality` when the path parameter is passed the wrong way
(AI-3199, piece 1 of AI-3160).
## How it works
```
api.router().routes("image") Go: client.Routes(ctx, ModalityImage)
-> GET /v1/image/routes (router already serves it: images_test.go:173)
<- [ image-fast -> fal/alibaba/qwen-image-3/text-to-image,
image-quality -> google/gemini-3.1-flash-image ] default first
```
| Change | Result | Test |
| --- | --- | --- |
| `Client.Routes(ctx, modality)` in `sdks/go/client/routes.go` | One
call lists a modality's shortcuts |
`TestTheImageRoutesAreListedInTheOrderTheRouterGave` |
| `Router.routes(modality)` in `sdks/js/src/router.ts` |
`api.router().routes("image")` | `search > lists a modality's routes,
default first` (resource.test.ts) |
| `## Pictures` in `acceleration/README.md` | Request, response,
options, routes, time, limits, billing | docs |
| `image-fast`, `image-quality` rows in "The default groups" | The
routes table lists the image modality | docs |
| SDK tests serve the routes in an order no sort would give | They prove
the router's order (default first) is kept |
`TestTheImageRoutesAreListedInTheOrderTheRouterGave`, JS `lists a
modality's routes in the order the router gave...` |
Go goes first per AGENTS.md; the sdk skill has a note for Python, .NET,
Ruby, Rust and PHP.
## Not done here
- **The public docs page and the public routes table.** They live in
getstream.io (`content/docs`), outside this repo and outside what this
change was allowed to touch. The README section is the in-repo source
for that page.
- **What its README describes has shipped in #2013 and #2004**, now on
`accelerate`: the agent's `image:` setting and `generate_image`, drawn
in the background and posted when ready, and FAL's
`queued_ms`/`drawn_ms` on the usage row (#2013); this branch is merged
with them. Its code (the SDK `routes` methods) depends on nothing.
- The `401 api_key is required` in the ticket comes from the gateway in
front of the router, not the router (the `connectors` skill lists that
body as the gateway's). The SDK sends the credential that proxy wants
(an `api_key` header, `sdks/js/src/backend.ts:224`), which a raw request
without it lacks. Whether the proxy passes `/v1/image/routes` was not
checked against staging.
## Validation
- `cd sdks/go && go test ./...`: all packages ok. The new test fails if
`Routes` asks for another path or modality.
- `cd sdks/js && npm test` on Node 22: 601 tests, 0 failed.
- The README's curl was not run against a live router (unverified). Its
facts come from code: `images.go`, `imagegen.go:30-39` (4 pictures, 10
MiB, 4096 px), `fal.go:43-58` (3 s poll, 2 jobs per process, 512-2048
px), `router.yaml` image section, `prices/vendor.json`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Laurent Schaffner <laurentschaffner@Laurents-MacBook-Pro-Stream.local>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Async delivery, the fix Laurent chose from the production data below, shipped in #2013 (merged into
accelerate):generate_imageanswers at once and the picture is drawn in the background and posted when ready. This PR is the rest of AI-3201, merged withaccelerateafter #2013, so it carries no image job code of its own.Why
One picture took 60 to 140 s, past the 30 s caller-tool timeout, so a customer's own image tool failed with "the image generator timed out" until they set
tool_timeout_ms: 180000(AI-3201, piece 3 of AI-3160). The docs saidtool_timeout_ms"Zero is the default" without saying what the default is, andfork()could not take it.This PR ships the timeout half. The latency half is a finding, not a change, because choosing the fix needs production data this work could not get (below).
How it works
tool_timeout_msdoc onCreateSessionRequesttimeout_msreplaces ittool_timeout_msonForkSessionRequestTestAForkOfAnEndedSessionTakesTheToolTimeoutItIsGiven,TestAForksToolTimeoutWinsOverItsRunningParentsforkSpeccarries the timeout across a named configTestAForkNamingAConfigKeepsItsRunningParentsToolTimeoutForkOptions.ToolTimeout; JSfork({ tool_timeout_ms })TestAForksToolTimeoutGoesOverInMilliseconds; resource.test.ts "renames, voices, stops and forks..."A slow caller tool already had its own way out:
SessionTool.timeout_ms(schemas.go:1143, JStimeoutMs) replaces the session's for that tool alone (TestAToolsOwnTimeoutReplacesTheSessions,session_test.go:1640). The built-ingenerate_image(shipped with #2013) is not bound by either: it runs in the background with its own 240 s.Where the 60 to 140 s can go (code reading, no production numbers)
imagegen/fal/fal.go:47,:185-190image-fastis onlyfal/alibaba/qwen-image-3/text-to-image. FAL is submit-then-poll, every 3 s, the first poll 3 s after submitrouting/router.yaml:3469-3497,fal.go:43,:217-249image-fast(one candidate); a job FAL accepted is never retried elsewhereimagerouter/router.gofailover rulesync_mode), decoded once to check, not re-encoded; no download from a provider URLfal.go:263,:310-313,imagegen.goVerifyWhat is recorded cannot split these. A request row has
latency_msonly (routing/call.go:113), which holds the slot wait, FAL's queue, the drawing and the result fetch together;duration_msis not set for images.router_first_output_secondsbuckets stop at 60 s (routing/traffic.go:43), so every 60 to 140 s picture lands in+Inf. Nothing logs or spans FAL's queue time.No local timing was taken: no FAL key is configured on this machine, and the vendor suite (
imagegen/fal/fal_integration_test.go) only checks a picture finishes within 2 minutes.A parent that ended in the router process that held it still has its spec there, so a fork of it keeps its timeout; the field's "30 seconds" is for a parent known only from its row. The description does not say so, to avoid another round of regenerating ten clients for one clause.
Production data (read-only, Prometheus on the prod clusters)
router_first_output_secondsobserves each usage row'slatency_ms / 1000(routing/traffic.go:149-150), so its per-pod_sumand_countgive each image request'slatency_ms. Prometheus onus-east4,us-west1andeurope-west4(accelerate-prod) andus-east1(staging, benchmark) keeps data from 2026-10-29; Postgres was not queried (no client in the router image, and starting a pod in prod was out of bounds).us-east4, allfal/alibaba/qwen-image-3/text-to-image(theimage-fastmodel), alloutcome=ok. None inus-west1,europe-west4, staging or benchmarklatency_mseachcount(up{job="accelerate-router"}), 10-min samples)Each request's completion is pinned to the ~30 s scrape interval:
IN_QUEUErequests wait instead of failing, and that the actual limit is on the dashboard's Concurrency page (concurrency limits). Our account's figure is only there. Even at 2, A and D ran with nothing else in flight and still took 72 s and 132 s.IN_QUEUEfromIN_PROGRESS(the poll reads onlystatus,fal.go:230-232).Recommendation. Do not raise the cap: it never bound. No faster route exists to switch to:
image-fasthas one model, andimage-quality(Gemini) is configured as the slower, dearer one and has no production requests to measure. What removes the failure is not making anyone wait on a tool timeout: AI-3200's built-ingenerate_image(240 s) and a caller tool's owntimeout_mscover today's 60 to 132 s, and async "post when ready" delivery is the durable fix if pictures are to stay this slow. Before choosing a faster model, record FAL's queue time against its drawing time per job, so the next incident says which of the two it was. No code change is made for this here, since the data rules the cap out rather than pointing at a one-line fix.Not done here: open decisions
tool_timeout_mson resume.sessions.resume()sends nothing to the router: it fetches the row and watches. A conversation reopened in the process that held it keeps its spec, timeout included. One reopened from its row (reopenedFromRow) gets neither the timeout nor the caller's declared tools back, since the row keeps neither. Carrying it needs either a session-row column (a migration) or a field on the request that reopens it (respond). That is an API design choice, so it is left open.Validation
CI:
integration (internal/api)failsTestMemorySuiteatmemory_recall_test.go:218, asaccelerateitself does.accelerate's last two runs (43444872895, 43440533515) fail it andintegration (internal/memory/mem0)the same way; the mem0 job's log shows the shared mem0 account out of its monthly retrieval quota (50,000 used of 50,000, resets 2026-11-13 21:24 UTC). This PR touches neither. The earlierTestAdmissionSuitefailure passed on rerun.go test -tags integration ./internal/api -run 'TestSessionConnectorsSuite/...|TestSessionToolsSuite': the three new fork tests and the existing fork and tool suites pass. With bothforkSpeclines removed, all three new tests fail. Wider run:go test ./internal/api ./internal/sessionok;-tags integration ./internal/api -run 'TestSessionConnectorsSuite|TestSessionCreateSuite|TestSessionToolsSuite|TestDocumentsSuite|TestAgentTeamsSuite|TestSessionVerbsSuite'ok;sdks/swift/generate.py --checkup to date.cd sdks/go && go test ./...: ok.cd sdks/js && npm test(Node 22): 600 passed, 0 failed.npm run types -- --check: in step.generate.py(Docker) and Darttool/generate.dart(in adart:stablecontainer) regenerated; the diffs add the fork field and the new description only.ForkSessionRequestwith named arguments, and the new parameter defaults to nil/null.dev.py checkwas not run: the worktree has no synced Python env. Only generated Python models changed.🤖 Generated with Claude Code