diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte
index 3c870832ee..0f833084c7 100644
--- a/frontend/src/lib/components/graph/FlowGraphV2.svelte
+++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte
@@ -52,7 +52,6 @@
import TriggersNode from './renderers/nodes/TriggersNode.svelte'
import { Alert, Drawer } from '../common'
import Button from '../common/button/Button.svelte'
- import FlowYamlEditor from '../flows/header/FlowYamlEditor.svelte'
import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte'
import type { TriggerContext } from '../triggers'
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
@@ -1296,7 +1295,10 @@
{#if insertable}
-
+
+ {#await import('../flows/header/FlowYamlEditor.svelte') then FlowYamlEditor}
+
+ {/await}
{/if}
{#if canUseDiffDrawer}
diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte
index f86ec9d5bf..1d9edc683b 100644
--- a/frontend/src/lib/components/home/ItemsList.svelte
+++ b/frontend/src/lib/components/home/ItemsList.svelte
@@ -41,7 +41,6 @@
import NoItemFound from './NoItemFound.svelte'
import WorkspaceEmptyState from './WorkspaceEmptyState.svelte'
import HubProjectPickerModal from './HubProjectPickerModal.svelte'
- import ImportProjectModal from './ImportProjectModal.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import ListFilters from './ListFilters.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -1009,6 +1008,11 @@
// the create menu's Import section open the same dialog, and mounting one per entry point
// would put two of them on the page at once while the workspace is still empty.
let hubPick = $state(undefined)
+ // Sticky, so the import modal mounts on the first pick and then stays for its close animation.
+ let hubImportRequested = $state(false)
+ $effect(() => {
+ if (hubPick) hubImportRequested = true
+ })
let hubPickerOpen = $state(false)
/**
@@ -2077,7 +2081,13 @@
hubPick = project
}}
/>
- (hubPick = undefined)} {onImported} />
+
+{#if hubImportRequested}
+ {#await import('./ImportProjectModal.svelte') then ImportProjectModal}
+ (hubPick = undefined)} {onImported} />
+ {/await}
+{/if}
diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
index 2d88d1deb7..387eea15d0 100644
--- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
+++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte
@@ -247,8 +247,16 @@
function onWindowKeydownCapture(e: KeyboardEvent) {
if (e.key !== 'Escape' || !chatHost.loading) return
const active = document.activeElement
+ // Focus parked on the body answers for the panel on screen only: the flow chat keeps
+ // a panel per conversation mounted, several of which can be loading, and a hidden
+ // one's listener would otherwise swallow the press (immediate form, below) and stop
+ // its own turn instead. `inert` marks the panels behind, and is matched as an
+ // attribute so the browsers without `checkVisibility` read it too.
+ const hidden =
+ panelEl?.closest('[inert]') != null ||
+ panelEl?.checkVisibility?.({ visibilityProperty: true }) === false
const focusOnChat =
- !active || active === document.body || (panelEl?.contains(active) ?? false)
+ !hidden && (!active || active === document.body || panelEl?.contains(active) === true)
// An Escape while a run form is open must not discard what the user typed, so the action
// row alone stops the turn — wherever it is mounted, since the preview panel holds the
// form outside `panelEl`. Matched by call: two chats can be loading at once, and one's
@@ -487,22 +495,30 @@
const flatFiles = Array.from(dt.files ?? [])
const topLevelImages = flatFiles.filter(isImageFile)
const imageWork: Promise[] = []
+ // The composer this drop landed on, held for the whole routing: a panel destroyed
+ // while a file is being read clears the binding, and the file would then reach no
+ // composer at all.
+ const dropped = aiChatInput
if (topLevelImages.length > 0) {
- imageWork.push(aiChatInput?.addImages(topLevelImages) ?? Promise.resolve())
+ imageWork.push(dropped?.addImages(topLevelImages) ?? Promise.resolve())
}
// Text-file routing must await handle/entry resolution before it can call
// addTextFiles — hold sending across that window (taken BEFORE the first
// await) or a send mid-resolution would land the drop on the next message.
- const releaseSendHold = aiChatInput?.holdSendForIngestion()
+ const releaseSendHold = dropped?.holdSendForIngestion()
try {
- await routeDroppedTextAndFolders(dt, flatFiles)
+ await routeDroppedTextAndFolders(dt, flatFiles, dropped)
} finally {
releaseSendHold?.()
}
await Promise.all(imageWork)
}
- async function routeDroppedTextAndFolders(dt: DataTransfer, flatFiles: File[]) {
+ async function routeDroppedTextAndFolders(
+ dt: DataTransfer,
+ flatFiles: File[],
+ input: typeof aiChatInput
+ ) {
if (canUseFsAccess) {
// getAsFileSystemHandle calls are kicked off synchronously inside this call.
const handles = await handlesFromDataTransfer(dt)
@@ -514,7 +530,10 @@
? flatFiles
: await Promise.all(handles.filter(isFileHandle).map((h) => h.getFile()))
// Loose files attach to the message, like images.
- await attachNonImageFiles(looseFiles.filter((f) => !isImageFile(f)))
+ await attachNonImageFiles(
+ looseFiles.filter((f) => !isImageFile(f)),
+ input
+ )
// Folders link as a live handle.
const dirs = handles.filter(isDirectoryHandle)
if (dirs.length > 0 && !canLinkFolders) {
@@ -551,7 +570,7 @@
if (canLinkFolders) await handleAddFiles(folderEntries)
else sendUserToast('Folders cannot be attached in this chat — drop individual files.', true)
}
- await attachNonImageFiles(topLevelText)
+ await attachNonImageFiles(topLevelText, input)
}
}
@@ -563,8 +582,10 @@
input.value = '' // allow re-selecting the same file
}
- async function attachNonImageFiles(files: File[]) {
- await aiChatInput?.addNonImageFiles(files)
+ /** `input` is the composer the files are for, captured before any await: read off the
+ * binding afterwards it would be null once the panel has been destroyed. */
+ async function attachNonImageFiles(files: File[], input: typeof aiChatInput) {
+ await input?.addNonImageFiles(files)
}
async function attachPickedFiles(picked: File[]) {
@@ -572,7 +593,7 @@
const others = picked.filter((f) => !isImageFile(f))
// Reserved before the other work is awaited — see onPanelDrop.
const imageWork = imageFiles.length > 0 ? aiChatInput?.addImages(imageFiles) : undefined
- await attachNonImageFiles(others)
+ await attachNonImageFiles(others, aiChatInput)
await imageWork
}
diff --git a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
index 9744085567..6cb398ee44 100644
--- a/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
+++ b/frontend/src/lib/components/copilot/chat/flow/FlowAIChat.svelte
@@ -2,7 +2,7 @@
import FlowModuleSchemaMap from '$lib/components/flows/map/FlowModuleSchemaMap.svelte'
import { getContext, tick, untrack } from 'svelte'
import type { ExtendedOpenFlow, FlowEditorContext } from '$lib/components/flows/types'
- import type { InputTransform } from '$lib/gen'
+ import { ApiError, type InputTransform } from '$lib/gen'
import type { FlowAIChatHelpers } from './core'
import { chatMemoryId } from '../global/core'
import { createInlineScriptSession } from './inlineScriptsUtils'
@@ -174,7 +174,19 @@
previewArgs.val = args
}
// Call the UI test function which opens preview panel
- return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value))
+ try {
+ return await onTestFlow?.(conversationId ?? chatMemoryId(flowStore.val.value))
+ } catch (e) {
+ // The flow chat is still answering in that conversation, and the server takes one
+ // turn at a time. Said plainly here: the raw refusal names a job the model has
+ // no use for.
+ if (e instanceof ApiError && e.status === 409) {
+ throw new Error(
+ 'That chat is still answering an earlier message; wait for it to finish before testing again.'
+ )
+ }
+ throw e
+ }
},
getLintErrors: async (moduleId: string): Promise => {
diff --git a/frontend/src/lib/components/flows/conversations/FlowChat.svelte b/frontend/src/lib/components/flows/conversations/FlowChat.svelte
index 01e2f60850..04671e061b 100644
--- a/frontend/src/lib/components/flows/conversations/FlowChat.svelte
+++ b/frontend/src/lib/components/flows/conversations/FlowChat.svelte
@@ -1,13 +1,22 @@
diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte
index cad5688d89..f502be90cb 100644
--- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte
+++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte
@@ -3,14 +3,14 @@
import { Loader2, MessageCircle, Settings2 } from 'lucide-svelte'
import AIChatDisplay from '$lib/components/copilot/chat/AIChatDisplay.svelte'
import { setChatViewHost } from '$lib/components/copilot/chat/chatViewHost'
- import { FlowChatViewHost } from './flowChatViewHost.svelte'
+ import type { FlowChatViewHost } from './flowChatViewHost.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import SchemaForm from '$lib/components/SchemaForm.svelte'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { emptyString, type DynamicInput } from '$lib/utils'
- import { onDestroy, tick, untrack } from 'svelte'
+ import { tick, untrack } from 'svelte'
import type { Chat } from 'windmill-chat'
- import { chatFlowKey } from './flowChatProps'
+ import { saveFlowChatInputs } from './flowChatProps'
import type { FlowModule } from '$lib/gen'
import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte'
import {
@@ -30,6 +30,14 @@
interface Props {
chat: Chat
+ /** The conversation's host, which outlives this panel. One panel follows one chat for
+ * its whole life, so a later value of either prop never reaches it. */
+ chatHost: FlowChatViewHost
+ /** The flow inputs the reader chose, shared by every conversation in the flow: FlowChat
+ * holds them, since each conversation has a panel of its own mounted. */
+ inputValues: Record
+ /** Whether a conversation is a test chat, once the list has said. */
+ isTestOf?: (conversationId: string) => boolean | undefined
deploymentInProgress?: boolean
additionalInputsSchema?: Record
/** The flow's modules, read for the AI agent inputs the composer drives: the provider wiring
@@ -50,6 +58,9 @@
let {
chat,
+ chatHost: chatHostProp,
+ inputValues = $bindable(),
+ isTestOf = undefined,
deploymentInProgress = false,
additionalInputsSchema,
flowModules,
@@ -97,12 +108,7 @@
const modelGap = $derived(agentModelGap(modelWiring, subject))
const showModelButton = $derived(showsModelButton(modelWiring))
- // LocalStorage helpers
- const STORAGE_KEY_PREFIX = 'windmill_flow_chat_inputs_'
-
let showInputsModal = $state(false)
- // Conversation settings, persisted per flow: what the reader chose, and nothing else.
- let inputValues = $state>(loadInputsFromStorage() ?? {})
let modalDraft = $state>({})
/** What the flow's own form would open on. */
@@ -128,31 +134,9 @@
// value, an author's default — is made safe before it reaches the provider.
const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs))
- function getStorageKey(): string {
- return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}`
- }
-
- function loadInputsFromStorage(): Record | null {
- try {
- const stored = localStorage.getItem(getStorageKey())
- return stored ? JSON.parse(stored) : null
- } catch (e) {
- console.error('Failed to load inputs from localStorage:', e)
- return null
- }
- }
-
- function saveInputsToStorage(values: Record) {
- try {
- localStorage.setItem(getStorageKey(), JSON.stringify(values))
- } catch (e) {
- console.error('Failed to save inputs to localStorage:', e)
- }
- }
-
function setInputValue(name: string, value: any) {
inputValues = { ...inputValues, [name]: value }
- saveInputsToStorage(inputValues)
+ saveFlowChatInputs({ path, identity }, inputValues)
}
function handleModalConfirm() {
@@ -167,47 +151,48 @@
)
)
inputValues = kept
- saveInputsToStorage(inputValues)
+ saveFlowChatInputs({ path, identity }, inputValues)
showInputsModal = false
}
function openInputsModal() {
- modalDraft = { ...effectiveInputs, ...(loadInputsFromStorage() ?? inputValues) }
+ modalDraft = { ...effectiveInputs, ...inputValues }
showInputsModal = true
}
- // The host follows the chat it was built on for the life of this component: FlowChat
- // remounts the interface under `{#key chat}`, so a later value of the prop never reaches it.
- const chatHost = new FlowChatViewHost(
- untrack(() => chat),
- {
- additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
- attachmentsTarget: () => attachmentsTarget,
- attachmentsUnavailable: () =>
- workspaceStorage.current
- ? undefined
- : 'This workspace has no object storage, so files cannot be attached.',
- workspace: () => workspace,
- sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
- // The model controls only: a retry changes model when the reader did, but replays
- // the run's own attachments rather than whatever the composer holds now.
- inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
- }
- )
+ // The host belongs to the conversation, not to this panel: the pool keeps it alive so a
+ // message queued here still goes out once the reader has moved on. What it reads is this
+ // panel's, set on mount; one panel shows one conversation for its whole life.
+ const chatHost = untrack(() => chatHostProp)
+ chatHost.setOptions({
+ additionalInputs: () => (additionalInputsSchema ? { ...runInputs } : undefined),
+ attachmentsTarget: () => attachmentsTarget,
+ attachmentsUnavailable: () =>
+ workspaceStorage.current
+ ? undefined
+ : 'This workspace has no object storage, so files cannot be attached.',
+ workspace: () => workspace,
+ sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason,
+ // The model controls only: a retry changes model when the reader did, but replays
+ // the run's own attachments rather than whatever the composer holds now.
+ inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined)
+ })
setChatViewHost(chatHost)
+ // Read off the host's own conversation: a message queued here goes out after the reader
+ // has moved on, when the shown conversation may be of the other kind.
+ const isTest = $derived.by(() => {
+ const id = chatHost.state.conversationId
+ return id === undefined ? undefined : isTestOf?.(id)
+ })
// A chat of the other kind can be read from here but not added to: the server refuses a
// preview run into a deployed conversation and the reverse, so the composer says why first.
const wrongKindReason = $derived.by(() => {
- const { conversationId, conversations } = chatHost.state
- const open = conversations.find((c) => c.id === conversationId)
- if (open?.isTest === undefined || open.isTest === (conversationKind === 'test'))
- return undefined
- return open.isTest
+ if (isTest === undefined || isTest === (conversationKind === 'test')) return undefined
+ return isTest
? 'This chat was run from the flow editor. Start a new chat to continue here.'
: 'This chat belongs to the deployed flow. Start a new chat to test.'
})
- onDestroy(() => chatHost.dispose())
// What the Configure-inputs modal asks for: every flow input the composer does not
// edit itself.
diff --git a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte
index d2ea5c5061..8c6a102fef 100644
--- a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte
+++ b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte
@@ -5,11 +5,13 @@
Plus,
Trash2,
Pen,
+ PencilLine,
Filter,
PanelLeftClose,
PanelLeftOpen
} from 'lucide-svelte'
- import CountBadge from '$lib/components/common/badge/CountBadge.svelte'
+ import UnreadCountBadge from '$lib/components/common/badge/UnreadCountBadge.svelte'
+ import SessionStatusDot from '$lib/components/sessions/SessionStatusDot.svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -21,11 +23,16 @@
import { twMerge } from 'tailwind-merge'
import { fade } from 'svelte/transition'
import { tick, untrack } from 'svelte'
- import type { Chat, ChatState, Conversation, ConversationKind } from 'windmill-chat'
+ import type { Chat, Conversation, ConversationKind } from 'windmill-chat'
+ import type { FlowChatPool, FlowChatPoolState } from './flowChatPool'
+ import type { ComposerAttachment, FlowChatViewHost } from './flowChatViewHost.svelte'
interface Props {
- chat: Chat
- chatState: ChatState
+ /** Lists, renames and deletes the flow's conversations; runs no turn itself. */
+ listChat: Chat
+ /** The conversations' own chats: which one is shown, and what each is doing. */
+ pool: FlowChatPool
+ poolState: FlowChatPoolState
/**
* Which conversations the list holds at first. The editor shows its own test chats,
* since testing is what happens there; a deployed flow shows the chats its users
@@ -40,7 +47,13 @@
canFilterKind?: boolean
}
- let { chat, chatState, defaultKind = 'deployed', canFilterKind = false }: Props = $props()
+ let {
+ listChat,
+ pool,
+ poolState,
+ defaultKind = 'deployed',
+ canFilterKind = false
+ }: Props = $props()
let expanded = $state(false)
let list = $state(undefined)
@@ -58,13 +71,13 @@
let renameDraft = $state('')
let renameInput = $state(undefined)
- const turnInFlight = $derived(
- chatState.status === 'submitted' || chatState.status === 'streaming'
+ const totalUnread = $derived(
+ Object.values(poolState.unread).reduce((total, count) => total + count, 0)
)
$effect(() => {
const l = list
- const c = chat
+ const c = listChat
if (!l) return
untrack(() => {
// Every load goes through here, the first one and infinite scroll included. A
@@ -72,13 +85,18 @@
// selected kind brings its own, whichever of the two lands last.
l.setLoader(async (page, perPage) => {
const requested = kind
+ // Only a listing read now says which turns run: the rows the chat holds keep
+ // what the last one said, and a rename or delete publishes those again.
+ const since = pool.listingStarted()
const rows = await c.loadConversations({ page, perPage, kind: requested })
+ pool.setListed(rows, since)
return requested === kind ? rows : items
})
l.setDeleteItemFn(async (id: string) => {
deletingId = id
try {
await c.deleteConversation(id)
+ pool.forget(id)
sendUserToast('Conversation deleted successfully')
} catch (error) {
console.error('Failed to delete conversation:', error)
@@ -103,10 +121,10 @@
await list?.loadData('forceRefresh')
}
- const draftShown = $derived(draft && !items.some((c) => c.id === chatState.conversationId))
+ const draftShown = $derived(draft && poolState.selectedId === undefined)
function newChat() {
- chat.newConversation()
+ pool.newChat()
draft = true
}
@@ -118,17 +136,16 @@
/**
* Narrow the list to one kind of chat and reload it. The open conversation goes with it
- * when it is not of the new kind: the composer sends into whatever is selected, and a
+ * when it is not of the new kind: the composer sends into whatever is shown, and a
* conversation keeps the kind it was created with, so a turn sent into one the list no
- * longer shows would be stored where nothing here lists it.
+ * longer shows would be stored where nothing here lists it. A turn running in it goes on.
*/
async function setKind(next: ConversationKind) {
- // A turn writes into the open conversation, which a kind that excludes it would close.
- if (next === kind || turnInFlight) return
+ if (next === kind) return
kind = next
- const open = items.find((c) => c.id === chatState.conversationId)
+ const open = items.find((c) => c.id === poolState.selectedId)
const stillListed = open === undefined || next === 'all' || (next === 'test') === open.isTest
- if (!stillListed) chat.newConversation()
+ if (!stillListed) pool.newChat()
await list?.loadData('forceRefresh')
}
@@ -149,11 +166,11 @@
const current = items.find((c) => c.id === id)
if (!current || title === '' || title === current.title) return
try {
- await chat.renameConversation(id, title)
+ await listChat.renameConversation(id, title)
// The list holds its own rows, loaded through the loader: patched rather than
// reloaded, so the row keeps its place without a round trip. The title is read
// back from the chat, which holds it as the server stored it (a long one is cut).
- const stored = chat.getState().conversations.find((c) => c.id === id)?.title ?? title
+ const stored = listChat.getState().conversations.find((c) => c.id === id)?.title ?? title
items = items.map((c) => (c.id === id ? { ...c, title: stored } : c))
} catch (error) {
console.error('Failed to rename conversation:', error)
@@ -177,8 +194,34 @@
function getConversationTitle(conversation: Conversation): string {
return conversation.title || `Conversation ${conversation.createdAt.slice(0, 10)}`
}
+
+ /** The session sidebar's dot vocabulary, which knows no `queued`: that is its own mark. */
+ function dotStatus(conversationId: string): 'streaming' | 'error' | 'idle' {
+ const activity = poolState.activity[conversationId]
+ return activity === 'running' ? 'streaming' : activity === 'error' ? 'error' : 'idle'
+ }
+{#snippet statusDot(conversation: Conversation)}
+
+
+ {#snippet resting()}
+
+ {/snippet}
+
+{/snippet}
+
{/if}
From 97fa55b719a874067d1a81a1d680a42d1f1a4c98 Mon Sep 17 00:00:00 2001
From: hugocasa
Date: Tue, 29 Sep 2026 12:40:25 +0200
Subject: [PATCH 21/33] feat: detect and alert when a schedule skips
occurrences (#10917)
* docs: plan for detecting skipped schedule occurrences
Design plan only, no implementation. Records the scheduler's re-anchoring
behaviour, the measurements behind it, and the three-piece design that came
out of reviewing the alternatives.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* docs: state the user-facing outcome in the schedule plan
The plan described the mechanism but never what a user would see, which made it
hard to judge what the work is worth.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* docs: state which cause the schedule plan catches, and correct its scope
Records which of the two causes each piece covers, and corrects the overrun
scope: a script schedule carrying retry or dynamic_skip is pushed as a
SingleStepFlow, so it re-arms at step 0 entry and its occurrences overlap like
a flow's. Resolves the no_flow_overlap question, splits the read-time work into
bounded detection and editor-only counting behind measured croner costs, and
fixes the delivery order.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* feat: count the occurrences a schedule skipped
A schedule that overruns its interval, or waits for a worker, silently loses
the occurrences in between: the scheduler keeps one queued occurrence and
re-anchors on the clock, so nothing records that a run was due and never
happened.
Recovers the sequence from rows that already exist rather than writing per
occurrence. `push_scheduled_job` anchors on `now_from_db` inside the
transaction that inserts the job, and `v2_job.created_at` defaults to that
same transaction timestamp, so `scheduled_for = find_next(created_at)` holds
exactly and the whole occurrence history is derivable.
The schedules list reports how many of the recent runs were followed by a lost
occurrence, and a new occurrences endpoint carries the per-run wait and
duration behind it. Detection is one `find_next` per gap, which stays bounded
on a full page; counting walks the gap and runs only for a single schedule.
The one write is `occurrence_baseline_at`, advanced at create, edit,
re-enable and re-arm. Gaps older than it span a pause, a cron change, a
re-enable or a reconciler re-arm, none of which mean runs were lost.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* feat: show the wait and run time behind a schedule's skipped occurrences
The list badge says a schedule is losing runs; this says which of the two
causes did it. A large wait means not enough workers, a long run means the job
outgrew its interval, and the pair is what tells them apart. Sits under the
existing upcoming-events panel, so due and overdue read together.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* feat: flag a schedule that is running late right now
Reconstruction is retrospective: a gap only appears once the next occurrence
has a row, which needs the current one to finish. A schedule wedged mid-run
shows nothing until it moves, which is the case an operator most wants to see.
An occurrence still in flight past the time its own successor was due will
cost that successor, so `now > find_next(scheduled_for)` is the signal, needing
no threshold and self-calibrating across a daily and a per-minute schedule. It
applies only where occurrences serialize; an overlapping schedule starts its
successor on time and would flag constantly while healthy.
The queue is read in one aggregating pass keyed on (trigger, runnable_path)
rather than a subquery per schedule, and an overlapping schedule holds more
than one root row, hence the aggregate.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* feat: run the schedule overrun alert from the monitor pass
Wires `schedule_overrun_alerts` in next to `jobs_waiting_alerts`, every 30
iterations (~5 min). Its Enterprise implementation lives in
windmill-labs/windmill-ee-private#772; only the wiring and the OSS stub are
here.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* chore: refresh the sqlx offline cache
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* feat: record and alert when a schedule skips occurrences
push_scheduled_job compares each chained occurrence with the slot after
the previous one. A gap is written to schedule.skipped_occurrences off the
push transaction, alerts once when a clean schedule starts skipping, and
recovers on the next clean chain. The schedules list shows a badge.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* feat: alert only on a streak of skipping runs, keep a recent skip visible
The skip state now describes the current streak and is written in the push
transaction, so it commits or rolls back with the push. The alert fires
once when 3 runs in a row skipped, and the list keeps a muted badge for 7
days after the latest skip. Editing or toggling a schedule resets it.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* fix: name the missed-occurrence state after what it counts, alert only once committed
Renames the columns to late_run_streak, missed_occurrences and
last_missed_at, keeps the missed count after a streak ends so the muted
badge can show it, and rewords both badges. The alert task now reads the
streak FOR SHARE, which waits for the push transaction, so a push that
rolls back and retries alerts once. A failed slot count leaves the streak
untouched, and a schedule deleted mid-push no longer fails it. Adds an
integration test for the streak and its reset.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* fix: recover the late run alert, store the missed slot, name it missed throughout
The alert now recovers (and so acknowledges itself) when a streak that
alerted ends on a run on time, under the schedule:{path} resource used by
the other trigger alerts. last_missed_at records the last missed cron slot
rather than when the late run chained, and the counting helpers say
missed, since skipped already names occurrences queued and not run.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* fix: scope the late run alert to its workspace, acknowledge it on edit, toggle and delete
Recovery acknowledges alerts by resource alone, so the resource now
carries the workspace. Editing, toggling or deleting a schedule clears
its streak and a disabled or deleted one never chains a run on time, so
those handlers acknowledge its open alert after committing. Past the
1000-slot cap, last_missed_at falls back to the detection time.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
* refactor: raise the late run alert like the other critical alerts
Drops the recovery, the workspace-scoped resource and the acknowledgement
on edit, toggle and delete: the alert now fires once per streak with no
resource and is acknowledged from the alerts feed, as the trigger and job
failure alerts are. The FOR SHARE read stays, so a push that rolls back
across the flow path's retries still alerts once. Notes in openapi that
past 1000 misses in one late run the count is a floor and the time
approximate.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01LJ9wpjWp2YgLUSqt1Ai5d6
---------
Co-authored-by: Claude Opus 5
---
...d76a4e5702c0324a27ef3a5740ef5d98d8946.json | 31 ----
...bed4c3d8b025947becd1c614757e7e29a5d8.json} | 12 +-
...bc3553985b757b48962e9976b5da5b812bd05.json | 49 ++++++
...7128fa332a52e3b451aac307234d02a51de8f.json | 15 ++
...b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json | 23 +++
...406bcbe888ddece7dec3330778591573031fb.json | 31 ++++
...0a20126a207397cb9ed2dc18cd0b2baa57fa.json} | 12 +-
...c63922f0eebcba2b2cefc4290c4baee5c071.json} | 12 +-
...51151_schedule_missed_occurrences.down.sql | 3 +
...5151151_schedule_missed_occurrences.up.sql | 7 +
backend/windmill-api-schedule/src/lib.rs | 24 ++-
backend/windmill-api/openapi.yaml | 10 ++
backend/windmill-queue/src/schedule.rs | 141 +++++++++++++++++-
backend/windmill-queue/tests/schedule_push.rs | 58 +++++++
backend/windmill-types/src/schedule.rs | 4 +
.../components/schedules/SchedulesList.svelte | 34 ++++-
16 files changed, 416 insertions(+), 50 deletions(-)
delete mode 100644 backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json
rename backend/.sqlx/{query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json => query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json} (95%)
create mode 100644 backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json
create mode 100644 backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json
create mode 100644 backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json
create mode 100644 backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json
rename backend/.sqlx/{query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json => query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json} (79%)
rename backend/.sqlx/{query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json => query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json} (73%)
create mode 100644 backend/migrations/20260925151151_schedule_missed_occurrences.down.sql
create mode 100644 backend/migrations/20260925151151_schedule_missed_occurrences.up.sql
diff --git a/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json b/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json
deleted file mode 100644
index 0dbcf04c6e..0000000000
--- a/backend/.sqlx/query-0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946.json
+++ /dev/null
@@ -1,31 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT\n schedule.path, t.jobs FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "path",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "jobs",
- "type_info": "JsonArray"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Int8",
- "Int8",
- "Text"
- ]
- },
- "nullable": [
- false,
- null
- ]
- },
- "hash": "0f141ca6a58901dee1ddcf25694d76a4e5702c0324a27ef3a5740ef5d98d8946"
-}
diff --git a/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json b/backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json
similarity index 95%
rename from backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json
rename to backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json
index 6d948ee8ae..6c4d73699b 100644
--- a/backend/.sqlx/query-2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6.json
+++ b/backend/.sqlx/query-1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
+ "query": "\n INSERT INTO schedule (\n workspace_id, path, schedule, timezone, edited_by, script_path,\n is_flow, args, enabled, email, permissioned_as,\n on_failure, on_failure_times, on_failure_exact, on_failure_extra_args,\n on_recovery, on_recovery_times, on_recovery_extra_args,\n on_success, on_success_extra_args,\n ws_error_handler_muted, retry, summary, no_flow_overlap,\n tag, paused_until, cron_version, description, dynamic_skip, labels\n ) VALUES (\n $1, $2, $3, $4, $5, $6,\n $7, $8, $9, $10, $11,\n $12, $13, $14, $15,\n $16, $17, $18,\n $19, $20,\n $21, $22, $23, $24,\n $25, $26, $27, $28, $29, $30\n )\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ",
"describe": {
"columns": [
{
@@ -167,6 +167,11 @@
"ordinal": 32,
"name": "labels",
"type_info": "TextArray"
+ },
+ {
+ "ordinal": 33,
+ "name": "late_run_streak",
+ "type_info": "Int4"
}
],
"parameters": {
@@ -236,8 +241,9 @@
true,
true,
true,
- true
+ true,
+ false
]
},
- "hash": "2effe885aafaa0c5fc1f969b8c6ffbd38c53fb586251ba780b75a007e5a47ed6"
+ "hash": "1c51aab269efb558640ea502e63ebed4c3d8b025947becd1c614757e7e29a5d8"
}
diff --git a/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json b/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json
new file mode 100644
index 0000000000..e1fe716eab
--- /dev/null
+++ b/backend/.sqlx/query-39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05.json
@@ -0,0 +1,49 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT\n schedule.path, t.jobs, schedule.late_run_streak, schedule.missed_occurrences, schedule.last_missed_at FROM schedule,\n LATERAL(SELECT ARRAY(\n SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)\n FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE trigger_kind = 'schedule'\n AND trigger = schedule.path\n AND c.workspace_id = $1\n AND j.workspace_id = $1\n AND parent_job IS NULL AND runnable_path = schedule.script_path\n AND status <> 'skipped'\n ORDER BY completed_at DESC\n LIMIT 20\n ) AS jobs) t\n WHERE workspace_id = $1 AND NOT starts_with(schedule.path, $4)\n ORDER BY edited_at DESC\n LIMIT $2 OFFSET $3",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "jobs",
+ "type_info": "JsonArray"
+ },
+ {
+ "ordinal": 2,
+ "name": "late_run_streak",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 3,
+ "name": "missed_occurrences",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 4,
+ "name": "last_missed_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Int8",
+ "Int8",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ null,
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "39793465641124401a732744777bc3553985b757b48962e9976b5da5b812bd05"
+}
diff --git a/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json b/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json
new file mode 100644
index 0000000000..814659877f
--- /dev/null
+++ b/backend/.sqlx/query-466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE schedule SET late_run_streak = 0\n WHERE workspace_id = $1 AND path = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "466b2c68074202146da10d4b0757128fa332a52e3b451aac307234d02a51de8f"
+}
diff --git a/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json b/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json
new file mode 100644
index 0000000000..552f2f57de
--- /dev/null
+++ b/backend/.sqlx/query-4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT late_run_streak FROM schedule WHERE workspace_id = $1 AND path = $2 FOR SHARE",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "late_run_streak",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "4a6fb702794ce250a8217034ae9b0b719f61fe0cb8a7a2c76be4b7422a7138a0"
+}
diff --git a/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json b/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json
new file mode 100644
index 0000000000..72699120db
--- /dev/null
+++ b/backend/.sqlx/query-640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb.json
@@ -0,0 +1,31 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE schedule SET late_run_streak = late_run_streak + 1,\n missed_occurrences = CASE WHEN late_run_streak = 0 THEN $3\n ELSE missed_occurrences + $3 END,\n last_missed_at = $4\n WHERE workspace_id = $1 AND path = $2\n RETURNING late_run_streak, missed_occurrences",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "late_run_streak",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 1,
+ "name": "missed_occurrences",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Int4",
+ "Timestamptz"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "640482b37fe2aa63a5082250170406bcbe888ddece7dec3330778591573031fb"
+}
diff --git a/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json b/backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json
similarity index 79%
rename from backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json
rename to backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json
index 5c0e45b0d4..b5afa669f5 100644
--- a/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json
+++ b/backend/.sqlx/query-793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
+ "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels),\n late_run_streak = 0,\n missed_occurrences = 0,\n last_missed_at = NULL\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ",
"describe": {
"columns": [
{
@@ -167,6 +167,11 @@
"ordinal": 32,
"name": "labels",
"type_info": "TextArray"
+ },
+ {
+ "ordinal": 33,
+ "name": "late_run_streak",
+ "type_info": "Int4"
}
],
"parameters": {
@@ -233,8 +238,9 @@
true,
true,
true,
- true
+ true,
+ false
]
},
- "hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56"
+ "hash": "793810c58d9e872da52d478db61c0a20126a207397cb9ed2dc18cd0b2baa57fa"
}
diff --git a/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json b/backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json
similarity index 73%
rename from backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json
rename to backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json
index a205bcbbe6..0df21a408a 100644
--- a/backend/.sqlx/query-642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe.json
+++ b/backend/.sqlx/query-ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ",
+ "query": "\n UPDATE schedule SET\n enabled = $1,\n email = $2,\n late_run_streak = 0,\n missed_occurrences = 0,\n last_missed_at = NULL\n WHERE path = $3 AND workspace_id = $4\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels,\n late_run_streak\n ",
"describe": {
"columns": [
{
@@ -167,6 +167,11 @@
"ordinal": 32,
"name": "labels",
"type_info": "TextArray"
+ },
+ {
+ "ordinal": 33,
+ "name": "late_run_streak",
+ "type_info": "Int4"
}
],
"parameters": {
@@ -210,8 +215,9 @@
true,
true,
true,
- true
+ true,
+ false
]
},
- "hash": "642ca096c1c151fc01445593c8d2ed879a9a7be6ff044a9ce1b581ba725665fe"
+ "hash": "ca54e732d521416cb2692c8c4406c63922f0eebcba2b2cefc4290c4baee5c071"
}
diff --git a/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql b/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql
new file mode 100644
index 0000000000..b89d6340ae
--- /dev/null
+++ b/backend/migrations/20260925151151_schedule_missed_occurrences.down.sql
@@ -0,0 +1,3 @@
+ALTER TABLE schedule DROP COLUMN last_missed_at;
+ALTER TABLE schedule DROP COLUMN missed_occurrences;
+ALTER TABLE schedule DROP COLUMN late_run_streak;
diff --git a/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql b/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql
new file mode 100644
index 0000000000..469eed3a93
--- /dev/null
+++ b/backend/migrations/20260925151151_schedule_missed_occurrences.up.sql
@@ -0,0 +1,7 @@
+-- A late run finished (or, for flows, started) after the next occurrence was due, so that
+-- occurrence was missed. late_run_streak counts late runs in a row and is reset by a run on
+-- time; missed_occurrences is what the latest streak missed and last_missed_at when, both
+-- kept after the streak ends so a recent one stays visible. An edit or toggle clears all.
+ALTER TABLE schedule ADD COLUMN late_run_streak INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE schedule ADD COLUMN missed_occurrences INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE schedule ADD COLUMN last_missed_at TIMESTAMPTZ;
diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs
index 78cd6eb378..7a523c89fa 100644
--- a/backend/windmill-api-schedule/src/lib.rs
+++ b/backend/windmill-api-schedule/src/lib.rs
@@ -409,7 +409,8 @@ async fn create_schedule(
paused_until,
cron_version,
dynamic_skip,
- labels
+ labels,
+ late_run_streak
"#,
w_id,
ns.path,
@@ -621,7 +622,10 @@ async fn edit_schedule(
edited_by = $25,
edited_at = now(),
permissioned_as = $26,
- labels = COALESCE($27, labels)
+ labels = COALESCE($27, labels),
+ late_run_streak = 0,
+ missed_occurrences = 0,
+ last_missed_at = NULL
WHERE path = $19 AND workspace_id = $20
RETURNING
workspace_id,
@@ -656,7 +660,8 @@ async fn edit_schedule(
paused_until,
cron_version,
dynamic_skip,
- labels
+ labels,
+ late_run_streak
"#,
es.schedule,
es.timezone,
@@ -1004,6 +1009,9 @@ async fn list_schedule(
pub struct ScheduleWJobs {
pub path: String,
pub jobs: Option>,
+ pub late_run_streak: i32,
+ pub missed_occurrences: i32,
+ pub last_missed_at: Option>,
}
async fn list_schedule_with_jobs(
@@ -1020,7 +1028,7 @@ async fn list_schedule_with_jobs(
// - use of the `ix_v2_job_root_by_path` index; hence the `parent_job IS NULL` clause.
// - both `workspace_id = $1` checks are required to hit both indexes.
"SELECT
- schedule.path, t.jobs FROM schedule,
+ schedule.path, t.jobs, schedule.late_run_streak, schedule.missed_occurrences, schedule.last_missed_at FROM schedule,
LATERAL(SELECT ARRAY(
SELECT json_build_object('id', id, 'success', status = 'success', 'duration_ms', duration_ms)
FROM v2_job_completed c JOIN v2_job j USING (id)
@@ -1157,7 +1165,10 @@ pub async fn set_enabled(
r#"
UPDATE schedule SET
enabled = $1,
- email = $2
+ email = $2,
+ late_run_streak = 0,
+ missed_occurrences = 0,
+ last_missed_at = NULL
WHERE path = $3 AND workspace_id = $4
RETURNING
workspace_id,
@@ -1192,7 +1203,8 @@ pub async fn set_enabled(
paused_until,
cron_version,
dynamic_skip,
- labels
+ labels,
+ late_run_streak
"#,
payload.enabled,
authed.email,
diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml
index 00fb3e07de..4a1ce62bad 100644
--- a/backend/windmill-api/openapi.yaml
+++ b/backend/windmill-api/openapi.yaml
@@ -32107,6 +32107,16 @@ components:
- id
- success
- duration_ms
+ late_run_streak:
+ type: integer
+ description: runs in a row, up to the latest, that started or finished after the next occurrence was due, so that occurrence was missed
+ missed_occurrences:
+ type: integer
+ description: occurrences missed by the latest streak of late runs, kept after it ends; a lower bound once a single late run misses 1000
+ last_missed_at:
+ type: string
+ format: date-time
+ description: latest missed occurrence, kept after the schedule runs on time again; past 1000 misses in one late run, when the miss was detected, at most one period later
ErrorHandler:
type: string
diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs
index 40c1292b74..97b175fa9e 100644
--- a/backend/windmill-queue/src/schedule.rs
+++ b/backend/windmill-queue/src/schedule.rs
@@ -36,7 +36,7 @@ use windmill_common::DB;
use windmill_common::{
error::{self, Result},
schedule::Schedule,
- utils::{now_from_db, ScheduleType, StripPath},
+ utils::{now_from_db, report_critical_error, ScheduleType, StripPath},
};
/// Helper to fetch metadata for a schedule's script or flow
@@ -203,6 +203,62 @@ pub async fn push_scheduled_job<'c>(
return Ok(tx);
}
+ // Only a chained push (`now_cutoff` is the previous occurrence) can miss one: create,
+ // edit, enable and re-arm start fresh, and a pause, even one already over, is deliberate.
+ let missed = now_cutoff
+ .filter(|_| schedule.paused_until.is_none())
+ .and_then(|prev| {
+ // A failed count leaves the streak as it was rather than reading as a run on time.
+ count_missed_occurrences(&sched, &tz, prev, next)
+ .inspect_err(|e| {
+ tracing::warn!(
+ "failed to count the occurrences schedule {} missed: {e}",
+ &schedule.path
+ )
+ })
+ .ok()
+ });
+ if let Some((missed, last_missed)) = missed {
+ // Past the cap the walk stops short of the last miss; the time it was detected is at
+ // most one period after it.
+ let last_missed = last_missed.map(|l| if missed == MAX_COUNTED_MISSES { now } else { l });
+ if let Some(last_missed) = last_missed {
+ let streak = sqlx::query!(
+ "UPDATE schedule SET late_run_streak = late_run_streak + 1,
+ missed_occurrences = CASE WHEN late_run_streak = 0 THEN $3
+ ELSE missed_occurrences + $3 END,
+ last_missed_at = $4
+ WHERE workspace_id = $1 AND path = $2
+ RETURNING late_run_streak, missed_occurrences",
+ &schedule.workspace_id,
+ &schedule.path,
+ missed as i32,
+ last_missed,
+ )
+ .fetch_optional(&mut *tx)
+ .warn_after_seconds_with_sql(1, "update_schedule_late_run_streak".to_string())
+ .await?;
+ if let Some(streak) = streak.filter(|s| s.late_run_streak == LATE_RUNS_BEFORE_ALERT) {
+ tokio::spawn(alert_late_run_streak(
+ db.clone(),
+ schedule.workspace_id.clone(),
+ schedule.path.clone(),
+ streak.missed_occurrences,
+ ));
+ }
+ } else if schedule.late_run_streak > 0 {
+ sqlx::query!(
+ "UPDATE schedule SET late_run_streak = 0
+ WHERE workspace_id = $1 AND path = $2",
+ &schedule.workspace_id,
+ &schedule.path,
+ )
+ .execute(&mut *tx)
+ .warn_after_seconds_with_sql(1, "reset_schedule_late_run_streak".to_string())
+ .await?;
+ }
+ }
+
let mut args: HashMap> = HashMap::new();
if let Some(args_v) = &schedule.args {
@@ -622,6 +678,68 @@ pub async fn push_scheduled_job<'c>(
Ok(tx) // TODO: Bubble up pushed UUID from here
}
+const MAX_COUNTED_MISSES: u32 = 1000;
+
+/// Due slots of the cron strictly between the previous occurrence and the next one, and
+/// the last of them. A chain on time costs one `find_next`: its first slot is `next` itself.
+fn count_missed_occurrences(
+ sched: &ScheduleType,
+ tz: &chrono_tz::Tz,
+ prev: DateTime,
+ next: DateTime,
+) -> Result<(u32, Option>)> {
+ let mut count = 0;
+ let mut last = None;
+ let mut slot = prev.with_timezone(tz);
+ while count < MAX_COUNTED_MISSES {
+ match sched.find_next(&slot) {
+ Ok(s) if s.with_timezone(&Utc) < next => {
+ count += 1;
+ last = Some(s.with_timezone(&Utc));
+ slot = s;
+ }
+ Ok(_) => break,
+ Err(e) => return Err(e),
+ }
+ }
+ Ok((count, last))
+}
+
+/// A single late run is a blip (a slow run, a worker restart, an edit mid-run); only a
+/// streak alerts, once, when it reaches this length. The schedules list shows every one.
+const LATE_RUNS_BEFORE_ALERT: i32 = 3;
+
+/// Spawned: it reaches the instance alert channels, which must not hold up the push.
+async fn alert_late_run_streak(db: DB, w_id: String, path: String, missed: i32) {
+ // The push transaction holds this row until it ends, so FOR SHARE waits for it: a push
+ // that rolled back leaves the streak short of the threshold, and only its committed
+ // retry alerts.
+ let committed = sqlx::query_scalar!(
+ "SELECT late_run_streak FROM schedule WHERE workspace_id = $1 AND path = $2 FOR SHARE",
+ &w_id,
+ &path,
+ )
+ .fetch_optional(&db)
+ .await;
+ match committed {
+ Ok(Some(streak)) if streak >= LATE_RUNS_BEFORE_ALERT => {}
+ Ok(_) => return,
+ Err(e) => {
+ tracing::warn!("failed to confirm the late run streak of schedule {w_id}/{path}: {e}");
+ return;
+ }
+ }
+ report_critical_error(
+ format!(
+ "Schedule {path} missed {missed} occurrences: its last {LATE_RUNS_BEFORE_ALERT} runs in a row started or finished too late"
+ ),
+ db,
+ Some(&w_id),
+ None,
+ )
+ .await;
+}
+
/// Enabled schedules with no occurrence in the queue, as `(workspace_id, path)`.
///
/// Every path that completes a scheduled job pushes the next occurrence in the
@@ -743,7 +861,7 @@ pub async fn get_schedule_opt<'c>(
path: &str,
) -> Result
+ Missed {pluralize(missed_occurrences ?? 0, 'occurrence')}: the last
+ {late_run_streak === 1 ? 'run' : `${late_run_streak} runs in a row`} started or finished
+ too late.
+
+ {/snippet}
+
+ {:else if last_missed_at && Date.now() - new Date(last_missed_at).getTime() < MISSED_VISIBLE_MS}
+
+
+ {#snippet text()}
+
+ Missed {pluralize(missed_occurrences ?? 0, 'occurrence')}, the last on
+ {displayDate(last_missed_at)}. On schedule since.
+
+ {/snippet}
+
{/if}
From 797147ea7a4a911d3c82e9ac9cdaf066ad2795d6 Mon Sep 17 00:00:00 2001
From: hugocasa
Date: Tue, 29 Sep 2026 14:27:23 +0200
Subject: [PATCH 22/33] fix: report a worker's last job when it ran under one
poll interval (#11399)
* fix: report a worker's last job when it ran under one poll interval
Co-Authored-By: Claude Opus 5.5 (1M context)
* fix: share the unreported job slot with the interactive worker shell
Co-Authored-By: Claude Opus 5.5 (1M context)
* test: pin that a main-loop ping without a job keeps the last one
Co-Authored-By: Claude Opus 5.5 (1M context)
---------
Co-authored-by: Claude Opus 5.5 (1M context)
---
...e424819133ce38dab7300a5fca4c6f54536d.json} | 6 ++-
backend/tests/worker_ping_ip.rs | 50 ++++++++++++++++++-
backend/windmill-common/src/worker.rs | 10 +++-
backend/windmill-worker/src/worker.rs | 12 +++++
backend/windmill-worker/src/worker_utils.rs | 8 ++-
5 files changed, 80 insertions(+), 6 deletions(-)
rename backend/.sqlx/{query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json => query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json} (70%)
diff --git a/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json b/backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json
similarity index 70%
rename from backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json
rename to backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json
index ceb05001e4..f3958e713d 100644
--- a/backend/.sqlx/query-0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626.json
+++ b/backend/.sqlx/query-d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6",
+ "query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip),\n current_job_id = COALESCE($14, current_job_id), current_job_workspace_id = COALESCE($15, current_job_workspace_id) WHERE worker = $6",
"describe": {
"columns": [],
"parameters": {
@@ -17,10 +17,12 @@
"Float4",
"Float4",
"Bool",
+ "Varchar",
+ "Uuid",
"Varchar"
]
},
"nullable": []
},
- "hash": "0c18351237816fe0c56e23801fcb8e70dbffcf08ed121e55c871f727c4ddf626"
+ "hash": "d267e03115ee74ab7ca7edd8a010e424819133ce38dab7300a5fca4c6f54536d"
}
diff --git a/backend/tests/worker_ping_ip.rs b/backend/tests/worker_ping_ip.rs
index 8f98398b40..772f4193c3 100644
--- a/backend/tests/worker_ping_ip.rs
+++ b/backend/tests/worker_ping_ip.rs
@@ -1,5 +1,9 @@
use sqlx::{Pool, Postgres};
-use windmill_common::{external_ip::UNKNOWN_IP, worker::insert_ping_query};
+use uuid::Uuid;
+use windmill_common::{
+ external_ip::UNKNOWN_IP,
+ worker::{insert_ping_query, update_worker_ping_main_loop_query},
+};
async fn insert_ping(db: &Pool, worker: &str, ip: Option<&str>) -> anyhow::Result<()> {
insert_ping_query(
@@ -42,3 +46,47 @@ async fn unresolved_ip_keeps_the_reclaimed_rows_address(db: Pool) -> a
assert_eq!(ip, UNKNOWN_IP);
Ok(())
}
+
+async fn main_loop_ping(
+ db: &Pool,
+ worker: &str,
+ last_job: Option<(Uuid, &str)>,
+) -> anyhow::Result<()> {
+ update_worker_ping_main_loop_query(
+ worker,
+ &[],
+ None,
+ None,
+ Some(1),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ false,
+ None,
+ last_job,
+ db,
+ )
+ .await
+}
+
+/// The main-loop ping sends a job only once, so most pings carry none. Those must keep the last
+/// job the row holds, whether this ping or the job poller wrote it.
+#[sqlx::test]
+async fn main_loop_ping_without_a_job_keeps_the_last_one(db: Pool) -> anyhow::Result<()> {
+ insert_ping(&db, "wk-last-job", None).await?;
+ let job_id = Uuid::new_v4();
+ main_loop_ping(&db, "wk-last-job", Some((job_id, "admins"))).await?;
+ main_loop_ping(&db, "wk-last-job", None).await?;
+ let (current_job_id, workspace_id): (Option, Option) = sqlx::query_as(
+ "SELECT current_job_id, current_job_workspace_id FROM worker_ping WHERE worker = $1",
+ )
+ .bind("wk-last-job")
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(current_job_id, Some(job_id));
+ assert_eq!(workspace_id.as_deref(), Some("admins"));
+ Ok(())
+}
diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs
index ad0a5068e8..d480a89851 100644
--- a/backend/windmill-common/src/worker.rs
+++ b/backend/windmill-common/src/worker.rs
@@ -1939,6 +1939,9 @@ pub async fn update_ping_http(
insert_ping.occupancy_rate_30m,
insert_ping.native_mode.unwrap_or(false),
insert_ping.ip.as_deref(),
+ insert_ping
+ .last_job_executed
+ .zip(insert_ping.last_job_workspace_id.as_deref()),
db,
)
.await?
@@ -2220,12 +2223,15 @@ pub async fn update_worker_ping_main_loop_query(
occupancy_rate_30m: Option,
native_mode: bool,
ip: Option<&str>,
+ last_job: Option<(Uuid, &str)>,
db: &DB,
) -> anyhow::Result<()> {
+ let (last_job_id, last_job_workspace_id) = last_job.unzip();
timeout(Duration::from_secs(10), sqlx::query!(
"UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,
occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),
- memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip) WHERE worker = $6",
+ memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, ip = COALESCE($13, ip),
+ current_job_id = COALESCE($14, current_job_id), current_job_workspace_id = COALESCE($15, current_job_workspace_id) WHERE worker = $6",
jobs_executed,
tags,
occupancy_rate,
@@ -2239,6 +2245,8 @@ pub async fn update_worker_ping_main_loop_query(
occupancy_rate_30m,
native_mode,
ip,
+ last_job_id,
+ last_job_workspace_id,
)
.execute(db))
.await??;
diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs
index a2ddac713e..1b4cfdd771 100644
--- a/backend/windmill-worker/src/worker.rs
+++ b/backend/windmill-worker/src/worker.rs
@@ -2321,6 +2321,7 @@ fn start_interactive_worker_shell(
job_completed_tx: JobCompletedSender,
base_internal_url: String,
worker_dir: String,
+ unreported_job: Arc>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
let started_at = Instant::now();
@@ -2407,6 +2408,8 @@ fn start_interactive_worker_shell(
flow_runners,
} = extract_job_and_perms(job, &conn).await;
+ *unreported_job.lock().unwrap() = Some((job.id, job.workspace_id.clone()));
+
let authed_client = AuthedClient::new(
base_internal_url.to_owned(),
job.workspace_id.clone(),
@@ -2899,6 +2902,11 @@ pub async fn run_worker(
// Only jobs run by this process count towards EXIT_AFTER_N_JOBS: the point is the age of
// the environment, not the lifetime total.
let mut jobs_executed_in_env: u64 = 0;
+ // The job poller only records a job in `worker_ping` once it has run for a poll interval, so
+ // the main-loop ping reports the last pulled job for shorter ones. The interactive shell runs
+ // jobs under this worker's name too and must set the same slot, or the ping would overwrite a
+ // shell job its poller recorded with an older main-loop job. Sent once, then left to the poller.
+ let unreported_job: Arc>> = Default::default();
let is_dedicated_worker: bool = {
let config = WORKER_CONFIG.load();
@@ -3016,6 +3024,7 @@ pub async fn run_worker(
job_completed_tx.clone(),
base_internal_url.to_owned(),
worker_dir.clone(),
+ unreported_job.clone(),
);
Some(it_shell)
@@ -3219,6 +3228,7 @@ pub async fn run_worker(
let read_cgroups =
*REFRESH_CGROUP_READINGS && last_reading.elapsed().as_secs() > NUM_SECS_READINGS;
+ let last_job = unreported_job.lock().unwrap().take();
update_worker_ping_full(
&conn,
read_cgroups,
@@ -3228,6 +3238,7 @@ pub async fn run_worker(
&mut occupancy_metrics,
&killpill_tx,
ip,
+ last_job.as_ref().map(|(id, w_id)| (*id, w_id.as_str())),
)
.await;
@@ -3562,6 +3573,7 @@ pub async fn run_worker(
last_executed_job = None;
jobs_executed += 1;
+ *unreported_job.lock().unwrap() = Some((job.id, job.workspace_id.clone()));
let mut dirties_env = dirties_worker_env(
job.kind,
&job.tag,
diff --git a/backend/windmill-worker/src/worker_utils.rs b/backend/windmill-worker/src/worker_utils.rs
index 67e9831481..3a313e9360 100644
--- a/backend/windmill-worker/src/worker_utils.rs
+++ b/backend/windmill-worker/src/worker_utils.rs
@@ -28,6 +28,7 @@ pub(crate) async fn update_worker_ping_full(
occupancy_metrics: &mut OccupancyMetrics,
killpill_tx: &KillpillSender,
ip: Option<&str>,
+ last_job: Option<(Uuid, &str)>,
) {
let wc = WORKER_CONFIG.load();
let tags = wc.worker_tags.clone();
@@ -67,6 +68,7 @@ pub(crate) async fn update_worker_ping_full(
occupancy_rate_30m,
native_mode,
ip,
+ last_job,
)
})
.retry(
@@ -114,6 +116,7 @@ async fn update_worker_ping_full_inner(
occupancy_rate_30m: Option,
native_mode: bool,
ip: Option<&str>,
+ last_job: Option<(Uuid, &str)>,
) -> anyhow::Result<()> {
match conn {
Connection::Sql(db) => {
@@ -131,6 +134,7 @@ async fn update_worker_ping_full_inner(
occupancy_rate_30m,
native_mode,
ip,
+ last_job,
db,
)
.await?;
@@ -141,8 +145,8 @@ async fn update_worker_ping_full_inner(
UPDATE_PING_URL,
None,
&Ping {
- last_job_executed: None,
- last_job_workspace_id: None,
+ last_job_executed: last_job.map(|(id, _)| id),
+ last_job_workspace_id: last_job.map(|(_, w_id)| w_id.to_string()),
worker_instance: None,
ip: ip.map(str::to_string),
tags: Some(tags.to_vec()),
From 8741843d2e884783eed1c00bbc2293e526f3e80d Mon Sep 17 00:00:00 2001
From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com>
Date: Tue, 29 Sep 2026 14:45:47 +0200
Subject: [PATCH 23/33] feat: external instance cluster for data tables and
Ducklake catalogs (#11197)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix pg_dump stuck on version 17 on nix
* fix(datatables): refuse a malformed role annotation instead of ignoring it
`-- Role operator`, `-- role operator;` and `-- role operator -- why` all failed
the annotation parser's exact-match rule, so the query fell through to the data
table's default role and ran, silently, under a login the author did not choose.
Naming a role exists precisely to not do that.
A leading comment whose first word is `role` is now an annotation attempt: the
keyword matches case-insensitively, one trailing `;` is tolerated, and anything
else is an error naming the line. Only callers that already know the target is a
`datatable://` reference ever run this, so ordinary SQL keeps its comments.
Also bumps the dev shell's postgres client to 18 — it trailed the server the dev
database runs, which takes out every data table export, clone and fork-with-data.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): refuse a malformed role query string instead of ignoring it
`?Role=analytics`, `?role=` and `?x=1&role=…` all fell through the reference
parser's exact-match rule, so the connection resolved to the data table's default
role and ran under a login the caller never asked for — the URI half of the same
trap as a malformed `-- role` annotation.
The key now matches case-insensitively, and anything else in the query string is
an error naming it; `role` is the only parameter a reference takes. Callers that
only need the entry keep a lenient `datatable_ref_name`, since they never act on
the role. The DuckDB `ATTACH` parser propagates it rather than attaching under
the default.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_012ti5HyeTikPMYyW8YSdiHR
* fix(datatables): carry the role annotation into the row_to_json retry
The retry rebuilds its SQL from `pruneComments(code)`, so the leading comment
block never reached the second attempt — and with it the `-- role ` line
that decides which login the query runs as. The retry connected as the data
table's default role instead, so a query the first attempt was denied could
succeed on the second, reported as "recovered with the row_to_json fix".
Carry the leading comment block over. The retry itself is unchanged.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* chore(datatables): don't mount the roles UI until the ACL editor lands
Enforcement ships first. The permissions drawer is what turns roles on, and the
catalog section is what creates them — both are only useful once there is a way
to grant a role the privileges it needs, which arrives with the ACL editor. Left
mounted they would offer a feature whose other half does not exist.
The two components are complete and reviewed; only their call sites here are
commented out, with a note pointing the follow-up PRs at them.
Co-Authored-By: Claude Opus 5 (1M context)
Claude-Session: https://claude.ai/code/session_01BjfMkJyKzodxkobqGZ6Lqb
* fix(datatables): honour `-- role: x`, and fix the DuckDB attach test
Two review findings, both real.
`attach_datatable_parses_name_and_role` never compiled: `parse_attach_datatable`
returns `Result