Skip to content

Commit 1236b29

Browse files
dolhoclaude
andcommitted
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>
1 parent 7fdce89 commit 1236b29

9 files changed

Lines changed: 880 additions & 45 deletions

File tree

src/backend/routers/schedules.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,16 @@ async def trigger_schedule(
483483
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
484484
detail="Scheduler service unavailable"
485485
)
486+
elif response.status_code == 409:
487+
# #1968: the scheduler declined because this schedule is
488+
# already running. Relayed as a 409 rather than flattened into
489+
# the 500 below, because it is not a failure — it is the answer
490+
# to the caller's question, and the old handler's silent
491+
# `"status": "triggered"` for this case is the bug.
492+
raise HTTPException(
493+
status_code=status.HTTP_409_CONFLICT,
494+
detail="Schedule is already executing"
495+
)
486496
elif response.status_code != 200:
487497
logger.error(f"Scheduler trigger failed: {response.status_code} - {response.text}")
488498
raise HTTPException(
@@ -505,12 +515,20 @@ async def trigger_schedule(
505515
"schedule_id": schedule_id,
506516
"schedule_name": result.get("schedule_name"),
507517
"triggered_by": "manual",
518+
# #1968: the audit row can now name the execution it
519+
# started, so a trigger and its run are joinable after the
520+
# fact rather than only correlatable by timestamp.
521+
"execution_id": result.get("execution_id"),
508522
},
509523
)
510524

511525
return {
512526
"status": "triggered",
513527
"schedule_id": schedule_id,
528+
# #1968: the field the MCP tool has always read and never
529+
# found. It was absent here because the scheduler responded
530+
# before the row existed; it now creates the row first.
531+
"execution_id": result.get("execution_id"),
514532
"schedule_name": result.get("schedule_name"),
515533
"agent_name": result.get("agent_name"),
516534
"message": result.get("message", "Execution started")

src/cli/trinity_cli/commands/schedules.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,13 @@ def trigger_schedule(agent, schedule_id):
4141
"""Trigger a schedule immediately."""
4242
client = TrinityClient()
4343
data = client.post(f"/api/agents/{agent}/schedules/{schedule_id}/trigger")
44-
click.echo(f"Triggered schedule {schedule_id} on '{agent}'")
44+
# #1968: `data` was fetched and thrown away, so the command could not tell
45+
# the user which run it had just started. The response now carries a real
46+
# execution_id; print it, guarded, since an older backend still omits it.
47+
execution_id = (data or {}).get("execution_id")
48+
if execution_id:
49+
click.echo(
50+
f"Triggered schedule {schedule_id} on '{agent}' (execution {execution_id})"
51+
)
52+
else:
53+
click.echo(f"Triggered schedule {schedule_id} on '{agent}'")

src/frontend/src/components/SchedulesPanel.vue

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1337,7 +1337,20 @@ async function triggerSchedule(schedule) {
13371337
await loadExecutions(schedule.id)
13381338
}
13391339
} catch (error) {
1340-
reportActionFailure(error, `run the schedule "${schedule.name}" now`)
1340+
// #1968: the backend now answers 409 when the schedule is already running,
1341+
// instead of reporting a success that started nothing. That is not the
1342+
// "nothing was changed — try again" case reportActionFailure describes: a
1343+
// run IS in flight, and retrying only hits the same lock. Say what is
1344+
// actually true, and reload so the user can see the run in question.
1345+
if (error?.response?.status === 409) {
1346+
actionError.value = `"${schedule.name}" is already running — no new run was started.`
1347+
actionErrorDetail.value = ''
1348+
if (expandedSchedule.value === schedule.id) {
1349+
await loadExecutions(schedule.id)
1350+
}
1351+
} else {
1352+
reportActionFailure(error, `run the schedule "${schedule.name}" now`)
1353+
}
13411354
} finally {
13421355
triggerLoading.value = null
13431356
}

src/mcp-server/src/tools/schedules.ts

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { z } from "zod";
8-
import { TrinityClient } from "../client.js";
8+
import { ApiError, TrinityClient } from "../client.js";
99
import type { McpAuthContext } from "../types.js";
1010

1111
/**
@@ -536,17 +536,52 @@ export function createScheduleTools(
536536

537537
// #1970: carry the caller identity through so the resulting execution
538538
// row is attributable to this key/agent, not just to the key owner.
539-
const result = await apiClient.triggerAgentSchedule(
540-
agent_name,
541-
schedule_id,
542-
authContext?.agentName,
543-
authContext
544-
? { keyId: authContext.keyId, keyName: authContext.keyName }
545-
: undefined
546-
);
539+
let result;
540+
try {
541+
result = await apiClient.triggerAgentSchedule(
542+
agent_name,
543+
schedule_id,
544+
authContext?.agentName,
545+
authContext
546+
? { keyId: authContext.keyId, keyName: authContext.keyName }
547+
: undefined
548+
);
549+
} catch (error) {
550+
// #1968: "already running" is an ANSWER, not a failure. Surfacing the
551+
// raw ApiError would hand the calling agent a stack-shaped string it
552+
// has to parse; a structured status lets it decide to wait and poll
553+
// instead of retrying into the same lock.
554+
if (error instanceof ApiError && error.status === 409) {
555+
console.log(`[trigger_agent_schedule] Schedule '${schedule_id}' is already executing`);
556+
return JSON.stringify({
557+
status: "already_running",
558+
schedule_id,
559+
agent_name,
560+
message:
561+
"This schedule is already executing, so no new run was started. " +
562+
"Poll list_recent_executions for the run in flight.",
563+
}, null, 2);
564+
}
565+
throw error;
566+
}
547567

548568
console.log(`[trigger_agent_schedule] Triggered schedule '${schedule_id}' for agent '${agent_name}', execution_id: ${result.execution_id}`);
549569

570+
// #1968: `execution_id` used to be absent from the response, so this
571+
// string always read "...with ID 'undefined'". Guard the wording rather
572+
// than assume — an old scheduler behind a new MCP server still omits it,
573+
// and a confident lie about the id is what this issue is about.
574+
if (!result.execution_id) {
575+
return JSON.stringify({
576+
status: "triggered",
577+
schedule_id,
578+
execution_id: null,
579+
message:
580+
"Schedule triggered, but the backend returned no execution id. " +
581+
"Find the run via list_recent_executions.",
582+
}, null, 2);
583+
}
584+
550585
return JSON.stringify({
551586
status: "triggered",
552587
schedule_id,

src/mcp-server/src/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,12 @@ export interface ScheduleToggleResult {
270270
export interface ScheduleTriggerResult {
271271
status: "triggered";
272272
schedule_id: string;
273-
execution_id: string;
273+
// #1968: optional, and that is the honest declaration. This was typed as a
274+
// required `string` while the backend never sent the field at all — which is
275+
// precisely why the compiler stayed happy while every trigger interpolated
276+
// `undefined` into its success message. A current backend fills it in; an
277+
// older one still omits it, so callers must check rather than trust the type.
278+
execution_id?: string;
274279
message?: string;
275280
}
276281

src/scheduler/main.py

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -210,17 +210,76 @@ async def _trigger_handler(self, request: web.Request) -> web.Response:
210210
f"source_user_id={origin.user_id} source_agent={origin.agent_name}"
211211
)
212212

213-
# Execute in background (fire-and-forget)
213+
# #1968: acquire the lock and create the execution row HERE, before
214+
# responding. Both facts the caller needs — whether the trigger took
215+
# effect, and which execution it produced — only exist after these two
216+
# steps, and the old handler answered before either had happened.
217+
#
218+
# It reported `"status": "triggered"` with no id even when the lock was
219+
# denied and nothing ran, so a suppressed trigger and a real one were
220+
# byte-identical to the caller. The MCP tool then interpolated the
221+
# missing key and told every agent `Execution started with ID
222+
# 'undefined'`.
223+
lock = self.scheduler_service.lock_manager.try_acquire_schedule_lock(schedule_id)
224+
if not lock:
225+
logger.warning(
226+
f"Trigger for {schedule_id} (triggered_by={triggered_by}): "
227+
"schedule already executing"
228+
)
229+
return web.json_response({
230+
"status": "already_running",
231+
"schedule_id": schedule_id,
232+
"schedule_name": schedule.name,
233+
"agent_name": schedule.agent_name,
234+
"message": "Schedule is already executing",
235+
}, status=409)
236+
237+
# From here the lock is HELD. Every path below must either hand it to
238+
# the background task or release it — a leak would wedge the schedule
239+
# until the Redis TTL expires.
240+
try:
241+
execution = self.scheduler_service.db.create_execution(
242+
schedule_id=schedule.id,
243+
agent_name=schedule.agent_name,
244+
message=schedule.message,
245+
triggered_by=triggered_by,
246+
model_used=schedule.model,
247+
source_user_id=origin.user_id,
248+
source_user_email=origin.user_email,
249+
source_agent_name=origin.agent_name,
250+
source_mcp_key_id=origin.mcp_key_id,
251+
source_mcp_key_name=origin.mcp_key_name,
252+
)
253+
except Exception as exc:
254+
lock.release()
255+
logger.error(f"Trigger for {schedule_id}: could not create execution: {exc}")
256+
return web.json_response(
257+
{"error": "Failed to create execution record"}, status=500
258+
)
259+
260+
if not execution:
261+
lock.release()
262+
logger.error(f"Trigger for {schedule_id}: could not create execution record")
263+
return web.json_response(
264+
{"error": "Failed to create execution record"}, status=500
265+
)
266+
267+
# Execute in background, handing over the lock we already hold and the
268+
# row we already created.
214269
asyncio.create_task(
215270
self._execute_manual_trigger(
216-
schedule_id, triggered_by=triggered_by, origin=origin
271+
schedule_id,
272+
triggered_by=triggered_by,
273+
origin=origin,
274+
lock=lock,
275+
execution=execution,
217276
)
218277
)
219278

220-
# Return immediately; execution creates its own record asynchronously
221279
return web.json_response({
222280
"status": "triggered",
223281
"schedule_id": schedule_id,
282+
"execution_id": execution.id,
224283
"schedule_name": schedule.name,
225284
"agent_name": schedule.agent_name,
226285
"triggered_by": triggered_by,
@@ -232,31 +291,55 @@ async def _execute_manual_trigger(
232291
schedule_id: str,
233292
triggered_by: str = "manual",
234293
origin: Optional[ExecutionOrigin] = None,
294+
lock=None,
295+
execution=None,
235296
):
236-
"""Execute a manually or webhook-triggered schedule."""
237-
try:
238-
# Acquire lock (prevents concurrent execution)
239-
lock = self.scheduler_service.lock_manager.try_acquire_schedule_lock(schedule_id)
297+
"""Execute a manually or webhook-triggered schedule.
298+
299+
#1968: `lock` and `execution` are acquired/created by `_trigger_handler`
300+
BEFORE it responds, so the response can carry a real `execution_id` and
301+
a denied lock can be reported as 409 instead of a false "triggered".
302+
Both are passed in rather than taken here; this coroutine's job is to
303+
run the schedule and, whatever happens, release the lock.
304+
305+
They stay optional so the method keeps working if called without them
306+
(it then acquires its own lock, as before, and lets the service create
307+
the row) — a caller that forgets is degraded, not broken.
308+
309+
Whichever way the lock arrived, this coroutine owns it from here and
310+
releases it exactly once. Structured as acquire-then-single-`finally`
311+
rather than a release in both the `finally` and an error branch: the
312+
second release is the dangerous one, since a lock re-acquired by the
313+
next run in between would be freed out from under it.
314+
"""
315+
if lock is None:
316+
try:
317+
lock = self.scheduler_service.lock_manager.try_acquire_schedule_lock(
318+
schedule_id
319+
)
320+
except Exception as exc:
321+
logger.error(f"Trigger for {schedule_id}: lock acquisition failed: {exc}")
322+
return
240323
if not lock:
241324
logger.warning(
242325
f"Trigger for {schedule_id} (triggered_by={triggered_by}): "
243326
"schedule already executing"
244327
)
245328
return
246329

247-
try:
248-
await self.scheduler_service._execute_schedule_with_lock(
249-
schedule_id,
250-
triggered_by=triggered_by,
251-
origin=origin
252-
)
253-
finally:
254-
lock.release()
255-
330+
try:
331+
await self.scheduler_service._execute_schedule_with_lock(
332+
schedule_id,
333+
triggered_by=triggered_by,
334+
origin=origin,
335+
execution=execution
336+
)
256337
except Exception as e:
257338
logger.error(
258339
f"Trigger execution failed for {schedule_id} (triggered_by={triggered_by}): {e}"
259340
)
341+
finally:
342+
lock.release()
260343

261344
async def _run_until_shutdown(self):
262345
"""Run the scheduler until shutdown signal."""

0 commit comments

Comments
 (0)