From f9825b14ea7369a0beb7258773c83ead7793e91d Mon Sep 17 00:00:00 2001 From: Diego Imbert Date: Mon, 7 Sep 2026 20:38:31 +0200 Subject: [PATCH] fix(schedules): load the runnable by its own kind, and let a newer config win MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kind was adopted after the fetch, so a flow-backed schedule was looked up through ScriptService: a 404 into the swallowed catch, and a form with no runnable — no arguments schema and no runnable actions. The hosted view cannot say which kind it is either, since it only knows the schedule's path. And applying a config awaits that fetch halfway through its assignments, with nothing to stop an older application finishing over a newer one — two chat writes landing close together could leave the form half of each. Each application now supersedes the last, and a superseded one stops rather than writing on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4 --- .../schedules/ScheduleEditorInner.svelte | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index 6f4b3f828e..5f35492e79 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -472,15 +472,16 @@ } } - async function loadScript(p: string | undefined): Promise { + async function loadScript(p: string | undefined, generation?: number): Promise { if (p) { runnable = undefined try { - if (is_flow) { - runnable = await FlowService.getFlowByPath({ workspace: wsId!, path: p }) - } else { - runnable = await ScriptService.getScriptByPath({ workspace: wsId!, path: p }) - } + const loaded = is_flow + ? await FlowService.getFlowByPath({ workspace: wsId!, path: p }) + : await ScriptService.getScriptByPath({ workspace: wsId!, path: p }) + // Superseded while the request was in flight: this is the runnable of a + // config the form has already moved off. + if (generation === undefined || generation === applyGeneration) runnable = loaded } catch (err) {} } else { runnable = undefined @@ -603,7 +604,14 @@ } } + // Every application of a config supersedes the one before it — a chat write + // arriving through the draft sync, a load, a draft overlay. `loadScript` awaits + // halfway through the assignments, so without this an older apply finishes last + // and writes its half of the form over the newer one. + let applyGeneration = 0 + async function loadScheduleCfg(cfg: Record): Promise { + const generation = ++applyGeneration loading = true cronVersion = cfg.cron_version ?? 'v2' @@ -619,9 +627,13 @@ labels = cfg.labels ?? undefined description = cfg.description ?? '' script_path = cfg.script_path ?? '' - await loadScript(script_path) - + // Before the fetch, which reads it: a flow-backed schedule looked up through + // ScriptService 404s into the swallowed catch below, leaving the form with no + // runnable — no arguments schema, no runnable actions. itemKind = cfg.is_flow ? 'flow' : 'script' + await loadScript(script_path, generation) + if (generation !== applyGeneration) return + no_flow_overlap = cfg.no_flow_overlap ?? false wsErrorHandlerMuted = cfg.ws_error_handler_muted ?? false retry = cfg.retry