>(
+ [],
+ );
// Saving reads it first. Then the rebind lands, and the promotion commits between the shell
// read and the retry.
const shellProjects = yield* Ref.make([promoted, scratch, promoted]);
@@ -526,7 +545,7 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", ()
sendToThread: (input) =>
Ref.updateAndGet(sends, (all) => [
...all,
- { projectId: input.projectId, commandId: input.commandId },
+ { projectId: input.projectId, commandId: input.commandId, mode: input.mode },
]).pipe(
Effect.flatMap((all) =>
all.length < 3
@@ -545,7 +564,11 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", ()
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({
namedProjectsRoot: "/projects",
- isScratchProject: () => Effect.succeed(false),
+ isScratchProject: () =>
+ Deferred.succeed(saving, undefined).pipe(
+ Effect.andThen(Deferred.await(resumeSave)),
+ Effect.as(true),
+ ),
}),
NodeCrypto.layer,
Scheduler.layer,
@@ -555,7 +578,7 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", ()
yield* Effect.gen(function* () {
const service = yield* ScheduledTaskService.ScheduledTaskService;
const threadId = ThreadId.make("thread:promoted-during-run");
- const { task } = yield* service.upsert({
+ const input: ScheduledTaskUpsertInput = {
id: ScheduledTaskId.make("task:promoted-during-run"),
title: "Check in",
prompt: "Check progress",
@@ -567,7 +590,10 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", ()
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
interactionMode: "default",
- });
+ };
+ yield* service.upsert({ ...input, sendMode: "auto" });
+ // A save from a client or tool that predates the mode keeps it.
+ const { task } = yield* service.upsert(input);
const result = yield* service.runNow({ id: task.id });
@@ -578,6 +604,17 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", ()
[promoted, scratch, promoted],
);
assert.equal(new Set(attempts.map((attempt) => attempt.commandId)).size, 1);
+ assert.deepEqual(new Set(attempts.map((attempt) => attempt.mode)), new Set(["auto"]));
+ const staleSave = yield* service
+ .upsert({
+ ...input,
+ workspaceStrategy: { type: "worktree", branch: "task", baseRef: "main" },
+ })
+ .pipe(Effect.forkChild);
+ yield* Deferred.await(saving);
+ yield* service.upsert({ ...input, sendMode: "restart" });
+ yield* Deferred.succeed(resumeSave, undefined);
+ assert.equal((yield* Fiber.join(staleSave)).task.sendMode, "restart");
}).pipe(Effect.provide(layer));
}).pipe(Effect.provide(SqlitePersistence.layerMemory)),
);
diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts
index 9ed134be2da5a..845fe6e12a03b 100644
--- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts
+++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts
@@ -219,6 +219,7 @@ interface ScheduledTaskRow {
readonly project_id: string;
readonly thread_id: string | null;
readonly thread_mode: string;
+ readonly send_mode: string;
readonly workspace_strategy_json: string;
readonly model_selection_json: string;
readonly runtime_mode: string;
@@ -408,6 +409,7 @@ const decodeRow = Effect.fnUntraced(
projectId: row.project_id,
threadId: row.thread_id,
threadMode: row.thread_mode,
+ sendMode: row.send_mode,
workspaceStrategy,
modelSelection,
runtimeMode: row.runtime_mode,
@@ -532,6 +534,7 @@ export const layer = Layer.effect(
project_id,
thread_id,
thread_mode,
+ send_mode,
workspace_strategy_json,
model_selection_json,
runtime_mode,
@@ -574,6 +577,7 @@ export const layer = Layer.effect(
project_id,
thread_id,
thread_mode,
+ send_mode,
workspace_strategy_json,
model_selection_json,
runtime_mode,
@@ -634,6 +638,7 @@ export const layer = Layer.effect(
const saveTask = (
task: ScheduledTask,
requireExisting: boolean,
+ sendMode: ScheduledTaskUpsertInput["sendMode"],
/** The schedule this save read, when it left the schedule unchanged. */
keptSchedule: ScheduledTask["schedule"] | null,
webhook: {
@@ -652,6 +657,7 @@ export const layer = Layer.effect(
project_id,
thread_id,
thread_mode,
+ send_mode,
workspace_strategy_json,
model_selection_json,
runtime_mode,
@@ -677,6 +683,7 @@ export const layer = Layer.effect(
${task.projectId},
${task.threadId},
${scheduledTaskThreadMode(task)},
+ ${task.sendMode},
${JSON.stringify(task.workspaceStrategy)},
${JSON.stringify(task.modelSelection)},
${task.runtimeMode},
@@ -709,6 +716,7 @@ export const layer = Layer.effect(
ELSE COALESCE(excluded.thread_id, scheduled_tasks.thread_id)
END,
thread_mode = excluded.thread_mode,
+ send_mode = COALESCE(${sendMode ?? null}, scheduled_tasks.send_mode),
workspace_strategy_json = excluded.workspace_strategy_json,
model_selection_json = excluded.model_selection_json,
runtime_mode = excluded.runtime_mode,
@@ -894,39 +902,48 @@ export const layer = Layer.effect(
const shell = yield* threadManagement.getThreadShell(threadId);
if (shell === null) return;
const thread = { projectId: shell.projectId, threadId };
- const { runs } = yield* threadManagement.getProjectThreadRecords(thread, ["runs"]);
- const dispatched = runs.find((run) => run.userMessageId === messageId);
+ const { runs, messages: dispatchedMessages } =
+ yield* threadManagement.getProjectThreadRecords(thread, ["runs", "messages"], {
+ messageIds: [messageId],
+ });
+ const messageRunId = dispatchedMessages.find((message) => message.id === messageId)?.runId;
+ const dispatched =
+ runs.find((run) => run.userMessageId === messageId) ??
+ runs.find((run) => run.id === messageRunId);
if (dispatched === undefined) return;
- const { run } = yield* threadManagement.waitForThread({
- ...thread,
- runId: dispatched.id,
- timeoutMs: RUN_OUTCOME_WAIT_MS,
- });
- if (
- run === null ||
- run.status === "completed" ||
- !ThreadManagementService.isTerminalRunStatus(run.status)
- )
- return;
- const { turnItems, messages } = yield* threadManagement.getProjectThreadRecords(thread, [
- "turnItems",
- "messages",
- ]);
- // A queued message promoted to a steer cancels its run and joins the active one.
- if (
- run.status === "cancelled" &&
- messages.find((message) => message.id === messageId)?.runId !== run.id
- )
+ let runId = dispatched.id;
+ while (true) {
+ const { run } = yield* threadManagement.waitForThread({
+ ...thread,
+ runId,
+ timeoutMs: RUN_OUTCOME_WAIT_MS,
+ });
+ if (run === null || !ThreadManagementService.isTerminalRunStatus(run.status)) return;
+ const { messages } = yield* threadManagement.getProjectThreadRecords(thread, ["messages"], {
+ messageIds: [messageId],
+ });
+ const currentRunId = messages.find((message) => message.id === messageId)?.runId;
+ // A declined steer or a promoted queued message can move into another run.
+ if (currentRunId != null && currentRunId !== runId) {
+ runId = currentRunId;
+ continue;
+ }
+ if (run.status === "completed" || (run.status === "cancelled" && currentRunId == null))
+ return;
+ const { turnItems } = yield* threadManagement.getProjectThreadRecords(thread, [
+ "turnItems",
+ ]);
+ const error =
+ latestRootProviderFailure(run, turnItems)?.message ?? UNSUCCESSFUL_RUN_ERRORS[run.status];
+ const updated = yield* sql`
+ UPDATE scheduled_tasks
+ SET last_run_status = 'failed', last_run_error = ${error}
+ WHERE task_id = ${taskId} AND last_run_at = ${startedAtIso} AND last_run_status = 'succeeded'
+ RETURNING task_id
+ `;
+ if (updated.length > 0) yield* notifyChanged;
return;
- const error =
- latestRootProviderFailure(run, turnItems)?.message ?? UNSUCCESSFUL_RUN_ERRORS[run.status];
- const updated = yield* sql`
- UPDATE scheduled_tasks
- SET last_run_status = 'failed', last_run_error = ${error}
- WHERE task_id = ${taskId} AND last_run_at = ${startedAtIso} AND last_run_status = 'succeeded'
- RETURNING task_id
- `;
- if (updated.length > 0) yield* notifyChanged;
+ }
});
const runTask = Effect.fn("ScheduledTaskService.runTask")(function* (
@@ -1047,7 +1064,7 @@ export const layer = Layer.effect(
scheduledTaskId: active.id,
text: prompt,
attachments: [],
- mode: "queue",
+ mode: active.sendMode,
...(threadMode === "fresh_session" ? { freshContext: true } : {}),
createdBy: active.createdBy,
creationSource: active.creationSource,
@@ -1544,6 +1561,8 @@ export const layer = Layer.effect(
projectId: input.projectId,
threadId,
threadMode,
+ // Omitted keeps the stored mode, so clients and tools that predate it keep it on re-save.
+ sendMode: input.sendMode ?? existingTask?.sendMode ?? "queue",
workspaceStrategy,
modelSelection: input.modelSelection,
runtimeMode: input.runtimeMode,
@@ -1563,6 +1582,7 @@ export const layer = Layer.effect(
yield* saveTask(
task,
input.requireExisting === true,
+ input.sendMode,
scheduleUnchanged ? existingTask.schedule : null,
webhook,
);
diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx
index de655f374af5e..8eeafc939d1b9 100644
--- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx
+++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx
@@ -27,6 +27,7 @@ const task: ScheduledTask = {
schedule: { type: "interval", everyMs: 60_000 },
projectId,
threadId: ThreadId.make("older-thread"),
+ sendMode: "queue",
workspaceStrategy: { type: "root" },
modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" },
runtimeMode: "full-access",
diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx
index 5e83dd1baadd9..2d285694cdfdb 100644
--- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx
+++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx
@@ -22,6 +22,7 @@ import type {
ScheduledTask,
ScheduledTaskId,
ScheduledTaskThreadMode,
+ ScheduledTaskSendMode,
ScheduledTaskUpsertInput,
ScheduledTaskWebhookDeliverySummary,
ThreadId,
@@ -35,6 +36,10 @@ import {
type WebhookFilterDraft,
} from "@t3tools/client-runtime/scheduled-task-webhook";
import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone";
+import {
+ SCHEDULED_TASK_SEND_MODE_COPY,
+ SCHEDULED_TASK_SEND_MODES,
+} from "@t3tools/client-runtime/scheduled-task-send-mode";
import {
MAX_WEBHOOK_DELIVERY_AGE_MINUTES,
MAX_WEBHOOK_FILTERS,
@@ -179,6 +184,7 @@ const EMPTY_DRAFT: DraftState = {
projectId: "",
threadId: "",
threadMode: "new_thread",
+ sendMode: "queue",
workspaceMode: "worktree",
baseRef: "main",
startFromOrigin: true,
@@ -1543,6 +1549,8 @@ function ScheduledTaskEditorForm({
projectId: selectedProjectId as ProjectId,
threadId: draft.threadId ? (draft.threadId as ThreadId) : null,
...(offersThreadModes ? { threadMode: draft.threadMode } : {}),
+ // Only an edited mode is sent, so a stale form keeps a mode changed elsewhere.
+ ...(draft.sendMode === task?.sendMode ? {} : { sendMode: draft.sendMode }),
workspaceStrategy,
modelSelection,
// Tasks have no mode picker; a mode above the administrator's ceiling would fail every run.
@@ -1690,6 +1698,36 @@ function ScheduledTaskEditorForm({
}
/>
+ {draft.threadMode === "continue" &&
+ environment?.serverConfig?.environment.capabilities.scheduledTaskSendMode === true ? (
+
+
+
+ {SCHEDULED_TASK_SEND_MODE_COPY[draft.sendMode].description}
+
+
+ ) : null}
+