feat(runs): report the run id from the endpoints that start a run - #32
Merged
Conversation
The run API only matched `claw_tasks.task_id`, and the dispatcher above
Claw does not hold one. It has the session id `POST /v1/sessions` returned
and stored on its run row, which is the key every other read contract
between the two systems is on. Against the deployed endpoint that gave two
answers, neither usable:
GET /v1/runs/{session-id} -> 404 run_not_found
GET /v1/runs?ids={session-id} -> 200 {"runs":[],"requested":1}
The second is the worse one. An empty array is what "none of those have
finished yet" looks like, so a consumer polling with the wrong key is told
a plausible thing forever and never learns it is asking wrongly.
Both keys were already columns on the row and `SELECT_COLUMNS` already read
`session_id`, so this changes a predicate rather than adding a join:
- `RunView` carries `session_id`. Additive; no field moves or changes
meaning, and `run_id` is still the identity of a run.
- `GET /v1/runs?session_ids=a,b,c` filters on `session_id = ANY($1)`, with
the `?ids=` handling unchanged: `parseIds` de-duplication, the same
ownership scope, a refusal rather than a silent trim over the cap, no
cursor, and ids that matched nothing absent rather than invented.
Two decisions worth stating.
Both parameters in one call is a 400 (`ids_and_session_ids_are_exclusive`)
rather than an intersection, on the reasoning `too_many_ids` already uses:
a caller sending both has not established which key it holds, and an
intersection of two disagreeing sets is an empty array it will read as
"nothing finished".
A session owns many runs -- a DAG expands to a root plus a row per node, a
batch to one of those per input, a chat to a row per turn, and a retry
clones a row -- with only a non-unique index on `session_id` and no guard
anywhere against it. So `?session_ids=` can answer with more runs than ids
it was given, and `requested` counts sessions asked about rather than runs
to expect. Each run carries its `session_id` for the caller to group on.
The session cap is 350 against the task-id 500 because a UUID is 37 bytes
with its comma to a task id's 32: 500 of them is ~18.5 KB of request line,
past Node's 16 KB limit, and answers HTTP 431 with no body -- the sizeless
transport error the task-id cap exists to prevent. Measured, and asserted
in a test that reads the cap off the route's own refusal so raising the
constant fails rather than quietly outgrowing it. `max_ids` on the refusal
names whichever cap applied.
Also splits the `/v1/runs` handler, which was over the size limit before
this change and further over after it, into the two independent reads it
already contained, and gives the batch read a `task_id` tiebreaker -- rows
written in one transaction share `NOW()`, so `created_at` ties are the
normal case once a whole session comes back at once.
Co-authored-by: Cursor <cursoragent@cursor.com>
`/v1/runs` answers to a run id, and nothing that starts a run handed one
back. A caller got a session id and a message id, and neither names a
row: a session owns many runs -- a turn per message, a root plus a node
per DAG, one of those per batch input, a clone per retry -- so looking up
by session returns the whole history and leaves the caller to guess which
entry is its own. The guess matters because the field being read is
`kill_reason`, and taking a nested DAG node's `oom` for the dispatch's own
counts an inner memory limit against the caller's configuration.
The id already existed at every one of these points and was dropped on
the way out. `openChatRun` mints it, `HandOffResult` carries it through
both doorbell outcomes, and `dispatchTaskToBrain` assigned it to a local
and then returned without it -- only the soft-queue branch reported it,
which is the least common of the four.
- `DispatchResult.dispatched` carries `runId`, as `queued` already did.
All three success paths report the row they opened: ordinary publish,
doorbell, and the publish that timed out after delivery and found the
row already claimed. Neither failing kind carries one -- `rejected` may
refuse before a row exists, and `publish_failed` has compensated the
row it opened, so an id on either would name a row that will not run.
- `POST /v1/sessions` with a `message` reports `data.message.run_id`, and
`POST /v1/sessions/{id}/messages` reports a top-level `run_id`. Both
are additive. Create without a message starts no run and reports none.
A message accepted while the session is busy keeps answering
`{"queued": true}` with no `run_id`, deliberately. It is written to
`claw_pending_messages` and its run row is opened later by the drain; an
id minted at accept time would answer 404 until then, and would name
nothing at all if the replay is later refused a workspace or its
admission. Closing that gap means creating the run when the input is
accepted, which is a lifecycle change rather than a response change, and
is called out as such in the API docs.
Also fixes three ways the query contract this PR adds could be got wrong,
found by testing the guards rather than the happy path:
- Filter selection tested truthiness, so `?session_ids=` read as absent
and answered a complaint about `state`, and `?ids=&session_ids=x` read
as one parameter and was answered -- the exact confusion the
exclusivity rule exists to refuse. It now tests presence.
- A repeated query key parses to an array, and every reader here is
written for a string, so `?ids=a&ids=b` reached a TypeError and
answered 500. `state` and `since` did the same, which predates this
branch. Any repeated parameter is now a 400 naming the parameter.
- `?session_ids=` had no ceiling on the rows it returned. The `?ids=`
cap bounds its own answer, one row per primary key, but a session owns
any number of runs, so the size was set by history rather than by the
request. It now reads one row past 1000 and refuses the call whole
rather than truncating, since a short answer is indistinguishable from
those sessions having only that many runs.
Documents run identity as its own section: where to get a `run_id` from
each endpoint that starts a run, that a session is the container rather
than a second name for a run, and that `?session_ids=` is a collection
filter for discovery and reconciliation.
Co-authored-by: Cursor <cursoragent@cursor.com>
The idempotency cache holds a verbatim response for 24h, so for a day after this ships a replayed create can be answered out of an entry a handler wrote when `data.message.run_id` was not part of the contract. Both replay sites handed that entry straight back, which answers with a shape the endpoint no longer promises -- and to the caller it is indistinguishable from a create that started no run, which is the one thing this response exists to settle. The field is now resolved rather than replayed: the id comes from the `claw_tasks` row the cached message actually opened, found by the message id the entry already carries. Nothing is minted. On the entry old enough that no row resolves -- deleting a session cancels its rows rather than removing them, so this reaches back further than the TTL -- the reply is returned as it was stored, without a `run_id`, which the queued path already establishes the meaning of: no run to name. The lookup is best-effort like the save side, because a backfill failing is not a reason to fail a create the client already completed once. Both sites are covered, including the busy-poll replay a request reaches when the advisory lock timed out -- the degraded path, and no more entitled to the older shape than the other one. Co-authored-by: omnigent <noreply@omnigent.ai>
weilei0120
approved these changes
Sep 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
/v1/runsanswers to a run id, and nothing that starts a run handed one back. Acaller got a session id and a message id. Neither names a row.
A session owns many runs — a turn per message, a root plus a node per DAG, one of
those per batch input, a clone per retry — so looking a session up returns its
whole history and leaves the caller to work out which entry is its own. That
guess is the whole problem, because the field being read is
kill_reason:reading a nested DAG node's
oomas the dispatch's own counts an inner memorylimit against the caller's configuration, which is exactly the distinction
kill_reasonexists to make.Verified against a deployment, with a valid credential, before this branch:
The second is the worse one: an empty array is indistinguishable from "none of
those have finished yet", so a consumer polling with the wrong key gets a
plausible answer forever.
What this does
Two things, and the second is what actually closes the loop.
1.
/v1/runscan enumerate a session's runssession_idwas already a column on the row and already inSELECT_COLUMNS, sothis is a change of predicate, not a join.
RunViewcarriessession_id. Additive;run_idis still the identity.GET /v1/runs?session_ids=a,b,cfilters onsession_id = ANY($1), withthe same
parseIdsde-duplication, ownership scope, refuse-don't-trim cap andabsent-on-miss semantics as
?ids=.This is a collection filter for discovery and reconciliation, not a second
way of naming a run, and the docs now say so in those words.
2. The endpoints that start a run report its id
This is the part a consumer needs, and the id already existed at every one of
these points — it was just dropped on the way out.
openChatRunmints it,HandOffResultcarries it through both doorbell outcomes, anddispatchTaskToBrainassigned it to a local and then returned without it. Onlythe soft-queue branch reported it, which is the least common of the four.
DispatchResult.dispatchedcarriesrunId, asqueuedalready did. Allthree success paths now report the row they opened: ordinary publish,
doorbell, and the publish that timed out after delivery and found the row
already claimed by a worker.
POST /v1/sessionswith amessagereportsdata.message.run_id.POST /v1/sessions/{id}/messagesreports a top-levelrun_id.Neither failing kind carries an id, deliberately:
rejectedmay refuse before arow exists, and
publish_failedhas compensated the row it opened, so an id oneither would name a row that is not going to run.
docs/run-api.mdgains a "Run identity" section tabulating where the id is inevery creation response — single task, DAG, batch, workbench and retry included,
which already returned theirs.
The one gap, stated plainly
A message accepted while the session is busy still answers
{"queued": true}with no
run_id.That path writes to
claw_pending_messagesand its run row is opened later bythe drain, when the turn in front of it finishes. There is genuinely no id to
report yet. Minting one now would answer 404 on
/v1/runsuntil the queuedrains, and would name nothing at all if the replay is later refused a workspace
or its admission.
Closing it means creating the run when the input is accepted — a lifecycle
change, not a response change, touching admission, workspace binding, deadlines,
cancellation and the sweeper. It belongs with the run write control plane, and
the docs say so under "Compatibility" rather than leaving callers to discover
it.
Three bugs in the query contract
Found by testing the guards rather than the happy path, and fixed here since
this PR introduces the surface they sit on.
?session_ids=statesession_ids must not be empty?ids=&session_ids=xids_and_session_ids_are_exclusive?ids=a&ids=braw.split is not a functionrepeated_query_parameter?state=terminal&state=x.trim is not a functionrepeated_query_parameterThe first two came from testing truthiness rather than presence: an empty value
is still the caller naming a parameter. The 500s come from a repeated key
parsing to an array while every reader here is written for a string — the
stateandsincecases predate this branch.And a ceiling on the answer.
?session_ids=had none. The?ids=cap boundsits own answer —
task_idis the primary key, so 500 ids are at most 500 rows —but a session owns any number of runs, so the size of that answer was set by
history rather than by the request. It now reads one row past 1000 and refuses
the call whole (
too_many_runs, withmax_runs) rather than truncating: a shortanswer is indistinguishable from those sessions having only that many runs, and
a caller reconciling dispatches would read the omitted ones as never having
existed.
Two caps, not one
session_idsaccepts 350 ids whereidsaccepts 500. Applying the same numberreintroduces the exact failure the
idscap exists to prevent, because a sessionid is a UUID: 37 bytes with its comma against a task id's 32. Measured on this
route:
431 with no body is the sizeless transport error the cap was chosen to convert
into a readable 400.
max_idson thetoo_many_idsrefusal names whichever capapplied, so callers read it rather than hold either number.
Two notes from measuring: the existing comment said a task id and its comma were
27 bytes and 500 was ~13.5 KB — it was counting the ULID without the
ktsk_prefix; the real figures are 32 bytes and ~16 KB, corrected in place. That leaves
?ids=at 500 with only ~350 B of headroom. It passes with a lean header set andI have not changed it — lowering a documented cap is a breaking change — but
it is thin and worth a follow-up.
Session-to-runs is one-to-many, confirmed from the schema
Not a schema accident, the normal case:
inputs × (nodes + 1), one sessionclaw_taskshastask_idas its primary key and only a non-unique index onsession_id(idx_tasks_session). No unique constraint, no application guard.So for
?session_ids=,requestedcounts sessions asked about, not runs toexpect, and
runsmay be longer. That is whysession_idis on each run: groupthe answer by it.
Tests
Twenty new cases; every guard checked by planting the violation it exists to
catch. All sixteen mutations fail the intended test and pass without it.
toRunViewstops returningsession_idsession_idsmatched againsttask_idids+session_idsno longer refusedrun_idrun_idWorth calling out three:
existing
servereturns its rows whatever the SQL says, so a route thatdropped the clause entirely would have passed;
serveOwnedreads the clauseout of the statement and enforces it.
too_many_idsresponse instead ofhardcoding 350, so raising the constant fails the test rather than quietly
outgrowing it.
run_id, so the gap above cannotbe closed by accident with a fabricated one.
session-run-id-response.test.tsdrives the real routes over HTTP, including anidempotent replay resolving to the same run.
Full workspace suite: 2,265 tests pass. Build, typecheck, all nine repo lint
scripts, and the public tree scan are clean.
Compatibility
Additive throughout.
?ids=is unchanged in predicate, response shape, cap anderror vocabulary.
session_idandrun_idare new fields no existing callerreads. No migration, no schema change.
run_idis the stable public identifier and is currently the same value astask_id; it stays the public name through the planned rename of the underlyingtable, so callers should store
run_idand not depend on the two being spelledalike.
Nothing from the event-bus half of #12 is reintroduced.