Skip to content

feat(runs): report the run id from the endpoints that start a run - #32

Merged
weilei0120 merged 3 commits into
mainfrom
feat/runs-session-id-join
Sep 7, 2026
Merged

feat(runs): report the run id from the endpoints that start a run#32
weilei0120 merged 3 commits into
mainfrom
feat/runs-session-id-join

Conversation

@zoroyihan7

@zoroyihan7 zoroyihan7 commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

The problem

/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. 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 oom as the dispatch's own counts an inner memory
limit against the caller's configuration, which is exactly the distinction
kill_reason exists to make.

Verified against a deployment, with a valid credential, before this branch:

GET /v1/runs/{session-id}     -> 404 {"ok":false,"error":"run_not_found"}
GET /v1/runs?ids={session-id} -> 200 {"runs":[],"requested":1}

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/runs can enumerate a session's runs

session_id was already a column on the row and already in SELECT_COLUMNS, so
this is a change of predicate, not a join.

  • RunView carries session_id. Additive; run_id is still the identity.
  • GET /v1/runs?session_ids=a,b,c filters on session_id = ANY($1), with
    the same parseIds de-duplication, ownership scope, refuse-don't-trim cap and
    absent-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. 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 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/sessions with a message reports data.message.run_id.
  • POST /v1/sessions/{id}/messages reports a top-level run_id.

Neither failing kind carries an id, deliberately: 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 is not going to run.

docs/run-api.md gains a "Run identity" section tabulating where the id is in
every 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_messages and its run row is opened later by
the 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/runs until the queue
drains, 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.

Request Before Now
?session_ids= 400 complaining about state 400 session_ids must not be empty
?ids=&session_ids=x 200, exclusivity bypassed 400 ids_and_session_ids_are_exclusive
?ids=a&ids=b 500 raw.split is not a function 400 repeated_query_parameter
?state=terminal&state=x 500 .trim is not a function 400 repeated_query_parameter

The 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
state and since cases predate this branch.

And a ceiling on the answer. ?session_ids= had none. The ?ids= cap bounds
its own answer — task_id is 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, with max_runs) rather than truncating: a short
answer 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_ids accepts 350 ids where ids accepts 500. Applying the same number
reintroduces the exact failure the ids cap exists to prevent, because a session
id is a UUID: 37 bytes with its comma against a task id's 32. Measured on this
route:

ids request line result
500 task ids 16,027 B 200
350 session ids 12,985 B 200
500 session ids 18,535 B HTTP 431

431 with no body is the sizeless transport error the cap was chosen to convert
into a readable 400. max_ids on the too_many_ids refusal names whichever cap
applied, 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 and
I 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:

Flow Rows per session
DAG submit 1 virtual root + 1 per node
Batch submit inputs × (nodes + 1), one session
Chat 1 per dispatched turn
Retry clones a new row, keeps the old

claw_tasks has task_id as its primary key and only a non-unique index on
session_id (idx_tasks_session). No unique constraint, no application guard.

So for ?session_ids=, requested counts sessions asked about, not runs to
expect, and runs may be longer. That is why session_id is on each run: group
the 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.

Planted violation Caught by
toRunView stops returning session_id S1, S1b, S1c
session_ids matched against task_id S1
batch read drops the ownership scope S3
session ids capped at the task-id number S4
ids + session_ids no longer refused S5
session cap raised to 500 S4, S6
batch ordering loses its tiebreaker S1c
presence check falls back to truthiness S7
repeated-parameter guard removed S8
session read loses its row ceiling S10
over-wide answer truncated not refused S10
create-with-message drops run_id N2, N5
immediate message drops run_id N3
default / doorbell / worker-held dispatch loses its id D10, D10b, D10d

Worth calling out three:

  • S3 needed a stub that actually applies the ownership predicate. The
    existing serve returns its rows whatever the SQL says, so a route that
    dropped the clause entirely would have passed; serveOwned reads the clause
    out of the statement and enforces it.
  • S6 reads the cap off the route's own too_many_ids response instead of
    hardcoding 350, so raising the constant fails the test rather than quietly
    outgrowing it.
  • N4 asserts the queued path returns no run_id, so the gap above cannot
    be closed by accident with a fabricated one.

session-run-id-response.test.ts drives the real routes over HTTP, including an
idempotent 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 and
error vocabulary. session_id and run_id are new fields no existing caller
reads. No migration, no schema change.

run_id is the stable public identifier and is currently the same value as
task_id; it stays the public name through the planned rename of the underlying
table, so callers should store run_id and not depend on the two being spelled
alike.

Nothing from the event-bus half of #12 is reintroduced.

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>
@zoroyihan7
zoroyihan7 requested a review from a team as a code owner September 4, 2026 15:03
`/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>
@zoroyihan7 zoroyihan7 changed the title feat(runs): let the run API answer by session id feat(runs): report the run id from the endpoints that start a run Sep 4, 2026
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
weilei0120 merged commit 20ff17e into main Sep 7, 2026
45 checks passed
@weilei0120
weilei0120 deleted the feat/runs-session-id-join branch September 7, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants