Skip to content

feat(server): restrict scheduled tasks to days, hours, and run caps - #14354

Closed
saphid wants to merge 13 commits into
pingdotgg:t3code/codex-turn-mappingfrom
saphid:feat/schedule-restrictions-upstream
Closed

saphid wants to merge 13 commits into
pingdotgg:t3code/codex-turn-mappingfrom
saphid:feat/schedule-restrictions-upstream

Conversation

@saphid

@saphid saphid commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Continued in #14714, which has the same head (3c9a91e024), the current proof and explicit prerequisites. This PR was closed by a maintainer and cannot be reopened by the author.

What Changed

Scheduled tasks gain three optional restrictions:

  • Days: an interval task can be limited to selected weekdays.
  • Hours: an interval task can be limited to a same-day local time window.
  • Run limit: any task can be limited to a lifetime number of runs.

Example: every 30 minutes, Monday to Friday, 09:00–17:00, at most 16 runs.

Behavior:

  • Outside the allowed days or window: an interval run that comes due is rescheduled to the next allowed time, not run late.
  • At the run limit: the task pauses in the same write that records the run. This reuses the existing pause state; there is no new workflow.
  • Changing the limit: raising or clearing it keeps a paused task paused. The user resumes it with the existing Enable/Resume control.
  • What counts toward the limit: manual, failed and interrupted attempts.
  • While the limit is used up: Run now and Resume are disabled.
  • Where the rules apply: the same rules hold in the web and mobile Scheduled tasks editors, the chat Automations panel, and the MCP schedule_task/update_scheduled_task tools.

In both editors, the Enabled switch shows the live task's state until the user changes it. It stays off and locked while the run limit of the schedule being saved is used up. A save sends enabled only after an explicit switch change. The last commit (3c9a91e024) adds this; preparing the UI proof showed that without it, a capped task's switch could appear on while the server kept the task paused.

Why this is a focused configuration option

  • Existing capability. Scheduled tasks already run a prompt on an interval or at a fixed time of day, from Settings on web and mobile, chat Automations, and MCP. They can already be paused, resumed, run immediately and edited.
  • What the options control. Only when an existing interval task may fire (weekdays, a same-day window), and how many times any task may fire before it pauses. Pausing and resuming use the task's existing enabled state and controls. There is no new screen, task type or way to start work.
  • Defaults are unchanged. All three fields are optional and absent by default; an empty weekday set means every day.
    • Without them, nextScheduledRunAt returns the same from + everyMs as before, the fixed-time path is unchanged, and the run-limit check is false.
    • Schedule.test.ts covers this: "keeps unrestricted intervals on their cadence" and "treats an empty weekday mask as every day".
    • New tasks created from web or mobile carry no restrictions unless the user sets them.
  • Behavior inside the option. Explicit resume after the limit, and counting every attempt, only affect tasks where the user set a limit.

No prior Ideas discussion or maintainer approval is linked. If you consider the limit's pause/resume semantics a product choice that needs direction first, I'll open a discussion.

One problem. This PR lets users bound when and how often an existing scheduled task runs. The contract, server, web, mobile, MCP, test and user-guide changes are parts of that one problem, and so is the Enabled-switch change, which keeps the limit's paused state truthful. If you prefer, the run limit could be split from the day/hour restrictions.

Stack. This lands after two separate one-problem PRs that are still open: #11592 (atomic scheduled-task edits, head d2c7ba7fd9) → #11635 (pause tasks bound to archived threads, head a3821de1da) → this PR. The comparison against the base therefore also shows their changes. To review only this contribution, use a3821de1da...3c9a91e024 (19 files).

UI Changes

All captures use disposable, isolated T3 homes and a fixture git project ("Nightly reports"), with no real user data.

  • Before: merge base 0fb3731eb4. The scheduled-task client and server files are identical at the current target b729550de6.
  • After: 3c9a91e024.
  • Mobile: iOS 27.0 Simulator (iPhone 17 Pro), T3 Code Dev client.
  • Web: T3's Browser panel.
  • GIFs: sampled at 5–6 fps from real-time recordings; each links to its MP4. The web GIFs are cropped to the content pane.
  • Receipt: every file's sha256 and capture conditions are in proof-receipt.json.

Before (base 0fb3731)

Interval tasks offer only "run every N minutes", and the row has no run-limit state.

Before, web: the interval editor has only Run every N minutes; the row menu offers Edit, Run now and Delete
web MP4

Before, mobile: the interval editor offers only Minutes between runs; the row menu offers Edit, Pause, Run now and Delete

mobile MP4. This base mobile recording predates T3's Device panel on this machine: the Simulator was driven with AXe and recorded with simctl. The UI it shows is the real base build.

After: a restriction-only save survives reopening

  1. An existing task is edited to Mon–Fri, 09:00–17:00 and Stop after 2, with nothing else changed.
  2. After saving, the row lists the restrictions.
  3. Reopening the editor shows the same values.

Mobile check: the server stored {"type":"interval","everyMs":900000,"weekdays":[1,2,3,4,5],"window":{"start":"09:00","end":"17:00"},"maxRuns":2}. The interval and prompt were unchanged by the edit.

After, web: only the days, window and Stop after are changed; the row lists them; reopening shows the same values
web MP4

After, mobile: weekdays, 9:00 am to 5:00 pm and Stop after 2 are saved, shown in the row, and still set after reopening

mobile MP4

After: the limit pauses the task, and resuming takes an explicit action

Mobile:

  1. Two real Run now runs (GPT-6-Luna, prompt "Reply with the single word OK.") both succeed.
  2. The row shows "Run limit reached (2/2)"; Resume and Run now are disabled (accessibility enabled=false).
  3. In the editor, Enabled is off and locked, and tapping it does nothing.
  4. Raising Stop after to 3 unlocks the switch, which stays off. After saving, the row still shows "Paused" (server enabled = 0).
  5. Resume is now enabled. Tapping it schedules the next run (server enabled = 1).

Web: the same task is shown in the overnight observation below, auto-paused at "Run limit reached (3/3)":

  1. The row's Enable switch and Run now are disabled, and the editor's switch is locked with "Run limit reached. Raise or clear the limit to enable it."
  2. Raising the limit to 4 and saving leaves it Paused.
  3. Turning Enable on explicitly schedules the next run.

After, mobile: two Run now runs reach the limit; Resume and Run now are disabled; the editor switch is locked; raising the limit keeps it paused; explicit Resume schedules the next run

mobile MP4

After, web: at 3/3 the Enable switch and Run now are disabled and the editor switch is locked; raising the limit to 4 keeps the task paused until Enable is turned on
web MP4

Stills and an overnight scheduler observation

Capture notes

  • Mobile "after" recordings: taken after a full app launch on 3c9a91e024, driven through T3's Device panel/AgentDevice, at 1206×2622 in real time.
  • Fixture side effects:
    • One stray Run now press, caused by the list reordering while a menu was open, gave another fixture task a single run.
    • One mobile fixture task got a typo'd prompt during setup. It was never run, and the recorded edit changed only the restrictions.
  • Not exercised: Android, desktop (Electron) and remote/relay connections.

Verification

Focused checks only; no repo-wide checks were run.

Check 7eb5427 (previous head) 3c9a91e024 3c9a91e024 cherry-picked on 7eb5427 + target 23309198ba
Web scheduled-task logic (2 files) 55 passed 59 passed 59 passed
Mobile draft (1 file) 46 passed 51 passed 51 passed
Contracts scheduledTask.test.ts 9 passed client-only change 9 passed (7eb5427 + target)
Server scheduling + MCP (4 files) 120 passed client-only change 120 passed (7eb5427 + target)
Web/mobile typecheck; lint and format of changed files not run passed typecheck passed

Mergeability: the branch merges cleanly into the current target b729550de6 (git merge-tree). None of the target's 27 commits since 23309198ba touch scheduled-task client, server, contract or MCP schedule code. The test suites above were not re-run against b729550de6 itself.

Compatibility limits:

  • An older client that replaces a whole schedule can remove restrictions it doesn't understand. Explicit false is honored, but mixed-version preservation is not claimed.
  • Windows use the environment's local clock and cannot cross midnight.
  • A window opening inside a spring-forward gap can skip that day. Existing interval and fall-back behavior is retained.

Checklist

Attribution:

  • Original implementation: GPT-6.1 Sol. Independent review: GPT-6 Astra (Codex harness through T3 Code).
  • Recovery, the Enabled-switch change, mobile proof and publication: Claude Opus 5.5 (Claude Code harness through T3 Code).
  • Web capture: GPT-6 Astra (Codex harness through T3 Code).
  • Independent reviews of the change: five rounds by GPT-6.1 Sol, high reasoning (Codex harness through T3 Code). The final round found no actionable issues.

🤖 Generated with Claude Code

github-actions Bot and others added 4 commits September 27, 2026 09:04
Replace stale read + full-row upsert with a scoped partial UPDATE inside a
transaction: CAS guards on the enabled/schedule basis, typed conflicts for
contested edits, Option.none on delete (never resurrects), merged
project/thread binding validated against the v2 projection inside the same
transaction, and BUSY-family contention retries on every run-state write.
Web Settings saves through a new scheduledTasks.update WS RPC with a
dirty-patch builder; a bound task project move is explicit
unbind-and-move (threadId: null) with a detach hint on the Project field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Archiving or deleting a thread pauses its bound schedules while keeping run
history. Re-enabling is explicit: an enablement watermark (enabled_seq,
migration 56) keeps a delayed archive event from undoing a later re-enable,
and a run accepted before the archive keeps its recorded outcome. A
domain-event reactor pauses promptly, a startup sweep covers archives
committed while the server was down, and dispatch re-checks the binding
inside the run-claim transaction.

MCP schedule mutations now require a live caller-owned run and mode
coverage for the modes the task will execute under. The authorizing run,
caller modes and destination modes are pinned into the write transaction;
restricted callers keep disable, rename and delete access. Archived caller
threads are rejected.

Ported onto the upstream OV2 branch from the original commits 64ebc96656,
9a8864c1c5, 887a2cbd56, b13acde17c, 56227a7fca, f289c404b8, 55ffa82883,
3890bfa8c5 and c525f94b3c. Project scoping for delete and runNow uses the
`projectId` input from pingdotgg#11592 instead of a separate `expectedProjectId`;
the Settings dirty-field patch work (bdec0f85e8) now lives in pingdotgg#11592.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Sep 30, 2026
Comment thread apps/server/src/scheduledTasks/ScheduledTaskService.ts Outdated
Comment thread apps/web/src/components/settings/ScheduledTasksSettings.tsx Outdated
Comment thread apps/server/src/scheduledTasks/ScheduledTaskService.ts
@macroscopeapp

macroscopeapp Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a large, cross-cutting scheduled-task capability that changes production scheduling, persistence, thread lifecycle behavior, MCP authorization, and RPC handling, including a sensitive authorization-package change and database migrations. The scope and runtime impact exceed automatic-approval criteria.

Notes:

  • No code objects were reviewed. Approvability was decided on eligibility alone.

You can add or adjust custom eligibility rules. Learn more.

…t dispatch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread apps/server/src/scheduledTasks/ScheduledTaskService.ts
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Comment thread apps/server/src/scheduledTasks/ScheduledTaskService.ts
Comment thread apps/server/src/scheduledTasks/ScheduledTaskService.ts Outdated
// Earlier tasks in this batch can take a while: judge each task's
// window against the moment it is about to dispatch, not the poll.
const current = yield* localNow;
return yield* isMissedFixedTimeRun(task.schedule, dueAt, current) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes service dispatch behavior, but the focused tests only exercise the restriction helper; none verifies that a task due in-window is skipped when an earlier task delays dispatch past the window. Could you add a service-level test with a test clock and test layers that advances time during the earlier dispatch, then asserts the later task is rescheduled without launching?

Posted via Macroscope — Effect Service Conventions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Sep 30, 2026
// never compacted for non-legacy commands — an accepted thread.archive
// receipt's result_sequence is the archived event's sequence — so the
// watermark unions both sources. Pin the partial archive index so the
// event lookup never walks the thread's unrelated event history.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This log annotation includes every paused task ID, so an archive with many bound tasks produces an unbounded log field. Could you log the count instead?

Suggested change
// event lookup never walks the thread's unrelated event history.
pausedTaskCount: paused.length,

Posted via Macroscope — Effect Service Conventions

if (paused.length === 0) return;
yield* Effect.logInfo("Paused schedule tasks bound to a thread that no longer accepts runs", {
threadId,
taskIds: paused.map((row) => row.task_id),

This comment was marked as duplicate.

Copy link
Copy Markdown
Member

Note

This comment is posted by Julius' dot

Closing this PR under the changed-behavior and UI verification requirement, assessed at 7eb5427.

The current contribution changes the web and mobile schedule editors, restriction-only saves, and the visible Run now/Resume behavior once a lifetime cap is reached. I inspected the linked historical GIFs: they show the earlier web editor and a saved restriction summary, but no mobile UI or cap-reached → change-cap → explicit-resume interaction. The description explicitly says they predate this integration and that current web/mobile UI proof is still missing.

The focused service/client tests are useful. Current CI logs confirm the scheduling service, schedule calculation, mobile draft, and web draft suites passed; those checks do not demonstrate the actual client interactions. The stacked prerequisites in #11592 and #11635 explain why the cumulative comparison includes atomic-update and archive-pause work, but do not supply the missing client evidence.

For reconsideration, attach or link current before/after screenshots for the changed web and mobile controls, plus a short recording showing a restriction-only save surviving reopening and the run-cap pause/resume behavior, including the disabled Run now/Resume state and explicit re-enable after changing the cap. Identify the tested commit, environment, and observed results, and state any remaining platform limits. Keep PR-only media in the PR rather than committing it. See closure and reconsideration.

@saphid

saphid commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Requesting reconsideration of the closure under Provide evidence for the changed behavior. I can't reopen a maintainer-closed PR myself (reopenPullRequest: "Could not open the pull request."), so could a maintainer reopen it if this now meets the bar?

The missing proof is now in the description. All captures are real, with disposable isolated fixtures:

  • Tested revisions: base 0fb3731eb4 (merge base) and candidate 3c9a91e024.
  • Environments:
    • Web: T3's Browser panel.
    • Mobile: iOS 27.0 Simulator (iPhone 17 Pro), T3 Code Dev client, driven through T3's Device panel after a full app launch on the candidate.
  • Restriction-only save survives reopening (web and mobile). Only the days, window and Stop after change; they show in the row and are still set when the editor reopens. On mobile, the server stored weekdays 1–5, 09:00–17:00 and maxRuns 2, with the interval and prompt unchanged.
  • Run limit → explicit resume (web and mobile).
    1. Mobile: two real Run now runs reach "Run limit reached (2/2)". Web shows a task that auto-paused at 3/3.
    2. Run now and Resume/Enable are disabled, and the editor's Enabled switch is locked.
    3. Raising the limit keeps the task Paused (server enabled = 0).
    4. Only an explicit Resume/Enable schedules the next run (enabled = 1).
  • Base: interval-only editor with no run-limit state, on both clients. The base mobile recording predates the Device panel (AXe + simctl), and the description says so.
  • Media: every file's sha256 and capture conditions are in proof-receipt.json. The MP4s are linked beside the GIFs.

Head updated (fast-forward): 7eb542711e → 3c9a91e024. The new commit came out of preparing the proof. The editors' Enabled switch now shows what a save will do: it is locked while the limit is used up, and only an explicit switch change sends enabled. Without this, a capped task's switch could appear on while the server kept the task paused. The previous head is kept at saphid:backup/pr-14354-head-7eb5427.

Checks

  • Focused tests pass at the candidate (web 59, mobile 51) and when the candidate is cherry-picked onto the previous head merged with the target.
  • Server and contracts suites (120 and 9) pass at the previous head merged with the target.
  • The branch merges cleanly into the current target b729550de6, whose newer commits don't touch scheduled-task code.
  • Five independent review rounds by GPT-6.1 Sol (high reasoning); the last found no actionable issues.

Scope. This is a focused configuration option for the existing scheduled-task capability. With no restriction set, scheduling is unchanged. No prior approval is linked; if you consider the limit's pause/resume semantics a product decision, I'll open an Ideas discussion. The PR still lands after the open #11592 and #11635. To see only this contribution, use a3821de1da...3c9a91e024.

Not checked: Android, desktop (Electron) and remote/relay connections.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants