fix(scheduled-tasks): prevent dual-server double dispatch of daily tasks (#2713)

* fix(scheduled-tasks): claim schedule occurrences across server instances

Two OpenChamber servers sharing project config each armed timers and both
dispatched the same daily/weekly/cron/once slot (#2710). Claim the occurrence
in shared config under a cross-process write lock before creating a session.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(scheduled-tasks): harden occurrence claim failure and lock ownership

Address PR review blockers: release running-slot bookkeeping when claim
throws, avoid silently dropping an armed occurrence after a due-slack sync,
verify lock-file ownership on release, and cover real on-disk lock behavior.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(scheduled-tasks): always release running slot on state-write failures

Wrap runTask bookkeeping in finally so claim, manual-start, and completion
lock timeouts cannot stuck-run a task; drop the diskNext claim guard that
suppressed later occurrences; recover unparseable locks via mtime age.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(scheduled-tasks): stop re-arming past nextRunAt and clear stuck running

Only schedule future nextRunAt values so once-task losers and claim-failed
paths cannot spin delay-0 retries. Clear past once nextRunAt on claim, and on
completion-write failure retry terminal status so manual runNow still returns
the session instead of a hard 500 with lastStatus stuck running.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* fix(scheduled-tasks): release write chain on lock acquire timeout

withProjectWriteLock left the in-process promise chain pending when
acquireProjectFileLock timed out, wedging every later project write and
stranding runTask before finally. Always release the chain; surface
persistError on run; record once claim failures in task state.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-13 16:14:42 +03:00
committed by GitHub
co-authored by Serhii Dziupin
parent 86e6a2ae76
commit e99f6560be
20 changed files with 1436 additions and 117 deletions
@@ -382,12 +382,16 @@ export function ScheduledTasksDialog() {
}
setMutatingTaskID(task.id);
try {
const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id);
const { sessionId, persistError } = await runScheduledTaskNow(selectedProjectID, task.id);
await Promise.all([
reloadTasks(selectedProjectID, { silent: true }),
refreshGlobalSessions(),
]);
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
if (persistError) {
toast.warning(t('sessions.scheduledTasks.dialog.toast.startedPersistWarning'));
} else {
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
}
if (sessionId) {
// Jump straight into the started session; selecting it also closes
// this surface (MainLayout closes surfaces on session selection).
+1
View File
@@ -216,6 +216,7 @@ export const dict = {
'sessions.scheduledTasks.dialog.toast.deleted': 'Geplante Aufgabe gelöscht',
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Fehler beim Löschen der Aufgabe',
'sessions.scheduledTasks.dialog.toast.started': 'Aufgabe gestartet',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Aufgabe gestartet, aber der Status konnte nicht gespeichert werden. Sie wird möglicherweise weiterhin als laufend angezeigt, bis der nächste erfolgreiche Lauf abgeschlossen ist.',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Fehler beim Ausführen der Aufgabe',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Geplante Aufgabe "{taskName}" löschen?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Loop-Aufgabe „{taskName}“ und ihre Markdown-Datei löschen?',
+1
View File
@@ -236,6 +236,7 @@ export const dict = {
'sessions.scheduledTasks.dialog.toast.deleted': 'Scheduled task deleted',
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Failed to delete task',
'sessions.scheduledTasks.dialog.toast.started': 'Task started',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Task started, but its status could not be saved. It may still show as running until the next successful run.',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Failed to run task',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Delete scheduled task "{taskName}"?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Delete loop task "{taskName}" and its markdown file?',
+1
View File
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.scheduledTasks.dialog.toast.deleted": "Tarea programada eliminada",
"sessions.scheduledTasks.dialog.toast.deleteFailed": "No se pudo eliminar la tarea",
"sessions.scheduledTasks.dialog.toast.started": "Tarea iniciada",
"sessions.scheduledTasks.dialog.toast.startedPersistWarning": "La tarea se inició, pero no se pudo guardar su estado. Puede seguir apareciendo como en ejecución hasta el siguiente inicio correcto.",
"sessions.scheduledTasks.dialog.toast.runFailed": "No se pudo ejecutar la tarea",
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Eliminar tarea programada \"{taskName}\"?",
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '¿Eliminar la tarea de bucle «{taskName}» y su archivo Markdown?',
+1
View File
@@ -68,6 +68,7 @@ export const dict = {
'sessions.scheduledTasks.dialog.toast.deleted': 'Tâche planifiée supprimée',
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Échec de la suppression de la tâche',
'sessions.scheduledTasks.dialog.toast.started': 'Tâche démarrée',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Tâche démarrée, mais son statut na pas pu être enregistré. Elle peut encore apparaître comme en cours jusqu’à la prochaine exécution réussie.',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Échec de l\'exécution de la tâche',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Supprimer la tâche planifiée « {taskName} » ?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Supprimer la tâche en boucle « {taskName} » et son fichier Markdown ?',
+1
View File
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.deleted': 'スケジュールタスクを削除しました',
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'タスクの削除に失敗しました',
'sessions.scheduledTasks.dialog.toast.started': 'タスクを開始しました',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'タスクは開始されましたが、状態を保存できませんでした。次回の正常実行まで「実行中」と表示される場合があります。',
'sessions.scheduledTasks.dialog.toast.runFailed': 'タスクの実行に失敗しました',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'スケジュールタスク「{taskName}」を削除しますか?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'ループタスク「{taskName}」とその Markdown ファイルを削除しますか?',
+1
View File
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.deleted': '예약 작업 삭제됨',
'sessions.scheduledTasks.dialog.toast.deleteFailed': '작업 삭제 실패',
'sessions.scheduledTasks.dialog.toast.started': '작업 시작됨',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': '작업은 시작되었지만 상태를 저장하지 못했습니다. 다음 성공 실행까지 계속 실행 중으로 표시될 수 있습니다.',
'sessions.scheduledTasks.dialog.toast.runFailed': '작업 실행 실패',
'sessions.scheduledTasks.dialog.confirm.deleteTask': '예약 작업 "{taskName}" 삭제?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '루프 작업 "{taskName}" 및 해당 Markdown 파일을 삭제할까요?',
+1
View File
@@ -364,6 +364,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.deleted': 'Zaplanowane zadanie usunięte',
'sessions.scheduledTasks.dialog.toast.deleteFailed': 'Nie udało się usunąć zadania',
'sessions.scheduledTasks.dialog.toast.started': 'Zadanie uruchomione',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': 'Zadanie zostało uruchomione, ale nie udało się zapisać jego stanu. Może nadal być pokazywane jako uruchomione do następnego udanego uruchomienia.',
'sessions.scheduledTasks.dialog.toast.runFailed': 'Nie udało się uruchomić zadania',
'sessions.scheduledTasks.dialog.confirm.deleteTask': 'Usunąć zaplanowane zadanie "{taskName}"?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Usunąć zadanie pętli „{taskName}” i jego plik Markdown?',
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.scheduledTasks.dialog.toast.deleted": "Tarefa agendada excluída",
"sessions.scheduledTasks.dialog.toast.deleteFailed": "Não foi possível excluir a tarefa",
"sessions.scheduledTasks.dialog.toast.started": "Tarefa iniciada",
"sessions.scheduledTasks.dialog.toast.startedPersistWarning": "A tarefa foi iniciada, mas o status não pôde ser salvo. Ela pode continuar aparecendo como em execução até a próxima execução bem-sucedida.",
"sessions.scheduledTasks.dialog.toast.runFailed": "Não foi possível executar a tarefa",
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Excluir tarefa agendada \"{taskName}\"?",
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Excluir a tarefa de loop “{taskName}” e seu arquivo Markdown?',
+1
View File
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
"sessions.scheduledTasks.dialog.toast.deleted": "Заплановане завдання видалено",
"sessions.scheduledTasks.dialog.toast.deleteFailed": "Не вдалося видалити завдання",
"sessions.scheduledTasks.dialog.toast.started": "Завдання розпочато",
"sessions.scheduledTasks.dialog.toast.startedPersistWarning": "Завдання запущено, але його стан не вдалося зберегти. Воно може й надалі відображатися як виконуване до наступного успішного запуску.",
"sessions.scheduledTasks.dialog.toast.runFailed": "Не вдалося виконати завдання",
"sessions.scheduledTasks.dialog.confirm.deleteTask": "Видалити заплановане завдання \"{taskName}\"?",
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': 'Видалити циклічне завдання «{taskName}» і його Markdown-файл?',
@@ -237,6 +237,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.deleted': '计划任务已删除',
'sessions.scheduledTasks.dialog.toast.deleteFailed': '删除任务失败',
'sessions.scheduledTasks.dialog.toast.started': '任务已启动',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': '任务已启动,但未能保存其状态。在下一次成功运行之前,它可能仍显示为正在运行。',
'sessions.scheduledTasks.dialog.toast.runFailed': '运行任务失败',
'sessions.scheduledTasks.dialog.confirm.deleteTask': '删除计划任务“{taskName}”?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '删除循环任务“{taskName}”及其 Markdown 文件?',
@@ -250,6 +250,7 @@ export const dict: Record<I18nKey, string> = {
'sessions.scheduledTasks.dialog.toast.deleted': '排程任務已刪除',
'sessions.scheduledTasks.dialog.toast.deleteFailed': '刪除任務失敗',
'sessions.scheduledTasks.dialog.toast.started': '任務已啟動',
'sessions.scheduledTasks.dialog.toast.startedPersistWarning': '任務已啟動,但無法儲存其狀態。在下一次成功執行之前,它可能仍顯示為執行中。',
'sessions.scheduledTasks.dialog.toast.runFailed': '執行任務失敗',
'sessions.scheduledTasks.dialog.confirm.deleteTask': '刪除排程任務「{taskName}」?',
'sessions.scheduledTasks.dialog.confirm.deleteLoopFile': '刪除迴圈任務「{taskName}」及其 Markdown 檔案?',
+7 -1
View File
@@ -143,7 +143,10 @@ export const syncScheduledTaskLoops = async (projectID: string): Promise<void> =
await fetchScheduledTasks(projectID);
};
export const runScheduledTaskNow = async (projectID: string, taskID: string): Promise<{ sessionId?: string }> => {
export const runScheduledTaskNow = async (
projectID: string,
taskID: string,
): Promise<{ sessionId?: string; persistError?: string }> => {
const safeProjectID = ensureProjectID(projectID);
const safeTaskID = ensureProjectID(taskID);
const response = await runtimeFetch(`/api/projects/${encodeURIComponent(safeProjectID)}/scheduled-tasks/${encodeURIComponent(safeTaskID)}/run`, {
@@ -158,5 +161,8 @@ export const runScheduledTaskNow = async (projectID: string, taskID: string): Pr
const parsed = await response.json().catch(() => null);
return {
sessionId: typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0 ? parsed.sessionId : undefined,
persistError: typeof parsed?.persistError === 'string' && parsed.persistError.trim().length > 0
? parsed.persistError.trim()
: undefined,
};
};