Summary
Every manual schedule trigger reports execution_id: undefined to the caller, even though the execution starts correctly. The scheduler's trigger handler is fire-and-forget — it spawns the execution as a background task and returns before the execution record exists, so no id can be included. The caller therefore cannot correlate its trigger with an execution, poll its status, or fetch its result.
Component
Scheduler / Backend (schedules router) / MCP Server
Priority
P2 — the execution runs correctly; only the returned identifier is unusable. Workaround is to poll list_recent_executions and guess by timestamp.
Error
Returned to every MCP trigger_agent_schedule caller:
Schedule triggered. Execution started with ID 'undefined'.
Meanwhile the execution exists and completes normally — it simply was never named back to the caller.
Location
- File:
src/scheduler/main.py
- Line: 152–212 —
_trigger_handler(); asyncio.create_task(...) at 200, response at 205–212 (no execution_id key)
- File:
src/backend/routers/schedules.py
- Line: 483–489 — return dict passes through five fields, no
execution_id
- File:
src/mcp-server/src/tools/schedules.ts
- Line: 539, 544–545 — reads
result.execution_id, which is therefore always undefined
Root Cause
_trigger_handler() validates the schedule, then fires the execution asynchronously and responds immediately:
asyncio.create_task(self._execute_manual_trigger(schedule_id))
return web.json_response({
"status": "triggered",
"schedule_id": schedule_id,
"schedule_name": schedule.name,
"agent_name": schedule.agent_name,
"message": "Execution started in background"
})
The execution record is only created later, inside _execute_schedule_with_lock() → db.create_execution(). At response time no id exists, so none is returned. The backend relays the same five fields, and the MCP tool interpolates the missing key into its success string.
A second consequence of the same ordering: the response is emitted before _execute_manual_trigger() even attempts the distributed lock. If the lock is denied because the schedule is already running, the trigger silently no-ops while the caller still receives "status": "triggered". A suppressed trigger and a successful one are byte-identical to the caller — the only trace is a scheduler-side WARNING.
Reproduction Steps
- Create any agent schedule.
- Trigger it manually — MCP
trigger_agent_schedule, or POST /api/agents/{name}/schedules/{schedule_id}/trigger.
- Observe the response:
execution_id is absent (REST) / undefined (MCP), while the execution is in fact running.
- Optional, for the second half: trigger once, then trigger the same schedule again while the first run is still in flight. Both calls return
"status": "triggered"; only one execution is created.
Suggested Fix
Acquire the lock and create the execution record synchronously in _trigger_handler, then hand the pre-created execution to the background task. This returns a real id and lets a suppressed trigger be reported honestly:
# src/scheduler/main.py — _trigger_handler()
lock = self.scheduler_service.lock_manager.try_acquire_schedule_lock(schedule_id)
if not lock:
return web.json_response(
{"status": "already_running", "schedule_id": schedule_id,
"message": "Schedule is already executing"},
status=409,
)
execution = self.scheduler_service.db.create_execution(
schedule_id=schedule.id,
agent_name=schedule.agent_name,
message=schedule.message,
triggered_by="manual",
model_used=schedule.model,
)
asyncio.create_task(self._execute_manual_trigger(schedule_id, lock=lock, execution=execution))
return web.json_response({
"status": "triggered",
"schedule_id": schedule_id,
"execution_id": execution.id, # <-- the missing field
"schedule_name": schedule.name,
"agent_name": schedule.agent_name,
"message": "Execution started in background",
})
Then propagate execution_id through src/backend/routers/schedules.py:483 so the MCP tool's existing result.execution_id read resolves. The MCP side needs no change once the field is present end to end.
If the 409 path is considered a behaviour change, the minimal variant is to return execution_id only and leave locking as-is — but the honest-suppression half is where most of the operator value sits.
Environment
- Observed on a deployed instance running
1d3863d5
- Confirmed still present at
4d743fdb (2026-07-27) by code inspection — the handler, the backend return dict and the MCP read are unchanged
Related
src/scheduler/main.py — _trigger_handler(), _execute_manual_trigger()
src/backend/routers/schedules.py — manual trigger delegation
src/mcp-server/src/tools/schedules.ts — trigger_agent_schedule
- Same investigation surfaced two sibling gaps in the manual-trigger path (silent lock-denied cron tick; unpopulated execution origin columns) — filed separately
Summary
Every manual schedule trigger reports
execution_id: undefinedto the caller, even though the execution starts correctly. The scheduler's trigger handler is fire-and-forget — it spawns the execution as a background task and returns before the execution record exists, so no id can be included. The caller therefore cannot correlate its trigger with an execution, poll its status, or fetch its result.Component
Scheduler / Backend (schedules router) / MCP Server
Priority
P2 — the execution runs correctly; only the returned identifier is unusable. Workaround is to poll
list_recent_executionsand guess by timestamp.Error
Returned to every MCP
trigger_agent_schedulecaller:Meanwhile the execution exists and completes normally — it simply was never named back to the caller.
Location
src/scheduler/main.py_trigger_handler();asyncio.create_task(...)at 200, response at 205–212 (noexecution_idkey)src/backend/routers/schedules.pyexecution_idsrc/mcp-server/src/tools/schedules.tsresult.execution_id, which is therefore alwaysundefinedRoot Cause
_trigger_handler()validates the schedule, then fires the execution asynchronously and responds immediately:The execution record is only created later, inside
_execute_schedule_with_lock()→db.create_execution(). At response time no id exists, so none is returned. The backend relays the same five fields, and the MCP tool interpolates the missing key into its success string.A second consequence of the same ordering: the response is emitted before
_execute_manual_trigger()even attempts the distributed lock. If the lock is denied because the schedule is already running, the trigger silently no-ops while the caller still receives"status": "triggered". A suppressed trigger and a successful one are byte-identical to the caller — the only trace is a scheduler-side WARNING.Reproduction Steps
trigger_agent_schedule, orPOST /api/agents/{name}/schedules/{schedule_id}/trigger.execution_idis absent (REST) /undefined(MCP), while the execution is in fact running."status": "triggered"; only one execution is created.Suggested Fix
Acquire the lock and create the execution record synchronously in
_trigger_handler, then hand the pre-created execution to the background task. This returns a real id and lets a suppressed trigger be reported honestly:Then propagate
execution_idthroughsrc/backend/routers/schedules.py:483so the MCP tool's existingresult.execution_idread resolves. The MCP side needs no change once the field is present end to end.If the 409 path is considered a behaviour change, the minimal variant is to return
execution_idonly and leave locking as-is — but the honest-suppression half is where most of the operator value sits.Environment
1d3863d54d743fdb(2026-07-27) by code inspection — the handler, the backend return dict and the MCP read are unchangedRelated
src/scheduler/main.py—_trigger_handler(),_execute_manual_trigger()src/backend/routers/schedules.py— manual trigger delegationsrc/mcp-server/src/tools/schedules.ts—trigger_agent_schedule