diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx index b92c1ec725b47..b736797515814 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.test.tsx @@ -158,6 +158,7 @@ beforeEach(() => { schedule: { type: "fixed_time", timeOfDay: "05:00" }, projectId: ProjectId.make("project"), threadId: null, + sendMode: "queue", workspaceStrategy: { type: "root" }, modelSelection: { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index d71fbc8fd9d24..c26685223f924 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -58,6 +58,10 @@ import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScroll import { SegmentedControl } from "../../components/ui/SegmentedControl"; import { ThemedSwitch } from "../../components/ui/ThemedSwitch"; import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; +import { + SCHEDULED_TASK_SEND_MODE_COPY, + SCHEDULED_TASK_SEND_MODES, +} from "@t3tools/client-runtime/scheduled-task-send-mode"; import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { usePreparedConnection } from "../../state/session"; import { buildModelOptions } from "../../lib/modelOptions"; @@ -810,6 +814,7 @@ function TaskForm({ enabled: draft.enabled, threadId: draft.threadId, ...(offersThreadModes ? { threadMode: draft.threadMode } : {}), + ...(draft.sendMode === draft.task?.sendMode ? {} : { sendMode: draft.sendMode }), workspaceStrategy: draft.workspace === "root" ? { type: "root" } @@ -999,6 +1004,32 @@ function TaskForm({ ) : null} + {draft.threadMode === "continue" && + config?.environment.capabilities.scheduledTaskSendMode === true ? ( + + ({ + id: mode, + title: SCHEDULED_TASK_SEND_MODE_COPY[mode].label, + state: mode === draft.sendMode ? "on" : undefined, + })) + } + onSelect={(id) => { + const mode = SCHEDULED_TASK_SEND_MODES.find((candidate) => candidate === id); + if (mode) setDraft((current) => ({ ...current, sendMode: mode })); + }} + /> + + ) : null} + { schedule: { type: "webhook", signature: null }, projectId, threadId: null, + sendMode: "queue", workspaceStrategy: { type: "root" }, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "approval-required", @@ -3624,6 +3625,7 @@ describe("OrchestratorMcpService provider resolution", () => { schedule: { type: "interval", everyMs: 3_600_000 }, projectId, threadId: null, + sendMode: "queue", workspaceStrategy: { type: "root" }, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, runtimeMode: "approval-required", diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 97ab0a8db1657..665ef297955c4 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -105,11 +105,28 @@ const layerDispatchChokepointTest = id: ThreadId.make("thread:thread-management:chokepoint"), projectId: ProjectId.make("project:thread-management:chokepoint"), deletedAt: null, + archivedAt: null, modelSelection: { instanceId: ProviderInstanceId.make("codex_work"), model: "test", }, }, + // The steer retry test's send, accepted into a turn that has since ended. + runs: [{ id: RunId.make("run:thread-management:steer-replay"), status: "completed" }], + providerTurns: [], + messages: [ + { + id: MessageId.make("message:thread-management:steer-replay"), + runId: RunId.make("run:thread-management:steer-replay"), + }, + ], + turnItems: [ + { + type: "user_message", + messageId: MessageId.make("message:thread-management:steer-replay"), + inputIntent: "steer", + }, + ], } as unknown as OrchestrationV2ThreadProjection), dispatch: (command) => Effect.sync(() => { @@ -211,6 +228,43 @@ it.effect( }, ); +it.effect("a retried steer replays its accepted send after the steered turn has ended", () => { + const projectId = ProjectId.make("project:thread-management:chokepoint"); + const threadId = ThreadId.make("thread:thread-management:chokepoint"); + const commandId = CommandId.make("command:thread-management:steer-replay"); + const dispatched: Array = []; + const layerTest = layerDispatchChokepointTest(ServerSettings.layerTest())(dispatched); + + return Effect.gen(function* () { + const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2; + yield* receipts.insertIfAbsent({ + commandId, + threadId, + commandType: "message.dispatch", + acceptedAt: yield* DateTime.now, + resultSequence: 1, + status: "accepted", + error: null, + }); + + const threads = yield* ThreadManagementService.ThreadManagementService; + const result = yield* threads.sendToThread({ + projectId, + commandId, + threadId, + messageId: MessageId.make("message:thread-management:steer-replay"), + text: "CI failed.", + attachments: [], + mode: "steer", + createdBy: "user", + creationSource: "server", + }); + + expect(result.delivery).toBe("steered"); + expect(dispatched).toEqual(["message.dispatch"]); + }).pipe(Effect.provide(layerTest)); +}); + it.effect.each([ { label: "fails a send that starts a turn", running: false }, { label: "still steers a running turn", running: true }, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 611d9a3244f24..bfbb1571ffa28 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -884,16 +884,20 @@ const make = Effect.gen(function* () { { readonly type: "message.dispatch" } >["dispatchMode"]; if (mode === "steer" || mode === "restart") { - if (steerableRun === undefined) { + if (steerableRun === undefined && !(yield* hasReceipt(input.commandId))) { return yield* new ThreadManagementNoSteerableRunError({ threadId: input.threadId, mode, }); } - dispatchMode = { - type: mode === "steer" ? "steer_active" : "restart_active", - targetRunId: steerableRun.id, - }; + // A retry of an accepted send replays its receipt, even after the steered turn has ended. + dispatchMode = + steerableRun === undefined + ? { type: "queue_after_active" } + : { + type: mode === "steer" ? "steer_active" : "restart_active", + targetRunId: steerableRun.id, + }; } else if (mode === "auto" && steerableRun !== undefined) { dispatchMode = { type: "steer_active", targetRunId: steerableRun.id }; } else { diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 9eaddd70c1278..63ae12acda3c5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -103,6 +103,7 @@ import Migration0092 from "./Migrations/092_QueuedTurnInputIndexJsonValid.ts"; import Migration0093 from "./Migrations/093_ProviderTurnAttemptIndex.ts"; import Migration0094 from "./Migrations/094_TurnItemMessageOrdinalIndex.ts"; import Migration0095 from "./Migrations/095_TurnItemInlineWorkOutput.ts"; +import Migration0096 from "./Migrations/096_ScheduledTaskSendMode.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -201,6 +202,7 @@ const migrationEntries = [ [93, "ProviderTurnAttemptIndex", Migration0093], [94, "TurnItemMessageOrdinalIndex", Migration0094], [95, "TurnItemInlineWorkOutput", Migration0095], + [96, "ScheduledTaskSendMode", Migration0096], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/096_ScheduledTaskSendMode.ts b/apps/server/src/persistence/Migrations/096_ScheduledTaskSendMode.ts new file mode 100644 index 0000000000000..de5a18489333d --- /dev/null +++ b/apps/server/src/persistence/Migrations/096_ScheduledTaskSendMode.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE scheduled_tasks ADD COLUMN send_mode TEXT NOT NULL DEFAULT 'queue'`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4c5ea36726c4a..0ad04c9719a10 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -75,6 +75,7 @@ describe("V2 preview upgrade", () => { [93, "ProviderTurnAttemptIndex"], [94, "TurnItemMessageOrdinalIndex"], [95, "TurnItemInlineWorkOutput"], + [96, "ScheduledTaskSendMode"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -193,6 +194,7 @@ describe("V2 preview upgrade", () => { [93, "ProviderTurnAttemptIndex"], [94, "TurnItemMessageOrdinalIndex"], [95, "TurnItemInlineWorkOutput"], + [96, "ScheduledTaskSendMode"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts index 1335e369484a5..5311c6867a409 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.test.ts @@ -422,14 +422,19 @@ it.effect("thread cleanup notifies subscribers and a finishing run cannot recrea ); it.effect.each([ - { status: "failed", error: "Weekly usage limit reached" }, - { status: "interrupted", error: "The run was stopped." }, - { status: "cancelled", error: "The run was cancelled." }, -] as const)("a run that ends $status after its message was accepted records it", (outcome) => + { status: "failed", error: "Weekly usage limit reached", mode: "queue" }, + { status: "failed", error: "Weekly usage limit reached", mode: "steer" }, + { status: "failed", error: "Weekly usage limit reached", mode: "auto" }, + { status: "interrupted", error: "The run was stopped.", mode: "auto", followUp: true }, + { status: "interrupted", error: "The run was stopped.", mode: "queue" }, + { status: "cancelled", error: "The run was cancelled.", mode: "queue" }, +] as const)("a $mode run that ends $status after its message was accepted records it", (outcome) => Effect.gen(function* () { const projectId = ProjectId.make("project:test"); const ended = yield* Deferred.make(); const sent = yield* Ref.make(""); + const messageRun = yield* Ref.make("run:2"); + const followUp = "followUp" in outcome; const run = { id: "run:2", status: outcome.status, rootNodeId: "node:root" }; const layer = ScheduledTaskService.layer.pipe( Layer.provide( @@ -441,14 +446,17 @@ it.effect.each([ getThreadShell: () => Effect.succeed(threadShell(projectId)), getProjectThreadRecords: () => Effect.map( - Ref.get(sent), - (userMessageId) => + Effect.all([Ref.get(sent), Ref.get(messageRun)]), + ([userMessageId, runId]) => ({ runs: [ { id: "run:1", status: "completed", userMessageId: "message:earlier" }, - { ...run, userMessageId }, + { + ...run, + userMessageId: outcome.mode === "queue" ? userMessageId : "message:active", + }, ], - messages: [{ id: userMessageId, runId: "run:2" }], + messages: [{ id: userMessageId, runId }], turnItems: [ { type: "error", @@ -462,9 +470,15 @@ it.effect.each([ ), waitForThread: (input) => Deferred.await(ended).pipe( + Effect.andThen(Ref.set(messageRun, followUp ? "run:3" : "run:2")), Effect.as({ threadId: input.threadId, - run: { ...run, id: input.runId, userMessageId: "" }, + run: { + ...run, + id: input.runId, + userMessageId: "", + status: followUp && input.runId === "run:2" ? "completed" : outcome.status, + }, timedOut: false, } as never), ), @@ -484,6 +498,7 @@ it.effect.each([ const { task } = yield* service.upsert({ id: ScheduledTaskId.make("task:quota"), title: "Quota bookkeeping", + sendMode: outcome.mode, prompt: "Update the notes", enabled: true, schedule: { type: "interval", everyMs: 60_000 }, @@ -513,7 +528,11 @@ it.effect("a bound run follows its thread when promotion moves it mid-send", () Effect.gen(function* () { const scratch = ProjectId.make("project:scratch"); const promoted = ProjectId.make("project:promoted"); - const sends = yield* Ref.make>([]); + const saving = yield* Deferred.make(); + const resumeSave = yield* Deferred.make(); + const sends = yield* Ref.make>( + [], + ); // 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} +