Skip to content

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

Closed
saphid wants to merge 16 commits into
pingdotgg:t3code/codex-turn-mappingfrom
saphid:pr/schedule-restrictions-20261002
Closed

saphid wants to merge 16 commits into
pingdotgg:t3code/codex-turn-mappingfrom
saphid:pr/schedule-restrictions-20261002

Conversation

@saphid

@saphid saphid commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Replaces #14354. It was closed for missing current web/mobile UI proof, and as the author I can't reopen a maintainer-closed PR (reopenPullRequest: "Could not open the pull request."). This PR started from #14354's final head, 3c9a91e024. Two later commits address review findings (see Review follow-ups). The #14354 branch and a backup of its previous head (saphid:backup/pr-14354-head-7eb5427) are kept.

Prerequisites. This PR is not mergeable on its own. It is stacked on two open PRs, which must land first, in order: #11592 (atomic scheduled-task edits, head d2c7ba7fd9) → #11635 (pause tasks bound to archived threads, head a3821de1da) → this PR.

Cumulative diff. The diff against the base is cumulative: 48 files (+9,160/−386) from the merge base 0fb3731eb4. That covers the prerequisites (40 files up to #11635's head) plus this PR's own 19 files (+2,048/−192). 11 of the 19 are further edits to files the prerequisites also change. To review only this PR's layer, use a3821de1da...47ff79839e.

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...47ff79839e (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 de95adc336.
  • After: 3c9a91e024. The two later commits don't change the captured flows; see Review follow-ups.
  • 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.

Review follow-ups since 3c9a91e024

These came from Macroscope's review and independent reviews. All are in this PR's restriction layer; the base has no time windows.

  • 01a7974c83 fixes two issues:
    • A window time stored as 9:00 showed as a blank time field on web (the contract allows single-digit hours). The editor now pads it. Real-input check on this commit: the native inputs read 09:00/17:00 and display 09:00 am / 05:00 pm (still; disclosed fixture write while the server was stopped).
    • During a repeated fall-back hour, the next run could land in the past and keep being rescheduled. Restricted intervals now add elapsed time instead of calendar time.
  • 47ff79839e: a window that overlaps a fall-back hour now runs again when the clock re-enters it later the same day, instead of skipping to the next day. This covers windows that start inside the repeated hour and windows that start before it, including half-hour shifts.

Why the media above is still valid: the captures ran at 3c9a91e024, and none of the captured flows involve a DST transition or an unpadded stored time. The later commits change only those cases, so the UI shown is unchanged. They are covered by focused tests plus the still above.

Left unchanged:

  • Two issues the reviews found in base code, which this PR doesn't touch:
    • Unrestricted intervals use calendar arithmetic across fall-back.
    • Fixed-time timeOfDay values like 9:00 aren't padded on web.
  • One documented limitation: a window whose opening falls inside a spring-forward gap still skips that day, even if later parts of the window exist.

Verification

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

Check 7eb5427 (#14354 head) 3c9a91e024 47ff79839e (this head)
Web scheduled-task logic (2 files) 55 passed 59 passed 60 passed
Mobile draft (1 file) 46 passed 51 passed unchanged since 3c9a91e024 (51)
Contracts scheduledTask.test.ts 9 passed unchanged unchanged (9)
Server scheduling + MCP (4 files) 120 passed unchanged (120) 124 passed
Typecheck of changed packages; lint and format of changed files not run passed (web, mobile) passed (server, web)

Earlier, the suites also passed with 3c9a91e024 cherry-picked onto 7eb5427 merged with target 23309198ba: web 59, mobile 51, contracts 9, server 120.

Independent reviews of the follow-ups (GPT-6.1 Sol, high reasoning, Codex harness through T3 Code): neither 01a7974c83 nor 47ff79839e introduced any actionable issue. The last review compared 224,400 schedule cases against the earliest permitted run and found no fall-back mismatches. The only mismatches were the documented spring-forward limitation.

Mergeability: this head (47ff79839e) merges cleanly into the current target de95adc336 (git merge-tree). None of the target's 28 commits since 23309198ba touch scheduled-task client, server, contract or MCP schedule code. The target's migrations stop at 056, so the stack's 057/058 don't collide. The test suites above were not re-run against de95adc336 itself: its lockfile changed, and reinstalling was deferred for disk reasons.

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 whose opening falls inside a spring-forward gap skips that day.
  • Windows that overlap a fall-back hour run again when the clock re-enters them (see Review follow-ups).

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: GPT-6.1 Sol, high reasoning (Codex harness through T3 Code). Five rounds covered the Enabled-switch change, and further rounds covered each follow-up commit.

🤖 Generated with Claude Code

github-actions Bot and others added 14 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>
…t dispatch

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eave it

Editing a task that had used up its run cap still let the Enabled switch
turn on; Save reported success, but the server keeps capped tasks paused,
so reopening showed it off again. The switch could also show a stale value
when the task paused, or another client changed it, while the editor was
open.

The web and mobile editors now show the live task's enabled state until the
user uses the switch, keep it off and locked while the cap of the schedule
the save will keep is used up, and send enabled only after an explicit
switch action. Raising or clearing the cap therefore keeps a paused task
paused until the user turns Enabled on, and a pause made elsewhere is never
undone implicitly. Mobile also treats an explicit Enabled change as unsaved
work when deciding whether to warn before discarding the editor.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. label Oct 2, 2026
@github-actions github-actions Bot added the size:XXL 1,000+ changed lines (additions + deletions). label Oct 2, 2026
aria-label="Window start"
className="w-32"
disabled={!draft.windowEnabled}
value={draft.windowStart}

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.

🟡 Medium settings/ScheduledTasksSettings.tsx:1006

Opening a task with a persisted window time such as 9:00 renders the native time input blank, so users cannot see the configured restriction even though it remains active. The bindings pass draft.windowStart and draft.windowEnd directly to type="time", which requires padded HH:mm values; normalize both values before binding them.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/components/settings/ScheduledTasksSettings.tsx around line 1006:

Opening a task with a persisted window time such as `9:00` renders the native time input blank, so users cannot see the configured restriction even though it remains active. The bindings pass `draft.windowStart` and `draft.windowEnd` directly to `type="time"`, which requires padded `HH:mm` values; normalize both values before binding them.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 01a7974: the contract accepts single-digit hours such as 9:00, and the web editor passed them unchanged to the native time inputs. taskToDraft now pads window start/end to HH:mm, and a regression test covers it. A real-input check on the fixed head shows the inputs reading 09:00/17:00 and displaying 09:00 am / 05:00 pm. Padding happens symmetrically on the editor baseline, so an unchanged save still sends no schedule patch.

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.

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/scheduledTasks/Schedule.ts
@macroscopeapp

macroscopeapp Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This is a substantial, cross-cutting scheduler feature that changes production scheduling, persistence, orchestration, authorization, and archive lifecycle behavior across server, web, and mobile code. New migrations and auth/dispatch gates, plus complex DST and concurrency handling, make the blast radius too broad for automatic approval.

Not approved because:

  • 1 blocking correctness issue found at or above your repo's Minimum Blocking Severity

Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more.

github-actions Bot and others added 2 commits October 2, 2026 14:30
…back, show unpadded times

Two Macroscope findings on the scheduled-task restrictions:

- In a repeated fall-back hour, the restricted interval schedule added the
  interval with calendar arithmetic, which re-resolves the repeated hour to
  its first occurrence. The next run could land before the current time,
  so a window opening inside that hour kept the task due and rescheduled
  on every poll. The restricted path now adds elapsed time, and a window
  opening that resolves before the candidate steps forward on the
  candidate's own offset.
- The contract accepts window times such as "9:00", but the web editor
  passed them unchanged to native time inputs, which only display
  zero-padded HH:mm, so the configured window showed as blank. The editor
  now pads them when it reads a task.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…indow in a fall-back hour

A time window that overlaps a fall-back hour is entered a second time when
the hour repeats. After the first pass through the window ended, the
restricted interval schedule skipped straight to the next day, although
the repeated part of the window was still ahead and the dispatch check
would allow a run in it. The schedule now uses the earliest re-entry later
the same day: the repeated window opening, or the fall-back instant itself
when the window began before the repeated hour (for example 00:30-01:30 in
New York, or Lord Howe's 30-minute fall-back).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@juliusmarminge juliusmarminge added the macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews label Oct 2, 2026 — with ChatGPT Codex Connector

Copy link
Copy Markdown
Member

Note

This comment is posted by Julius' dot

Closing under UI verification for the current cumulative diff. The new web/iOS recordings establish restriction-only saves and run-cap pause/resume. However, this 48-file comparison also includes the mobile archived-thread banner/Unarchive control and web archive notice from #11635. None of the current captures or recordings shows those changed surfaces or archive → pause → unarchive → explicit re-enable; the supplied flows stay in the scheduled-task editors.

Please add before/after captures and a short recording for those cumulative archive interactions, or rebase after the prerequisites land so they leave this diff, then request reconsideration. The repaired restriction/cap evidence remains useful.

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

Labels

macroscope-review Opt PRs made by unvouched contributors in for Macroscope review. Vouched contributors auto-reviews 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