feat(server): server-side scheduled tasks — start agent runs automatically - #7986
feat(server): server-side scheduled tasks — start agent runs automatically#7986ImBIOS wants to merge 2 commits into
Conversation
…cally Adds task.schedule / task.cancel commands, a scheduler layer that fires due tasks through the orchestration engine with deterministic idempotent command ids, and a reactor that turns each fire into a normal thread.turn.start on the anchor thread. Includes projection table + queries, listTasks RPC, web settings panel section, and docs. Fixes pingdotgg#7966
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
UI consistency review of the new ScheduledTasksSection settings panel. Four findings, all in apps/web/src/components/settings/ScheduledTasksSection.tsx: two Select triggers render the raw stored value instead of a label (the repo's documented SelectValue contract), the two triggers and the prompt input are missing the accessible names every other settings control provides, the trigger widths break the w-full sm:w-* responsive pattern used by sibling rows, and the Schedule button stays enabled in a state where submitting is a silent no-op.
Posted via Macroscope — UI Consistency
| <Button | ||
| size="sm" | ||
| onClick={() => void submit()} | ||
| disabled={submitting || prompt.trim().length === 0} | ||
| > | ||
| Schedule | ||
| </Button> |
There was a problem hiding this comment.
submit() returns early when anchorThread is undefined (the initial threadKey is captured before threads load, and it is never reconciled when the thread list changes), but the button stays enabled — the user gets a live control that does nothing and no feedback. Consider reflecting that precondition in disabled.
| <Button | |
| size="sm" | |
| onClick={() => void submit()} | |
| disabled={submitting || prompt.trim().length === 0} | |
| > | |
| Schedule | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={() => void submit()} | |
| disabled={submitting || !anchorThread || prompt.trim().length === 0} | |
| > | |
| Schedule | |
| </Button> |
Posted via Macroscope — UI Consistency
| <SelectTrigger className="w-56"> | ||
| <SelectValue /> | ||
| </SelectTrigger> |
There was a problem hiding this comment.
Same SelectValue issue here: with options rendered inline the trigger displays the raw preset key (tomorrow-9) rather than PRESET_LABELS[preset]. Passing the label as children (and adding the aria-label / responsive width the other settings selects use) restores the intended text.
| <SelectTrigger className="w-56"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectTrigger className="w-full sm:w-56" aria-label="Scheduled task schedule"> | |
| <SelectValue>{PRESET_LABELS[preset]}</SelectValue> | |
| </SelectTrigger> |
Posted via Macroscope — UI Consistency
| <Select value={threadKey} onValueChange={(value) => setThreadKey(String(value))}> | ||
| <SelectTrigger className="w-64"> | ||
| <SelectValue /> | ||
| </SelectTrigger> |
There was a problem hiding this comment.
A bare SelectValue with items built inline (no items map on the Select root) prints the raw stored value, so this trigger shows env-id:thread-id instead of the thread title — see the comment on viewportSelectLabel in IntegrationsSettings.tsx. Every other settings SelectTrigger also carries an aria-label and uses the w-full sm:w-* width so the control does not stay fixed-width on narrow viewports.
| <Select value={threadKey} onValueChange={(value) => setThreadKey(String(value))}> | |
| <SelectTrigger className="w-64"> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <Select value={threadKey} onValueChange={(value) => setThreadKey(String(value))}> | |
| <SelectTrigger className="w-full sm:w-64" aria-label="Scheduled task thread"> | |
| <SelectValue>{anchorThread?.title ?? "Select a thread"}</SelectValue> | |
| </SelectTrigger> |
Posted via Macroscope — UI Consistency
| description="Sent as the user message for each run." | ||
| control={ | ||
| <div className="flex w-full max-w-xl items-center gap-2"> | ||
| <Input |
There was a problem hiding this comment.
SettingsRow's title renders as an h3, not a <label>, so this input has no accessible name beyond its placeholder. Other settings inputs (e.g. the project name field in ProjectSettingsPanel.tsx) pass an explicit aria-label.
| <Input | |
| <Input | |
| aria-label="Scheduled task prompt" |
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
Effect service conventions: 4 findings on the newly introduced task services. The three new services are defined in the legacy Services/ + Layers/ split with standalone *Shape interfaces and *Live layer names, instead of the canonical single-module form (inline interface in Context.Service, exported make, exported layer) already used by e.g. apps/server/src/persistence/AuthSessions.ts. One error-construction site copies cause.message into a structural field.
Posted via Macroscope — Effect Service Conventions
| Effect.try({ | ||
| try: () => parseScheduleJson(row.taskId, row.scheduleJson), | ||
| catch: (cause) => | ||
| new PersistenceDecodeError({ | ||
| operation: "ProjectionSnapshotQuery.decodeTaskRows:scheduleJson", | ||
| issue: cause instanceof Error ? cause.message : String(cause), | ||
| ...(cause !== undefined ? { cause } : {}), | ||
| }), | ||
| }).pipe( | ||
| Effect.flatMap((schedule) => | ||
| decodeTaskSchema({ |
There was a problem hiding this comment.
issue here just copies the message of an Error manufactured in parseScheduleJson, and PersistenceDecodeError.message is then derived from that string — the wrapper message ends up derived from cause.message rather than from structural attributes. Consider decoding the schedule with the Effect variant and mapping the real SchemaError, so issue comes from the schema issue and the underlying error is preserved as cause:
const decodeTaskScheduleJson = Schema.decodeUnknownEffect(Schema.fromJsonString(TaskScheduleSpec));
// in decodeTaskRows
decodeTaskScheduleJson(row.scheduleJson).pipe(
Effect.mapError(
toPersistenceDecodeError("ProjectionSnapshotQuery.decodeTaskRows:scheduleJson"),
),
)The task id can travel in the operation/correlation context instead of being baked into a synthetic message.
Posted via Macroscope — Effect Service Conventions
| /** | ||
| * ProjectionTaskRepositoryShape - Service API for projected task records. | ||
| */ | ||
| export interface ProjectionTaskRepositoryShape { | ||
| /** | ||
| * Insert or replace a projected task row. | ||
| * | ||
| * Upserts by `taskId`. | ||
| */ | ||
| readonly upsert: (task: ProjectionTask) => Effect.Effect<void, ProjectionRepositoryError>; | ||
|
|
||
| /** | ||
| * Read a projected task row by id. | ||
| */ | ||
| readonly getById: ( | ||
| input: GetProjectionTaskInput, | ||
| ) => Effect.Effect<Option.Option<ProjectionTask>, ProjectionRepositoryError>; | ||
|
|
||
| /** | ||
| * List projected tasks for a project. | ||
| */ | ||
| readonly listByProjectId: ( | ||
| input: ListProjectionTasksByProjectInput, | ||
| ) => Effect.Effect<ReadonlyArray<ProjectionTask>, ProjectionRepositoryError>; | ||
|
|
||
| /** | ||
| * Delete projected task state by id. | ||
| */ | ||
| readonly deleteById: ( | ||
| input: DeleteProjectionTaskInput, | ||
| ) => Effect.Effect<void, ProjectionRepositoryError>; | ||
| } | ||
|
|
||
| /** | ||
| * ProjectionTaskRepository - Service tag for task persistence. | ||
| */ | ||
| export class ProjectionTaskRepository extends Context.Service< | ||
| ProjectionTaskRepository, | ||
| ProjectionTaskRepositoryShape | ||
| >()("t3/persistence/Services/ProjectionTasks/ProjectionTaskRepository") {} |
There was a problem hiding this comment.
New repository service uses a standalone ProjectionTaskRepositoryShape plus a Services/ + Layers/ split and a ProjectionTaskRepositoryLive layer name. Consider following the already-migrated persistence modules (apps/server/src/persistence/AuthSessions.ts, ProviderSessionRuntime.ts): one module apps/server/src/persistence/ProjectionTasks.ts holding the schemas, the Context.Service tag with the interface inline, make, and export const layer = Layer.effect(ProjectionTaskRepository, make) — no *Shape type and no *Live alias.
Posted via Macroscope — Effect Service Conventions
| export interface TaskFireReactorShape { | ||
| /** | ||
| * Start consuming task.fired events within the provided scope. | ||
| */ | ||
| readonly start: () => Effect.Effect<void, never, Scope.Scope>; | ||
|
|
||
| /** | ||
| * Wait until every enqueued task.fired event has been processed. | ||
| */ | ||
| readonly drain: Effect.Effect<void>; | ||
| } | ||
|
|
||
| export class TaskFireReactor extends Context.Service<TaskFireReactor, TaskFireReactorShape>()( | ||
| "t3/orchestration/Services/TaskFireReactor", | ||
| ) {} |
There was a problem hiding this comment.
Same convention issue as TaskScheduler: this new service keeps a standalone TaskFireReactorShape and lives in Services/ with its construction in Layers/. Consider collapsing both into apps/server/src/orchestration/TaskFireReactor.ts with the interface inline in the Context.Service declaration, an exported make, and export const layer = Layer.effect(TaskFireReactor, make); reference the shape as TaskFireReactor["Service"] in the implementation and in the harness/test layers that stub it.
Posted via Macroscope — Effect Service Conventions
| export interface TaskSchedulerShape { | ||
| /** | ||
| * Start the background scheduler tick loop within the provided scope. | ||
| */ | ||
| readonly start: () => Effect.Effect<void, never, Scope.Scope>; | ||
|
|
||
| /** | ||
| * Run one tick immediately: dispatch `task.fire` for every armed task whose | ||
| * nextFireAt has passed. Returns the number of fires dispatched. Exposed as | ||
| * a deterministic seam for tests — never wait on the wall-clock loop. | ||
| */ | ||
| readonly tick: () => Effect.Effect<number, never>; | ||
| } | ||
|
|
||
| export class TaskScheduler extends Context.Service<TaskScheduler, TaskSchedulerShape>()( | ||
| "t3/orchestration/Services/TaskScheduler", | ||
| ) {} |
There was a problem hiding this comment.
New service is split across Services/TaskScheduler.ts + Layers/TaskScheduler.ts with a standalone TaskSchedulerShape. Since this is new code, consider using the canonical single-module form: apps/server/src/orchestration/TaskScheduler.ts containing (in order) imports, the Context.Service tag with the interface declared inline, make, then export const layer = Layer.effect(TaskScheduler, make).
Concretely:
- drop
TaskSchedulerShapeand refer to the inferred shape asTaskScheduler["Service"]["tick"]/["start"]inLayers/TaskScheduler.ts; - rename
makeTaskSchedulerLive/TaskSchedulerLivetomake/layer(the options-taking variant can stay aslayerWith(options)ormake(options)); - update the consumers (
server.ts,serverRuntimeStartup.ts,TaskScheduler.test.ts,orphanedProviderSessionStartup.integration.test.ts) to the canonical path.
Posted via Macroscope — Effect Service Conventions
| threadId: row.threadId, | ||
| ...(row.name !== null ? { name: row.name } : {}), | ||
| prompt: row.prompt, | ||
| schedule: parseScheduleJson(row.taskId, row.scheduleJson), |
There was a problem hiding this comment.
🟠 High Layers/ProjectionSnapshotQuery.ts:2053
getCommandReadModel turns an invalid persisted schedule_json into an Effect defect, so one malformed task can terminate orchestration engine startup instead of returning the declared PersistenceDecodeError. parseScheduleJson is called inside Effect.sync, and the later Effect.mapError does not handle defects; reuse decodeTaskRows (or wrap parsing with Effect.try) here.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts around line 2053:
`getCommandReadModel` turns an invalid persisted `schedule_json` into an Effect defect, so one malformed task can terminate orchestration engine startup instead of returning the declared `PersistenceDecodeError`. `parseScheduleJson` is called inside `Effect.sync`, and the later `Effect.mapError` does not handle defects; reuse `decodeTaskRows` (or wrap parsing with `Effect.try`) here.
| </SettingsSection> | ||
|
|
||
| <ScheduledTasksSection | ||
| environmentId={representative.environmentId} |
There was a problem hiding this comment.
🟡 Medium settings/ProjectSettingsPanel.tsx:1149
ScheduledTasksSection always loads and mutates tasks for representative, so selecting another checkout leaves its tasks invisible and prevents scheduling actions for it; the section is also hidden when only a non-representative checkout supports scheduling. Use selectedCheckout for the environment, project, and filtered threads.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ProjectSettingsPanel.tsx around line 1149:
`ScheduledTasksSection` always loads and mutates tasks for `representative`, so selecting another checkout leaves its tasks invisible and prevents scheduling actions for it; the section is also hidden when only a non-representative checkout supports scheduling. Use `selectedCheckout` for the environment, project, and filtered threads.
|
|
||
| const start: TaskFireReactorShape["start"] = Effect.fn("start")(function* () { | ||
| yield* forkParked( | ||
| Stream.runForEach(orchestrationEngine.streamDomainEvents, (event) => { |
There was a problem hiding this comment.
🟠 High Layers/TaskFireReactor.ts:70
Committed task.fired events are permanently lost if the server crashes before this live streamDomainEvents subscriber processes them, so the scheduled agent turn is skipped even though nextFireAt has advanced. Additionally, processTaskFiredSafely swallows every non-interruption dispatch failure, causing transient engine or persistence errors to complete the drain item without retry. Use a durable/replayed cursor or equivalent redelivery path, and only suppress the expected deleted-anchor error (or retry other failures).
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/TaskFireReactor.ts around line 70:
Committed `task.fired` events are permanently lost if the server crashes before this live `streamDomainEvents` subscriber processes them, so the scheduled agent turn is skipped even though `nextFireAt` has advanced. Additionally, `processTaskFiredSafely` swallows every non-interruption `dispatch` failure, causing transient engine or persistence errors to complete the drain item without retry. Use a durable/replayed cursor or equivalent redelivery path, and only suppress the expected deleted-anchor error (or retry other failures).
| case "tomorrow-9": | ||
| return { kind: "once", at: nextNineAm(now) }; | ||
| case "daily-9": | ||
| return { kind: "interval", everyMs: DAY_MS }; |
There was a problem hiding this comment.
🟠 High settings/ScheduledTasksSection.tsx:43
The daily-9 and weekly-mon-9 presets do not run at 09:00 or on Monday: they first fire one interval after scheduling, so a task created at 14:30 runs daily at 14:30 and weekly on the creation weekday. resolveSchedule returns only everyMs, leaving the server to anchor the recurrence to creation time; include an explicit next-fire anchor (or otherwise preserve the selected 09:00/Monday schedule) when constructing these schedules.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ScheduledTasksSection.tsx around line 43:
The `daily-9` and `weekly-mon-9` presets do not run at 09:00 or on Monday: they first fire one interval after scheduling, so a task created at 14:30 runs daily at 14:30 and weekly on the creation weekday. `resolveSchedule` returns only `everyMs`, leaving the server to anchor the recurrence to creation time; include an explicit next-fire anchor (or otherwise preserve the selected 09:00/Monday schedule) when constructing these schedules.
| return [unsettledEvent, activityAppendedEvent]; | ||
| } | ||
|
|
||
| case "task.schedule": { |
There was a problem hiding this comment.
🟡 Medium orchestration/decider.ts:1416
Forced project.delete removes the anchor threads but leaves their active tasks armed. Those tasks later reach task.fire, fail because the anchor thread is deleted, and remain due forever, causing repeated scheduler retries. Cancel all project tasks as part of the forced deletion sequence before deleting the project.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 1416:
Forced `project.delete` removes the anchor threads but leaves their active tasks armed. Those tasks later reach `task.fire`, fail because the anchor thread is deleted, and remain due forever, causing repeated scheduler retries. Cancel all project tasks as part of the forced deletion sequence before deleting the project.
| command, | ||
| threadId: command.threadId, | ||
| }); | ||
| if (anchorThread.deletedAt !== null) { |
There was a problem hiding this comment.
🟡 Medium orchestration/decider.ts:1427
task.schedule can create a task with command.projectId while anchoring it to a thread from a different project, so the task is listed under one project but its scheduled turns run on another project's thread. After requireThread, reject the command when anchorThread.projectId !== command.projectId.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/decider.ts around line 1427:
`task.schedule` can create a task with `command.projectId` while anchoring it to a thread from a different project, so the task is listed under one project but its scheduled turns run on another project's thread. After `requireThread`, reject the command when `anchorThread.projectId !== command.projectId`.
| ## Cancel a task | ||
|
|
||
| Open **Settings → Projects → Scheduled tasks** and select **Cancel** next to the task. | ||
| Cancelled tasks stay visible until the panel is refreshed elsewhere. |
There was a problem hiding this comment.
🟢 Low user/scheduled-tasks.md:24
The guide falsely tells users that cancelled tasks disappear when the panel is refreshed, but listTasks returns them and ScheduledTasksSection renders a “cancelled” label, so refresh never removes these entries. Remove this sentence or state that cancellation only stops future runs.
| Cancelled tasks stay visible until the panel is refreshed elsewhere. | |
| Cancelled tasks remain visible with a “cancelled” label after cancellation. |
🤖 Copy this AI Prompt to have your agent fix this:
In file @docs/user/scheduled-tasks.md around line 24:
The guide falsely tells users that cancelled tasks disappear when the panel is refreshed, but `listTasks` returns them and `ScheduledTasksSection` renders a “cancelled” label, so refresh never removes these entries. Remove this sentence or state that cancellation only stops future runs.
| snapshotSequence: computeSnapshotSequence(stateRows), | ||
| projects, | ||
| threads, | ||
| tasks: yield* decodeTaskRows(taskRows), |
There was a problem hiding this comment.
🟡 Medium Layers/ProjectionSnapshotQuery.ts:1818
getSnapshot can advertise a snapshotSequence newer than the task data it returns, so consumers may resume after that sequence and permanently miss task updates while the task projector is lagging. computeSnapshotSequence only considers REQUIRED_SNAPSHOT_PROJECTORS, which omits ORCHESTRATION_PROJECTOR_NAMES.tasks; add the task projector to that set so both getSnapshot and getCommandReadModel wait for task state to reach the advertised watermark.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts around line 1818:
`getSnapshot` can advertise a `snapshotSequence` newer than the task data it returns, so consumers may resume after that sequence and permanently miss task updates while the task projector is lagging. `computeSnapshotSequence` only considers `REQUIRED_SNAPSHOT_PROJECTORS`, which omits `ORCHESTRATION_PROJECTOR_NAMES.tasks`; add the task projector to that set so both `getSnapshot` and `getCommandReadModel` wait for task state to reach the advertised watermark.
| return { kind: "interval", everyMs: DAY_MS }; | ||
| case "weekly-mon-9": | ||
| return { kind: "interval", everyMs: 7 * DAY_MS }; | ||
| } |
There was a problem hiding this comment.
Daily presets ignore 9:00
High Severity
The daily-9 and weekly-mon-9 presets label and document wall-clock times at 9:00, but resolveSchedule only sends an interval with everyMs. The decider then sets the first fire to schedule-time plus the interval, so recurring runs land at the create time of day, not 9:00 or Monday morning.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.
| return yield* new OrchestrationCommandInvariantError({ | ||
| commandType: command.type, | ||
| detail: `task ${command.taskId} anchor thread '${task.threadId}' is deleted and cannot fire`, | ||
| }); |
There was a problem hiding this comment.
Deleted thread sticks forever
High Severity
When task.fire rejects because the anchor thread is deleted, nextFireAt never advances and the engine stores a rejected receipt for the deterministic server:task-fire:<taskId>:<nextFireAt> id. Later ticks keep hitting that receipt, so the task stays due forever with no fire and only cancel clears it.
Additional Locations (2)
Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.
| cause: Cause.pretty(cause), | ||
| }); | ||
| }), | ||
| ); |
There was a problem hiding this comment.
Failed turns drop forever
High Severity
After a successful task.fired, nextFireAt already advanced, but the reactor only listens to live PubSub and swallows thread.turn.start failures with no retry or startup reconciliation. A crash or transient dispatch error between fire and turn permanently skips that run.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.
| commandType: command.type, | ||
| detail: `Thread '${command.threadId}' is deleted and cannot anchor task '${command.taskId}'.`, | ||
| }); | ||
| } |
There was a problem hiding this comment.
Cross-project task anchors allowed
Medium Severity
task.schedule requires the project and thread to exist but never checks that the thread belongs to that project. A client can attach a task under one project while anchoring fires to another project's thread, so listing and ownership diverge.
Reviewed by Cursor Bugbot for commit 6f33ca5. Configure here.
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR adds a new persisted scheduling workflow that autonomously starts agent runs through a background server reactor, along with schema, RPC, and UI changes. Its broad runtime impact and unresolved concerns around fire delivery, recurrence timing, and environment/project scoping warrant human review. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
The scheduler fired whenever tasks existed, with no way to turn the behavior off short of cancelling every task. Adds an enableScheduledTasks ServerSettings flag (default on): the tick reads it per cycle and skips the due-scan when off, stored schedules are untouched and resume with normal coalescing when re-enabled, and the per-project schedule UI hides while the switch is off. Surfaces as Settings -> Integrations -> Automation -> Scheduled tasks, following the enableAgentBrowserAccess pattern end-to-end: contracts schema + patch key, per-tick server gate, web toggle with search entry and restore-defaults wiring, and user docs.
|
Added a master switch for the feature:
|
There was a problem hiding this comment.
Re-reviewed at d9b0640. apps/web/src/components/settings/ScheduledTasksSection.tsx is unchanged since my previous review, so the four findings from that review still stand (raw SelectValue printing the stored value on both triggers, missing aria-label/responsive width on the triggers and prompt input, and the Schedule button staying enabled when no anchor thread resolves). I have not re-posted them. One additional layout finding is noted inline.
Posted via Macroscope — UI Consistency
|
|
||
| return ( | ||
| <SettingsSection title="Scheduled tasks"> | ||
| <p className="text-sm text-muted-foreground"> |
There was a problem hiding this comment.
Free-standing text and lists inside a SettingsSection need the row gutter: SettingsSection renders children with no horizontal padding, and SettingsRow supplies its own px-3 sm:px-4. Sibling paragraphs elsewhere follow that (e.g. ProjectSettingsPanel.tsx's "No actions configured for this checkout." uses px-3 ... sm:px-4), so these paragraphs and the task <ul> below sit flush to the panel edge and misalign with the rows above them. Same gutter is needed on the empty-state paragraph (line 219) and the <ul> (line 224).
| <p className="text-sm text-muted-foreground"> | |
| <p className="px-3 text-sm text-muted-foreground sm:px-4"> |
Posted via Macroscope — UI Consistency
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.
There are 6 total unresolved issues (including 4 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d9b0640. Configure here.
| Effect.catchCause((cause) => | ||
| Effect.logWarning("task.scheduler.settings-read-failed", { cause }).pipe(Effect.as(true)), | ||
| ), | ||
| ); |
There was a problem hiding this comment.
Scheduler kill switch fails open
Medium Severity
TaskScheduler.tick treats a failed getSettings read as enableScheduledTasks: true via Effect.catchCause, so a settings/secrets read error keeps firing due tasks even after the user turned the switch off. That is the opposite of enableAgentBrowserAccess, which fails closed with Effect.catch so an explicit off cannot silently become on. catchCause also recovers interruptions, so a shutting-down tick can keep dispatching.
Reviewed by Cursor Bugbot for commit d9b0640. Configure here.
| thread.projectId === representative.id, | ||
| )} | ||
| /> | ||
| ) : null} |
There was a problem hiding this comment.
Wrong env gates schedule UI
Medium Severity
The new enableScheduledTasks gate reads usePrimarySettings(), but ScheduledTasksSection is rendered for representative.environmentId. In a multi-environment workspace, primary off hides (or primary on shows) the schedule UI for a different server whose own flag and scheduler still control firing, so users can lose cancel/create UI while tasks keep running, or schedule tasks that never fire.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit d9b0640. Configure here.
| ) : null} | ||
| </SettingsSection> | ||
|
|
||
| {settings.enableScheduledTasks ? ( |
There was a problem hiding this comment.
🟠 High settings/ProjectSettingsPanel.tsx:1148
ScheduledTasksSection is shown or hidden using the primary server's settings.enableScheduledTasks, even when the section targets representative.environmentId. This exposes scheduling for remote servers whose scheduler will not run the stored tasks, and hides it for remote servers where scheduling is enabled; use the target environment's server settings for this guard.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ProjectSettingsPanel.tsx around line 1148:
`ScheduledTasksSection` is shown or hidden using the primary server's `settings.enableScheduledTasks`, even when the section targets `representative.environmentId`. This exposes scheduling for remote servers whose scheduler will not run the stored tasks, and hides it for remote servers where scheduling is enabled; use the target environment's server settings for this guard.
Evidence trail:
Reviewed commit d9b0640. `apps/web/src/components/settings/ProjectSettingsPanel.tsx:291-305,1148-1157` (`usePrimarySettings()` controls a section targeting `representative.environmentId`). `apps/web/src/hooks/useSettings.ts:292-305` (`useEnvironmentSettings` is environment-scoped; `usePrimarySettings` reads the primary server). `apps/web/src/state/server.ts:74-76` (`primaryServerSettingsAtom`). `apps/web/src/components/settings/ScheduledTasksSection.tsx:73-99` (target environment/capability handling, but no target settings guard). `packages/contracts/src/settings.ts:620-631` (disabled setting stops dispatch and clients should hide the UI). `apps/server/src/orchestration/Layers/TaskScheduler.ts:37-50` (disabled server scheduler returns without dispatching). Verify with `git show d9b0640 -- apps/web/src/components/settings/ProjectSettingsPanel.tsx apps/web/src/hooks/useSettings.ts apps/web/src/state/server.ts apps/web/src/components/settings/ScheduledTasksSection.tsx packages/contracts/src/settings.ts apps/server/src/orchestration/Layers/TaskScheduler.ts`.
| const nowIso = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); | ||
| // A failed due-scan must not kill the loop: log and retry next tick. | ||
| const dueTasks = yield* projectionSnapshotQuery | ||
| .listDueTasks(nowIso) |
There was a problem hiding this comment.
🟡 Medium Layers/TaskScheduler.ts:58
Tasks with offset-based at values fire late because listDueTasks(nowIso) compares the persisted ISO strings lexically against normalized UTC nowIso; for example, 2026-08-25T14:00:00+02:00 sorts after 2026-08-25T12:00:00.000Z and is not selected until roughly 14:00Z. Normalize persisted due times or compare parsed instants instead.
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/TaskScheduler.ts around line 58:
Tasks with offset-based `at` values fire late because `listDueTasks(nowIso)` compares the persisted ISO strings lexically against normalized UTC `nowIso`; for example, `2026-08-25T14:00:00+02:00` sorts after `2026-08-25T12:00:00.000Z` and is not selected until roughly `14:00Z`. Normalize persisted due times or compare parsed instants instead.
Evidence trail:
Commit d9b0640. `apps/server/src/orchestration/Layers/TaskScheduler.ts:52-58`; `apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts:511-533`; `apps/server/src/orchestration/decider.ts:1445-1449`; `packages/contracts/src/orchestration.ts:434-437`. Verify with `git show d9b0640 -- apps/server/src/orchestration/Layers/TaskScheduler.ts apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts apps/server/src/orchestration/decider.ts packages/contracts/src/orchestration.ts`.


Fixes #7966
Problem
T3 Code only starts agent runs when the user sends a message. Time-shaped workflows — nightly test runs, delayed follow-ups, recurring chores — have no primitive. Thread snooze is visibility-only and its wake is client-derived, so nothing happens if no client is open or the server restarts.
Fix
Event-sourced scheduled tasks that start turns on an existing thread:
task.schedule/task.cancelcommands (client-dispatchable), server-internaltask.fire, eventstask.scheduled/task.fired/task.cancelledon a newtaskaggregate kind. Schedule spec v1: one-shotator intervaleveryMs(≥ 1 min).TaskSchedulerLive, modeled onProviderSessionReaper): 15s tick finds due tasks in the projection and dispatchestask.firewith deterministic commandIds (server:task-fire:<taskId>:<nextFireAt>) — crash retries collapse into the existing idempotent command receipts.TaskFireReactor): consumestask.firedand dispatches a normalthread.turn.startto the anchor thread with the task prompt; modes ride on the event payload so no read-model lookup is needed.projection_taskstable (migration 042), projector cases,listTasks/listDueTasksqueries,orchestration.listTasksRPC,taskSchedulingcapability flag.Deliberately out of scope for v1: cron syntax, unanchored tasks (auto-created threads need a default-model policy), event-based triggers.
Verification
decider.scheduled-tasks.test.ts(11 tests: schedule validation, drift-anchored fire math, downtime coalescing, once-spending, early-fire/cancelled rejection, idempotent cancel) andTaskScheduler.test.ts(deterministic fire ids, empty tick).ox-alpha (opencode CLI)
Note
Medium Risk
Touches core orchestration (decider, projections, startup) and can auto-dispatch agent turns, though behavior is gated by settings, idempotent fire commands, and extensive new tests.
Overview
Adds event-sourced scheduled tasks so the server can start agent turns on an existing thread without a user message — one-shot or interval schedules, cancel, and persistence across restarts.
Domain & persistence: New
taskaggregate withtask.schedule/task.cancel(clients) and internaltask.fire, plustask.scheduled/task.fired/task.cancelledevents. The decider enforces future first-fire times, idempotent cancel, due-time guards, and interval math that anchors to prior slots and coalesces missed ticks. Read model gainstasks, SQLiteprojection_tasks(migration 042), projector cases, andlistTasks/listDueTasks. Event store and command receipts acceptTaskIdstreams.Runtime:
TaskSchedulerticks (~15s), respects serverenableScheduledTasks, and dispatchestask.firewith deterministic command IDs for idempotent retries.TaskFireReactorturnstask.firedintothread.turn.starton the anchor thread. Both join orchestration startup alongside existing reactors.orchestration.listTasksRPC andtaskSchedulingcapability advertise support; RPC auth mapslistTasksto orchestration read scope.Clients: Web gets Integrations Automation toggle and per-project Scheduled tasks UI (schedule/cancel/list); client-runtime adds schedule/cancel commands and a scheduled-tasks query atom.
Reviewed by Cursor Bugbot for commit d9b0640. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add server-side scheduled tasks that auto-start agent runs
task.schedule,task.cancel, and internaltask.firecommands with full event-sourced lifecycle (task.scheduled,task.fired,task.cancelled) in contracts, decider, and projectorTaskSchedulerruns a periodic tick (default 15s) that queries due tasks and dispatches deterministictask.firecommands;TaskFireReactorconsumestask.firedevents and starts a newthread.turn.startturn on the anchor thread using the task's promptprojection_taskstable (migration 42), repository, projection pipeline, and snapshot query APIs (listTasks,listDueTasks) with a partial index onnext_fire_atfor efficient due-task lookupsenableScheduledTasks, defaulttrue), a per-projectScheduledTasksSectionfor scheduling/cancelling tasks, and client command executors (scheduleTask,cancelTask) with serial per-task concurrencyorchestration.listTasksWebSocket RPC requires the orchestration read scope and returns tasks for a projectenableScheduledTasksdefaults totrue, so the scheduler starts firing on existing servers after deploy; verify migration 042 (projection_tasks) applies cleanly and the partial indexidx_projection_tasks_next_fire_atis createdMacroscope summarized d9b0640.