You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(scheduler): return a real execution_id, and 409 when nothing started (#1968)
`_trigger_handler` was fire-and-forget: it spawned the run with
`asyncio.create_task` and responded immediately, *before* the execution
record existed. So it had no id to return. The backend relayed the same
id-less fields, and the MCP tool interpolated the missing key — telling
every agent `Execution started with ID 'undefined'`, on every trigger,
while the execution ran fine. Callers could not correlate a trigger with
its run, poll it, or fetch its result; the workaround was to guess from
`list_recent_executions` by timestamp.
The same ordering hid a second problem. The response was emitted before
`_execute_manual_trigger` had even attempted the distributed lock, so a
trigger suppressed because the schedule was already running still
answered `"status": "triggered"`. A suppressed trigger and a real one
were byte-identical to the caller.
The handler now acquires the lock and creates the row synchronously,
then hands both to the background task. That makes two facts sayable
that simply did not exist yet at response time: which execution this is,
and whether one was started at all.
* 200 carries a real `execution_id`, valid the moment the caller
receives it — a fast poller must not 404.
* 409 `already_running` replaces the false "triggered", with no id and
no row, because nothing ran.
Exactly one row per trigger: `_execute_schedule_with_lock` takes the
pre-created execution and skips its own create. Two rows would hand the
caller an id naming a row that never runs while a second did the work.
Because a row can now exist before a gate decides not to run, an
abandoned run FAILs its pre-created row rather than leaving it `running`
forever — canary E-01's exact signature, and a task the UI would show
indefinitely.
The handler also now holds the lock across a DB write, which is new, so
every exit from that window releases it: creation raising, creation
returning None, the run raising, and normal completion — exactly once
each. A second release is the dangerous one, since a lock re-acquired by
the next run in between would be freed out from under it.
Relayed through the remaining surfaces:
* the backend forwards `execution_id` (and records it on the audit row,
so a trigger and its run are joinable after the fact) and maps 409
rather than flattening it into "Failed to trigger schedule" — a
worse lie than the original, since it claims failure where the
schedule is healthily busy;
* the MCP tool returns a structured `already_running` instead of
throwing, so an agent gets a decision it can act on, and GUARDS the
id instead of interpolating it — an older backend still omits the
field, and swapping one confident lie for another is not a fix;
* `ScheduleTriggerResult.execution_id` becomes optional. Typing it as
a required `string` while the wire never sent it is precisely why
the compiler stayed happy through every `undefined`;
* the UI reads 409 as "already running" rather than "nothing was
changed — try again", and the CLI prints the id it was already
fetching and discarding.
tests/unit/test_1968_trigger_execution_id.py — 22 checks, 17 of which
fail against the pre-fix tree.
Related to #1968
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
0 commit comments